From c629ca0a1d118cf670e5c7e596dc43ecb906967f Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 10:02:57 +0300 Subject: [PATCH 01/25] =?UTF-8?q?spec:=20issue-backlog-sweep=20=E2=80=94?= =?UTF-8?q?=2015-issue=20sweep=20frame=20+=20exported=20spec=20(devague=20?= =?UTF-8?q?/scope=20+=20/think=20+=20/challenge)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QtUixLUjgEt51PXS9TPVt3 --- .devague/frames/issue-backlog-sweep.json | 840 +++++++++++++++++++ docs/specs/2026-07-28-issue-backlog-sweep.md | 141 ++++ 2 files changed, 981 insertions(+) create mode 100644 .devague/frames/issue-backlog-sweep.json create mode 100644 docs/specs/2026-07-28-issue-backlog-sweep.md diff --git a/.devague/frames/issue-backlog-sweep.json b/.devague/frames/issue-backlog-sweep.json new file mode 100644 index 0000000..e9e9117 --- /dev/null +++ b/.devague/frames/issue-backlog-sweep.json @@ -0,0 +1,840 @@ +{ + "slug": "issue-backlog-sweep", + "title": "issue-backlog-sweep", + "schema_version": 3, + "status": "exported", + "created": "2026-07-28T06:49:19Z", + "updated": "2026-07-28T07:02:03Z", + "claims": [ + { + "id": "c1", + "kind": "announcement", + "text": "devague closes its fifteen-issue backlog: exports become lossless and lint-clean, rejected and contested content stops leaking into artifacts, plans gain live coverage targets with per-target deferral, claims and hard questions gain amend and resolve moves, the delivery summary scopes to confirmed work, gate 2 gains a durable split artifact, and scope exploration fans out to smaller-tier subagents", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h1", + "text": "honest only if one release genuinely closes every one of the fifteen listed issues \u2014 each closed with a pointer to the shipping test/section, none closed as wontfix-by-stealth or half-delivered", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c2", + "kind": "requirement", + "text": "export renders every parked kind: `render/spec_md.py:110-118` `_follow_up` filters to `follow_up`/`out_of_scope` only, so an unresolved `unknown_nonblocking` park renders nowhere in the spec; all open parks must render grouped by kind, and the pinning test `tests/test_render.py:301-306` flips from asserting absence to asserting presence (#93, #49)", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h2", + "text": "honest only if a converged frame carrying open parks of all four kinds exports a spec where every open park appears, labeled by kind \u2014 verified by flipping tests/test_render.py:301-306 from asserting absence to asserting presence", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c3", + "kind": "requirement", + "text": "hard questions render honestly: `render/spec_md.py:104-107` shows no resolved marker and iterates all claims regardless of status \u2014 resolved questions render as resolved (or are omitted) and questions on rejected claims never render (#49, #83)", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h3", + "text": "honest only if a resolved hard question renders with a resolved marker (or is omitted by documented choice) and a hard question on a rejected claim never reaches the export \u2014 regression covering the #83 repro shape: capture, interrogate --risk, reject, converge, export", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c4", + "kind": "requirement", + "text": "a user move resolves claim-attached hard questions: nothing in the codebase sets `HardQuestion.resolved` (only vagueness `frame.py:243` and plan-risk `plan.py:187` resolves exist), `set_status` (`frame.py:264-273`) routes only c*/h* ids, so the gate at `convergence.py:111-116` blocks forever; `suggest_move` (`convergence.py:206-211`) must name the real move (#48, #52)", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h4", + "text": "honest only if the block-resolve-converge sequence completes through CLI moves alone (no state-JSON hand-edit), suggest_move names the shipped move verbatim, and resolved state survives a save/load round-trip", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c5", + "kind": "requirement", + "text": "reject cascades over attachments: rejecting a claim cascades to (or refuses over) its honesty conditions and hard questions, reporting what it took; `_assumption_warnings` (`convergence.py:120-126`) and the blocking-question gate skip rejected claims; `devague review` stops listing orphaned conditions (#83)", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h5", + "text": "honest only if rejecting a claim with attachments reports exactly what it cascaded over (or refuses with an actionable hint), post-reject converge emits zero warnings about the rejected claim, and review lists zero orphaned conditions", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c6", + "kind": "requirement", + "text": "markdown escaping lands at the single verbatim seam: `render/_md_safety.py` (today only `autolink_urls`/`heading_safe`) gains identifier-aware escaping \u2014 code-span wrapping for underscore and dunder tokens per the #87 comment, fixing MD037 and MD050 \u2014 applied at every verbatim site in `spec_md.py`, `plan_md.py`, and `summary_md.py` (#87)", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h6", + "text": "honest only if a frame whose text contains `_read_file`, `__init__.py`, `*`, `[`, a backtick, and a leading `#` exports spec-md and plan-md passing markdownlint-cli2 default config \u2014 the integration test extends tests/test_export_markdownlint_integration.py", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c7", + "kind": "requirement", + "text": "an amend move preserves identity: claims (`--text`/`--kind`, keeping id, honesty conditions, instruction, and inbound seeds), scope entries (`scope --amend sN --finding`), and plan risks (`plan risk --amend rN --text`); amending a confirmed item flips it to proposed with an echoed flip, matching the `interrogate.py:58-68` and plan `_FLIP_SUFFIX` precedent (#84)", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h7", + "text": "honest only if amending a confirmed claim keeps its id, honesty conditions, instruction, and inbound scope seeds, flips it to proposed with an echoed flip, and correcting one number costs exactly one move \u2014 with the same holding for scope --amend and plan risk --amend", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c8", + "kind": "requirement", + "text": "`scope --seeds` accepts question ids: `Frame.add_scope_entry` (`frame.py:252-254`) validates seeds via `find_claim` only, so `q*` is refused today \u2014 yet the /scope routing table sends needs-a-user-decision findings to `question`, so that branch must be seedable (#84)", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h8", + "text": "honest only if scope --seeds accepts a valid q* id, still refuses an unknown q* id with the show hint, and the seeded question renders in the exported scope-exploration section", + "status": "rejected", + "instruction": "" + }, + { + "id": "h23", + "text": "honest only if scope --seeds accepts a valid `q*` id, still refuses an unknown `q*` id with the show hint, and the seeded question renders in the exported scope-exploration section", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c9", + "kind": "requirement", + "text": "`plan cover` and `plan task --covers` validate against live-derived targets: `_require_target` (`cli/_commands/plan.py:145-151`) reads the stored snapshot that only `converge`/`export` refresh (`plan.py:496,530`) while `plan status` re-derives without persisting \u2014 verified by live repro: `plan status` recommends `plan cover t1 --target c7` and that exact move refuses with unknown coverage target; after `plan converge` it succeeds (#90)", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h9", + "text": "honest only if the verified repro inverts: after the frame grows a confirmed claim, plan status recommends cover for it AND that exact cover succeeds immediately, with no intervening plan converge required", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c10", + "kind": "requirement", + "text": "a per-target deferral move (for example `plan defer --reason`): deferred targets stop blocking `_missing_coverage` (`plan_convergence.py:26-32`), render as a Deferred targets section in plan-md (no such section exists, `render/plan_md.py:78-102`), and `plan status` distinguishes deliberately-deferred from not-yet-covered; today an `out_of_scope` risk has zero gate effect (#85)", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h10", + "text": "honest only if a plan with deferred targets converges and exports, the export names every deferred target with its reason in a Deferred targets section, and plan status reports deliberately-deferred distinctly from not-yet-covered \u2014 exercising the shell-cli shape of 90 covered plus 12 deferred", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c11", + "kind": "requirement", + "text": "`plan task --dep` validates at creation: `add_dep` (`plan.py:134-136`) is a bare append, so self-dependencies and dangling deps are accepted silently (pinned by `tests/test_cli_plan.py:87-96`, which flips); refuse self-reference and unknown ids with actionable hints (#86)", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h11", + "text": "honest only if plan task --dep naming the about-to-be-assigned id or an unknown id refuses at creation with an actionable hint, and tests/test_cli_plan.py:87-96 is flipped rather than deleted", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c12", + "kind": "requirement", + "text": "`plan confirm` and `plan reject` go multi-id transactional: today single-id (`cli/_commands/plan.py:827-835`) versus the frame-side batched `nargs` surface (`confirm.py:90-97`, `reject.py:10-15`) (#86)", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h12", + "text": "honest only if plan confirm and plan reject with N ids apply transactionally \u2014 all valid or none applied \u2014 matching the frame-side contract in behavior and error text", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c13", + "kind": "requirement", + "text": "`devague summary` scopes to confirmed tasks: `_planned_work_lines`, `_actual_delivery_lines`, and `summary_data` (`render/summary_md.py:119-143, 265-271`) iterate every task today; rejected tasks leave Planned Work and the Actual Delivery table, replaced by a one-line rejected-count note; `dependency_waves` already excludes rejected tasks (`plan.py:285`) so the --pr wave map is safe (#88)", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h13", + "text": "honest only if a plan with N confirmed and M rejected tasks emits exactly N Actual Delivery rows and N Planned Work entries plus a single line counting the M rejected, and the --pr wave map stays rejected-free", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c14", + "kind": "requirement", + "text": "approved deviations surface against contested claims: an approved deviation carries `--affects` claim ids (`deviate.py:78-89`) but no export/show/status code touches the delivery store today (zero grep hits); derive the back-reference read-only via enumerate `plan_store.list_slugs()`, filter `plan.frame_slug`, load the delivery per plan slug (#92)", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h14", + "text": "honest only if an approved deviation whose --affects names a confirmed claim yields a contested marker under that claim on re-export AND a contested line in show/status, all derived read-only across the plan-slug join with zero frame-state mutation", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c15", + "kind": "requirement", + "text": "split-plan writes a durable gate-2 artifact: `docs/plans/--split.md` beside the exported plan-md, overwrite-in-place on re-run; today the script has no write step at all (`assign-to-workforce.sh:262-283` \u2014 subcommands split-plan, waves, help only) (#82)", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h15", + "text": "honest only if the written split artifact carries real per-task summaries, acceptance criteria, instructions, the owner/model annotation block, and the End state section; re-running overwrites the same dated path; the file lints clean", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c16", + "kind": "requirement", + "text": "the /scope skill fans exploration out to smaller-tier read-only subagents (sonnet default): method-only change to SKILL.md steps 1-2 (`scope/SKILL.md:43-50`, today serial first-person), sweeping `learn.py` `SCOPE_STAGE` (`learn.py:153-171`), `docs/skills.md:156-176`, and the `docs/skill-sources.md` version-stamp row (#79, #91)", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h16", + "text": "honest only if scope SKILL.md, learn.py SCOPE_STAGE, docs/skills.md, and the skill-sources ledger all describe the same subagent fan-out with the same default tier \u2014 a doc-alignment check, not four diverging texts", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c17", + "kind": "assumption", + "text": "issue #82 first ask is already shipped and its symptom was a stale vendored copy: since 0.16.0 the script renders real task content from `plan waves --json` (`assign-to-workforce.sh:202`; the JSON carries summary, instruction, acceptance criteria, and covers per `cli/_commands/plan.py:569-584`), so the live gaps are only the durable artifact and owner/model recording", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c18", + "kind": "assumption", + "text": "schema bumps are needed and currently hazardous: `HardQuestion(**q)` and `Vagueness(**v)` raw unpacking (`frame.py:315,322`) plus the version gate running after `from_dict` (`store.py:136-147`) means any new persisted field crashes older binaries with a raw TypeError instead of the fail-closed IncompatibleSchemaError; resolve and amend fields need a frame schema bump with the load order fixed", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c19", + "kind": "assumption", + "text": "the deviation-to-claim join stays derived, not stored: `Delivery` has no frame slug (`delivery.py:57` keys by plan slug only) and Frame has no reverse plan index, so #92 rendering derives the contested set at render time instead of adding reverse-pointer state", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c20", + "kind": "non_goal", + "text": "the CLI stays deterministic and non-orchestrating (issue #20): scope subagent fan-out lives in skill method text, never as a CLI verb; any per-task owner/model assignment for #82 is recorded as inert data \u2014 the Model column today is presentation-only (`assign-to-workforce.sh:156-159`, `DEFAULT_MODEL = \"sonnet\"`)", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c21", + "kind": "boundary", + "text": "the exported spec stays a point-in-time record: the #92 fix never mutates claims, churns ids, or makes specs editable \u2014 contested markers are a pure render-time derivation, honoring the maintainer ruling quoted in #92 that deviate is the marking of the change", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h17", + "text": "honest only if the #92 implementation adds no mutation path to frame state \u2014 the contested derivation is a pure function over existing frame, plan, and delivery state, and no claim id or text changes on re-export", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c22", + "kind": "boundary", + "text": "escaping is presentational only: frame and plan JSON keep raw unescaped text and `show --json` output is unchanged, per the #87 acceptance criteria", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h18", + "text": "honest only if frame and plan JSON on disk are byte-identical before and after an export, and show --json output is unchanged by the escaping change", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c23", + "kind": "non_goal", + "text": "origin skills are never re-vendored back from guildmaster: every skill change lands in this repo and updates the `docs/skills.md` narrative plus the `docs/skill-sources.md` ledger row (`docs/skill-sources.md:38-66`)", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c24", + "kind": "decision", + "text": "issue 92 ships on both surfaces: contested markers in re-exported specs plus a read-only contested-by-dN line in `devague show`/`status` (resolves q1)", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c25", + "kind": "decision", + "text": "issue 82 owner/model assignment is artifact-only: recorded in the durable split artifact via an annotation block the assign-to-workforce skill reads and writes \u2014 no plan schema bump, no `plan assign` verb (resolves q2)", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c26", + "kind": "decision", + "text": "the sweep ships as one combined release: single branch, single PR closing all fifteen issues, one version bump (resolves q3)", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c27", + "kind": "audience", + "text": "operator agents driving devague across AgentCulture repos \u2014 the field reporters behind these issues (headspace-cli, reachy-mini-cli, shell-cli, arm101-cli, agentfront) \u2014 plus the humans owning the spec, split-plan, and final-PR gates who read the exported artifacts", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h19", + "text": "honest only if every fix maps back to at least one named field report and each reporter repro in the issue bodies is covered by a regression test \u2014 no fix ships that answers nobody", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c28", + "kind": "after_state", + "text": "all fifteen open issues (#48, #49, #52, #79, #82-#88, #90-#93) close on one release: exported artifacts are lossless, lint-clean, and honest about rejection and deviation; plans cover live targets, defer deliberately, and validate deps at creation; hard questions resolve and claims amend without id churn; the summary is confirmed-only; gate 2 leaves a durable artifact; scope fans out to smaller-tier subagents", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h20", + "text": "honest only if the release closes all fifteen without regressing the existing gates: the full suite, coverage, and the markdownlint integration tests pass on the same commit that closes them", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c29", + "kind": "before_state", + "text": "fifteen open issues document real field failures on 0.20.x: two permanent convergence deadlocks (blocking hard questions unresolvable, cover refusing what converge demands \u2014 both worked around by hand-editing state JSON), specs that silently drop parks and leak rejected content, exports that fail downstream CI lint, an 87-row delivery table for 19 real tasks, and a five-move id-churning path to fix one number in a claim", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h21", + "text": "honest only if each named failure reproduces on 0.20.1 before the fix \u2014 the two deadlocks, the park drop, the rejected-content leak, the MD037/MD050 failures, and the 87-row summary all have failing-first tests or recorded repros", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c30", + "kind": "success_signal", + "text": "all 15 issues closed by the release with a regression test each; exported spec-md, plan-md, and split artifacts produce 0 markdownlint-cli2 errors under the default config; the full pytest suite stays green with coverage >= 95%; the 3 downstream workarounds named in the issues (render_plan.py projection, docs/specs lint ignores, hand-edited frame JSON) become deletable", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h22", + "text": "honest only if the signals are checked mechanically: issue closure count, markdownlint exit code, pytest exit code and coverage threshold, and the downstream-workaround deletability confirmed on the issues by the reporting repos", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c31", + "kind": "requirement", + "text": "every new or changed CLI surface in the sweep \u2014 hard-question resolve, claim/scope/risk amend, `plan defer`, multi-id `plan confirm`/`reject`, live-target cover \u2014 is documented in the `learn` and `explain` recipes in the same release; issue #52 makes this an explicit acceptance criterion for the resolve move", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h24", + "text": "honest only if devague learn and devague explain output name every new verb/flag shipped in the sweep, checked by tests that grep the recipe text for each surface", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c32", + "kind": "requirement", + "text": "the render-time escaper never alters text inside existing code spans: claim text already mixes prose and backticked tokens (this frame is itself the counter-evidence corpus), so identifier wrapping must skip spans that are already code and stay stable across repeated exports", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h25", + "text": "honest only if exporting this very frame twice in a row is byte-stable and lints clean \u2014 the frame whose claims mix backticked and bare identifiers is the regression corpus", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c33", + "kind": "requirement", + "text": "export flags scope entries whose `--seeds` cite a rejected claim instead of rendering a dead reference \u2014 the fourth #84 acceptance criterion, which claim c7 (amend) does not cover on its own", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h26", + "text": "honest only if an exported spec whose scope entry seeds a rejected claim renders a visible rejected marker (or resolves the reference) instead of a bare dead id", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c34", + "kind": "requirement", + "text": "the contested-by-deviation derivation fails open: a missing, corrupt, or newer-schema delivery store never breaks `export`, `show`, or `status` \u2014 the artifact renders without markers and a diagnostic goes to stderr", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h27", + "text": "honest only if export, show, and status succeed on a frame whose delivery store is missing, truncated, or declares a newer schema \u2014 covered by tests for all three corruption shapes", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c35", + "kind": "assumption", + "text": "decision c25 (no plan schema change) is scoped to the #82 owner/model recording only \u2014 the #85 defer move requires persisting deferred-target state, which means a PLAN_SCHEMA_VERSION bump, and `plan_store.load` (line 54 vs 62) has the same late-version-gate hazard c18 records for frames, so the load-order fix must cover both stores", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c36", + "kind": "decision", + "text": "the #48/#52 resolve move ships as `devague interrogate --resolve ` with an optional `--decision` recording how it was answered \u2014 the claim id disambiguates the shared qN namespace (resolves q4)", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + } + ], + "open_vagueness": [ + { + "id": "v1", + "text": "issue 86 reports plan converge missed a self-cycle on 0.20.0 but the code shows evaluate includes `_missing_dep_integrity` \u2014 unverified which is true for the installed 0.20.0; creation-time validation fixes it either way", + "kind": "unknown_nonblocking", + "claim_id": null, + "resolved": false, + "resolution": "", + "resolution_claim_id": null + }, + { + "id": "v2", + "text": "downstream repos carry stale vendored copies and workarounds to retire once fixes land: reachy-mini-cli split placeholder table and render_plan.py projection (issues 82/85), shell-cli markdownlint ignores for docs/specs (issue 87); notify via guildmaster re-broadcast", + "kind": "follow_up", + "claim_id": null, + "resolved": false, + "resolution": "", + "resolution_claim_id": null + }, + { + "id": "v3", + "text": "whether issue 85 secondary ask \u2014 warn when a task covers many targets with few acceptance criteria \u2014 ships in this sweep or as a later plan-convergence warning heuristic", + "kind": "unknown_nonblocking", + "claim_id": null, + "resolved": false, + "resolution": "", + "resolution_claim_id": null + }, + { + "id": "v4", + "text": "behavior of live-target cover when the source frame has regressed below convergence: `_live` refuses in that state \u2014 whether cover falls back to the stored snapshot or refuses with the reconverge hint is an implementation-time decision", + "kind": "unknown_nonblocking", + "claim_id": null, + "resolved": false, + "resolution": "", + "resolution_claim_id": null + }, + { + "id": "v5", + "text": "re-exporting any historical committed frame after the escaping change rewrites its dated spec file with code-span wrapping \u2014 expected and presentational-only, but the first re-export after upgrade produces a large diff reviewers should anticipate", + "kind": "unknown_nonblocking", + "claim_id": null, + "resolved": false, + "resolution": "", + "resolution_claim_id": null + } + ], + "scope_entries": [ + { + "id": "s1", + "surface": "devague/render/spec_md.py", + "finding": "park-kind filter `_follow_up` (lines 110-118) renders only `follow_up`/`out_of_scope`; hard questions (104-107) render with no resolved marker and no parent-status filter; all verbatim text passes only through `autolink_urls`, never an escaper", + "seeds": [ + "c2", + "c3", + "c6" + ] + }, + { + "id": "s2", + "surface": "devague/convergence.py", + "finding": "blocking-question gate (111-116) and assumption warnings (120-126) iterate claims status-agnostically, so rejected claims still block and warn; `suggest_move` (206-211) advertises a resolve path no CLI move implements", + "seeds": [ + "c4", + "c5" + ] + }, + { + "id": "s3", + "surface": "devague/frame.py", + "finding": "`set_status` (264-273) routes only c*/h*; `HardQuestion.resolved` exists but nothing sets it; `add_scope_entry` (252-254) validates seeds via `find_claim` only; no amend machinery exists for claim text or scope entries; `HardQuestion(**q)`/`Vagueness(**v)` unpacking (315, 322) rejects unknown keys", + "seeds": [ + "c4", + "c7", + "c8", + "c18" + ] + }, + { + "id": "s4", + "surface": "devague/store.py", + "finding": "the schema-version gate (143-147) runs after `from_dict` (136), so a newer-schema frame with new nested fields dies with a raw TypeError before reaching the fail-closed IncompatibleSchemaError", + "seeds": [ + "c18" + ] + }, + { + "id": "s5", + "surface": "devague/cli/_commands/plan.py", + "finding": "`_require_target` (145-151) validates cover/--covers against the stored snapshot; `_live` (108-118) re-derives from the live frame for converge/export/status; converge and export persist the refreshed snapshot (496-502, 527-543) but status does not; `plan confirm`/`reject` are single-id (827-835); `plan task --dep` is never validated (220-232)", + "seeds": [ + "c9", + "c11", + "c12" + ] + }, + { + "id": "s6", + "surface": "devague/plan_convergence.py", + "finding": "`_missing_coverage` (26-32) iterates every target unconditionally \u2014 no deferral set exists and `out_of_scope` risks have zero gate effect; `_missing_dep_integrity` (100-121) is shared by converge and waves, so both catch cycles but only after creation", + "seeds": [ + "c10", + "c11" + ] + }, + { + "id": "s7", + "surface": "live repro of issue 90 against the 0.20.1 tree", + "finding": "scratchpad run: frame converges, plan seeded, frame grows c7/h7, `plan status` recommends `devague plan cover t1 --target c7`, that exact move refuses with unknown coverage target, and after a persisting `plan converge` the same cover succeeds \u2014 the escape hatch exists but is accidental and the recommended move is the refusing path", + "seeds": [ + "c9" + ] + }, + { + "id": "s8", + "surface": "devague/render/plan_md.py and devague/render/summary_md.py", + "finding": "no markdown escaping beyond `autolink_urls`/`heading_safe`; plan-md has no Deferred targets section (sections: title, announcement, Tasks, Risks at 78-102); summary Planned Work (119-129) and Actual Delivery (132-143) iterate every task with no status filter while `dependency_waves` (plan.py:285) already excludes rejected tasks", + "seeds": [ + "c6", + "c10", + "c13" + ] + }, + { + "id": "s9", + "surface": "devague/delivery.py, devague/delivery_store.py, devague/cli/_commands/deviate.py", + "finding": "DeviationRecord carries `affects` validated against plan task ids, target ids, and all frame claim/honesty ids (deviate.py:78-89); statuses are proposed/approved/rejected; the store is keyed by plan slug only \u2014 no frame slug on Delivery and no reverse index on Frame", + "seeds": [ + "c14", + "c19" + ] + }, + { + "id": "s10", + "surface": "devague/cli/_commands/export.py, show.py, status.py", + "finding": "zero references to the delivery store in any of the five frame-side view/export modules (grep confirmed) \u2014 no path exists today to mark a confirmed claim contested by an approved deviation", + "seeds": [ + "c14" + ] + }, + { + "id": "s11", + "surface": ".claude/skills/assign-to-workforce/SKILL.md and scripts/assign-to-workforce.sh", + "finding": "the split-plan table renders real task content since 0.16.0 (script line 202 reads summaries from the `plan waves --json` payload); the Model column is a presentation-only hardcoded sonnet default (156-159); no --write flag and no *-split.md artifact exists anywhere in the repo (subcommands: split-plan, waves, help at 262-283)", + "seeds": [ + "c15", + "c17", + "c20" + ] + }, + { + "id": "s12", + "surface": ".claude/skills/scope/SKILL.md", + "finding": "steps 1-2 (lines 43-50) describe serial first-person exploration; no subagent, fan-out, or model-tier language anywhere in the skill, and no precedent in think or spec-to-plan \u2014 the only fan-out pattern in the kit is assign-to-workforce worktree orchestration, which is the wrong shape for read-only exploration", + "seeds": [ + "c16" + ] + }, + { + "id": "s13", + "surface": "devague/cli/_commands/learn.py", + "finding": "`SCOPE_STAGE` (153-171) and `ASSIGN_TO_WORKFORCE_GUIDANCE` (65-112) assert method text that goes stale if the skills change; skill bodies are linked by raw GitHub URL, not embedded, so only the capsule dicts need sweeping", + "seeds": [ + "c15", + "c16" + ] + }, + { + "id": "s14", + "surface": "docs/skills.md and docs/skill-sources.md", + "finding": "origin-skill ledger rows carry per-skill version stamps that must be updated on any skill change (skill-sources.md:38-66); docs/skills.md:255-256 already drifts from SKILL.md:138-140 on where task briefs quote from", + "seeds": [ + "c23" + ] + }, + { + "id": "s15", + "surface": "tests/", + "finding": "pinned tests that must flip: `test_render.py:301-306` (nonblocking park stays unlisted) and `test_cli_plan.py:87-96` (unknown dep accepted); `test_export_markdownlint_integration.py` runs real markdownlint-cli2 as the escaping guard; `test_summary.py:227` is the rejected-X-omitted template; `test_e2e_resolve.py` is the resolve-lifecycle template for hard questions", + "seeds": [ + "c2", + "c6", + "c11", + "c13" + ] + }, + { + "id": "s16", + "surface": "challenge pass / adjacent-systems lens: learn.py + explain recipes", + "finding": "four new CLI surfaces land in this sweep and none of the spec claims covered documenting them; #52 explicitly requires learn/explain updates for the resolve move", + "seeds": [ + "c31" + ] + }, + { + "id": "s17", + "surface": "challenge pass / counter-evidence lens: render/_md_safety.py + this frame as corpus", + "finding": "this frame mixes backticked and bare identifiers in the same claim texts \u2014 an escaper that wraps inside existing code spans would corrupt its own spec on the next export; idempotence across repeated exports is required", + "seeds": [ + "c32" + ] + }, + { + "id": "s18", + "surface": "challenge pass / lifecycle lens: spec_md scope section + issue 84 acceptance criteria", + "finding": "the amend claim c7 covers three amend surfaces but not the dead-seed-reference acceptance criterion; reject-then-recapture (which this session itself performed on h8) leaves scope seeds citing rejected ids", + "seeds": [ + "c33" + ] + }, + { + "id": "s19", + "surface": "challenge pass / containment lens: delivery-store reads from export/show/status", + "finding": "the contested-marker join adds the first delivery-store read on the frame side; a corrupt or newer-schema delivery file must degrade to markerless rendering, never a crashed export", + "seeds": [ + "c34" + ] + }, + { + "id": "s20", + "surface": "challenge pass / migration lens: plan.py PLAN_SCHEMA_VERSION + plan_store.py load order", + "finding": "probe confirmed plan_store.load parses via from_dict (line 54) before the version gate (line 62) \u2014 the same hazard c18 records for frames; defer state forces a plan schema bump, so the fix must cover both stores", + "seeds": [ + "c35", + "c18" + ] + }, + { + "id": "s21", + "surface": "challenge pass / actors lens: qN id namespace across HardQuestion and the questions file", + "finding": "probe confirmed both hard questions (frame.py:201) and durable questions assign qN ids independently \u2014 recorded as pending decision q4 (seeds not linkable: --seeds refuses q ids until c8 lands, which is itself finding evidence for c8)", + "seeds": [] + }, + { + "id": "s22", + "surface": "challenge pass / concurrency lens: worktree-copied .devague state during fan-out", + "finding": "clean pass: each worktree owns a full checkout copy of .devague, conflicts surface at reconcile per the documented worktree-contention convention; residual risk only if two agents ever share one checkout \u2014 no new claim seeded", + "seeds": [] + }, + { + "id": "s23", + "surface": "challenge pass / reversibility lens: one-combined-release decision c26", + "finding": "clean pass: a CLI release reverts by version rollback/yank; the schema bumps are fail-closed downgrade-safe once the load-order fix lands (c18/c35); no new claim seeded", + "seeds": [] + } + ] +} diff --git a/docs/specs/2026-07-28-issue-backlog-sweep.md b/docs/specs/2026-07-28-issue-backlog-sweep.md new file mode 100644 index 0000000..1d0c7e0 --- /dev/null +++ b/docs/specs/2026-07-28-issue-backlog-sweep.md @@ -0,0 +1,141 @@ +# issue-backlog-sweep + +> devague closes its fifteen-issue backlog: exports become lossless and lint-clean, rejected and contested content stops leaking into artifacts, plans gain live coverage targets with per-target deferral, claims and hard questions gain amend and resolve moves, the delivery summary scopes to confirmed work, gate 2 gains a durable split artifact, and scope exploration fans out to smaller-tier subagents + +## Audience + +- operator agents driving devague across AgentCulture repos — the field reporters behind these issues (headspace-cli, reachy-mini-cli, shell-cli, arm101-cli, agentfront) — plus the humans owning the spec, split-plan, and final-PR gates who read the exported artifacts + +## Before → After + +- Before: fifteen open issues document real field failures on 0.20.x: two permanent convergence deadlocks (blocking hard questions unresolvable, cover refusing what converge demands — both worked around by hand-editing state JSON), specs that silently drop parks and leak rejected content, exports that fail downstream CI lint, an 87-row delivery table for 19 real tasks, and a five-move id-churning path to fix one number in a claim +- After: all fifteen open issues (#48, #49, #52, #79, #82-#88, #90-#93) close on one release: exported artifacts are lossless, lint-clean, and honest about rejection and deviation; plans cover live targets, defer deliberately, and validate deps at creation; hard questions resolve and claims amend without id churn; the summary is confirmed-only; gate 2 leaves a durable artifact; scope fans out to smaller-tier subagents + +## Requirements + +- export renders every parked kind: `render/spec_md.py:110-118` `_follow_up` filters to `follow_up`/`out_of_scope` only, so an unresolved `unknown_nonblocking` park renders nowhere in the spec; all open parks must render grouped by kind, and the pinning test `tests/test_render.py:301-306` flips from asserting absence to asserting presence (#93, #49) + - honesty: honest only if a converged frame carrying open parks of all four kinds exports a spec where every open park appears, labeled by kind — verified by flipping tests/test_render.py:301-306 from asserting absence to asserting presence +- hard questions render honestly: `render/spec_md.py:104-107` shows no resolved marker and iterates all claims regardless of status — resolved questions render as resolved (or are omitted) and questions on rejected claims never render (#49, #83) + - honesty: honest only if a resolved hard question renders with a resolved marker (or is omitted by documented choice) and a hard question on a rejected claim never reaches the export — regression covering the #83 repro shape: capture, interrogate --risk, reject, converge, export +- a user move resolves claim-attached hard questions: nothing in the codebase sets `HardQuestion.resolved` (only vagueness `frame.py:243` and plan-risk `plan.py:187` resolves exist), `set_status` (`frame.py:264-273`) routes only c*/h* ids, so the gate at `convergence.py:111-116` blocks forever; `suggest_move` (`convergence.py:206-211`) must name the real move (#48, #52) + - honesty: honest only if the block-resolve-converge sequence completes through CLI moves alone (no state-JSON hand-edit), suggest_move names the shipped move verbatim, and resolved state survives a save/load round-trip +- reject cascades over attachments: rejecting a claim cascades to (or refuses over) its honesty conditions and hard questions, reporting what it took; `_assumption_warnings` (`convergence.py:120-126`) and the blocking-question gate skip rejected claims; `devague review` stops listing orphaned conditions (#83) + - honesty: honest only if rejecting a claim with attachments reports exactly what it cascaded over (or refuses with an actionable hint), post-reject converge emits zero warnings about the rejected claim, and review lists zero orphaned conditions +- markdown escaping lands at the single verbatim seam: `render/_md_safety.py` (today only `autolink_urls`/`heading_safe`) gains identifier-aware escaping — code-span wrapping for underscore and dunder tokens per the #87 comment, fixing MD037 and MD050 — applied at every verbatim site in `spec_md.py`, `plan_md.py`, and `summary_md.py` (#87) + - honesty: honest only if a frame whose text contains `_read_file`, `__init__.py`, `*`, `[`, a backtick, and a leading `#` exports spec-md and plan-md passing markdownlint-cli2 default config — the integration test extends tests/test_export_markdownlint_integration.py +- an amend move preserves identity: claims (`--text`/`--kind`, keeping id, honesty conditions, instruction, and inbound seeds), scope entries (`scope --amend sN --finding`), and plan risks (`plan risk --amend rN --text`); amending a confirmed item flips it to proposed with an echoed flip, matching the `interrogate.py:58-68` and plan `_FLIP_SUFFIX` precedent (#84) + - honesty: honest only if amending a confirmed claim keeps its id, honesty conditions, instruction, and inbound scope seeds, flips it to proposed with an echoed flip, and correcting one number costs exactly one move — with the same holding for scope --amend and plan risk --amend +- `scope --seeds` accepts question ids: `Frame.add_scope_entry` (`frame.py:252-254`) validates seeds via `find_claim` only, so `q*` is refused today — yet the /scope routing table sends needs-a-user-decision findings to `question`, so that branch must be seedable (#84) + - honesty: honest only if scope --seeds accepts a valid `q*` id, still refuses an unknown `q*` id with the show hint, and the seeded question renders in the exported scope-exploration section +- `plan cover` and `plan task --covers` validate against live-derived targets: `_require_target` (`cli/_commands/plan.py:145-151`) reads the stored snapshot that only `converge`/`export` refresh (`plan.py:496,530`) while `plan status` re-derives without persisting — verified by live repro: `plan status` recommends `plan cover t1 --target c7` and that exact move refuses with unknown coverage target; after `plan converge` it succeeds (#90) + - honesty: honest only if the verified repro inverts: after the frame grows a confirmed claim, plan status recommends cover for it AND that exact cover succeeds immediately, with no intervening plan converge required +- a per-target deferral move (for example `plan defer --reason`): deferred targets stop blocking `_missing_coverage` (`plan_convergence.py:26-32`), render as a Deferred targets section in plan-md (no such section exists, `render/plan_md.py:78-102`), and `plan status` distinguishes deliberately-deferred from not-yet-covered; today an `out_of_scope` risk has zero gate effect (#85) + - honesty: honest only if a plan with deferred targets converges and exports, the export names every deferred target with its reason in a Deferred targets section, and plan status reports deliberately-deferred distinctly from not-yet-covered — exercising the shell-cli shape of 90 covered plus 12 deferred +- `plan task --dep` validates at creation: `add_dep` (`plan.py:134-136`) is a bare append, so self-dependencies and dangling deps are accepted silently (pinned by `tests/test_cli_plan.py:87-96`, which flips); refuse self-reference and unknown ids with actionable hints (#86) + - honesty: honest only if plan task --dep naming the about-to-be-assigned id or an unknown id refuses at creation with an actionable hint, and tests/test_cli_plan.py:87-96 is flipped rather than deleted +- `plan confirm` and `plan reject` go multi-id transactional: today single-id (`cli/_commands/plan.py:827-835`) versus the frame-side batched `nargs` surface (`confirm.py:90-97`, `reject.py:10-15`) (#86) + - honesty: honest only if plan confirm and plan reject with N ids apply transactionally — all valid or none applied — matching the frame-side contract in behavior and error text +- `devague summary` scopes to confirmed tasks: `_planned_work_lines`, `_actual_delivery_lines`, and `summary_data` (`render/summary_md.py:119-143, 265-271`) iterate every task today; rejected tasks leave Planned Work and the Actual Delivery table, replaced by a one-line rejected-count note; `dependency_waves` already excludes rejected tasks (`plan.py:285`) so the --pr wave map is safe (#88) + - honesty: honest only if a plan with N confirmed and M rejected tasks emits exactly N Actual Delivery rows and N Planned Work entries plus a single line counting the M rejected, and the --pr wave map stays rejected-free +- approved deviations surface against contested claims: an approved deviation carries `--affects` claim ids (`deviate.py:78-89`) but no export/show/status code touches the delivery store today (zero grep hits); derive the back-reference read-only via enumerate `plan_store.list_slugs()`, filter `plan.frame_slug`, load the delivery per plan slug (#92) + - honesty: honest only if an approved deviation whose --affects names a confirmed claim yields a contested marker under that claim on re-export AND a contested line in show/status, all derived read-only across the plan-slug join with zero frame-state mutation +- split-plan writes a durable gate-2 artifact: `docs/plans/--split.md` beside the exported plan-md, overwrite-in-place on re-run; today the script has no write step at all (`assign-to-workforce.sh:262-283` — subcommands split-plan, waves, help only) (#82) + - honesty: honest only if the written split artifact carries real per-task summaries, acceptance criteria, instructions, the owner/model annotation block, and the End state section; re-running overwrites the same dated path; the file lints clean +- the /scope skill fans exploration out to smaller-tier read-only subagents (sonnet default): method-only change to SKILL.md steps 1-2 (`scope/SKILL.md:43-50`, today serial first-person), sweeping `learn.py` `SCOPE_STAGE` (`learn.py:153-171`), `docs/skills.md:156-176`, and the `docs/skill-sources.md` version-stamp row (#79, #91) + - honesty: honest only if scope SKILL.md, learn.py SCOPE_STAGE, docs/skills.md, and the skill-sources ledger all describe the same subagent fan-out with the same default tier — a doc-alignment check, not four diverging texts +- every new or changed CLI surface in the sweep — hard-question resolve, claim/scope/risk amend, `plan defer`, multi-id `plan confirm`/`reject`, live-target cover — is documented in the `learn` and `explain` recipes in the same release; issue #52 makes this an explicit acceptance criterion for the resolve move + - honesty: honest only if devague learn and devague explain output name every new verb/flag shipped in the sweep, checked by tests that grep the recipe text for each surface +- the render-time escaper never alters text inside existing code spans: claim text already mixes prose and backticked tokens (this frame is itself the counter-evidence corpus), so identifier wrapping must skip spans that are already code and stay stable across repeated exports + - honesty: honest only if exporting this very frame twice in a row is byte-stable and lints clean — the frame whose claims mix backticked and bare identifiers is the regression corpus +- export flags scope entries whose `--seeds` cite a rejected claim instead of rendering a dead reference — the fourth #84 acceptance criterion, which claim c7 (amend) does not cover on its own + - honesty: honest only if an exported spec whose scope entry seeds a rejected claim renders a visible rejected marker (or resolves the reference) instead of a bare dead id +- the contested-by-deviation derivation fails open: a missing, corrupt, or newer-schema delivery store never breaks `export`, `show`, or `status` — the artifact renders without markers and a diagnostic goes to stderr + - honesty: honest only if export, show, and status succeed on a frame whose delivery store is missing, truncated, or declares a newer schema — covered by tests for all three corruption shapes + +## Honesty conditions + +- honest only if one release genuinely closes every one of the fifteen listed issues — each closed with a pointer to the shipping test/section, none closed as wontfix-by-stealth or half-delivered +- honest only if the #92 implementation adds no mutation path to frame state — the contested derivation is a pure function over existing frame, plan, and delivery state, and no claim id or text changes on re-export +- honest only if frame and plan JSON on disk are byte-identical before and after an export, and show --json output is unchanged by the escaping change +- honest only if every fix maps back to at least one named field report and each reporter repro in the issue bodies is covered by a regression test — no fix ships that answers nobody +- honest only if the release closes all fifteen without regressing the existing gates: the full suite, coverage, and the markdownlint integration tests pass on the same commit that closes them +- honest only if each named failure reproduces on 0.20.1 before the fix — the two deadlocks, the park drop, the rejected-content leak, the MD037/MD050 failures, and the 87-row summary all have failing-first tests or recorded repros +- honest only if the signals are checked mechanically: issue closure count, markdownlint exit code, pytest exit code and coverage threshold, and the downstream-workaround deletability confirmed on the issues by the reporting repos + +## Success signals + +- all 15 issues closed by the release with a regression test each; exported spec-md, plan-md, and split artifacts produce 0 markdownlint-cli2 errors under the default config; the full pytest suite stays green with coverage >= 95%; the 3 downstream workarounds named in the issues (render_plan.py projection, docs/specs lint ignores, hand-edited frame JSON) become deletable + +## Scope / boundaries + +- the exported spec stays a point-in-time record: the #92 fix never mutates claims, churns ids, or makes specs editable — contested markers are a pure render-time derivation, honoring the maintainer ruling quoted in #92 that deviate is the marking of the change +- escaping is presentational only: frame and plan JSON keep raw unescaped text and `show --json` output is unchanged, per the #87 acceptance criteria + +## Non-goals + +- the CLI stays deterministic and non-orchestrating (issue #20): scope subagent fan-out lives in skill method text, never as a CLI verb; any per-task owner/model assignment for #82 is recorded as inert data — the Model column today is presentation-only (`assign-to-workforce.sh:156-159`, `DEFAULT_MODEL = "sonnet"`) +- origin skills are never re-vendored back from guildmaster: every skill change lands in this repo and updates the `docs/skills.md` narrative plus the `docs/skill-sources.md` ledger row (`docs/skill-sources.md:38-66`) + +## Assumptions + +- issue #82 first ask is already shipped and its symptom was a stale vendored copy: since 0.16.0 the script renders real task content from `plan waves --json` (`assign-to-workforce.sh:202`; the JSON carries summary, instruction, acceptance criteria, and covers per `cli/_commands/plan.py:569-584`), so the live gaps are only the durable artifact and owner/model recording +- schema bumps are needed and currently hazardous: `HardQuestion(**q)` and `Vagueness(**v)` raw unpacking (`frame.py:315,322`) plus the version gate running after `from_dict` (`store.py:136-147`) means any new persisted field crashes older binaries with a raw TypeError instead of the fail-closed IncompatibleSchemaError; resolve and amend fields need a frame schema bump with the load order fixed +- the deviation-to-claim join stays derived, not stored: `Delivery` has no frame slug (`delivery.py:57` keys by plan slug only) and Frame has no reverse plan index, so #92 rendering derives the contested set at render time instead of adding reverse-pointer state +- decision c25 (no plan schema change) is scoped to the #82 owner/model recording only — the #85 defer move requires persisting deferred-target state, which means a PLAN_SCHEMA_VERSION bump, and `plan_store.load` (line 54 vs 62) has the same late-version-gate hazard c18 records for frames, so the load-order fix must cover both stores + +## Scope exploration + +- `s1` — `devague/render/spec_md.py`: park-kind filter `_follow_up` (lines 110-118) renders only `follow_up`/`out_of_scope`; hard questions (104-107) render with no resolved marker and no parent-status filter; all verbatim text passes only through `autolink_urls`, never an escaper + - seeds: `c2`, `c3`, `c6` +- `s2` — `devague/convergence.py`: blocking-question gate (111-116) and assumption warnings (120-126) iterate claims status-agnostically, so rejected claims still block and warn; `suggest_move` (206-211) advertises a resolve path no CLI move implements + - seeds: `c4`, `c5` +- `s3` — `devague/frame.py`: `set_status` (264-273) routes only c*/h*; `HardQuestion.resolved` exists but nothing sets it; `add_scope_entry` (252-254) validates seeds via `find_claim` only; no amend machinery exists for claim text or scope entries; `HardQuestion(**q)`/`Vagueness(**v)` unpacking (315, 322) rejects unknown keys + - seeds: `c4`, `c7`, `c8`, `c18` +- `s4` — `devague/store.py`: the schema-version gate (143-147) runs after `from_dict` (136), so a newer-schema frame with new nested fields dies with a raw TypeError before reaching the fail-closed IncompatibleSchemaError + - seeds: `c18` +- `s5` — `devague/cli/_commands/plan.py`: `_require_target` (145-151) validates cover/--covers against the stored snapshot; `_live` (108-118) re-derives from the live frame for converge/export/status; converge and export persist the refreshed snapshot (496-502, 527-543) but status does not; `plan confirm`/`reject` are single-id (827-835); `plan task --dep` is never validated (220-232) + - seeds: `c9`, `c11`, `c12` +- `s6` — `devague/plan_convergence.py`: `_missing_coverage` (26-32) iterates every target unconditionally — no deferral set exists and `out_of_scope` risks have zero gate effect; `_missing_dep_integrity` (100-121) is shared by converge and waves, so both catch cycles but only after creation + - seeds: `c10`, `c11` +- `s7` — `live repro of issue 90 against the 0.20.1 tree`: scratchpad run: frame converges, plan seeded, frame grows c7/h7, `plan status` recommends `devague plan cover t1 --target c7`, that exact move refuses with unknown coverage target, and after a persisting `plan converge` the same cover succeeds — the escape hatch exists but is accidental and the recommended move is the refusing path + - seeds: `c9` +- `s8` — `devague/render/plan_md.py and devague/render/summary_md.py`: no markdown escaping beyond `autolink_urls`/`heading_safe`; plan-md has no Deferred targets section (sections: title, announcement, Tasks, Risks at 78-102); summary Planned Work (119-129) and Actual Delivery (132-143) iterate every task with no status filter while `dependency_waves` (plan.py:285) already excludes rejected tasks + - seeds: `c6`, `c10`, `c13` +- `s9` — `devague/delivery.py, devague/delivery_store.py, devague/cli/_commands/deviate.py`: DeviationRecord carries `affects` validated against plan task ids, target ids, and all frame claim/honesty ids (deviate.py:78-89); statuses are proposed/approved/rejected; the store is keyed by plan slug only — no frame slug on Delivery and no reverse index on Frame + - seeds: `c14`, `c19` +- `s10` — `devague/cli/_commands/export.py, show.py, status.py`: zero references to the delivery store in any of the five frame-side view/export modules (grep confirmed) — no path exists today to mark a confirmed claim contested by an approved deviation + - seeds: `c14` +- `s11` — `.claude/skills/assign-to-workforce/SKILL.md and scripts/assign-to-workforce.sh`: the split-plan table renders real task content since 0.16.0 (script line 202 reads summaries from the `plan waves --json` payload); the Model column is a presentation-only hardcoded sonnet default (156-159); no --write flag and no *-split.md artifact exists anywhere in the repo (subcommands: split-plan, waves, help at 262-283) + - seeds: `c15`, `c17`, `c20` +- `s12` — `.claude/skills/scope/SKILL.md`: steps 1-2 (lines 43-50) describe serial first-person exploration; no subagent, fan-out, or model-tier language anywhere in the skill, and no precedent in think or spec-to-plan — the only fan-out pattern in the kit is assign-to-workforce worktree orchestration, which is the wrong shape for read-only exploration + - seeds: `c16` +- `s13` — `devague/cli/_commands/learn.py`: `SCOPE_STAGE` (153-171) and `ASSIGN_TO_WORKFORCE_GUIDANCE` (65-112) assert method text that goes stale if the skills change; skill bodies are linked by raw GitHub URL, not embedded, so only the capsule dicts need sweeping + - seeds: `c15`, `c16` +- `s14` — `docs/skills.md and docs/skill-sources.md`: origin-skill ledger rows carry per-skill version stamps that must be updated on any skill change (skill-sources.md:38-66); docs/skills.md:255-256 already drifts from SKILL.md:138-140 on where task briefs quote from + - seeds: `c23` +- `s15` — `tests/`: pinned tests that must flip: `test_render.py:301-306` (nonblocking park stays unlisted) and `test_cli_plan.py:87-96` (unknown dep accepted); `test_export_markdownlint_integration.py` runs real markdownlint-cli2 as the escaping guard; `test_summary.py:227` is the rejected-X-omitted template; `test_e2e_resolve.py` is the resolve-lifecycle template for hard questions + - seeds: `c2`, `c6`, `c11`, `c13` +- `s16` — `challenge pass / adjacent-systems lens: learn.py + explain recipes`: four new CLI surfaces land in this sweep and none of the spec claims covered documenting them; #52 explicitly requires learn/explain updates for the resolve move + - seeds: `c31` +- `s17` — `challenge pass / counter-evidence lens: render/_md_safety.py + this frame as corpus`: this frame mixes backticked and bare identifiers in the same claim texts — an escaper that wraps inside existing code spans would corrupt its own spec on the next export; idempotence across repeated exports is required + - seeds: `c32` +- `s18` — `challenge pass / lifecycle lens: spec_md scope section + issue 84 acceptance criteria`: the amend claim c7 covers three amend surfaces but not the dead-seed-reference acceptance criterion; reject-then-recapture (which this session itself performed on h8) leaves scope seeds citing rejected ids + - seeds: `c33` +- `s19` — `challenge pass / containment lens: delivery-store reads from export/show/status`: the contested-marker join adds the first delivery-store read on the frame side; a corrupt or newer-schema delivery file must degrade to markerless rendering, never a crashed export + - seeds: `c34` +- `s20` — `challenge pass / migration lens: plan.py PLAN_SCHEMA_VERSION + plan_store.py load order`: probe confirmed plan_store.load parses via from_dict (line 54) before the version gate (line 62) — the same hazard c18 records for frames; defer state forces a plan schema bump, so the fix must cover both stores + - seeds: `c35`, `c18` +- `s21` — `challenge pass / actors lens: qN id namespace across HardQuestion and the questions file`: probe confirmed both hard questions (frame.py:201) and durable questions assign qN ids independently — recorded as pending decision q4 (seeds not linkable: --seeds refuses q ids until c8 lands, which is itself finding evidence for c8) +- `s22` — `challenge pass / concurrency lens: worktree-copied .devague state during fan-out`: clean pass: each worktree owns a full checkout copy of .devague, conflicts surface at reconcile per the documented worktree-contention convention; residual risk only if two agents ever share one checkout — no new claim seeded +- `s23` — `challenge pass / reversibility lens: one-combined-release decision c26`: clean pass: a CLI release reverts by version rollback/yank; the schema bumps are fail-closed downgrade-safe once the load-order fix lands (c18/c35); no new claim seeded + +## Decisions + +- issue 92 ships on both surfaces: contested markers in re-exported specs plus a read-only contested-by-dN line in `devague show`/`status` (resolves q1) +- issue 82 owner/model assignment is artifact-only: recorded in the durable split artifact via an annotation block the assign-to-workforce skill reads and writes — no plan schema bump, no `plan assign` verb (resolves q2) +- the sweep ships as one combined release: single branch, single PR closing all fifteen issues, one version bump (resolves q3) +- the #48/#52 resolve move ships as `devague interrogate --resolve ` with an optional `--decision` recording how it was answered — the claim id disambiguates the shared qN namespace (resolves q4) + +## Open / follow-up + +- downstream repos carry stale vendored copies and workarounds to retire once fixes land: reachy-mini-cli split placeholder table and render_plan.py projection (issues 82/85), shell-cli markdownlint ignores for docs/specs (issue 87); notify via guildmaster re-broadcast From afbb0c408d5dd8189eacc3d33d07b922c62f47ee Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 10:07:13 +0300 Subject: [PATCH 02/25] =?UTF-8?q?plan:=20issue-backlog-sweep=20=E2=80=94?= =?UTF-8?q?=2019=20tasks=20/=206=20waves=20covering=2052=20targets=20(deva?= =?UTF-8?q?gue=20/spec-to-plan)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QtUixLUjgEt51PXS9TPVt3 --- .devague/current_plan | 2 +- .devague/plans/issue-backlog-sweep.json | 648 +++++++++++++++++++ docs/plans/2026-07-28-issue-backlog-sweep.md | 164 +++++ 3 files changed, 813 insertions(+), 1 deletion(-) create mode 100644 .devague/plans/issue-backlog-sweep.json create mode 100644 docs/plans/2026-07-28-issue-backlog-sweep.md diff --git a/.devague/current_plan b/.devague/current_plan index d167253..a3a11d1 100644 --- a/.devague/current_plan +++ b/.devague/current_plan @@ -1 +1 @@ -resolve-parked-vagueness +issue-backlog-sweep diff --git a/.devague/plans/issue-backlog-sweep.json b/.devague/plans/issue-backlog-sweep.json new file mode 100644 index 0000000..92f0504 --- /dev/null +++ b/.devague/plans/issue-backlog-sweep.json @@ -0,0 +1,648 @@ +{ + "slug": "issue-backlog-sweep", + "title": "issue-backlog-sweep", + "frame_slug": "issue-backlog-sweep", + "schema_version": 3, + "status": "exported", + "created": "2026-07-28T07:04:39Z", + "updated": "2026-07-28T07:06:55Z", + "targets": [ + { + "id": "c1", + "kind": "announcement", + "text": "devague closes its fifteen-issue backlog: exports become lossless and lint-clean, rejected and contested content stops leaking into artifacts, plans gain live coverage targets with per-target deferral, claims and hard questions gain amend and resolve moves, the delivery summary scopes to confirmed work, gate 2 gains a durable split artifact, and scope exploration fans out to smaller-tier subagents" + }, + { + "id": "h1", + "kind": "honesty", + "text": "honest only if one release genuinely closes every one of the fifteen listed issues \u2014 each closed with a pointer to the shipping test/section, none closed as wontfix-by-stealth or half-delivered" + }, + { + "id": "c2", + "kind": "requirement", + "text": "export renders every parked kind: `render/spec_md.py:110-118` `_follow_up` filters to `follow_up`/`out_of_scope` only, so an unresolved `unknown_nonblocking` park renders nowhere in the spec; all open parks must render grouped by kind, and the pinning test `tests/test_render.py:301-306` flips from asserting absence to asserting presence (#93, #49)" + }, + { + "id": "h2", + "kind": "honesty", + "text": "honest only if a converged frame carrying open parks of all four kinds exports a spec where every open park appears, labeled by kind \u2014 verified by flipping tests/test_render.py:301-306 from asserting absence to asserting presence" + }, + { + "id": "c3", + "kind": "requirement", + "text": "hard questions render honestly: `render/spec_md.py:104-107` shows no resolved marker and iterates all claims regardless of status \u2014 resolved questions render as resolved (or are omitted) and questions on rejected claims never render (#49, #83)" + }, + { + "id": "h3", + "kind": "honesty", + "text": "honest only if a resolved hard question renders with a resolved marker (or is omitted by documented choice) and a hard question on a rejected claim never reaches the export \u2014 regression covering the #83 repro shape: capture, interrogate --risk, reject, converge, export" + }, + { + "id": "c4", + "kind": "requirement", + "text": "a user move resolves claim-attached hard questions: nothing in the codebase sets `HardQuestion.resolved` (only vagueness `frame.py:243` and plan-risk `plan.py:187` resolves exist), `set_status` (`frame.py:264-273`) routes only c*/h* ids, so the gate at `convergence.py:111-116` blocks forever; `suggest_move` (`convergence.py:206-211`) must name the real move (#48, #52)" + }, + { + "id": "h4", + "kind": "honesty", + "text": "honest only if the block-resolve-converge sequence completes through CLI moves alone (no state-JSON hand-edit), suggest_move names the shipped move verbatim, and resolved state survives a save/load round-trip" + }, + { + "id": "c5", + "kind": "requirement", + "text": "reject cascades over attachments: rejecting a claim cascades to (or refuses over) its honesty conditions and hard questions, reporting what it took; `_assumption_warnings` (`convergence.py:120-126`) and the blocking-question gate skip rejected claims; `devague review` stops listing orphaned conditions (#83)" + }, + { + "id": "h5", + "kind": "honesty", + "text": "honest only if rejecting a claim with attachments reports exactly what it cascaded over (or refuses with an actionable hint), post-reject converge emits zero warnings about the rejected claim, and review lists zero orphaned conditions" + }, + { + "id": "c6", + "kind": "requirement", + "text": "markdown escaping lands at the single verbatim seam: `render/_md_safety.py` (today only `autolink_urls`/`heading_safe`) gains identifier-aware escaping \u2014 code-span wrapping for underscore and dunder tokens per the #87 comment, fixing MD037 and MD050 \u2014 applied at every verbatim site in `spec_md.py`, `plan_md.py`, and `summary_md.py` (#87)" + }, + { + "id": "h6", + "kind": "honesty", + "text": "honest only if a frame whose text contains `_read_file`, `__init__.py`, `*`, `[`, a backtick, and a leading `#` exports spec-md and plan-md passing markdownlint-cli2 default config \u2014 the integration test extends tests/test_export_markdownlint_integration.py" + }, + { + "id": "c7", + "kind": "requirement", + "text": "an amend move preserves identity: claims (`--text`/`--kind`, keeping id, honesty conditions, instruction, and inbound seeds), scope entries (`scope --amend sN --finding`), and plan risks (`plan risk --amend rN --text`); amending a confirmed item flips it to proposed with an echoed flip, matching the `interrogate.py:58-68` and plan `_FLIP_SUFFIX` precedent (#84)" + }, + { + "id": "h7", + "kind": "honesty", + "text": "honest only if amending a confirmed claim keeps its id, honesty conditions, instruction, and inbound scope seeds, flips it to proposed with an echoed flip, and correcting one number costs exactly one move \u2014 with the same holding for scope --amend and plan risk --amend" + }, + { + "id": "c8", + "kind": "requirement", + "text": "`scope --seeds` accepts question ids: `Frame.add_scope_entry` (`frame.py:252-254`) validates seeds via `find_claim` only, so `q*` is refused today \u2014 yet the /scope routing table sends needs-a-user-decision findings to `question`, so that branch must be seedable (#84)" + }, + { + "id": "h23", + "kind": "honesty", + "text": "honest only if scope --seeds accepts a valid `q*` id, still refuses an unknown `q*` id with the show hint, and the seeded question renders in the exported scope-exploration section" + }, + { + "id": "c9", + "kind": "requirement", + "text": "`plan cover` and `plan task --covers` validate against live-derived targets: `_require_target` (`cli/_commands/plan.py:145-151`) reads the stored snapshot that only `converge`/`export` refresh (`plan.py:496,530`) while `plan status` re-derives without persisting \u2014 verified by live repro: `plan status` recommends `plan cover t1 --target c7` and that exact move refuses with unknown coverage target; after `plan converge` it succeeds (#90)" + }, + { + "id": "h9", + "kind": "honesty", + "text": "honest only if the verified repro inverts: after the frame grows a confirmed claim, plan status recommends cover for it AND that exact cover succeeds immediately, with no intervening plan converge required" + }, + { + "id": "c10", + "kind": "requirement", + "text": "a per-target deferral move (for example `plan defer --reason`): deferred targets stop blocking `_missing_coverage` (`plan_convergence.py:26-32`), render as a Deferred targets section in plan-md (no such section exists, `render/plan_md.py:78-102`), and `plan status` distinguishes deliberately-deferred from not-yet-covered; today an `out_of_scope` risk has zero gate effect (#85)" + }, + { + "id": "h10", + "kind": "honesty", + "text": "honest only if a plan with deferred targets converges and exports, the export names every deferred target with its reason in a Deferred targets section, and plan status reports deliberately-deferred distinctly from not-yet-covered \u2014 exercising the shell-cli shape of 90 covered plus 12 deferred" + }, + { + "id": "c11", + "kind": "requirement", + "text": "`plan task --dep` validates at creation: `add_dep` (`plan.py:134-136`) is a bare append, so self-dependencies and dangling deps are accepted silently (pinned by `tests/test_cli_plan.py:87-96`, which flips); refuse self-reference and unknown ids with actionable hints (#86)" + }, + { + "id": "h11", + "kind": "honesty", + "text": "honest only if plan task --dep naming the about-to-be-assigned id or an unknown id refuses at creation with an actionable hint, and tests/test_cli_plan.py:87-96 is flipped rather than deleted" + }, + { + "id": "c12", + "kind": "requirement", + "text": "`plan confirm` and `plan reject` go multi-id transactional: today single-id (`cli/_commands/plan.py:827-835`) versus the frame-side batched `nargs` surface (`confirm.py:90-97`, `reject.py:10-15`) (#86)" + }, + { + "id": "h12", + "kind": "honesty", + "text": "honest only if plan confirm and plan reject with N ids apply transactionally \u2014 all valid or none applied \u2014 matching the frame-side contract in behavior and error text" + }, + { + "id": "c13", + "kind": "requirement", + "text": "`devague summary` scopes to confirmed tasks: `_planned_work_lines`, `_actual_delivery_lines`, and `summary_data` (`render/summary_md.py:119-143, 265-271`) iterate every task today; rejected tasks leave Planned Work and the Actual Delivery table, replaced by a one-line rejected-count note; `dependency_waves` already excludes rejected tasks (`plan.py:285`) so the --pr wave map is safe (#88)" + }, + { + "id": "h13", + "kind": "honesty", + "text": "honest only if a plan with N confirmed and M rejected tasks emits exactly N Actual Delivery rows and N Planned Work entries plus a single line counting the M rejected, and the --pr wave map stays rejected-free" + }, + { + "id": "c14", + "kind": "requirement", + "text": "approved deviations surface against contested claims: an approved deviation carries `--affects` claim ids (`deviate.py:78-89`) but no export/show/status code touches the delivery store today (zero grep hits); derive the back-reference read-only via enumerate `plan_store.list_slugs()`, filter `plan.frame_slug`, load the delivery per plan slug (#92)" + }, + { + "id": "h14", + "kind": "honesty", + "text": "honest only if an approved deviation whose --affects names a confirmed claim yields a contested marker under that claim on re-export AND a contested line in show/status, all derived read-only across the plan-slug join with zero frame-state mutation" + }, + { + "id": "c15", + "kind": "requirement", + "text": "split-plan writes a durable gate-2 artifact: `docs/plans/--split.md` beside the exported plan-md, overwrite-in-place on re-run; today the script has no write step at all (`assign-to-workforce.sh:262-283` \u2014 subcommands split-plan, waves, help only) (#82)" + }, + { + "id": "h15", + "kind": "honesty", + "text": "honest only if the written split artifact carries real per-task summaries, acceptance criteria, instructions, the owner/model annotation block, and the End state section; re-running overwrites the same dated path; the file lints clean" + }, + { + "id": "c16", + "kind": "requirement", + "text": "the /scope skill fans exploration out to smaller-tier read-only subagents (sonnet default): method-only change to SKILL.md steps 1-2 (`scope/SKILL.md:43-50`, today serial first-person), sweeping `learn.py` `SCOPE_STAGE` (`learn.py:153-171`), `docs/skills.md:156-176`, and the `docs/skill-sources.md` version-stamp row (#79, #91)" + }, + { + "id": "h16", + "kind": "honesty", + "text": "honest only if scope SKILL.md, learn.py SCOPE_STAGE, docs/skills.md, and the skill-sources ledger all describe the same subagent fan-out with the same default tier \u2014 a doc-alignment check, not four diverging texts" + }, + { + "id": "c21", + "kind": "boundary", + "text": "the exported spec stays a point-in-time record: the #92 fix never mutates claims, churns ids, or makes specs editable \u2014 contested markers are a pure render-time derivation, honoring the maintainer ruling quoted in #92 that deviate is the marking of the change" + }, + { + "id": "h17", + "kind": "honesty", + "text": "honest only if the #92 implementation adds no mutation path to frame state \u2014 the contested derivation is a pure function over existing frame, plan, and delivery state, and no claim id or text changes on re-export" + }, + { + "id": "c22", + "kind": "boundary", + "text": "escaping is presentational only: frame and plan JSON keep raw unescaped text and `show --json` output is unchanged, per the #87 acceptance criteria" + }, + { + "id": "h18", + "kind": "honesty", + "text": "honest only if frame and plan JSON on disk are byte-identical before and after an export, and show --json output is unchanged by the escaping change" + }, + { + "id": "c27", + "kind": "audience", + "text": "operator agents driving devague across AgentCulture repos \u2014 the field reporters behind these issues (headspace-cli, reachy-mini-cli, shell-cli, arm101-cli, agentfront) \u2014 plus the humans owning the spec, split-plan, and final-PR gates who read the exported artifacts" + }, + { + "id": "h19", + "kind": "honesty", + "text": "honest only if every fix maps back to at least one named field report and each reporter repro in the issue bodies is covered by a regression test \u2014 no fix ships that answers nobody" + }, + { + "id": "c28", + "kind": "after_state", + "text": "all fifteen open issues (#48, #49, #52, #79, #82-#88, #90-#93) close on one release: exported artifacts are lossless, lint-clean, and honest about rejection and deviation; plans cover live targets, defer deliberately, and validate deps at creation; hard questions resolve and claims amend without id churn; the summary is confirmed-only; gate 2 leaves a durable artifact; scope fans out to smaller-tier subagents" + }, + { + "id": "h20", + "kind": "honesty", + "text": "honest only if the release closes all fifteen without regressing the existing gates: the full suite, coverage, and the markdownlint integration tests pass on the same commit that closes them" + }, + { + "id": "c29", + "kind": "before_state", + "text": "fifteen open issues document real field failures on 0.20.x: two permanent convergence deadlocks (blocking hard questions unresolvable, cover refusing what converge demands \u2014 both worked around by hand-editing state JSON), specs that silently drop parks and leak rejected content, exports that fail downstream CI lint, an 87-row delivery table for 19 real tasks, and a five-move id-churning path to fix one number in a claim" + }, + { + "id": "h21", + "kind": "honesty", + "text": "honest only if each named failure reproduces on 0.20.1 before the fix \u2014 the two deadlocks, the park drop, the rejected-content leak, the MD037/MD050 failures, and the 87-row summary all have failing-first tests or recorded repros" + }, + { + "id": "c30", + "kind": "success_signal", + "text": "all 15 issues closed by the release with a regression test each; exported spec-md, plan-md, and split artifacts produce 0 markdownlint-cli2 errors under the default config; the full pytest suite stays green with coverage >= 95%; the 3 downstream workarounds named in the issues (render_plan.py projection, docs/specs lint ignores, hand-edited frame JSON) become deletable" + }, + { + "id": "h22", + "kind": "honesty", + "text": "honest only if the signals are checked mechanically: issue closure count, markdownlint exit code, pytest exit code and coverage threshold, and the downstream-workaround deletability confirmed on the issues by the reporting repos" + }, + { + "id": "c31", + "kind": "requirement", + "text": "every new or changed CLI surface in the sweep \u2014 hard-question resolve, claim/scope/risk amend, `plan defer`, multi-id `plan confirm`/`reject`, live-target cover \u2014 is documented in the `learn` and `explain` recipes in the same release; issue #52 makes this an explicit acceptance criterion for the resolve move" + }, + { + "id": "h24", + "kind": "honesty", + "text": "honest only if devague learn and devague explain output name every new verb/flag shipped in the sweep, checked by tests that grep the recipe text for each surface" + }, + { + "id": "c32", + "kind": "requirement", + "text": "the render-time escaper never alters text inside existing code spans: claim text already mixes prose and backticked tokens (this frame is itself the counter-evidence corpus), so identifier wrapping must skip spans that are already code and stay stable across repeated exports" + }, + { + "id": "h25", + "kind": "honesty", + "text": "honest only if exporting this very frame twice in a row is byte-stable and lints clean \u2014 the frame whose claims mix backticked and bare identifiers is the regression corpus" + }, + { + "id": "c33", + "kind": "requirement", + "text": "export flags scope entries whose `--seeds` cite a rejected claim instead of rendering a dead reference \u2014 the fourth #84 acceptance criterion, which claim c7 (amend) does not cover on its own" + }, + { + "id": "h26", + "kind": "honesty", + "text": "honest only if an exported spec whose scope entry seeds a rejected claim renders a visible rejected marker (or resolves the reference) instead of a bare dead id" + }, + { + "id": "c34", + "kind": "requirement", + "text": "the contested-by-deviation derivation fails open: a missing, corrupt, or newer-schema delivery store never breaks `export`, `show`, or `status` \u2014 the artifact renders without markers and a diagnostic goes to stderr" + }, + { + "id": "h27", + "kind": "honesty", + "text": "honest only if export, show, and status succeed on a frame whose delivery store is missing, truncated, or declares a newer schema \u2014 covered by tests for all three corruption shapes" + } + ], + "tasks": [ + { + "id": "t1", + "summary": "Escaping engine in render/_md_safety.py", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "unit tests in tests/test_md_safety.py cover `_read_file`, `__init__.py`, `*`, `[`, backtick, and leading `#` inputs, plus already-backticked text passing through unchanged and idempotence on double application" + ], + "deps": [], + "covers": [ + "c32", + "h25" + ], + "instruction": "Add a pure escape function in devague/render/_md_safety.py only \u2014 no renderer call sites here (they land in t3/t9/t13). Wrap underscore/dunder identifiers in code spans per the #87 comment preference; escape remaining markdown control characters; skip text already inside code spans; make double application a no-op." + }, + { + "id": "t2", + "summary": "Schema and load-order hardening in both stores", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "a frame or plan JSON declaring a newer schema_version fails with the fail-closed IncompatibleSchemaError message, never a raw TypeError, covered by tests for both stores; existing v3 frames and plans load unchanged" + ], + "deps": [], + "covers": [], + "instruction": "Fix store.py and plan_store.py to check schema_version BEFORE parsing via from_dict (today store.py:136-147 and plan_store.py:54-62 parse first); make HardQuestion/Vagueness loading tolerant of unknown keys like Claim already is (frame.py:315,322); bump frame SCHEMA_VERSION for the new hard-question resolution field and PLAN_SCHEMA_VERSION for defer state." + }, + { + "id": "t3", + "summary": "spec_md renderer sweep: parks, hard questions, dead seeds, escaping", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "a converged frame with open parks of all four kinds exports a spec listing each park labeled by kind", + "a resolved hard question renders with a resolved marker and a hard question on a rejected claim is absent from the export (the #83 repro shape: capture, interrogate --risk, reject, converge, export)", + "a scope entry seeding a rejected claim renders a visible rejected marker instead of a bare dead id", + "exporting the issue-backlog-sweep frame twice is byte-stable, lints clean under markdownlint-cli2 default config, and frame JSON on disk is byte-identical before and after; `show --json` output unchanged" + ], + "deps": [ + "t1" + ], + "covers": [ + "c2", + "c3", + "c6", + "c22", + "c33", + "h2", + "h3", + "h6", + "h18", + "h26" + ], + "instruction": "devague/render/spec_md.py: render all open park kinds grouped by kind (replace the `_follow_up` filter at 110-118); render hard questions with resolved markers and skip rejected parent claims (104-107); flag scope entries whose seeds cite a rejected claim; apply the t1 escaper at every verbatim site. Flip tests/test_render.py:301-306." + }, + { + "id": "t4", + "summary": "Hard-question resolve move: interrogate --resolve", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "the block-resolve-converge sequence completes through CLI moves alone, suggest_move output names the shipped move, resolved state and decision text survive a save/load round-trip, and a rejected claim with an unresolved blocking question no longer blocks converge" + ], + "deps": [ + "t2" + ], + "covers": [ + "c4", + "h4" + ], + "instruction": "Per decision c36: `devague interrogate --resolve [--decision \"\"]`. Add Frame.resolve_hard_question storing the optional decision text (schema field from t2); make the convergence gate (convergence.py:111-116) skip rejected claims; update suggest_move (206-211) to name the shipped move verbatim." + }, + { + "id": "t5", + "summary": "Reject cascade over attachments", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "rejecting a claim with attachments reports exactly what cascaded; post-reject converge emits zero warnings about the rejected claim; review lists zero orphans; regression test asserts the risk text is absent from the exported markdown" + ], + "deps": [ + "t4" + ], + "covers": [ + "c5", + "h5" + ], + "instruction": "Rejecting a claim cascades to its honesty conditions and hard questions, echoing what it took (`c21 -> rejected (also rejected: h3, q1)`); `_assumption_warnings` (convergence.py:120-126) skips rejected claims; `devague review` stops listing conditions whose parent claim is rejected." + }, + { + "id": "t6", + "summary": "Claim and scope-entry amend moves", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "amending a confirmed claim keeps its id and attachments and flips it to proposed with an echo; correcting one number costs exactly one move; scope --amend replaces a finding in place" + ], + "deps": [ + "t5" + ], + "covers": [ + "c7", + "h7" + ], + "instruction": "New `devague amend --text/--kind` keeping id, honesty conditions, instruction, and inbound scope seeds; `devague scope --amend --finding`; amending a confirmed claim flips it to proposed with the echoed flip, matching the interrogate.py:58-68 precedent; origin never changes silently." + }, + { + "id": "t7", + "summary": "scope --seeds accepts question ids", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "scope --seeds with a valid `q*` id records; an unknown `q*` id is refused with the hint; the seeded question renders in the exported scope-exploration section" + ], + "deps": [ + "t6" + ], + "covers": [ + "c8", + "h23" + ], + "instruction": "Frame.add_scope_entry (frame.py:252-254) accepts `q*` ids resolving against claim-attached hard questions; unknown `q*` still refused with the show hint; exported scope section renders question seeds." + }, + { + "id": "t8", + "summary": "Live-target validation for cover and --covers", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "the verified #90 repro inverts: after the frame grows a confirmed claim, `plan status` recommends cover for it AND that exact cover succeeds immediately with no intervening converge; a target unknown to both stored and live sets is still refused" + ], + "deps": [], + "covers": [ + "c9", + "h9" + ], + "instruction": "Make `_require_target` (cli/_commands/plan.py:145-151) validate against live-frame-derived targets exactly as converge does via `_live` (108-118), persisting the refreshed snapshot on success; decide the regressed-frame fallback (frame park v4) here \u2014 either stored-snapshot fallback or refusal with the reconverge hint \u2014 and test the chosen behavior." + }, + { + "id": "t9", + "summary": "Per-target deferral: plan defer", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "a plan with deferred targets converges and exports; the export names every deferred target with its reason; status distinguishes deferred from uncovered; the shell-cli shape (90 covered, 12 deferred) converges in a test", + "plan-md output passes markdownlint-cli2 with underscore-bearing task text (MD050 regression from the #87 comment)" + ], + "deps": [ + "t1", + "t2", + "t8" + ], + "covers": [ + "c10", + "h10", + "c6" + ], + "instruction": "New `devague plan defer --reason \"\"` persisting deferral state (plan schema bump from t2); `_missing_coverage` (plan_convergence.py:26-32) excludes deferred targets; `plan status` reports deliberately-deferred distinctly; plan_md renders a Deferred targets section naming each with its reason, and applies the t1 escaper at its verbatim sites." + }, + { + "id": "t10", + "summary": "Dependency validation at task creation", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "creating a task with `--dep` naming its own id or an unknown id fails with the actionable hint; the flipped test passes; existing valid graphs are unaffected" + ], + "deps": [ + "t9" + ], + "covers": [ + "c11", + "h11" + ], + "instruction": "plan task `--dep` refuses the about-to-be-assigned id (self-cycle) and unknown task ids at creation with actionable hints; `depend --on ` gets the same checks; flip tests/test_cli_plan.py:87-96 rather than deleting it." + }, + { + "id": "t11", + "summary": "Multi-id transactional plan confirm/reject", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "plan reject with three ids where one is invalid applies none and says why; with all valid, applies all in one call; error hints inside the plan group name `plan explain`" + ], + "deps": [ + "t10" + ], + "covers": [ + "c12", + "h12" + ], + "instruction": "plan confirm/reject accept N ids applied transactionally (all valid or none), matching the frame-side contract (confirm.py:27-50); argument errors inside the plan group point at `devague plan explain `." + }, + { + "id": "t12", + "summary": "Plan-risk amend", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "amending a risk keeps its id, kind, and resolution state while replacing text; amending an unknown rid is refused with a hint" + ], + "deps": [ + "t11" + ], + "covers": [ + "c7" + ], + "instruction": "New `plan risk --amend --text \"\"` editing risk text in place, preserving id, kind, task link, and resolution state \u2014 the #84 comment case where a referenced task id rotates." + }, + { + "id": "t13", + "summary": "Summary scoped to confirmed tasks", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "a plan with N confirmed and M rejected tasks emits exactly N Actual Delivery rows and N Planned Work entries plus one line counting the M rejected; the --pr wave map stays rejected-free; regression test covers a mixed-status plan" + ], + "deps": [ + "t1" + ], + "covers": [ + "c13", + "h13" + ], + "instruction": "summary_md.py: Planned Work and Actual Delivery (119-143) plus summary_data (265-271) iterate confirmed tasks only, with a single line counting rejected tasks; apply the t1 escaper at verbatim sites; `dependency_waves` already excludes rejected (plan.py:285) so --pr needs no change \u2014 pin that with a test." + }, + { + "id": "t14", + "summary": "Contested-by-deviation derivation: export, show, status", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "an approved deviation whose --affects names a confirmed claim yields a contested marker on re-export and a contested line in show and status", + "export, show, and status succeed on a frame whose delivery store is missing, truncated, or declares a newer schema (three corruption-shape tests); frame JSON is byte-identical before and after" + ], + "deps": [ + "t3" + ], + "covers": [ + "c14", + "c21", + "c34", + "h14", + "h17", + "h27" + ], + "instruction": "Per decisions c24/c19: a pure read-only derivation joining frame claims to approved deviations via plan_store.list_slugs() filtered on frame_slug, then delivery_store per plan slug; re-exported specs render a contested marker under affected confirmed claims; show/status gain a contested line; missing/corrupt/newer-schema delivery stores degrade to markerless rendering with a stderr diagnostic \u2014 never a crash; zero frame-state mutation." + }, + { + "id": "t15", + "summary": "Durable gate-2 split artifact in assign-to-workforce", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "the written split artifact carries real task content, the annotation block, and End state; re-run overwrites in place; the file passes markdownlint-cli2" + ], + "deps": [], + "covers": [ + "c15", + "h15" + ], + "instruction": "Per decision c25 (artifact-only): the skill script gains a write mode producing `docs/plans/--split.md` \u2014 real per-task summaries, acceptance criteria, instructions from `plan waves --json`, an owner/model annotation block the skill reads back on re-run, and the End state section; re-running overwrites the same dated path; SKILL.md documents the flow; no plan schema change, no new CLI verb." + }, + { + "id": "t16", + "summary": "Scope skill fans out to smaller-tier subagents", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "SKILL.md instructs subagent fan-out with a default smaller tier and keeps the read-only + provenance hard rules; the no-wizard escape for small ideas survives" + ], + "deps": [], + "covers": [ + "c16", + "h16" + ], + "instruction": "scope/SKILL.md steps 1-2 gain a read-only subagent fan-out pattern with a smaller default tier (sonnet), scaled by surface count \u2014 small ideas still explore inline (no wizard); provenance and read-only rules unchanged." + }, + { + "id": "t17", + "summary": "learn/explain recipes cover every new surface", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "tests grep learn/explain output for each new verb and flag; the #52 acceptance criterion (learn/explain document the resolve path) passes" + ], + "deps": [ + "t4", + "t9" + ], + "covers": [ + "c31", + "h24" + ], + "instruction": "devague learn, learn skills (SCOPE_STAGE at learn.py:153-171, ASSIGN_TO_WORKFORCE_GUIDANCE at 65-112), and explain document: interrogate --resolve, amend, scope --amend, plan defer, plan risk --amend, multi-id plan confirm/reject, live cover, and the scope subagent fan-out." + }, + { + "id": "t18", + "summary": "Docs sweep, changelog, version bump", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "all four docs describe the shipped behavior; version-check CI passes; markdownlint-cli2 clean on changed docs" + ], + "deps": [ + "t15", + "t16", + "t17" + ], + "covers": [], + "instruction": "README.md, CLAUDE.md status entry, docs/skills.md, docs/skill-sources.md version-stamp rows (including the docs/skills.md:255-256 waves-json drift), CHANGELOG.md entry, minor version bump via /version-bump; sign nothing manually \u2014 cicd appends." + }, + { + "id": "t19", + "summary": "End-to-end verification and issue closure map", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "pytest -n auto green with coverage >= 95 percent; markdownlint integration green; a closure map lists all 15 issues each naming its regression test; the three downstream workarounds are named as deletable in the map" + ], + "deps": [ + "t7", + "t12", + "t13", + "t14", + "t18" + ], + "covers": [ + "c1", + "c27", + "c28", + "c29", + "c30", + "h1", + "h19", + "h20", + "h21", + "h22" + ], + "instruction": "Repro-first evidence: each of the fifteen issues has a named regression test; full suite green via run-tests with coverage at or above 95 percent; the markdownlint integration test exports the issue-backlog-sweep frame as corpus; produce the issue-to-test closure map for the PR body so each issue closes with a pointer." + } + ], + "risks": [ + { + "id": "r1", + "text": "task granularity across a 19-task combined release may shift once implementation starts; mid-run divergence routes through /deviate against gate 2", + "kind": "unknown_nonblocking", + "task_id": null, + "resolved": false, + "resolution": "" + }, + { + "id": "r2", + "text": "regressed-frame fallback for live cover (frame park v4) is decided inside t8 \u2014 either stored-snapshot fallback or refusal with the reconverge hint", + "kind": "unknown_nonblocking", + "task_id": "t8", + "resolved": false, + "resolution": "" + }, + { + "id": "r3", + "text": "first re-export of historical committed specs after the escaper lands produces large presentational diffs (frame park v5) \u2014 reviewers should expect them", + "kind": "unknown_nonblocking", + "task_id": null, + "resolved": false, + "resolution": "" + }, + { + "id": "r4", + "text": "downstream repos (reachy-mini-cli, shell-cli, headspace-cli) must re-vendor updated skills via the guildmaster re-broadcast after release and can then delete their workarounds", + "kind": "follow_up", + "task_id": null, + "resolved": false, + "resolution": "" + } + ] +} diff --git a/docs/plans/2026-07-28-issue-backlog-sweep.md b/docs/plans/2026-07-28-issue-backlog-sweep.md new file mode 100644 index 0000000..c96f74a --- /dev/null +++ b/docs/plans/2026-07-28-issue-backlog-sweep.md @@ -0,0 +1,164 @@ +# Build Plan — issue-backlog-sweep + +slug: `issue-backlog-sweep` · status: `exported` · from frame: `issue-backlog-sweep` + +> devague closes its fifteen-issue backlog: exports become lossless and lint-clean, rejected and contested content stops leaking into artifacts, plans gain live coverage targets with per-target deferral, claims and hard questions gain amend and resolve moves, the delivery summary scopes to confirmed work, gate 2 gains a durable split artifact, and scope exploration fans out to smaller-tier subagents + +## Tasks + +### t1 — Escaping engine in render/_md_safety.py + +- instruction: Add a pure escape function in devague/render/_md_safety.py only — no renderer call sites here (they land in t3/t9/t13). Wrap underscore/dunder identifiers in code spans per the #87 comment preference; escape remaining markdown control characters; skip text already inside code spans; make double application a no-op. +- covers: c32, h25 +- acceptance: + - unit tests in tests/test_md_safety.py cover `_read_file`, `__init__.py`, `*`, `[`, backtick, and leading `#` inputs, plus already-backticked text passing through unchanged and idempotence on double application + +### t2 — Schema and load-order hardening in both stores + +- instruction: Fix store.py and plan_store.py to check schema_version BEFORE parsing via from_dict (today store.py:136-147 and plan_store.py:54-62 parse first); make HardQuestion/Vagueness loading tolerant of unknown keys like Claim already is (frame.py:315,322); bump frame SCHEMA_VERSION for the new hard-question resolution field and PLAN_SCHEMA_VERSION for defer state. +- acceptance: + - a frame or plan JSON declaring a newer schema_version fails with the fail-closed IncompatibleSchemaError message, never a raw TypeError, covered by tests for both stores; existing v3 frames and plans load unchanged + +### t3 — spec_md renderer sweep: parks, hard questions, dead seeds, escaping + +- instruction: devague/render/spec_md.py: render all open park kinds grouped by kind (replace the `_follow_up` filter at 110-118); render hard questions with resolved markers and skip rejected parent claims (104-107); flag scope entries whose seeds cite a rejected claim; apply the t1 escaper at every verbatim site. Flip tests/test_render.py:301-306. +- depends on: t1 +- covers: c2, c3, c6, c22, c33, h2, h3, h6, h18, h26 +- acceptance: + - a converged frame with open parks of all four kinds exports a spec listing each park labeled by kind + - a resolved hard question renders with a resolved marker and a hard question on a rejected claim is absent from the export (the #83 repro shape: capture, interrogate --risk, reject, converge, export) + - a scope entry seeding a rejected claim renders a visible rejected marker instead of a bare dead id + - exporting the issue-backlog-sweep frame twice is byte-stable, lints clean under markdownlint-cli2 default config, and frame JSON on disk is byte-identical before and after; `show --json` output unchanged + +### t4 — Hard-question resolve move: interrogate --resolve + +- instruction: Per decision c36: `devague interrogate --resolve [--decision ""]`. Add Frame.resolve_hard_question storing the optional decision text (schema field from t2); make the convergence gate (convergence.py:111-116) skip rejected claims; update suggest_move (206-211) to name the shipped move verbatim. +- depends on: t2 +- covers: c4, h4 +- acceptance: + - the block-resolve-converge sequence completes through CLI moves alone, suggest_move output names the shipped move, resolved state and decision text survive a save/load round-trip, and a rejected claim with an unresolved blocking question no longer blocks converge + +### t5 — Reject cascade over attachments + +- instruction: Rejecting a claim cascades to its honesty conditions and hard questions, echoing what it took (`c21 -> rejected (also rejected: h3, q1)`); `_assumption_warnings` (convergence.py:120-126) skips rejected claims; `devague review` stops listing conditions whose parent claim is rejected. +- depends on: t4 +- covers: c5, h5 +- acceptance: + - rejecting a claim with attachments reports exactly what cascaded; post-reject converge emits zero warnings about the rejected claim; review lists zero orphans; regression test asserts the risk text is absent from the exported markdown + +### t6 — Claim and scope-entry amend moves + +- instruction: New `devague amend --text/--kind` keeping id, honesty conditions, instruction, and inbound scope seeds; `devague scope --amend --finding`; amending a confirmed claim flips it to proposed with the echoed flip, matching the interrogate.py:58-68 precedent; origin never changes silently. +- depends on: t5 +- covers: c7, h7 +- acceptance: + - amending a confirmed claim keeps its id and attachments and flips it to proposed with an echo; correcting one number costs exactly one move; scope --amend replaces a finding in place + +### t7 — scope --seeds accepts question ids + +- instruction: Frame.add_scope_entry (frame.py:252-254) accepts `q*` ids resolving against claim-attached hard questions; unknown `q*` still refused with the show hint; exported scope section renders question seeds. +- depends on: t6 +- covers: c8, h23 +- acceptance: + - scope --seeds with a valid `q*` id records; an unknown `q*` id is refused with the hint; the seeded question renders in the exported scope-exploration section + +### t8 — Live-target validation for cover and --covers + +- instruction: Make `_require_target` (cli/_commands/plan.py:145-151) validate against live-frame-derived targets exactly as converge does via `_live` (108-118), persisting the refreshed snapshot on success; decide the regressed-frame fallback (frame park v4) here — either stored-snapshot fallback or refusal with the reconverge hint — and test the chosen behavior. +- covers: c9, h9 +- acceptance: + - the verified #90 repro inverts: after the frame grows a confirmed claim, `plan status` recommends cover for it AND that exact cover succeeds immediately with no intervening converge; a target unknown to both stored and live sets is still refused + +### t9 — Per-target deferral: plan defer + +- instruction: New `devague plan defer --reason ""` persisting deferral state (plan schema bump from t2); `_missing_coverage` (plan_convergence.py:26-32) excludes deferred targets; `plan status` reports deliberately-deferred distinctly; plan_md renders a Deferred targets section naming each with its reason, and applies the t1 escaper at its verbatim sites. +- depends on: t1, t2, t8 +- covers: c10, h10, c6 +- acceptance: + - a plan with deferred targets converges and exports; the export names every deferred target with its reason; status distinguishes deferred from uncovered; the shell-cli shape (90 covered, 12 deferred) converges in a test + - plan-md output passes markdownlint-cli2 with underscore-bearing task text (MD050 regression from the #87 comment) + +### t10 — Dependency validation at task creation + +- instruction: plan task `--dep` refuses the about-to-be-assigned id (self-cycle) and unknown task ids at creation with actionable hints; `depend --on ` gets the same checks; flip tests/test_cli_plan.py:87-96 rather than deleting it. +- depends on: t9 +- covers: c11, h11 +- acceptance: + - creating a task with `--dep` naming its own id or an unknown id fails with the actionable hint; the flipped test passes; existing valid graphs are unaffected + +### t11 — Multi-id transactional plan confirm/reject + +- instruction: plan confirm/reject accept N ids applied transactionally (all valid or none), matching the frame-side contract (confirm.py:27-50); argument errors inside the plan group point at `devague plan explain `. +- depends on: t10 +- covers: c12, h12 +- acceptance: + - plan reject with three ids where one is invalid applies none and says why; with all valid, applies all in one call; error hints inside the plan group name `plan explain` + +### t12 — Plan-risk amend + +- instruction: New `plan risk --amend --text ""` editing risk text in place, preserving id, kind, task link, and resolution state — the #84 comment case where a referenced task id rotates. +- depends on: t11 +- covers: c7 +- acceptance: + - amending a risk keeps its id, kind, and resolution state while replacing text; amending an unknown rid is refused with a hint + +### t13 — Summary scoped to confirmed tasks + +- instruction: summary_md.py: Planned Work and Actual Delivery (119-143) plus summary_data (265-271) iterate confirmed tasks only, with a single line counting rejected tasks; apply the t1 escaper at verbatim sites; `dependency_waves` already excludes rejected (plan.py:285) so --pr needs no change — pin that with a test. +- depends on: t1 +- covers: c13, h13 +- acceptance: + - a plan with N confirmed and M rejected tasks emits exactly N Actual Delivery rows and N Planned Work entries plus one line counting the M rejected; the --pr wave map stays rejected-free; regression test covers a mixed-status plan + +### t14 — Contested-by-deviation derivation: export, show, status + +- instruction: Per decisions c24/c19: a pure read-only derivation joining frame claims to approved deviations via plan_store.list_slugs() filtered on frame_slug, then delivery_store per plan slug; re-exported specs render a contested marker under affected confirmed claims; show/status gain a contested line; missing/corrupt/newer-schema delivery stores degrade to markerless rendering with a stderr diagnostic — never a crash; zero frame-state mutation. +- depends on: t3 +- covers: c14, c21, c34, h14, h17, h27 +- acceptance: + - an approved deviation whose --affects names a confirmed claim yields a contested marker on re-export and a contested line in show and status + - export, show, and status succeed on a frame whose delivery store is missing, truncated, or declares a newer schema (three corruption-shape tests); frame JSON is byte-identical before and after + +### t15 — Durable gate-2 split artifact in assign-to-workforce + +- instruction: Per decision c25 (artifact-only): the skill script gains a write mode producing `docs/plans/--split.md` — real per-task summaries, acceptance criteria, instructions from `plan waves --json`, an owner/model annotation block the skill reads back on re-run, and the End state section; re-running overwrites the same dated path; SKILL.md documents the flow; no plan schema change, no new CLI verb. +- covers: c15, h15 +- acceptance: + - the written split artifact carries real task content, the annotation block, and End state; re-run overwrites in place; the file passes markdownlint-cli2 + +### t16 — Scope skill fans out to smaller-tier subagents + +- instruction: scope/SKILL.md steps 1-2 gain a read-only subagent fan-out pattern with a smaller default tier (sonnet), scaled by surface count — small ideas still explore inline (no wizard); provenance and read-only rules unchanged. +- covers: c16, h16 +- acceptance: + - SKILL.md instructs subagent fan-out with a default smaller tier and keeps the read-only + provenance hard rules; the no-wizard escape for small ideas survives + +### t17 — learn/explain recipes cover every new surface + +- instruction: devague learn, learn skills (SCOPE_STAGE at learn.py:153-171, ASSIGN_TO_WORKFORCE_GUIDANCE at 65-112), and explain document: interrogate --resolve, amend, scope --amend, plan defer, plan risk --amend, multi-id plan confirm/reject, live cover, and the scope subagent fan-out. +- depends on: t4, t9 +- covers: c31, h24 +- acceptance: + - tests grep learn/explain output for each new verb and flag; the #52 acceptance criterion (learn/explain document the resolve path) passes + +### t18 — Docs sweep, changelog, version bump + +- instruction: README.md, CLAUDE.md status entry, docs/skills.md, docs/skill-sources.md version-stamp rows (including the docs/skills.md:255-256 waves-json drift), CHANGELOG.md entry, minor version bump via /version-bump; sign nothing manually — cicd appends. +- depends on: t15, t16, t17 +- acceptance: + - all four docs describe the shipped behavior; version-check CI passes; markdownlint-cli2 clean on changed docs + +### t19 — End-to-end verification and issue closure map + +- instruction: Repro-first evidence: each of the fifteen issues has a named regression test; full suite green via run-tests with coverage at or above 95 percent; the markdownlint integration test exports the issue-backlog-sweep frame as corpus; produce the issue-to-test closure map for the PR body so each issue closes with a pointer. +- depends on: t7, t12, t13, t14, t18 +- covers: c1, c27, c28, c29, c30, h1, h19, h20, h21, h22 +- acceptance: + - pytest -n auto green with coverage >= 95 percent; markdownlint integration green; a closure map lists all 15 issues each naming its regression test; the three downstream workarounds are named as deletable in the map + +## Risks + +- [unknown_nonblocking] task granularity across a 19-task combined release may shift once implementation starts; mid-run divergence routes through /deviate against gate 2 +- [unknown_nonblocking] regressed-frame fallback for live cover (frame park v4) is decided inside t8 — either stored-snapshot fallback or refusal with the reconverge hint (task t8) +- [unknown_nonblocking] first re-export of historical committed specs after the escaper lands produces large presentational diffs (frame park v5) — reviewers should expect them +- [follow_up] downstream repos (reachy-mini-cli, shell-cli, headspace-cli) must re-vendor updated skills via the guildmaster re-broadcast after release and can then delete their workarounds From 70594525b305cca9213730598661c1b92815baa9 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 10:16:16 +0300 Subject: [PATCH 03/25] feat(scope): fan exploration out to smaller-tier read-only subagents (#79, #91, t16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps 1-2 of the /scope method now instruct subagent fan-out for a broad survey (5+ candidate surfaces), one read-only exploration subagent per surface or tight cluster, defaulting to a smaller tier (sonnet). 4 or fewer surfaces still explore inline, and the no-wizard escape for small ideas is unchanged. The load-bearing rule survives explicitly in both step 2/4 and the Hard rules section: subagents explore and report only, never running a devague move — the main agent alone runs capture/scope/question/park so provenance and the anti-fabrication contract stay in one place. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QtUixLUjgEt51PXS9TPVt3 --- .claude/skills/scope/SKILL.md | 52 ++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/.claude/skills/scope/SKILL.md b/.claude/skills/scope/SKILL.md index 6278085..3386d06 100644 --- a/.claude/skills/scope/SKILL.md +++ b/.claude/skills/scope/SKILL.md @@ -45,9 +45,33 @@ of the method: no wizard.) 1. **Enumerate candidate surfaces.** List what the idea *might* touch: source packages, CLI verbs, renderers, schemas, tests, docs, skills, CI workflows, sibling repos. `git ls-files` and the repo's `CLAUDE.md` are the usual map. -2. **Explore each surface read-only.** Read enough of each candidate to decide: - touched, not touched, or unknown. Exploration never mutates anything — - no edits, no state changes, no CLI moves yet. + Count the candidates — the count decides how step 2 explores them. +2. **Explore each surface read-only — inline for a handful, fanned out for a + broader survey.** Read enough of each candidate to decide: touched, not + touched, or unknown. Exploration never mutates anything — no edits, no + state changes, no CLI moves — while surveying, whether you explore inline + or fan out. + - **4 or fewer candidate surfaces: explore them yourself, inline, + serially**, exactly as before. Spinning up subagents to read three files + costs more than it saves, and the no-wizard rule still applies underneath + this: a small idea can skip scope exploration altogether (see *When to + use — and when to skip*, above). + - **5 or more candidate surfaces: fan out.** Dispatch one **read-only + exploration subagent per surface** (or per tight cluster of closely + related surfaces, e.g. "the five files under `render/`"), defaulting to a + **smaller tier — sonnet** — for every subagent in the fan-out. Sonnet is + the default, not a ceiling: escalate a single subagent to a stronger tier + only when that specific surface demands it (an ambiguous design doc + needing real synthesis, say), never as the default for the whole survey. + - **Subagents explore and report; they never run a `devague` move.** A + fan-out subagent's job ends at a read-only finding — touched / not + touched / unknown, with the file, line, or command output that grounds + the verdict — handed back to the main agent in its report. The main + agent alone runs every `capture`, `scope`, `question`, and `park` call in + step 4, from the subagents' reported evidence. This is the load-bearing + rule of the whole fan-out: provenance and the anti-fabrication contract + stay in one place (the main agent), never scattered across however many + subagent transcripts the user never sees. 3. **Classify every finding.** Each explored surface yields one of: - **in scope** — the idea changes it → becomes a `requirement` or `assumption` claim in the frame; @@ -65,7 +89,11 @@ of the method: no wizard.) Capture the claim it seeded first (`capture --kind ...`), then pass its id to `--seeds` — an unknown seed id is refused with a hint. Provenance, not generic disclaimers: a reviewer should be able to trace every boundary claim - back to something you read. + back to something you read. **This move is always the main agent's, never a + subagent's** — when step 2 fanned out, the main agent writes the finding + from the subagent's reported evidence (still citing the actual surface, not + "a subagent explored this"), so every seeded claim traces back to a + concrete file, line, or command output either way. ## How findings land (the shipped surface) @@ -102,14 +130,24 @@ followed*: ## Hard rules (do not violate) - **Exploration is read-only.** Surveying scope never edits files, never - mutates frame state, never runs a mutating CLI move. + mutates frame state, never runs a mutating CLI move — true whether you + explore inline or fan out to subagents (step 2). - **Provenance in every seeded claim.** A scope-derived claim cites what was - explored. If you didn't read it, don't claim it. + explored. If you didn't read it, don't claim it — including what a fan-out + subagent read on your behalf: its report is the evidence, not a substitute + for citing the surface. +- **Subagents explore; only the main agent moves.** When step 2 fans out to + read-only exploration subagents, they never run `devague capture`, `scope`, + `question`, or `park` — only report findings back. The main agent runs + every one of those moves itself, from the subagents' reported evidence, so + provenance and the anti-fabrication contract are never split across agents. - **LLM proposals stay proposed.** Findings you capture with `--origin llm` land `proposed`; the user confirms. Same anti-fabrication contract as `/think`. - **Don't become a wizard.** Scope exploration is optional-by-size and - adaptive. Never block a small idea on a survey it doesn't need. + adaptive. Never block a small idea on a survey it doesn't need — and even + a broad survey that fans out to subagents skips the fan-out entirely if the + idea itself is small (4 or fewer candidate surfaces, step 2). ## Worked example From 4b88608eb749e7b2519d013795476e423976ce19 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 10:17:04 +0300 Subject: [PATCH 04/25] fix(plan): validate cover targets against the live frame (#90, t8) `_require_target` only checked the plan's stored coverage-target snapshot, frozen at `plan new`/last `plan converge` time. But `plan status`/`converge` re-derive targets from the LIVE source frame, so a frame that legitimately grows a confirmed claim mid-run left `status` recommending a cover that `cover`/`task --covers` immediately refused as "unknown coverage target" -- the exact #90 repro. `_require_target` now falls back to the live frame when a target is absent from the stored snapshot, and persists the refreshed snapshot on success so the stored copy catches up without a separate `plan converge`. Decision (park v4 / plan risk r2): when the source frame has itself regressed below its own convergence gate, `_live`'s regression error is let through as-is rather than reworded into "unknown coverage target" -- an unverifiable target should say why, not blame the wrong thing. A target already known to the stored snapshot never touches the live frame at all, so it keeps working through a frame regression exactly as before. --- devague/cli/_commands/plan.py | 35 +++++++++- tests/test_cli_plan.py | 122 ++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/devague/cli/_commands/plan.py b/devague/cli/_commands/plan.py index 35071b2..7a9adcf 100644 --- a/devague/cli/_commands/plan.py +++ b/devague/cli/_commands/plan.py @@ -143,12 +143,45 @@ def _require_task(plan: Plan, tid: str): def _require_target(plan: Plan, target_id: str) -> None: - if plan.find_target(target_id) is None: + """Validate ``target_id`` against the plan's coverage targets (issue #90). + + ``converge``/``status``/``export`` all re-derive targets from the **live** + source frame via :func:`_live`, so when a frame legitimately grows a new + confirmed claim/honesty condition mid-run, ``status`` immediately recommends + covering that new id. Checking only the (possibly stale) *stored* snapshot + here would make that exact recommended cover refuse as "unknown coverage + target" — two moves disagreeing about the same id in the same breath, and a + plan that grew could never converge again. + + So: the stored snapshot is checked first (the common, no-I/O case — nothing + changed since the plan last converged). Only when ``target_id`` is absent + there do we consult the live frame; a hit refreshes and persists ``plan.targets`` + so the stored copy catches up without a separate ``plan converge`` (the caller's + own ``plan_store.save`` right after this call carries the refreshed snapshot). + + Decision (recorded as park v4 / plan risk r2): if the source frame has itself + regressed below its own convergence gate, :func:`_live` raises — that error is + let through here as-is rather than caught and reworded into "unknown coverage + target". A target absent from the stored snapshot when the frame is unhealthy + is genuinely unverifiable (it may be a real, newly-grown target that just + hasn't been re-converged, or a plain typo — there is no way to tell from a + frame that cannot currently be evaluated), so surfacing the real cause — "the + frame has regressed, re-converge it first" — is more honest than silently + blaming the target id for a problem that lives in the frame, not the plan. + A target already known to the stored snapshot is unaffected by any of this: + it never touches the live frame, so it keeps working through a frame + regression exactly as it did before this fix. + """ + if plan.find_target(target_id) is not None: + return + _frame, live_targets = _live(plan) # raises as-is on a regressed source frame + if not any(tg.id == target_id for tg in live_targets): raise DevagueError( EXIT_USER_ERROR, f"unknown coverage target: {target_id}", "run 'devague plan show' to see targets (c*/h*)", ) + plan.targets = live_targets # persist the refreshed snapshot on success # ── the re-confirm rule (#53 t5, sharpened in #53-esd t1) ──────────────────── diff --git a/tests/test_cli_plan.py b/tests/test_cli_plan.py index 7677c5d..602b01b 100644 --- a/tests/test_cli_plan.py +++ b/tests/test_cli_plan.py @@ -142,6 +142,128 @@ def test_cover_unknown_target_errors(tmp_path, monkeypatch, capsys) -> None: assert "unknown coverage target" in capsys.readouterr().err +# ── issue #90: cover/--covers validate against the LIVE frame ─────────────── +def test_status_recommends_cover_and_cover_succeeds_without_reconverge( + tmp_path, monkeypatch, capsys +) -> None: + """The verified #90 repro, inverted: a frame that grows a new confirmed claim + mid-run must not leave `plan status`'s recommended cover and `plan cover` + disagreeing about the same target in the same breath — `cover` must succeed + immediately, with no intervening `plan converge`.""" + slug = _converged_plan(monkeypatch, tmp_path, capsys) + assert main(["plan", "converge"]) == 0 # plan converged; targets snapshot = 12 + capsys.readouterr() + + # The frame legitimately grows: a new confirmed requirement claim + its + # confirmed honesty condition (c7 / h7) — the documented reopen/reconverge loop. + assert main(["capture", "--kind", "requirement", "a new requirement", "--origin", "user"]) == 0 + capsys.readouterr() + new_claim_id = next(c.id for c in store.load(slug).claims if c.kind == "requirement") + assert main(["interrogate", new_claim_id, "--honesty", "must hold", "--origin", "user"]) == 0 + capsys.readouterr() + assert main(["converge"]) == 0 # the frame itself still converges + capsys.readouterr() + + # `plan status` now recommends covering the new claim ... + rc = main(["plan", "status", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert any(new_claim_id in b for b in payload["blockers"]) + assert any(new_claim_id in m for m in payload["required_next_moves"]) + + # ... and that exact cover succeeds immediately — no `plan converge` in between. + rc = main(["plan", "cover", "t1", "--target", new_claim_id]) + assert rc == 0 + task = plan_store.load(slug).find_task("t1") + assert new_claim_id in task.covers + + # The refreshed live snapshot was persisted as a side effect of the successful + # cover, so the stored plan now knows about the new target too. + persisted = plan_store.load(slug) + assert persisted.find_target(new_claim_id) is not None + + +def test_task_inline_covers_also_validates_against_live_frame( + tmp_path, monkeypatch, capsys +) -> None: + """The `plan task --covers` call site shares `_require_target`; a target that + only exists in the live frame (not yet in the stored snapshot) must be + accepted there too.""" + slug = _converged_plan(monkeypatch, tmp_path, capsys) + assert main(["plan", "converge"]) == 0 + capsys.readouterr() + assert main(["capture", "--kind", "requirement", "another requirement"]) == 0 + new_claim_id = next(c.id for c in store.load(slug).claims if c.kind == "requirement") + main(["interrogate", new_claim_id, "--honesty", "must hold", "--origin", "user"]) + assert main(["converge"]) == 0 + capsys.readouterr() + + rc = main(["plan", "task", "cover the new one", "--covers", new_claim_id]) + assert rc == 0 + task = plan_store.load(slug).find_task("t2") + assert new_claim_id in task.covers + + +def test_cover_unknown_target_still_refused_after_frame_grows( + tmp_path, monkeypatch, capsys +) -> None: + """A target unknown to both the stored snapshot and the live-derived set is + still refused, even once the live-frame fallback exists.""" + slug = _converged_plan(monkeypatch, tmp_path, capsys) + assert main(["plan", "converge"]) == 0 + capsys.readouterr() + assert main(["capture", "--kind", "requirement", "grows the frame"]) == 0 + new_claim_id = next(c.id for c in store.load(slug).claims if c.kind == "requirement") + main(["interrogate", new_claim_id, "--honesty", "must hold", "--origin", "user"]) + assert main(["converge"]) == 0 + capsys.readouterr() + + rc = main(["plan", "cover", "t1", "--target", "zzz"]) + assert rc == 1 + assert "unknown coverage target" in capsys.readouterr().err + + +def test_cover_known_target_survives_frame_regression(tmp_path, monkeypatch, capsys) -> None: + """A target already in the plan's stored snapshot keeps working through `cover` + even after the source frame regresses below its own convergence gate — only a + target absent from the stored snapshot ever needs to consult the live frame.""" + slug = _converged_plan(monkeypatch, tmp_path, capsys) + assert main(["plan", "converge"]) == 0 + main(["plan", "task", "extra"]) # t2 + capsys.readouterr() + main(["park", "scale?", "--kind", "unknown_blocking"]) # regress the frame + capsys.readouterr() + + rc = main(["plan", "cover", "t2", "--target", "c1"]) # c1 already stored + assert rc == 0 + assert plan_store.load(slug).find_task("t2").covers == ["c1"] + + +def test_cover_new_target_refuses_with_reconverge_hint_when_frame_regressed( + tmp_path, monkeypatch, capsys +) -> None: + """Decision (b), park v4 / plan risk r2: when the source frame has regressed + below its own convergence gate, a target not yet in the stored snapshot cannot + be verified — `cover` refuses with the same reconverge hint `_live` already + gives `converge`/`status`/`export`, rather than mislabeling it 'unknown'.""" + slug = _converged_plan(monkeypatch, tmp_path, capsys) + assert main(["plan", "converge"]) == 0 + capsys.readouterr() + + # Grow the frame with a new confirmed requirement claim that has NO confirmed + # honesty condition yet — this alone regresses the frame below its own gate, + # and the new claim is not (and cannot yet be) in the plan's stored snapshot. + assert main(["capture", "--kind", "requirement", "an unresolved requirement"]) == 0 + new_claim_id = next(c.id for c in store.load(slug).claims if c.kind == "requirement") + capsys.readouterr() + + rc = main(["plan", "cover", "t1", "--target", new_claim_id]) + assert rc == 1 + err = capsys.readouterr().err + assert "regressed below convergence" in err + assert "re-converge the frame first" in err + + # ── confirm / reject (user-only) ──────────────────────────────────────────── def test_confirm_and_reject(tmp_path, monkeypatch, capsys) -> None: slug = _converged_frame(monkeypatch, tmp_path) From 4b97c57258d9beba5ac14ea63f3eddb58a13812b Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 10:17:43 +0300 Subject: [PATCH 05/25] fix(store): gate schema_version before parsing; tolerant nested loads (t2) store.load and plan_store.load parsed via from_dict before checking schema_version, so a genuinely newer-schema file crashed with a raw TypeError (HardQuestion(**q) / Vagueness(**v) reject unexpected kwargs) instead of the intended fail-closed IncompatibleSchemaError. Both loaders now read the raw JSON, check schema_version first, and only then build the domain object. HardQuestion/Vagueness construction in frame.from_dict is now tolerant of unknown keys the same way Claim already is, so a future field lands safely under a version bump instead of crashing same-version loads that happen to carry it. Bumps frame.SCHEMA_VERSION 3->4 (reserved for t4's HardQuestion resolution field) and plan.PLAN_SCHEMA_VERSION 3->4 (reserved for t9's per-target deferral state) - this task only hardens load order/tolerance, it does not add either field. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QtUixLUjgEt51PXS9TPVt3 --- devague/frame.py | 33 ++++++++- devague/plan.py | 5 +- devague/plan_store.py | 20 ++++-- devague/store.py | 21 ++++-- tests/test_frame.py | 7 +- tests/test_frame_schema_v2.py | 10 +-- tests/test_plan.py | 10 +-- tests/test_plan_store.py | 65 +++++++++++++++++ tests/test_store.py | 127 +++++++++++++++++++++++++++++++++- 9 files changed, 270 insertions(+), 28 deletions(-) diff --git a/devague/frame.py b/devague/frame.py index 612288a..38b9c96 100644 --- a/devague/frame.py +++ b/devague/frame.py @@ -13,7 +13,10 @@ # on a frame whose schema_version is newer/unknown (see #5, honesty condition h15). # v2 (#53 t1) adds Frame.scope_entries and Claim/HonestyCondition.instruction. # v3 (resolve-parked-vagueness t1) adds Vagueness.resolved / Vagueness.resolution. -SCHEMA_VERSION = 3 +# v4 (issue-backlog-sweep t2) is reserved for t4's HardQuestion resolution field; +# t2 itself only bumps the number, hardens store.load's check-before-parse order, +# and makes HardQuestion/Vagueness loading tolerant of unknown keys like Claim. +SCHEMA_VERSION = 4 CLAIM_KINDS = ( "announcement", @@ -312,14 +315,38 @@ def from_dict(d: dict) -> Frame: ) for h in c.get("honesty_conditions", []) ], - hard_questions=[HardQuestion(**q) for q in c.get("hard_questions", [])], + hard_questions=[ + HardQuestion( + id=q["id"], + text=q["text"], + resolved=q.get("resolved", False), + blocking=q.get("blocking", False), + ) + # Tolerant of unknown keys the same way Claim is above (t2, issue + # #53 issue-backlog-sweep): a future field (e.g. t4's resolution) + # must not raw-TypeError a same-or-older-version load. + for q in c.get("hard_questions", []) + ], links=list(c.get("links", [])), # v1 frames predate this field (#53 t1); default to "no instruction". instruction=c.get("instruction", ""), ) for c in d.get("claims", []) ] - vag = [Vagueness(**v) for v in d.get("open_vagueness", [])] + vag = [ + Vagueness( + id=v["id"], + text=v["text"], + kind=v["kind"], + claim_id=v.get("claim_id"), + resolved=v.get("resolved", False), + resolution=v.get("resolution", ""), + resolution_claim_id=v.get("resolution_claim_id"), + ) + # Tolerant of unknown keys the same way Claim is above (t2) — see the + # hard_questions comment just above for why this matters. + for v in d.get("open_vagueness", []) + ] scope_entries = [ ScopeEntry( id=s["id"], diff --git a/devague/plan.py b/devague/plan.py index 45480f7..d88940f 100644 --- a/devague/plan.py +++ b/devague/plan.py @@ -29,7 +29,10 @@ # plan-engine peer of frame.SCHEMA_VERSION). v2 (#53 t2) adds Task.instruction. # v3 (resolve-parked-vagueness t2) adds PlanRisk.resolved/resolution — the # plan-side twin of frame.SCHEMA_VERSION's Vagueness.resolved/resolution bump. -PLAN_SCHEMA_VERSION = 3 +# v4 (issue-backlog-sweep t2) is reserved for t9's per-target deferral state; t2 +# itself only bumps the number and hardens plan_store.load's check-before-parse +# order (the plan-side twin of the same frame.SCHEMA_VERSION v4 hardening). +PLAN_SCHEMA_VERSION = 4 TASK_STATUSES = ("proposed", "confirmed", "rejected") # Risks reuse the frame's open-vagueness kinds: a plan risk is the task-level peer of diff --git a/devague/plan_store.py b/devague/plan_store.py index 2f6a7a3..144aeba 100644 --- a/devague/plan_store.py +++ b/devague/plan_store.py @@ -12,6 +12,7 @@ import time from pathlib import Path +from devague.frame import parse_schema_version from devague.plan import PLAN_SCHEMA_VERSION, Plan, from_dict, to_dict from devague.store import validate_slug @@ -51,7 +52,19 @@ def load(slug: str) -> Plan: p = path_for(slug) if not p.exists(): raise FileNotFoundError(slug) - plan = from_dict(json.loads(p.read_text(encoding="utf-8"))) + raw = json.loads(p.read_text(encoding="utf-8")) + # Check the declared schema_version against the RAW dict before constructing + # the domain object: a genuinely newer-schema plan file must fail closed with + # IncompatiblePlanSchemaError, not an opaque TypeError from from_dict trying to + # build a nested dataclass it doesn't fully recognise yet (t2, the plan-side + # twin of store.load's same hardening). + version = parse_schema_version(raw, PLAN_SCHEMA_VERSION) + if version > PLAN_SCHEMA_VERSION: + raise IncompatiblePlanSchemaError( + f"plan {slug!r} uses schema_version {version}, but this " + f"devague supports up to {PLAN_SCHEMA_VERSION}; upgrade devague to read it" + ) + plan = from_dict(raw) validate_slug(plan.slug) # reject a tampered file whose internal slug escapes validate_slug(plan.frame_slug) # the linked frame slug must be safe to load too if plan.slug != slug: @@ -59,11 +72,6 @@ def load(slug: str) -> Plan: # whose internal slug disagrees with its filename could silently redirect # a later save onto a different plan, so reject it. raise ValueError(f"plan slug mismatch: file {slug!r} declares slug {plan.slug!r}") - if plan.schema_version > PLAN_SCHEMA_VERSION: - raise IncompatiblePlanSchemaError( - f"plan {slug!r} uses schema_version {plan.schema_version}, but this " - f"devague supports up to {PLAN_SCHEMA_VERSION}; upgrade devague to read it" - ) return plan diff --git a/devague/store.py b/devague/store.py index 936b39b..a0737ae 100644 --- a/devague/store.py +++ b/devague/store.py @@ -10,7 +10,7 @@ import time from pathlib import Path -from devague.frame import SCHEMA_VERSION, Frame, from_dict, to_dict +from devague.frame import SCHEMA_VERSION, Frame, from_dict, parse_schema_version, to_dict FRAMES_DIR = Path(".devague/frames") CURRENT = Path(".devague/current") @@ -133,18 +133,25 @@ def load(slug: str) -> Frame: p = path_for(slug) if not p.exists(): raise FileNotFoundError(slug) - frame = from_dict(json.loads(p.read_text(encoding="utf-8"))) + raw = json.loads(p.read_text(encoding="utf-8")) + # Check the declared schema_version against the RAW dict before constructing + # the domain object: from_dict raises a bare TypeError on an unknown key + # buried in a nested hard_questions/open_vagueness entry, so parsing a + # genuinely newer-schema file first would surface that opaque crash instead + # of the intended fail-closed IncompatibleSchemaError (t2, issue-backlog-sweep). + version = parse_schema_version(raw, SCHEMA_VERSION) + if version > SCHEMA_VERSION: + raise IncompatibleSchemaError( + f"frame {slug!r} uses schema_version {version}, but this " + f"devague supports up to {SCHEMA_VERSION}; upgrade devague to read it" + ) + frame = from_dict(raw) validate_slug(frame.slug) # reject a tampered file whose internal slug escapes if frame.slug != slug: # The embedded slug drives save() and the current-frame pointer; a file # whose internal slug disagrees with its filename could silently redirect # a later save onto a different frame, so reject it. raise ValueError(f"frame slug mismatch: file {slug!r} declares slug {frame.slug!r}") - if frame.schema_version > SCHEMA_VERSION: - raise IncompatibleSchemaError( - f"frame {slug!r} uses schema_version {frame.schema_version}, but this " - f"devague supports up to {SCHEMA_VERSION}; upgrade devague to read it" - ) return frame diff --git a/tests/test_frame.py b/tests/test_frame.py index 349b59d..303775c 100644 --- a/tests/test_frame.py +++ b/tests/test_frame.py @@ -124,8 +124,11 @@ def test_dataclasses_validate_enums() -> None: # --- resolve-parked-vagueness t1: Vagueness resolution state (schema v3) ------ -def test_schema_version_is_3() -> None: - assert SCHEMA_VERSION == 3 +def test_schema_version_is_4() -> None: + # v3 (resolve-parked-vagueness t1) added Vagueness.resolved/resolution; v4 + # (issue-backlog-sweep t2) reserves the next bump for t4's HardQuestion + # resolution field — t2 itself only hardens load order/tolerance. + assert SCHEMA_VERSION == 4 def test_vagueness_gains_resolved_and_resolution_defaults() -> None: diff --git a/tests/test_frame_schema_v2.py b/tests/test_frame_schema_v2.py index be289a0..6a82d3f 100644 --- a/tests/test_frame_schema_v2.py +++ b/tests/test_frame_schema_v2.py @@ -24,10 +24,12 @@ def test_schema_version_bumped_exactly_once() -> None: - # Pinned at 2 when this file was written (#53 t1); a later legitimate bump - # (resolve-parked-vagueness t1, v3: Vagueness.resolved/resolution) moves - # this pin forward rather than leaving a stale, now-false assertion. - assert SCHEMA_VERSION == 3 + # Pinned at 2 when this file was written (#53 t1); each later legitimate bump + # (resolve-parked-vagueness t1's v3: Vagueness.resolved/resolution; then + # issue-backlog-sweep t2's v4, reserved for t4's HardQuestion resolution + # field) moves this pin forward rather than leaving a stale, now-false + # assertion. + assert SCHEMA_VERSION == 4 def test_claim_and_honesty_instruction_default_empty() -> None: diff --git a/tests/test_plan.py b/tests/test_plan.py index 3eeb35f..c32a869 100644 --- a/tests/test_plan.py +++ b/tests/test_plan.py @@ -115,10 +115,12 @@ def test_plan_carries_schema_version() -> None: assert from_dict(to_dict(p)).schema_version == PLAN_SCHEMA_VERSION -def test_plan_schema_version_bumped_to_3_for_resolution_field() -> None: - # resolve-parked-vagueness t2: PlanRisk.resolved/resolution bumps the persisted - # plan shape exactly once (the plan-side twin of frame.SCHEMA_VERSION 2 -> 3). - assert PLAN_SCHEMA_VERSION == 3 +def test_plan_schema_version_bumped_to_4_for_defer_state() -> None: + # resolve-parked-vagueness t2 bumped to 3 for PlanRisk.resolved/resolution (the + # plan-side twin of frame.SCHEMA_VERSION 2 -> 3). issue-backlog-sweep t2 bumps + # again to 4, reserved for t9's per-target deferral state (the plan-side twin + # of frame.SCHEMA_VERSION's v4 reservation for t4's HardQuestion resolution). + assert PLAN_SCHEMA_VERSION == 4 def test_legacy_plan_without_schema_version_loads() -> None: diff --git a/tests/test_plan_store.py b/tests/test_plan_store.py index 7af5442..291ae80 100644 --- a/tests/test_plan_store.py +++ b/tests/test_plan_store.py @@ -158,6 +158,71 @@ def test_load_v2_plan_with_risk_but_no_resolution_fields(tmp_path, monkeypatch) assert r.resolution == "" +# --- issue-backlog-sweep t2: schema-gate-before-parse (plan side) ----------- + + +def test_load_rejects_newer_schema_before_parsing_nested_unknown_key(tmp_path, monkeypatch) -> None: + # The plan-side twin of the frame-store fix: before t2, from_dict ran BEFORE + # the schema_version gate, so a genuinely newer-schema plan file whose nested + # risks carried an unrecognised key would crash with a raw TypeError + # (PlanRisk(**r) rejects unexpected kwargs) instead of the intended + # fail-closed IncompatiblePlanSchemaError. The gate must now trip first. + monkeypatch.chdir(tmp_path) + p = _plan() + p.add_risk("scope?", "unknown_blocking") + plan_store.save(p) + path = plan_store.path_for("demo") + raw = json.loads(path.read_text(encoding="utf-8")) + raw["schema_version"] = PLAN_SCHEMA_VERSION + 1 + raw["risks"][0]["from_a_future_devague"] = "unknown" + path.write_text(json.dumps(raw), encoding="utf-8") + with pytest.raises(plan_store.IncompatiblePlanSchemaError, match="schema_version"): + plan_store.load("demo") + + +def test_load_v3_plan_shape_loads_unchanged_under_v4(tmp_path, monkeypatch) -> None: + # Acceptance criterion: existing v3 plans load unchanged after the v4 bump. + monkeypatch.chdir(tmp_path) + plan_store.PLANS_DIR.mkdir(parents=True, exist_ok=True) + v3 = { + "slug": "demo", + "title": "Demo", + "frame_slug": "demo", + "schema_version": 3, + "status": "drafting", + "created": "2026-01-01T00:00:00Z", + "updated": "2026-01-01T00:00:00Z", + "targets": [], + "tasks": [ + { + "id": "t1", + "summary": "first task", + "origin": "user", + "status": "confirmed", + "acceptance_criteria": [], + "deps": [], + "covers": [], + "instruction": "", + } + ], + "risks": [ + { + "id": "r1", + "text": "scope?", + "kind": "unknown_blocking", + "task_id": None, + "resolved": False, + "resolution": "", + } + ], + } + plan_store.path_for("demo").write_text(json.dumps(v3), encoding="utf-8") + loaded = plan_store.load("demo") + assert loaded.schema_version == 3 # a declared v3 stays 3, not silently upgraded + assert loaded.find_task("t1").summary == "first task" + assert loaded.find_risk("r1").kind == "unknown_blocking" + + def test_load_rejects_slug_mismatch(tmp_path, monkeypatch) -> None: # A file under demo.json whose internal slug is a *different* valid slug must # be rejected, so a later save() can't be redirected onto another plan. diff --git a/tests/test_store.py b/tests/test_store.py index 3334a56..83e70e9 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -5,7 +5,7 @@ import pytest from devague import store -from devague.frame import SCHEMA_VERSION, Frame, to_dict +from devague.frame import SCHEMA_VERSION, Frame, from_dict, to_dict def test_slugify_caps_and_sanitises() -> None: @@ -150,6 +150,131 @@ def test_save_load_roundtrip_resolved_vagueness_identical(tmp_path, monkeypatch) assert loaded.open_vagueness[0].resolution == "decided: cap at 10k" +# --- issue-backlog-sweep t2: schema-gate-before-parse + nested tolerance ----- + + +def test_load_rejects_newer_schema_before_parsing_nested_unknown_key(tmp_path, monkeypatch) -> None: + # Before t2's load-order fix, from_dict ran BEFORE the schema_version gate, so + # a genuinely newer-schema file whose nested hard_questions/open_vagueness + # carried an unrecognised key crashed with a raw TypeError (HardQuestion(**q) + # / Vagueness(**v) reject unexpected kwargs) instead of the intended + # fail-closed IncompatibleSchemaError. The gate must now trip first. + monkeypatch.chdir(tmp_path) + f = Frame(slug="demo", title="Demo") + c = f.add_claim("announcement", "shipped X") + f.add_hard_question(c, "what if empty?", blocking=True) + f.add_vagueness("unsure about scale", "unknown_blocking") + store.save(f) + p = store.path_for("demo") + raw = json.loads(p.read_text(encoding="utf-8")) + raw["schema_version"] = SCHEMA_VERSION + 1 + raw["claims"][0]["hard_questions"][0]["from_a_future_devague"] = "unknown" + raw["open_vagueness"][0]["from_a_future_devague"] = "unknown" + p.write_text(json.dumps(raw), encoding="utf-8") + with pytest.raises(store.IncompatibleSchemaError, match="schema_version"): + store.load("demo") + + +def test_hard_question_from_dict_tolerates_unknown_keys() -> None: + # Mirrors Claim's existing tolerant construction (frame.py from_dict): a + # nested hard_questions entry carrying a not-yet-recognised key must not + # raw-TypeError, so the field t4 eventually adds can land safely. + legacy = { + "slug": "s", + "title": "t", + "claims": [ + { + "id": "c1", + "kind": "announcement", + "text": "x", + "hard_questions": [ + { + "id": "q1", + "text": "what if empty?", + "blocking": True, + "resolution": "a future field this devague doesn't know yet", + } + ], + } + ], + "open_vagueness": [], + } + f = from_dict(legacy) + q = f.claims[0].hard_questions[0] + assert (q.id, q.text, q.blocking, q.resolved) == ("q1", "what if empty?", True, False) + + +def test_vagueness_from_dict_tolerates_unknown_keys() -> None: + legacy = { + "slug": "s", + "title": "t", + "claims": [], + "open_vagueness": [ + { + "id": "v1", + "text": "unsure about scale", + "kind": "unknown_blocking", + "from_a_future_devague": "unknown", + } + ], + } + f = from_dict(legacy) + v = f.open_vagueness[0] + assert (v.id, v.text, v.kind, v.resolved, v.resolution) == ( + "v1", + "unsure about scale", + "unknown_blocking", + False, + "", + ) + + +def test_load_v3_frame_shape_loads_unchanged_under_v4(tmp_path, monkeypatch) -> None: + # Acceptance criterion: existing v3 frames load unchanged after the v4 bump. + monkeypatch.chdir(tmp_path) + store.FRAMES_DIR.mkdir(parents=True, exist_ok=True) + v3 = { + "slug": "demo", + "title": "Demo", + "schema_version": 3, + "status": "drafting", + "created": "2026-01-01T00:00:00Z", + "updated": "2026-01-01T00:00:00Z", + "claims": [ + { + "id": "c1", + "kind": "announcement", + "text": "shipped X", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [ + {"id": "q1", "text": "what if empty?", "resolved": False, "blocking": True} + ], + "links": [], + "instruction": "", + } + ], + "open_vagueness": [ + { + "id": "v1", + "text": "unsure about scale", + "kind": "unknown_blocking", + "claim_id": None, + "resolved": False, + "resolution": "", + "resolution_claim_id": None, + } + ], + "scope_entries": [], + } + store.path_for("demo").write_text(json.dumps(v3), encoding="utf-8") + loaded = store.load("demo") + assert loaded.schema_version == 3 # a declared v3 stays 3, not silently upgraded + assert loaded.claims[0].hard_questions[0].blocking is True + assert loaded.open_vagueness[0].kind == "unknown_blocking" + + def test_load_legacy_v2_vagueness_defaults_resolved_fields(tmp_path, monkeypatch) -> None: monkeypatch.chdir(tmp_path) store.FRAMES_DIR.mkdir(parents=True, exist_ok=True) From d655c2dda19721dbb6bb594fed33ec59ed63f5a9 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 10:18:06 +0300 Subject: [PATCH 06/25] feat(render): identifier-aware markdown escaping for verbatim text (#87, t1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add md_safe_text() to render/_md_safety.py: wraps underscore/dunder identifiers (_read_file, __init__.py) in code spans per the #87 issue comment's preferred fix (fixes MD037 and MD050 in one move, reads better than backslash-escaping), and backslash-escapes the remaining control characters (*, [, ], a stray backtick, a leading #) as the fallback. Text already inside a code span is left byte-for-byte untouched and double application is a no-op (c32/h25). No renderer call sites yet — those land in t3/t9/t13. --- devague/render/_md_safety.py | 105 ++++++++++++++++++++++++-- tests/test_md_safety.py | 140 ++++++++++++++++++++++++++++++++++- 2 files changed, 235 insertions(+), 10 deletions(-) diff --git a/devague/render/_md_safety.py b/devague/render/_md_safety.py index b0ab5e5..910914e 100644 --- a/devague/render/_md_safety.py +++ b/devague/render/_md_safety.py @@ -1,16 +1,21 @@ -"""Shared rendering-time markdown-safety helpers for the export renderers (#64). +"""Shared rendering-time markdown-safety helpers for the export renderers (#64, +#87). ``devague export`` (spec-md, ``docs/specs/*.md``) and ``devague plan export`` (plan-md, ``docs/plans/*.md``) both interpolate free-form claim/task/honesty prose straight into markdown — headings, blockquotes, and bullets. Prose written as a sentence ("Ship the feature.") trips markdownlint's MD026 -(no-trailing-punctuation) when it lands in a heading, and a URL written bare -("see https://example.com") trips MD034 (no-bare-urls) wherever it lands. -Downstream repos that commit exported specs/plans and gate PRs on -markdownlint were hand-fixing every export until this landed. - -Both functions are applied **only** at render time — the underlying Frame/Plan -JSON keeps the original text verbatim (rendering never mutates state). +(no-trailing-punctuation) when it lands in a heading, a URL written bare +("see https://example.com") trips MD034 (no-bare-urls) wherever it lands, and +prose naming a Python identifier ("_read_file", "__init__.py") trips MD037 +(no-space-in-emphasis) or MD050 (strong-style) because Markdown reads a pair +of underscores as emphasis markers. Downstream repos that commit exported +specs/plans and gate PRs on markdownlint were hand-fixing every export (or +excluding the generated directory from lint entirely) until this landed. + +Every function here is applied **only** at render time — the underlying +Frame/Plan JSON keeps the original text verbatim (rendering never mutates +state, #87 acceptance criteria). The exact rules here are pinned against markdownlint-cli2 v0.21.0 (markdownlint v0.40.0) behavior, verified empirically, not just read from docs. @@ -102,3 +107,87 @@ def heading_safe(text: str) -> str: empirically against markdownlint-cli2: ``# Ship .`` still errors). """ return _HEADING_TRAILING_PUNCT_RE.sub("", autolink_urls(text)) + + +# ── md_safe_text (#87: MD037/MD050 + stray control characters) ─────────────── + +# A contiguous run of identifier-ish characters that contains at least one +# underscore — covers both leading-underscore names (``_read_file``) and +# dunder names (``__init__``). The character classes on either side of the +# mandatory ``_`` are themselves allowed to contain underscores too, so the +# match always extends to the full contiguous run rather than stopping at the +# first underscore. An optional trailing ``.`` (a small, deliberately +# conservative allowlist of common file extensions) lets a dunder *file name* +# such as ``__init__.py`` wrap as a single token instead of splitting at the +# dot — the exact shape named in the #87 MD050 follow-up comment. +_IDENTIFIER_RE = re.compile( + r"[A-Za-z0-9_]*_[A-Za-z0-9_]*" r"(?:\.(?:py|md|rst|json|ya?ml|toml|cfg|ini|sh|js|ts|rb|go))?" +) + +# Markdown control characters that corrupt rendered document structure when a +# verbatim field contains them (the #87 issue's fallback list: '*' opens +# emphasis, '[' / ']' open link/reference syntax). Underscore is deliberately +# excluded here — it is handled by identifier wrapping above, not blanket +# escaping. The negative lookbehind skips a character already escaped by a +# prior application, which is what makes double application a no-op (h25). +_STRAY_CONTROL_CHAR_RE = re.compile(r"(? str: + """Escape + wrap one *non-code-span* slice of text. + + Order matters for idempotence: stray backticks and stray control + characters are escaped first (their patterns cannot match any character + the identifier wrap or its own inserted backticks introduce), then + identifiers are wrapped in fresh code spans last. On a second pass those + fresh spans are recognized as pre-existing code spans by ``md_safe_text`` + before ``_escape_segment`` ever sees their contents again. + """ + segment = _STRAY_BACKTICK_RE.sub(r"\\`", segment) + segment = _STRAY_CONTROL_CHAR_RE.sub(r"\\\1", segment) + segment = _IDENTIFIER_RE.sub(lambda m: f"`{m.group(0)}`", segment) + return segment + + +def md_safe_text(text: str) -> str: + """Render-only escaping for verbatim claim/task/instruction text (#87). + + - Underscore- and dunder-bearing identifiers (``_read_file``, + ``__init__.py``) are wrapped in code spans rather than backslash-escaped + — the fix the #87 issue comment prefers: it reads better in the + rendered artifact *and* fixes both MD037 (no-space-in-emphasis) and + MD050 (strong-style) in one move, since Markdown never parses inside a + code span. + - The remaining markdown control characters named in the issue as having + "the same exposure" (``*``, ``[``, ``]``, a stray backtick, a leading + ``#``) fall back to backslash-escaping. + - Text already inside a code span (a matched backtick pair) is left + completely untouched, byte-for-byte — devague claim text routinely + mixes prose with backticked tokens (c32/h25, issue-backlog-sweep), so + this must never double-wrap or re-escape what is already a code span. + - Pure and idempotent: ``md_safe_text(md_safe_text(x)) == md_safe_text(x)`` + for any ``x`` (h25) — the underlying Frame/Plan JSON is never touched, + only the rendered copy. + """ + parts: list[str] = [] + last = 0 + for m in _CODE_SPAN_RE.finditer(text): + parts.append(_escape_segment(text[last : m.start()])) + parts.append(m.group(0)) # existing code span: verbatim, never touched + last = m.end() + parts.append(_escape_segment(text[last:])) + result = "".join(parts) + # A leading '#' would be read as an ATX heading marker if this text ever + # lands at the true start of a rendered line. Checking the *result's* + # first character (not the original text[0]) keeps this idempotent: once + # escaped, the string starts with '\\', not '#', so a second pass is a + # no-op. + if result.startswith("#"): + result = "\\" + result + return result diff --git a/tests/test_md_safety.py b/tests/test_md_safety.py index 0635e64..e853716 100644 --- a/tests/test_md_safety.py +++ b/tests/test_md_safety.py @@ -1,6 +1,8 @@ """Unit tests for ``devague.render._md_safety`` — the render-time markdown-safety helpers behind #64 (``devague export`` / ``devague plan export`` emitted markdown -that failed markdownlint's MD026 no-trailing-punctuation and MD034 no-bare-urls). +that failed markdownlint's MD026 no-trailing-punctuation and MD034 no-bare-urls) +and #87 (verbatim claim/task text containing underscore identifiers or other +markdown control characters failed MD037/MD050 or corrupted rendered structure). These pin the exact stripping/wrapping rules against markdownlint-cli2's actual default behavior (verified empirically against markdownlint v0.40.0, the version @@ -8,11 +10,24 @@ ".,;:!" (all punctuation minus '?', per markdownlint's `allPunctuationNoQuestion` helper) and MD034 leaves a URL alone only when it is already wrapped in ``<...>``, is the destination of a ``[text](url)`` link, or sits inside a code span. + +``md_safe_text`` (#87) covers the remaining verbatim-passthrough exposure named +in the issue and its MD050 follow-up comment: underscore/dunder identifiers +(``_read_file``, ``__init__.py``) are wrapped in code spans rather than +backslash-escaped (the issue's stated preference — reads better *and* fixes +both MD037 and MD050 in one move, since Markdown never parses inside a code +span), while the remaining control characters (``*``, ``[``, ``]``, a stray +backtick, a leading ``#``) fall back to backslash-escaping. Text already inside +a code span must pass through byte-for-byte unchanged (claim text routinely +mixes prose and backticked tokens — this is c32/h25 from the +issue-backlog-sweep frame), and applying the function twice must be a no-op +(the same h25 condition, plus the general render-time-only contract already +proven for autolink_urls/heading_safe). """ from __future__ import annotations -from devague.render._md_safety import autolink_urls, heading_safe +from devague.render._md_safety import autolink_urls, heading_safe, md_safe_text # ── heading_safe (MD026) ────────────────────────────────────────────────────── @@ -132,3 +147,124 @@ def test_heading_safe_autolinks_then_strips_trailing_period_after_url() -> None: # trips MD026 even though the URL itself is safe. out = heading_safe("Ship https://example.com.") assert out == "Ship " + + +# ── md_safe_text: identifier wrapping (#87, MD037/MD050) ───────────────────── + + +def test_md_safe_text_wraps_leading_underscore_identifier() -> None: + # The exact repro token from the #87 issue body. + assert md_safe_text("calls _read_file here") == "calls `_read_file` here" + + +def test_md_safe_text_wraps_dunder_file_identifier() -> None: + # The exact repro token from the #87 MD050 follow-up comment: a dunder + # file name trips strong-style emphasis, not just MD037. + text = "no functional export is added to shell/fs/__init__.py" + expected = "no functional export is added to shell/fs/`__init__.py`" + assert md_safe_text(text) == expected + + +def test_md_safe_text_wraps_multiple_identifiers_in_one_string() -> None: + text = "_read_file and __init__.py both matter" + expected = "`_read_file` and `__init__.py` both matter" + assert md_safe_text(text) == expected + + +def test_md_safe_text_does_not_wrap_plain_words() -> None: + text = "no underscores appear in this sentence at all" + assert md_safe_text(text) == text + + +# ── md_safe_text: remaining control characters (#87 fallback) ──────────────── + + +def test_md_safe_text_escapes_stray_asterisk() -> None: + assert md_safe_text("a * b") == r"a \* b" + + +def test_md_safe_text_escapes_stray_open_bracket() -> None: + assert md_safe_text("a [ b") == r"a \[ b" + + +def test_md_safe_text_escapes_stray_close_bracket() -> None: + assert md_safe_text("a ] b") == r"a \] b" + + +def test_md_safe_text_escapes_stray_backtick() -> None: + # A single, unpaired backtick cannot form a code span — it is a stray + # literal character and must be backslash-escaped like the other control + # characters, not left to accidentally pair with a later backtick. + assert md_safe_text("a ` b") == r"a \` b" + + +def test_md_safe_text_escapes_leading_hash() -> None: + # A leading '#' would be parsed as an ATX heading marker if the verbatim + # text ever lands at the start of a rendered line. + assert md_safe_text("# Heading") == r"\# Heading" + + +def test_md_safe_text_only_escapes_leading_hash_not_mid_text() -> None: + # A '#' that is not at the very start of the text has no heading meaning + # and must survive untouched. + text = "see issue #87 for details" + assert md_safe_text(text) == text + + +# ── md_safe_text: code spans are never touched ──────────────────────────────── + + +def test_md_safe_text_leaves_already_backticked_identifier_unchanged() -> None: + # Claim text routinely mixes prose with backticked tokens — an escaper + # that wraps or escapes inside an existing code span would corrupt it + # (c32/h25, issue-backlog-sweep). + text = "call `_read_file` directly" + assert md_safe_text(text) == text + + +def test_md_safe_text_leaves_already_backticked_control_chars_unchanged() -> None: + text = "run `a[b]*c` verbatim" + assert md_safe_text(text) == text + + +def test_md_safe_text_mixed_prose_and_backticked_tokens() -> None: + # Bare identifier gets wrapped; the already-backticked one is untouched. + text = "_read_file calls into `_write_file` next" + expected = "`_read_file` calls into `_write_file` next" + assert md_safe_text(text) == expected + + +# ── md_safe_text: idempotence (double application is a no-op, h25) ─────────── + + +def test_md_safe_text_is_idempotent_on_identifiers() -> None: + once = md_safe_text("_read_file and __init__.py") + twice = md_safe_text(once) + assert once == twice + + +def test_md_safe_text_is_idempotent_on_control_chars() -> None: + once = md_safe_text("a * b [ c ] d ` e") + twice = md_safe_text(once) + assert once == twice + + +def test_md_safe_text_is_idempotent_on_leading_hash() -> None: + once = md_safe_text("# Heading") + twice = md_safe_text(once) + assert once == twice + + +def test_md_safe_text_is_idempotent_on_mixed_content() -> None: + once = md_safe_text( + "tests/test_honesty.py machine-enforces the posture with two regexes: " + "_CLAIM bans affirmative text and a leading # would break a heading, " + "also [brackets] and *asterisks* and a stray ` backtick" + ) + twice = md_safe_text(once) + assert once == twice + + +def test_md_safe_text_noop_on_clean_text() -> None: + text = "Ship the feature with no special characters" + assert md_safe_text(text) == text From 7b654c60c480d3769d408c0fd7d9609a5cd79099 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 10:25:56 +0300 Subject: [PATCH 07/25] feat(assign-to-workforce): durable gate-2 split artifact (#82, t15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `split-plan --write`, producing docs/plans/--split.md alongside the exported plan-md: real per-task summaries, instructions, and acceptance criteria from `devague plan waves --json`; a Task assignments (Owner/Model) table the script reads back on re-run so a human's edited assignment survives regeneration; and the End state section. Re-running overwrites the same dated path in place. Artifact-only per decision c25 — no plan schema change, no new devague CLI verb; devague plan waves/show/ deliverables stay read-only. SKILL.md documents the flow. --- .claude/skills/assign-to-workforce/SKILL.md | 58 +++- .../scripts/assign-to-workforce.sh | 308 +++++++++++++++++- .../2026-07-28-issue-backlog-sweep-split.md | 217 ++++++++++++ tests/test_assign_to_workforce_script.py | 142 +++++++- 4 files changed, 716 insertions(+), 9 deletions(-) create mode 100644 docs/plans/2026-07-28-issue-backlog-sweep-split.md diff --git a/.claude/skills/assign-to-workforce/SKILL.md b/.claude/skills/assign-to-workforce/SKILL.md index 7376316..a1c9ef0 100644 --- a/.claude/skills/assign-to-workforce/SKILL.md +++ b/.claude/skills/assign-to-workforce/SKILL.md @@ -40,7 +40,7 @@ repository whose plan you are implementing (plans persist under `.devague/` in the current directory): ```bash -bash .claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh split-plan [--plan ] +bash .claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh split-plan [--plan ] [--write] bash .claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh waves [--plan ] [--json] bash .claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh help ``` @@ -55,14 +55,18 @@ implementation split plan: task map (task id, wave, summary verbatim, whether an instruction is present, acceptance-criteria count), proposed per-task agent + model assignment, the go/no-go question, and — last — an End state section that is the verbatim output of `devague plan deliverables` (#70), -degrading to a one-line hint on a `devague` too old to have the verb. The -`waves` subcommand forwards to `devague plan waves` verbatim. +degrading to a one-line hint on a `devague` too old to have the verb. Adding +`--write` (issue #82) additionally persists that same content — plus an +owner/model annotation table the script reads back on the next `--write` — +to a durable file next to the exported plan-md; see *The durable split +artifact* below. The `waves` subcommand forwards to `devague plan waves` +verbatim. ### Usage | Subcommand | What it does | |------------|--------------| -| `split-plan [--plan S]` | Read `devague plan waves --json` and print the implementation split plan — task map (summary/instruction/acceptance-criteria count, verbatim) with per-task agent + model proposal, go/no-go, and a trailing End state section quoting `devague plan deliverables` verbatim (one-line hint on an older devague) — ready for human go/no-go review. | +| `split-plan [--plan S] [--write]` | Read `devague plan waves --json` and print the implementation split plan — task map (summary/instruction/acceptance-criteria count, verbatim) with per-task agent + model proposal, go/no-go, and a trailing End state section quoting `devague plan deliverables` verbatim (one-line hint on an older devague) — ready for human go/no-go review. With `--write`, also persist that content to `docs/plans/--split.md` (issue #82) — the durable gate-2 record; re-running overwrites the same path in place and preserves any hand-edited Owner/Model cells. | | `waves [--plan S] [--json]` | Forward to `devague plan waves [--json]`. Read-only; lists wave batches. On a converged plan exits 0 listing the waves. | | `help` | Print usage. | @@ -113,6 +117,45 @@ bash .claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh split-pla Do not proceed to fan-out until the human approves the split plan. +### The durable split artifact (`split-plan --write`) + +Unlike the exported spec (`docs/specs/*.md`) and the exported plan +(`docs/plans/*.md`), the implementation split plan — gate 2 — survived only +in conversation before issue #82. `split-plan --write` closes that gap with +an **artifact-only** change (decision c25): the written file *is* the record; +there is no plan-schema change and no new `devague` CLI verb, so `devague +plan waves`/`show`/`deliverables` stay read-only exactly as before. + +```bash +bash .claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh split-plan --write +``` + +This writes (or overwrites) `docs/plans/--split.md` — +the same date-prefix convention `devague plan export` uses for the plan-md +it sits beside, derived from the plan's own `created` timestamp (via +`devague plan show --json`) rather than today's date, so re-running the +command is idempotent: it updates the same file in place instead of spawning +a dated duplicate. The file carries: + +1. The dependency waves and the full per-task content (summary, instruction, + acceptance criteria, covered targets) for every wave — quoted verbatim + from `devague plan waves --json`, organized under one `## Wave N` heading + per wave and one `### ` heading per task. +2. A **Task assignments** table (`Task | Owner | Model`) — the durable form + of the human's per-task owner/model decision (#82 ask 2). The script + **reads this table back** from any existing file at the same path before + regenerating: a human's edited `Owner`/`Model` cell for a given task id + survives the next `--write`, matched by task id, rather than being + clobbered back to the `sonnet` default. Only edit this table (or add a + new plan/task and re-run) — don't hand-edit the wave/task sections above + it, since those are fully regenerated every run. +3. The same End state section as plain `split-plan` — the verbatim output of + `devague plan deliverables`, nested under its own `## End state` heading. + +Present this file (or its stdout twin from plain `split-plan`) at the go/no-go +either way; `--write` is for keeping a committed record of what was actually +approved, not a replacement for the live review. + ### The `waves --json` payload — the single source for every brief `devague plan waves --json` emits `{"plan": "", "waves": [[...], ...], @@ -296,6 +339,13 @@ an older `devague`, it prints exactly one hint line naming the minimum version (e.g. `hint: End state view requires devague >= 0.18.0 (devague plan deliverables)`) and `split-plan` still exits 0. +`--write` adds exactly one line after all of the above: `wrote split +artifact: ` on the first run, `updated split artifact: ` on every +run after (issue #82). It calls one additional read-only command, +`devague plan show --json` (for the plan's `created` timestamp and title); +a failure there exits non-zero with that command's own stderr, same as a +`plan waves --json` failure. + ## Worked example Picking up after `/spec-to-plan` exported a plan for the frame `my-feature`: diff --git a/.claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh b/.claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh index 9527679..baec71f 100755 --- a/.claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh +++ b/.claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh @@ -21,6 +21,15 @@ # the dependency graph; it does not spawn agents, manage worktrees, or pick # a backend. This wrapper is the operator-facing helper. # +# `split-plan --write` (issue #82, decision c25, artifact-only) additionally +# persists a durable gate-2 record to `docs/plans/--split.md` +# — the same per-task content as the stdout table/waves listing, an +# owner/model annotation block the script reads back on re-run (so a human's +# edited assignments survive a regeneration instead of being clobbered), and +# the End state section. Re-running overwrites the same dated path in place. +# This adds no plan schema change and no new CLI verb — the written file IS +# the record; `devague plan waves`/`show`/`deliverables` stay read-only. +# # Origin: authored and maintained in agentculture/devague. guildmaster pulls this # skill from here and broadcasts it to the rest of the AgentCulture mesh, so # it is written to run anywhere — portable bash, no devague-checkout assumptions. @@ -64,7 +73,8 @@ usage() { assign-to-workforce.sh — fan out devague plan waves to parallel agents. Usage: - assign-to-workforce.sh split-plan [--plan ] print the implementation split plan + assign-to-workforce.sh split-plan [--plan ] [--write] + print the implementation split plan assign-to-workforce.sh waves [--plan ] [--json] list dependency waves assign-to-workforce.sh help this help @@ -78,6 +88,15 @@ Commands: output of `devague plan deliverables` (degrades to a one-line hint on an older devague lacking that verb). Present this to the human before any fan-out; do not proceed without approval. + + --write Also persist a durable gate-2 record to + `docs/plans/--split.md` (issue + #82, artifact-only): real per-task summaries, + instructions, and acceptance criteria; an + owner/model annotation table this script reads back + on the next `--write` run (so a human's edits + survive a regeneration); and the End state section. + Re-running overwrites the same dated path in place. waves Forward `devague plan waves` (and any extra flags) verbatim. On a converged plan exits 0 and lists the dependency waves. @@ -98,10 +117,20 @@ EOF # ── split-plan: render the implementation split plan for human review ──────── cmd_split_plan() { local extra_args=() - # Forward any --plan flag so waves targets the right plan. + local write_mode=0 + # Forward any --plan flag so waves/show/deliverables all target the right + # plan; --write is a script-only flag and is never forwarded to devague. while [ $# -gt 0 ]; do - extra_args+=("$1") - shift + case "$1" in + --write) + write_mode=1 + shift + ;; + *) + extra_args+=("$1") + shift + ;; + esac done local waves_json tmp_err waves_rc old_exit_trap @@ -256,6 +285,277 @@ PY else echo 'hint: End state view requires devague >= 0.18.0 (devague plan deliverables)' fi + + # ── --write: persist a durable gate-2 split artifact (#82 asks 2+3) ────── + # Artifact-only per decision c25: the written file IS the record — no plan + # schema change, no new CLI verb. `devague plan show --json` is the only + # extra read-only call (for the plan's `created`/`title`, to match the + # date-prefixed filename convention `devague plan export` already uses). + if [ "$write_mode" -eq 1 ]; then + local show_json show_rc + set +e + show_json="$("${DEVAGUE[@]}" plan show --json "${extra_args[@]}" 2>&1)" + show_rc=$? + set -e + if [ "$show_rc" -ne 0 ]; then + printf '%s\n' "$show_json" >&2 + return "$show_rc" + fi + + DEVAGUE_WAVES_JSON="$waves_json" \ + DEVAGUE_SHOW_JSON="$show_json" \ + DEVAGUE_DELIVERABLES_OK="$([ "$deliverables_rc" -eq 0 ] && echo 1 || echo 0)" \ + DEVAGUE_DELIVERABLES_MD="$deliverables_out" \ + python3 - <<'PY' +import datetime +import json +import os +import re +import sys +from pathlib import Path + +raw_waves = os.environ.get("DEVAGUE_WAVES_JSON", "").strip() +raw_show = os.environ.get("DEVAGUE_SHOW_JSON", "").strip() +deliverables_ok = os.environ.get("DEVAGUE_DELIVERABLES_OK") == "1" +deliverables_md = os.environ.get("DEVAGUE_DELIVERABLES_MD", "") + +try: + waves_data = json.loads(raw_waves) + show_data = json.loads(raw_show) +except json.JSONDecodeError as exc: + print(f"error: could not parse devague JSON for split artifact: {exc}", file=sys.stderr) + sys.exit(1) + +plan_slug = waves_data.get("plan", "(unknown)") +waves = waves_data.get("waves") or [] +tasks_meta = waves_data.get("tasks") or {} +plan_title = show_data.get("title") or plan_slug +plan_created = show_data.get("created") or "" + +# ── date prefix: mirrors devague.cli._paths.dated_name's rationale (the date +# comes from the object's persisted `created` stamp, never today's date, so +# re-running this script is idempotent — same path, not a dated duplicate). +# Re-implemented here rather than imported: this script stays portable and +# does not assume a devague checkout is importable (mesh-first resolution). +UNDATED_PREFIX = "0000-00-00" + + +def dated_prefix(created): + date = (created or "")[:10] + try: + datetime.datetime.strptime(date, "%Y-%m-%d") + except ValueError: + return UNDATED_PREFIX + return date + + +out_dir = Path("docs/plans") +out_path = out_dir / f"{dated_prefix(plan_created)}-{plan_slug}-split.md" + +# ── markdown-safety helpers — mirror devague/render/_md_safety.py exactly +# (duplicated, not imported, for the same portability reason as above): task +# summaries/instructions/acceptance criteria are free-form prose that can +# legitimately contain bare URLs (MD034) and — since this content also +# carries backtick-quoted CLI paths like `docs/plans/-...` +# — angle-bracket placeholder tokens that trip MD033 if they ever land +# outside a code span. `backtick_bare_angle_brackets` is this script's own +# defensive addition (not in the upstream helper) since raw task/instruction +# text is interpolated here, unlike the CLI's own renderers. +_HEADING_TRAILING_PUNCT_RE = re.compile(r"\s*[.,;:!。,;:!]+$") +_BARE_URL_RE = re.compile(r"(?()]+") +_URL_TRAILING_PUNCT = ".,;:!?'\"" +_CODE_SPAN_RE = re.compile(r"`[^`]*`") +_ANGLE_TOKEN_RE = re.compile(r"<[^<>\s][^<>]*>") + + +def _strip_url_trailing_punct(url): + trail = "" + while url: + ch = url[-1] + if ch in _URL_TRAILING_PUNCT: + trail = ch + trail + url = url[:-1] + elif ch == ")" and url.count("(") < url.count(")"): + trail = ch + trail + url = url[:-1] + else: + break + return url, trail + + +def _autolink_segment(segment): + def _wrap(match): + url, trail = _strip_url_trailing_punct(match.group(0)) + return f"<{url}>{trail}" + + return _BARE_URL_RE.sub(_wrap, segment) + + +def _split_code_spans(text, transform): + if not text: + return text + parts = [] + last = 0 + for m in _CODE_SPAN_RE.finditer(text): + parts.append(transform(text[last : m.start()])) + parts.append(m.group(0)) # code span: verbatim, never rewritten + last = m.end() + parts.append(transform(text[last:])) + return "".join(parts) + + +def autolink_urls(text): + if "https://" not in text and "http://" not in text: + return text + return _split_code_spans(text, _autolink_segment) + + +def backtick_bare_angle_brackets(text): + """Defensively code-span any ``-shaped token not already + inside a code span, so a stray angle-bracket placeholder in task prose + can never render as raw HTML (MD033).""" + if "<" not in text: + return text + return _split_code_spans( + text, lambda seg: _ANGLE_TOKEN_RE.sub(lambda m: f"`{m.group(0)}`", seg) + ) + + +def heading_safe(text): + return _HEADING_TRAILING_PUNCT_RE.sub("", autolink_urls(text)) + + +def safe_body(text): + return autolink_urls(backtick_bare_angle_brackets(text)) + + +def safe_heading(text): + return heading_safe(backtick_bare_angle_brackets(text)) + + +def parse_existing_assignments(path): + """Read back the `## Task assignments` table from a prior run, keyed by + task id -> (owner, model) — the annotation-block round-trip (#82 ask 2): + a human's edited Owner/Model cells survive the next `--write` rather than + being clobbered by a fresh default.""" + if not path.exists(): + return {} + lines = path.read_text(encoding="utf-8").splitlines() + try: + start = lines.index("## Task assignments") + except ValueError: + return {} + result = {} + for line in lines[start + 1 :]: + stripped = line.strip() + if stripped.startswith("## "): + break + if not stripped.startswith("|"): + continue + cells = [c.strip() for c in stripped.strip("|").split("|")] + if len(cells) != 3: + continue + if cells == ["Task", "Owner", "Model"]: + continue + if all(set(c) <= {"-"} for c in cells): + continue # the "| --- | --- | --- |" separator row + task_id = cells[0].strip("`") + result[task_id] = (cells[1], cells[2]) + return result + + +DEFAULT_MODEL = "sonnet" +task_order = [tid for wave in waves for tid in wave] +prior_assignments = parse_existing_assignments(out_path) + +lines = [] +lines.append(f"# Implementation Split Plan — {safe_heading(plan_title)}") +lines.append("") +lines.append( + f"slug: `{plan_slug}` · generated by `assign-to-workforce.sh split-plan " + "--write` from `devague plan waves --json` (issue #82). Re-running this " + "command overwrites this file in place; edits made to the **Task " + "assignments** table below survive a regeneration, matched by task id." +) +lines.append("") + +lines.append("## Dependency waves") +lines.append("") +for i, wave in enumerate(waves, 1): + ids = ", ".join(f"`{tid}`" for tid in wave) + lines.append(f"- Wave {i}: {ids}") +lines.append("") + +lines.append("## Task assignments") +lines.append("") +lines.append( + "Edit the Owner/Model columns before approving gate 2 — the default " + "Model is a presentation-only proposal (`sonnet`), never a " + "recommendation. Re-running `split-plan --write` preserves your edits " + "here, matched by task id." +) +lines.append("") +lines.append("| Task | Owner | Model |") +lines.append("| --- | --- | --- |") +for tid in task_order: + owner, model = prior_assignments.get(tid, ("", DEFAULT_MODEL)) + lines.append(f"| `{tid}` | {owner} | {model} |") +lines.append("") + +for i, wave in enumerate(waves, 1): + lines.append(f"## Wave {i}") + lines.append("") + for tid in wave: + meta = tasks_meta.get(tid) or {} + summary = meta.get("summary") or "(no summary recorded)" + lines.append(f"### {tid} — {safe_heading(summary)}") + lines.append("") + body = [] + instruction = meta.get("instruction") or "" + if instruction: + body.append(f"- instruction: {safe_body(instruction)}") + covers = meta.get("covers") or [] + if covers: + body.append(f"- covers: {', '.join(covers)}") + accept = meta.get("acceptance_criteria") or [] + if accept: + body.append("- acceptance:") + body.extend(f" - {safe_body(a)}" for a in accept) + if body: + lines.extend(body) + lines.append("") + +lines.append("## End state") +lines.append("") +if deliverables_ok and deliverables_md.strip(): + body_lines = deliverables_md.rstrip("\n").splitlines() + if body_lines and body_lines[0].startswith("# "): + body_lines = body_lines[1:] # drop the render's own title (MD025) + while body_lines and body_lines[0] == "": + # Drop the blank line the title left behind too — our own "## End + # state" heading above already supplied the one separating blank + # (MD012 no-multiple-blanks). + body_lines = body_lines[1:] + for line in body_lines: + if line.startswith("## "): + lines.append("#" + line) # demote so it nests under "## End state" + else: + lines.append(line) + lines.append("") +else: + lines.append( + "hint: End state view requires devague >= 0.18.0 " + "(`devague plan deliverables`)." + ) + lines.append("") + +content = "\n".join(lines).rstrip("\n") + "\n" +out_dir.mkdir(parents=True, exist_ok=True) +action = "updated" if out_path.exists() else "wrote" +out_path.write_text(content, encoding="utf-8") +print(f"{action} split artifact: {out_path}") +PY + fi } main() { diff --git a/docs/plans/2026-07-28-issue-backlog-sweep-split.md b/docs/plans/2026-07-28-issue-backlog-sweep-split.md new file mode 100644 index 0000000..da839d3 --- /dev/null +++ b/docs/plans/2026-07-28-issue-backlog-sweep-split.md @@ -0,0 +1,217 @@ +# Implementation Split Plan — issue-backlog-sweep + +slug: `issue-backlog-sweep` · generated by `assign-to-workforce.sh split-plan --write` from `devague plan waves --json` (issue #82). Re-running this command overwrites this file in place; edits made to the **Task assignments** table below survive a regeneration, matched by task id. + +## Dependency waves + +- Wave 1: `t1`, `t2`, `t8`, `t15`, `t16` +- Wave 2: `t3`, `t4`, `t9`, `t13` +- Wave 3: `t5`, `t10`, `t14`, `t17` +- Wave 4: `t6`, `t11`, `t18` +- Wave 5: `t7`, `t12` +- Wave 6: `t19` + +## Task assignments + +Edit the Owner/Model columns before approving gate 2 — the default Model is a presentation-only proposal (`sonnet`), never a recommendation. Re-running `split-plan --write` preserves your edits here, matched by task id. + +| Task | Owner | Model | +| --- | --- | --- | +| `t1` | | sonnet | +| `t2` | | sonnet | +| `t8` | | sonnet | +| `t15` | | sonnet | +| `t16` | | sonnet | +| `t3` | | sonnet | +| `t4` | | sonnet | +| `t9` | | sonnet | +| `t13` | | sonnet | +| `t5` | | sonnet | +| `t10` | | sonnet | +| `t14` | | sonnet | +| `t17` | | sonnet | +| `t6` | | sonnet | +| `t11` | | sonnet | +| `t18` | | sonnet | +| `t7` | | sonnet | +| `t12` | | sonnet | +| `t19` | | sonnet | + +## Wave 1 + +### t1 — Escaping engine in render/_md_safety.py + +- instruction: Add a pure escape function in devague/render/_md_safety.py only — no renderer call sites here (they land in t3/t9/t13). Wrap underscore/dunder identifiers in code spans per the #87 comment preference; escape remaining markdown control characters; skip text already inside code spans; make double application a no-op. +- covers: c32, h25 +- acceptance: + - unit tests in tests/test_md_safety.py cover `_read_file`, `__init__.py`, `*`, `[`, backtick, and leading `#` inputs, plus already-backticked text passing through unchanged and idempotence on double application + +### t2 — Schema and load-order hardening in both stores + +- instruction: Fix store.py and plan_store.py to check schema_version BEFORE parsing via from_dict (today store.py:136-147 and plan_store.py:54-62 parse first); make HardQuestion/Vagueness loading tolerant of unknown keys like Claim already is (frame.py:315,322); bump frame SCHEMA_VERSION for the new hard-question resolution field and PLAN_SCHEMA_VERSION for defer state. +- acceptance: + - a frame or plan JSON declaring a newer schema_version fails with the fail-closed IncompatibleSchemaError message, never a raw TypeError, covered by tests for both stores; existing v3 frames and plans load unchanged + +### t8 — Live-target validation for cover and --covers + +- instruction: Make `_require_target` (cli/_commands/plan.py:145-151) validate against live-frame-derived targets exactly as converge does via `_live` (108-118), persisting the refreshed snapshot on success; decide the regressed-frame fallback (frame park v4) here — either stored-snapshot fallback or refusal with the reconverge hint — and test the chosen behavior. +- covers: c9, h9 +- acceptance: + - the verified #90 repro inverts: after the frame grows a confirmed claim, `plan status` recommends cover for it AND that exact cover succeeds immediately with no intervening converge; a target unknown to both stored and live sets is still refused + +### t15 — Durable gate-2 split artifact in assign-to-workforce + +- instruction: Per decision c25 (artifact-only): the skill script gains a write mode producing `docs/plans/--split.md` — real per-task summaries, acceptance criteria, instructions from `plan waves --json`, an owner/model annotation block the skill reads back on re-run, and the End state section; re-running overwrites the same dated path; SKILL.md documents the flow; no plan schema change, no new CLI verb. +- covers: c15, h15 +- acceptance: + - the written split artifact carries real task content, the annotation block, and End state; re-run overwrites in place; the file passes markdownlint-cli2 + +### t16 — Scope skill fans out to smaller-tier subagents + +- instruction: scope/SKILL.md steps 1-2 gain a read-only subagent fan-out pattern with a smaller default tier (sonnet), scaled by surface count — small ideas still explore inline (no wizard); provenance and read-only rules unchanged. +- covers: c16, h16 +- acceptance: + - SKILL.md instructs subagent fan-out with a default smaller tier and keeps the read-only + provenance hard rules; the no-wizard escape for small ideas survives + +## Wave 2 + +### t3 — spec_md renderer sweep: parks, hard questions, dead seeds, escaping + +- instruction: devague/render/spec_md.py: render all open park kinds grouped by kind (replace the `_follow_up` filter at 110-118); render hard questions with resolved markers and skip rejected parent claims (104-107); flag scope entries whose seeds cite a rejected claim; apply the t1 escaper at every verbatim site. Flip tests/test_render.py:301-306. +- covers: c2, c3, c6, c22, c33, h2, h3, h6, h18, h26 +- acceptance: + - a converged frame with open parks of all four kinds exports a spec listing each park labeled by kind + - a resolved hard question renders with a resolved marker and a hard question on a rejected claim is absent from the export (the #83 repro shape: capture, interrogate --risk, reject, converge, export) + - a scope entry seeding a rejected claim renders a visible rejected marker instead of a bare dead id + - exporting the issue-backlog-sweep frame twice is byte-stable, lints clean under markdownlint-cli2 default config, and frame JSON on disk is byte-identical before and after; `show --json` output unchanged + +### t4 — Hard-question resolve move: interrogate --resolve + +- instruction: Per decision c36: `devague interrogate --resolve [--decision ""]`. Add Frame.resolve_hard_question storing the optional decision text (schema field from t2); make the convergence gate (convergence.py:111-116) skip rejected claims; update suggest_move (206-211) to name the shipped move verbatim. +- covers: c4, h4 +- acceptance: + - the block-resolve-converge sequence completes through CLI moves alone, suggest_move output names the shipped move, resolved state and decision text survive a save/load round-trip, and a rejected claim with an unresolved blocking question no longer blocks converge + +### t9 — Per-target deferral: plan defer + +- instruction: New `devague plan defer --reason ""` persisting deferral state (plan schema bump from t2); `_missing_coverage` (plan_convergence.py:26-32) excludes deferred targets; `plan status` reports deliberately-deferred distinctly; plan_md renders a Deferred targets section naming each with its reason, and applies the t1 escaper at its verbatim sites. +- covers: c10, h10, c6 +- acceptance: + - a plan with deferred targets converges and exports; the export names every deferred target with its reason; status distinguishes deferred from uncovered; the shell-cli shape (90 covered, 12 deferred) converges in a test + - plan-md output passes markdownlint-cli2 with underscore-bearing task text (MD050 regression from the #87 comment) + +### t13 — Summary scoped to confirmed tasks + +- instruction: summary_md.py: Planned Work and Actual Delivery (119-143) plus summary_data (265-271) iterate confirmed tasks only, with a single line counting rejected tasks; apply the t1 escaper at verbatim sites; `dependency_waves` already excludes rejected (plan.py:285) so --pr needs no change — pin that with a test. +- covers: c13, h13 +- acceptance: + - a plan with N confirmed and M rejected tasks emits exactly N Actual Delivery rows and N Planned Work entries plus one line counting the M rejected; the --pr wave map stays rejected-free; regression test covers a mixed-status plan + +## Wave 3 + +### t5 — Reject cascade over attachments + +- instruction: Rejecting a claim cascades to its honesty conditions and hard questions, echoing what it took (`c21 -> rejected (also rejected: h3, q1)`); `_assumption_warnings` (convergence.py:120-126) skips rejected claims; `devague review` stops listing conditions whose parent claim is rejected. +- covers: c5, h5 +- acceptance: + - rejecting a claim with attachments reports exactly what cascaded; post-reject converge emits zero warnings about the rejected claim; review lists zero orphans; regression test asserts the risk text is absent from the exported markdown + +### t10 — Dependency validation at task creation + +- instruction: plan task `--dep` refuses the about-to-be-assigned id (self-cycle) and unknown task ids at creation with actionable hints; `depend --on ` gets the same checks; flip tests/test_cli_plan.py:87-96 rather than deleting it. +- covers: c11, h11 +- acceptance: + - creating a task with `--dep` naming its own id or an unknown id fails with the actionable hint; the flipped test passes; existing valid graphs are unaffected + +### t14 — Contested-by-deviation derivation: export, show, status + +- instruction: Per decisions c24/c19: a pure read-only derivation joining frame claims to approved deviations via plan_store.list_slugs() filtered on frame_slug, then delivery_store per plan slug; re-exported specs render a contested marker under affected confirmed claims; show/status gain a contested line; missing/corrupt/newer-schema delivery stores degrade to markerless rendering with a stderr diagnostic — never a crash; zero frame-state mutation. +- covers: c14, c21, c34, h14, h17, h27 +- acceptance: + - an approved deviation whose --affects names a confirmed claim yields a contested marker on re-export and a contested line in show and status + - export, show, and status succeed on a frame whose delivery store is missing, truncated, or declares a newer schema (three corruption-shape tests); frame JSON is byte-identical before and after + +### t17 — learn/explain recipes cover every new surface + +- instruction: devague learn, learn skills (SCOPE_STAGE at learn.py:153-171, ASSIGN_TO_WORKFORCE_GUIDANCE at 65-112), and explain document: interrogate --resolve, amend, scope --amend, plan defer, plan risk --amend, multi-id plan confirm/reject, live cover, and the scope subagent fan-out. +- covers: c31, h24 +- acceptance: + - tests grep learn/explain output for each new verb and flag; the #52 acceptance criterion (learn/explain document the resolve path) passes + +## Wave 4 + +### t6 — Claim and scope-entry amend moves + +- instruction: New `devague amend --text/--kind` keeping id, honesty conditions, instruction, and inbound scope seeds; `devague scope --amend --finding`; amending a confirmed claim flips it to proposed with the echoed flip, matching the interrogate.py:58-68 precedent; origin never changes silently. +- covers: c7, h7 +- acceptance: + - amending a confirmed claim keeps its id and attachments and flips it to proposed with an echo; correcting one number costs exactly one move; scope --amend replaces a finding in place + +### t11 — Multi-id transactional plan confirm/reject + +- instruction: plan confirm/reject accept N ids applied transactionally (all valid or none), matching the frame-side contract (confirm.py:27-50); argument errors inside the plan group point at `devague plan explain `. +- covers: c12, h12 +- acceptance: + - plan reject with three ids where one is invalid applies none and says why; with all valid, applies all in one call; error hints inside the plan group name `plan explain` + +### t18 — Docs sweep, changelog, version bump + +- instruction: README.md, CLAUDE.md status entry, docs/skills.md, docs/skill-sources.md version-stamp rows (including the docs/skills.md:255-256 waves-json drift), CHANGELOG.md entry, minor version bump via /version-bump; sign nothing manually — cicd appends. +- acceptance: + - all four docs describe the shipped behavior; version-check CI passes; markdownlint-cli2 clean on changed docs + +## Wave 5 + +### t7 — scope --seeds accepts question ids + +- instruction: Frame.add_scope_entry (frame.py:252-254) accepts `q*` ids resolving against claim-attached hard questions; unknown `q*` still refused with the show hint; exported scope section renders question seeds. +- covers: c8, h23 +- acceptance: + - scope --seeds with a valid `q*` id records; an unknown `q*` id is refused with the hint; the seeded question renders in the exported scope-exploration section + +### t12 — Plan-risk amend + +- instruction: New `plan risk --amend --text ""` editing risk text in place, preserving id, kind, task link, and resolution state — the #84 comment case where a referenced task id rotates. +- covers: c7 +- acceptance: + - amending a risk keeps its id, kind, and resolution state while replacing text; amending an unknown rid is refused with a hint + +## Wave 6 + +### t19 — End-to-end verification and issue closure map + +- instruction: Repro-first evidence: each of the fifteen issues has a named regression test; full suite green via run-tests with coverage at or above 95 percent; the markdownlint integration test exports the issue-backlog-sweep frame as corpus; produce the issue-to-test closure map for the PR body so each issue closes with a pointer. +- covers: c1, c27, c28, c29, c30, h1, h19, h20, h21, h22 +- acceptance: + - pytest -n auto green with coverage >= 95 percent; markdownlint integration green; a closure map lists all 15 issues each naming its regression test; the three downstream workarounds are named as deletable in the map + +## End state + +### Announcement + +- devague closes its fifteen-issue backlog: exports become lossless and lint-clean, rejected and contested content stops leaking into artifacts, plans gain live coverage targets with per-target deferral, claims and hard questions gain amend and resolve moves, the delivery summary scopes to confirmed work, gate 2 gains a durable split artifact, and scope exploration fans out to smaller-tier subagents + +### After state + +- all fifteen open issues (#48, #49, #52, #79, #82-#88, #90-#93) close on one release: exported artifacts are lossless, lint-clean, and honest about rejection and deviation; plans cover live targets, defer deliberately, and validate deps at creation; hard questions resolve and claims amend without id churn; the summary is confirmed-only; gate 2 leaves a durable artifact; scope fans out to smaller-tier subagents + +### Success signals + +- all 15 issues closed by the release with a regression test each; exported spec-md, plan-md, and split artifacts produce 0 markdownlint-cli2 errors under the default config; the full pytest suite stays green with coverage >= 95%; the 3 downstream workarounds named in the issues (render_plan.py projection, docs/specs lint ignores, hand-edited frame JSON) become deletable + +### Terminal tasks + +- `t19` — End-to-end verification and issue closure map + - pytest -n auto green with coverage >= 95 percent; markdownlint integration green; a closure map lists all 15 issues each naming its regression test; the three downstream workarounds are named as deletable in the map + +### Open items + +- [unknown_nonblocking] issue 86 reports plan converge missed a self-cycle on 0.20.0 but the code shows evaluate includes `_missing_dep_integrity` — unverified which is true for the installed 0.20.0; creation-time validation fixes it either way +- [follow_up] downstream repos carry stale vendored copies and workarounds to retire once fixes land: reachy-mini-cli split placeholder table and render_plan.py projection (issues 82/85), shell-cli markdownlint ignores for docs/specs (issue 87); notify via guildmaster re-broadcast +- [unknown_nonblocking] whether issue 85 secondary ask — warn when a task covers many targets with few acceptance criteria — ships in this sweep or as a later plan-convergence warning heuristic +- [unknown_nonblocking] behavior of live-target cover when the source frame has regressed below convergence: `_live` refuses in that state — whether cover falls back to the stored snapshot or refuses with the reconverge hint is an implementation-time decision +- [unknown_nonblocking] re-exporting any historical committed frame after the escaping change rewrites its dated spec file with code-span wrapping — expected and presentational-only, but the first re-export after upgrade produces a large diff reviewers should anticipate +- [unknown_nonblocking] task granularity across a 19-task combined release may shift once implementation starts; mid-run divergence routes through /deviate against gate 2 +- [unknown_nonblocking] regressed-frame fallback for live cover (frame park v4) is decided inside t8 — either stored-snapshot fallback or refusal with the reconverge hint +- [unknown_nonblocking] first re-export of historical committed specs after the escaper lands produces large presentational diffs (frame park v5) — reviewers should expect them +- [follow_up] downstream repos (reachy-mini-cli, shell-cli, headspace-cli) must re-vendor updated skills via the guildmaster re-broadcast after release and can then delete their workarounds diff --git a/tests/test_assign_to_workforce_script.py b/tests/test_assign_to_workforce_script.py index 27b7904..dd1994a 100644 --- a/tests/test_assign_to_workforce_script.py +++ b/tests/test_assign_to_workforce_script.py @@ -28,8 +28,9 @@ import pytest -from devague import store +from devague import plan_store, store from devague.cli import main +from devague.cli._paths import dated_name _REPO_ROOT = Path(__file__).resolve().parent.parent _SCRIPT = ( @@ -306,3 +307,142 @@ def test_split_plan_degrades_gracefully_without_deliverables_verb(tmp_path, monk # down the whole command. assert "Go/no-go" in out assert "Create one git worktree per task" in out + + +# ── `split-plan --write`: the durable gate-2 split artifact (#82, decision +# c25 — artifact-only: the written file IS the record, no plan-schema change, +# no new CLI verb). ─────────────────────────────────────────────────────────── + +_MARKDOWNLINT = shutil.which("markdownlint-cli2") + + +def _run_split_plan_write(tmp_path: Path) -> subprocess.CompletedProcess[str]: + bin_dir = _devague_shim(tmp_path) + env = dict(os.environ) + env["PATH"] = f"{bin_dir}{os.pathsep}{env.get('PATH', '')}" + return subprocess.run( # noqa: S603 - fixed argv, no shell, test-only + [_BASH, str(_SCRIPT), "split-plan", "--write"], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + check=False, + ) + + +def _expected_split_path(tmp_path: Path) -> Path: + """The dated split-artifact path for the fixture plan, derived the same + way `devague plan export` names the plan-md it sits beside (#12) — so a + test asserting the wrong path would also mean the shipped filename + convention drifted from the plan-md one, not just a test bug.""" + plan = plan_store.load(plan_store.current_slug()) + plan_md_name = dated_name(plan.created, plan.slug) + assert plan_md_name.endswith(".md") + return tmp_path / "docs" / "plans" / (plan_md_name[: -len(".md")] + "-split.md") + + +def test_split_plan_write_creates_durable_artifact_with_real_content(tmp_path, monkeypatch) -> None: + _fixture_plan(monkeypatch, tmp_path) + result = _run_split_plan_write(tmp_path) + assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + + out_path = _expected_split_path(tmp_path) + assert out_path.exists(), f"expected split artifact at {out_path}" + rel = out_path.relative_to(tmp_path) + assert f"wrote split artifact: {rel}" in result.stdout + + text = out_path.read_text(encoding="utf-8") + + # Real per-task content, verbatim — never a placeholder (#82 ask 1, the + # part that already shipped in 0.16.0; still true of the artifact). + assert "Wire the login form" in text + assert "verify via `pytest`" in text + assert "form submits" in text + assert "errors render" in text + assert "Send the welcome email" in text + assert "email delivered" in text + # The artifact quotes the untruncated summary — unlike the stdout table's + # 72-char ellipsis truncation (#77), there is no width constraint on disk. + assert _LONG_SUMMARY in text + + # The owner/model annotation block (#82 ask 2), with every fixture task + # id present at its default (unassigned owner, `sonnet` model). + assert "## Task assignments" in text + assert "| Task | Owner | Model |" in text + for tid in ("t1", "t2", "t3"): + assert f"| `{tid}` | | sonnet |" in text + + # The End state section, nested under our own H1 (not a second title — + # MD025) and never duplicated. + assert "## End state" in text + assert text.count("\n# ") == 0 + assert text.startswith("# Implementation Split Plan — ") + + +def test_split_plan_write_rerun_overwrites_in_place_and_preserves_edit( + tmp_path, monkeypatch +) -> None: + """The crux of ask 2: a human hand-edits the Owner/Model cell for one + task; regenerating the artifact must carry that edit forward rather than + clobbering it back to the default, while everything else (including + other tasks' still-default rows) regenerates normally.""" + _fixture_plan(monkeypatch, tmp_path) + first = _run_split_plan_write(tmp_path) + assert first.returncode == 0, first.stderr + out_path = _expected_split_path(tmp_path) + rel = out_path.relative_to(tmp_path) + assert f"wrote split artifact: {rel}" in first.stdout + + original = out_path.read_text(encoding="utf-8") + assert "| `t2` | | sonnet |" in original + edited = original.replace("| `t2` | | sonnet |", "| `t2` | ori | opus |") + assert edited != original + out_path.write_text(edited, encoding="utf-8") + + second = _run_split_plan_write(tmp_path) + assert second.returncode == 0, second.stderr + assert f"updated split artifact: {rel}" in second.stdout + + regenerated = out_path.read_text(encoding="utf-8") + # The edited assignment survived the regeneration... + assert "| `t2` | ori | opus |" in regenerated + assert "| `t2` | | sonnet |" not in regenerated + # ...while an untouched task's row stays at the default, and every other + # section (waves, task content, End state) is regenerated, not skipped. + assert "| `t1` | | sonnet |" in regenerated + assert "Wire the login form" in regenerated + assert "## End state" in regenerated + + +def test_split_plan_without_write_flag_does_not_touch_disk(tmp_path, monkeypatch) -> None: + """The opt-in contract: plain `split-plan` (no `--write`) must never write + the artifact — this is what every earlier test in this file already + exercises implicitly, asserted here explicitly as a regression guard.""" + _fixture_plan(monkeypatch, tmp_path) + result = _run_split_plan(tmp_path) + assert result.returncode == 0, result.stderr + out_path = _expected_split_path(tmp_path) + assert not out_path.exists() + assert not (tmp_path / "docs").exists() + + +@pytest.mark.skipif(_MARKDOWNLINT is None, reason="markdownlint-cli2 not on PATH") +def test_split_plan_write_artifact_passes_markdownlint(tmp_path, monkeypatch) -> None: + _fixture_plan(monkeypatch, tmp_path) + result = _run_split_plan_write(tmp_path) + assert result.returncode == 0, result.stderr + out_path = _expected_split_path(tmp_path) + + # Lint under *this repo's* markdownlint config (MD013/MD060 disabled, + # MD024 siblings_only) — the config that actually gates this artifact + # once it is committed here — rather than markdownlint-cli2's stricter + # built-in defaults, which would flag unrelated things like line length. + shutil.copy(_REPO_ROOT / ".markdownlint-cli2.yaml", tmp_path / ".markdownlint-cli2.yaml") + lint = subprocess.run( # noqa: S603 - fixed argv, no shell, test-only + [_MARKDOWNLINT, str(out_path.relative_to(tmp_path))], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + ) + assert lint.returncode == 0, f"stdout:\n{lint.stdout}\nstderr:\n{lint.stderr}" From 615667cca968ea70fa6bcd5561306de4e3584b73 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 10:39:00 +0300 Subject: [PATCH 08/25] feat(interrogate): --resolve clears a claim's blocking hard question (#48, #52, t4) interrogate --resolve [--decision ""] adds Frame.resolve_hard_question and a resolve mode on the interrogate CLI parser (mirrors park --resolve), fixing the permanent convergence deadlock where nothing ever set HardQuestion.resolved. The blocking-question gate also skips rejected claims, and suggest_move now names the shipped move instead of the old dead-end "capture/confirm the resulting claim" hint. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QtUixLUjgEt51PXS9TPVt3 --- devague/cli/_commands/interrogate.py | 71 ++++++++++++++++++- devague/convergence.py | 14 +++- devague/frame.py | 36 +++++++++- tests/test_cli_moves.py | 89 ++++++++++++++++++++++++ tests/test_convergence.py | 47 +++++++++++++ tests/test_e2e_resolve.py | 100 ++++++++++++++++++++++++++- tests/test_frame.py | 100 +++++++++++++++++++++++++++ 7 files changed, 448 insertions(+), 9 deletions(-) diff --git a/devague/cli/_commands/interrogate.py b/devague/cli/_commands/interrogate.py index 948ac03..bdd06dc 100644 --- a/devague/cli/_commands/interrogate.py +++ b/devague/cli/_commands/interrogate.py @@ -1,4 +1,15 @@ -"""``devague interrogate`` — pressure-test a claim with honesty conditions / hard questions.""" +"""``devague interrogate`` — pressure-test a claim with honesty conditions / hard +questions, and resolve a claim's blocking hard question once it is answered. + +``--resolve QID [--decision TEXT]`` shares this verb's parser with the +add-flags mode (mirrors ``park``'s create/``--resolve`` split): the verb that +owns hard-question *creation* also owns its *resolution*, because +claim-attached hard questions and the durable ``.devague/questions/`` file +independently assign their own ``qN`` ids (decision c36) — the claim id +disambiguates which artifact is meant. Resolving is a USER decision, like +``confirm``/``park --resolve`` (issues #48, #52): the agent must never resolve +its own blocking question just to clear the gate. +""" from __future__ import annotations @@ -9,6 +20,8 @@ from devague.cli._frames import resolve from devague.cli._output import emit_diagnostic, emit_result +_ADD_FLAGS = ("honesty", "risk", "hard_question", "contradicts", "instruction") + def _resolve_target(frame, args): """Return ``(claim, honesty)`` for the target id, validating flag usage. @@ -68,8 +81,51 @@ def _apply_instruction(target, text: str) -> tuple[dict, str | None]: return {"kind": "instruction", "id": target.id, "status": target.status}, flip_note +def _resolve_hard_question_mode(frame, args: argparse.Namespace) -> int: + """``interrogate --resolve [--decision TEXT]`` — clear a claim's + blocking hard question (issues #48, #52, decision c36). A USER decision, + like ``confirm``/``park --resolve``: mutually exclusive with every + add-flag mode below, so it can never be smuggled into the same call that + raises a new question. + """ + used = [f for f in _ADD_FLAGS if getattr(args, f)] + if used: + raise DevagueError( + EXIT_USER_ERROR, + "--resolve cannot be combined with " + "--honesty/--hard-question/--risk/--contradicts/--instruction", + "run --resolve QID [--decision TEXT] on its own", + ) + try: + q = frame.resolve_hard_question(args.claim_id, args.resolve, args.decision or "") + except ValueError as err: + raise DevagueError( + EXIT_USER_ERROR, + str(err), + "run 'devague show' to see current claim and hard-question ids", + ) from err + store.save(frame) + if getattr(args, "json", False): + emit_result( + { + "claim": args.claim_id, + "id": q.id, + "resolved": True, + "resolution": q.resolution, + }, + json_mode=True, + ) + else: + emit_result(f"{q.id} on {args.claim_id} -> resolved", json_mode=False) + return 0 + + def cmd_interrogate(args: argparse.Namespace) -> int: frame = resolve(args.frame) + + if args.resolve: + return _resolve_hard_question_mode(frame, args) + claim, honesty = _resolve_target(frame, args) added = _add_claim_items(frame, claim, args) @@ -86,7 +142,8 @@ def cmd_interrogate(args: argparse.Namespace) -> int: raise DevagueError( EXIT_USER_ERROR, "nothing to interrogate", - "pass --honesty / --hard-question / --risk / --contradicts / --instruction", + "pass --honesty / --hard-question / --risk / --contradicts / " + "--instruction / --resolve", ) store.save(frame) if flip_note: @@ -128,6 +185,16 @@ def register(sub: argparse._SubParsersAction) -> None: default="llm", help="Who proposed the honesty condition.", ) + p.add_argument( + "--resolve", + metavar="QID", + help=( + "Mark claim_id's hard question QID resolved (a USER decision, like " + "confirm/park --resolve — not for the agent to invoke on its own). " + "Mutually exclusive with every add-flag above." + ), + ) + p.add_argument("--decision", help="Optional resolution note recorded with --resolve.") p.add_argument("--frame", help="Frame slug (default: current).") p.add_argument("--json", action="store_true", help="Emit structured JSON.") p.set_defaults(func=cmd_interrogate) diff --git a/devague/convergence.py b/devague/convergence.py index f63b91a..8581452 100644 --- a/devague/convergence.py +++ b/devague/convergence.py @@ -102,6 +102,14 @@ def _missing_open_uncertainty(frame: Frame) -> list[str]: resolution text, but it has already been resolved through the ``park --resolve`` move, so it must not keep blocking convergence (resolve-parked-vagueness t3, #45/#55/#57). + + A blocking hard question is cleared the same way, via + ``Frame.resolve_hard_question`` (``interrogate --resolve ``, + decision c36, issues #48/#52). It also stops blocking if its parent claim + has been explicitly ``rejected`` — the claim itself was decided against, + so an unresolved question on it is moot (issue #52's fix (3)); this is a + pure claim-status check, not a resolution, so a rejected claim's question + can still be resolved later without erroring. """ missing = [ f"blocking vagueness {v.id} unresolved" @@ -111,6 +119,7 @@ def _missing_open_uncertainty(frame: Frame) -> list[str]: missing += [ f"blocking hard question {q.id} on {c.id} unresolved" for c in frame.claims + if c.status != "rejected" for q in c.hard_questions if q.blocking and not q.resolved ] @@ -205,9 +214,10 @@ def suggest_move(blocker: str) -> str: return f'devague park --resolve {vid} --decision ""' m = re.search(r"blocking hard question (q\d+) on (c\d+)", blocker) if m: + qid, cid = m.group(1), m.group(2) return ( - f"resolve {m.group(1)} on {m.group(2)}: answer it, then " - f"capture/confirm the resulting claim" + f"this is a USER decision: devague interrogate {cid} --resolve {qid} " + f'--decision ""' ) return "devague show # inspect and decide" diff --git a/devague/frame.py b/devague/frame.py index 38b9c96..f26ae36 100644 --- a/devague/frame.py +++ b/devague/frame.py @@ -80,6 +80,10 @@ class HardQuestion: text: str resolved: bool = False blocking: bool = False + # v3 frames (and earlier) predate this field (issue-backlog-sweep t4, #48/#52); + # default to "" for a not-yet-resolved (or resolved-with-no-note) question. + # Set only via Frame.resolve_hard_question — mirrors Vagueness.resolution. + resolution: str = "" @dataclass @@ -248,6 +252,33 @@ def resolve_vagueness( v.resolution_claim_id = claim_id return v + def resolve_hard_question(self, claim_id: str, qid: str, resolution: str = "") -> HardQuestion: + """Mark a claim's hard question resolved — a USER decision, like confirm. + + Owned by ``devague interrogate --resolve [--decision TEXT]`` + (decision c36, issues #48/#52): claim-attached hard questions and the + durable ``.devague/questions/`` file independently assign their own + ``qN`` ids, so the claim id is what disambiguates which one is meant — + this method only ever searches ``claim_id``'s own ``hard_questions``. + Fails closed on an unknown claim id, a question id that doesn't exist + (or doesn't belong to that claim), and an already-resolved question, + rather than silently no-op'ing — the same contract as + :meth:`resolve_vagueness`. ``resolution`` is optional free text (unlike + ``park --resolve``'s required ``--decision``) recorded verbatim on + ``HardQuestion.resolution``. + """ + claim = self.find_claim(claim_id) + if claim is None: + raise ValueError(f"unknown claim id: {claim_id!r}") + q = next((q for q in claim.hard_questions if q.id == qid), None) + if q is None: + raise ValueError(f"no such hard question {qid!r} on claim {claim_id!r}") + if q.resolved: + raise ValueError(f"hard question {qid!r} is already resolved") + q.resolved = True + q.resolution = resolution + return q + def add_scope_entry( self, surface: str, finding: str, seeds: Optional[list[str]] = None ) -> ScopeEntry: @@ -321,10 +352,11 @@ def from_dict(d: dict) -> Frame: text=q["text"], resolved=q.get("resolved", False), blocking=q.get("blocking", False), + resolution=q.get("resolution", ""), ) # Tolerant of unknown keys the same way Claim is above (t2, issue - # #53 issue-backlog-sweep): a future field (e.g. t4's resolution) - # must not raw-TypeError a same-or-older-version load. + # #53 issue-backlog-sweep): a v3-or-older frame predates + # ``resolution`` (t4) and must not raw-TypeError on load. for q in c.get("hard_questions", []) ], links=list(c.get("links", [])), diff --git a/tests/test_cli_moves.py b/tests/test_cli_moves.py index 4d859f6..c85c31e 100644 --- a/tests/test_cli_moves.py +++ b/tests/test_cli_moves.py @@ -349,3 +349,92 @@ def test_interrogate_no_flags_errors(tmp_path, monkeypatch, capsys) -> None: rc = main(["interrogate", "c1"]) assert rc == 1 assert "nothing to interrogate" in capsys.readouterr().err + + +# --- issue-backlog-sweep (t4): interrogate --resolve, #48/#52 ---------------- + + +def test_interrogate_resolve_marks_hard_question_resolved_and_echoes_transition( + tmp_path, monkeypatch, capsys +) -> None: + _seed(monkeypatch, tmp_path) + main(["interrogate", "c1", "--hard-question", "is this real?", "--blocking"]) # q1 + capsys.readouterr() + rc = main(["interrogate", "c1", "--resolve", "q1", "--decision", "yes, verified"]) + assert rc == 0 + assert capsys.readouterr().out.strip() == "q1 on c1 -> resolved" + f = store.load(store.current_slug()) + q = f.claims[0].hard_questions[0] + assert q.resolved is True + assert q.resolution == "yes, verified" + + +def test_interrogate_resolve_json_parity(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + main(["interrogate", "c1", "--hard-question", "is this real?", "--blocking"]) # q1 + capsys.readouterr() + rc = main(["interrogate", "c1", "--resolve", "q1", "--decision", "yes, verified", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["claim"] == "c1" + assert payload["id"] == "q1" + assert payload["resolved"] is True + assert payload["resolution"] == "yes, verified" + + +def test_interrogate_resolve_without_decision_defaults_to_empty( + tmp_path, monkeypatch, capsys +) -> None: + # Unlike park --resolve, --decision is optional here. + _seed(monkeypatch, tmp_path) + main(["interrogate", "c1", "--hard-question", "is this real?", "--blocking"]) # q1 + capsys.readouterr() + rc = main(["interrogate", "c1", "--resolve", "q1"]) + assert rc == 0 + f = store.load(store.current_slug()) + assert f.claims[0].hard_questions[0].resolved is True + assert f.claims[0].hard_questions[0].resolution == "" + + +def test_interrogate_resolve_unknown_claim_refused(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + capsys.readouterr() + rc = main(["interrogate", "c99", "--resolve", "q1", "--decision", "x"]) + assert rc == 1 + err = capsys.readouterr().err + assert "unknown claim" in err and "hint" in err + + +def test_interrogate_resolve_unknown_qid_refused(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + capsys.readouterr() + rc = main(["interrogate", "c1", "--resolve", "q9", "--decision", "x"]) + assert rc == 1 + err = capsys.readouterr().err + assert "no such hard question" in err and "hint" in err + + +def test_interrogate_resolve_already_resolved_refused(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + main(["interrogate", "c1", "--hard-question", "is this real?", "--blocking"]) # q1 + main(["interrogate", "c1", "--resolve", "q1", "--decision", "first"]) + capsys.readouterr() + rc = main(["interrogate", "c1", "--resolve", "q1", "--decision", "second"]) + assert rc == 1 + err = capsys.readouterr().err + assert "already" in err and "hint" in err + f = store.load(store.current_slug()) + assert f.claims[0].hard_questions[0].resolution == "first" # untouched + + +def test_interrogate_resolve_combined_with_add_flag_refused(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + main(["interrogate", "c1", "--hard-question", "is this real?", "--blocking"]) # q1 + capsys.readouterr() + rc = main(["interrogate", "c1", "--resolve", "q1", "--decision", "x", "--honesty", "must hold"]) + assert rc == 1 + err = capsys.readouterr().err + assert "cannot be combined" in err + f = store.load(store.current_slug()) + assert f.claims[0].hard_questions[0].resolved is False + assert f.claims[0].honesty_conditions == [] # nothing was smuggled in either diff --git a/tests/test_convergence.py b/tests/test_convergence.py index 95f070d..cc141bc 100644 --- a/tests/test_convergence.py +++ b/tests/test_convergence.py @@ -143,3 +143,50 @@ def test_parked_items_still_lists_unresolved_nonblocking_vagueness() -> None: f.add_vagueness("ship a JSON Schema file?", "follow_up") res = evaluate(f) assert any("follow_up" in p for p in res.parked_items) + + +# --- issue-backlog-sweep (t4): hard-question resolve, #48/#52 ---------------- + + +def test_resolved_blocking_hard_question_no_longer_blocks() -> None: + f = _full_frame() + f.add_hard_question(f.claims[0], "what if zero?", blocking=True) # q1 + f.resolve_hard_question(f.claims[0].id, "q1", "decided: reject zero") + res = evaluate(f) + assert res.ready is True + assert not any("blocking hard question" in m for m in res.blockers) + assert res.required_next_moves == [] + + +def test_unresolved_blocking_hard_question_still_blocks_alongside_resolved_one() -> None: + f = _full_frame() + cid = f.claims[0].id + f.add_hard_question(f.claims[0], "what if zero?", blocking=True) # q1, resolved below + f.resolve_hard_question(cid, "q1", "decided: reject zero") + f.add_hard_question(f.claims[0], "what about negatives?", blocking=True) # q2, stays open + res = evaluate(f) + assert res.ready is False + assert any("q2" in m for m in res.blockers) + assert not any("q1" in m for m in res.blockers) + + +def test_rejected_claim_with_unresolved_blocking_question_no_longer_blocks() -> None: + # Issue #52's fix (3): the parent claim itself was decided against via + # reject, so its unresolved blocking question is moot and must not keep + # convergence permanently deadlocked. + f = _full_frame() + extra = f.add_claim("requirement", "an extra requirement", origin="user") + f.add_hard_question(extra, "is this even needed?", blocking=True) # q1 + extra.status = "rejected" + res = evaluate(f) + assert res.ready is True + assert not any("blocking hard question" in m for m in res.blockers) + + +def test_suggest_move_for_blocking_hard_question_names_interrogate_resolve_verbatim() -> None: + hint = suggest_move("blocking hard question q3 on c2 unresolved") + assert "devague interrogate c2 --resolve q3" in hint + assert "--decision" in hint + assert "USER" in hint + # the old dead-end hint (capture/confirm never flips q.resolved) must be gone + assert "capture/confirm the resulting claim" not in hint diff --git a/tests/test_e2e_resolve.py b/tests/test_e2e_resolve.py index c807e84..bf58e09 100644 --- a/tests/test_e2e_resolve.py +++ b/tests/test_e2e_resolve.py @@ -1,6 +1,6 @@ -"""t9: E2E repro + quality-gate coverage for issue 57's fix — the resolve -lifecycle driven through the real CLI, both engines (frame side and plan -side). +"""t9/t4: E2E repro + quality-gate coverage for issue 57's and issues #48/#52's +fixes — the resolve lifecycle driven through the real CLI, both engines +(frame side and plan side), plus the frame-side hard-question resolve. Before waves 1-2 landed, a parked ``unknown_blocking`` vagueness (frame side) or risk (plan side) could **never** be closed out through a move — the frame @@ -15,6 +15,13 @@ functions over the JSON, never a raw ``Path(".devague/...")`` open) or CLI ``--json`` output, matching the "no hand-editing" contract the resolve moves exist to uphold. + +A parallel, permanent convergence deadlock existed for a claim's *blocking +hard question* (``interrogate --hard-question --blocking`` / ``--contradicts``): +nothing in the codebase ever set ``HardQuestion.resolved``. ``interrogate +--resolve [--decision TEXT]`` (decision c36) is the fix; the tests below +(t4, issues #48/#52) mirror the same block-resolve-converge shape through CLI +moves alone. """ from __future__ import annotations @@ -216,3 +223,90 @@ def test_e2e_issue57_plan_risk_resolve_lifecycle(tmp_path, monkeypatch, capsys) plan = plan_store.load(slug) plan_path = Path("docs/plans") / f"{plan.created[:10]}-{slug}.md" assert plan_path.exists() + + +def test_e2e_issue48_52_hard_question_block_resolve_converge_lifecycle( + tmp_path, monkeypatch, capsys +) -> None: + """The exact #48/#52 repro: a blocking hard question deadlocks convergence + until ``interrogate --resolve --decision TEXT`` clears it — + driven only through CLI moves, with the resolved state and decision text + verified via a real ``store.load`` (a save/load round-trip), not a raw + JSON read. + """ + _converging_frame(monkeypatch, tmp_path) + + # The frame would already converge but for the blocking hard question + # raised below — prove that's what blocks it (not a fixture hole). + capsys.readouterr() + assert main(["converge", "--json"]) == 0 + assert _json_out(capsys)["ready_for_spec"] is True + + # --- issues #48/#52 repro ------------------------------------------------ + assert main(["interrogate", "c1", "--hard-question", "is this real?", "--blocking"]) == 0 # q1 + + capsys.readouterr() + assert main(["converge", "--json"]) == 0 + verdict = _json_out(capsys) + assert verdict["ready_for_spec"] is False + assert any( + "q1" in b and "c1" in b and "blocking hard question" in b for b in verdict["blockers"] + ) + # suggest_move names the real, shipped move — not the old dead-end hint. + assert any("devague interrogate c1 --resolve q1" in m for m in verdict["required_next_moves"]) + + capsys.readouterr() + assert ( + main(["interrogate", "c1", "--resolve", "q1", "--decision", "yes, verified end to end"]) + == 0 + ) + assert "q1 on c1 -> resolved" in capsys.readouterr().out + + # converge must now pass — the resolved hard question no longer blocks. + capsys.readouterr() + assert main(["converge", "--json"]) == 0 + verdict = _json_out(capsys) + assert verdict["ready_for_spec"] is True + + # Save/load round-trip: resolved state and decision text survive a fresh + # store.load, not just the in-memory object the CLI process already held. + frame = store.load(store.current_slug()) + q = next(q for c in frame.claims for q in c.hard_questions if q.id == "q1") + assert q.resolved is True + assert q.resolution == "yes, verified end to end" + + # export still works with the resolved question on record. + assert main(["export"]) == 0 + spec_path = Path("docs/specs") / f"{frame.created[:10]}-{frame.slug}.md" + assert spec_path.exists() + + +def test_e2e_issue52_rejected_claim_unresolved_blocking_question_no_longer_blocks( + tmp_path, monkeypatch, capsys +) -> None: + """Issue #52's fix (3): rejecting the parent claim also clears its + unresolved blocking hard question from the gate — driven through + ``confirm``/``reject`` alone, no ``--resolve`` needed for this path. + """ + _converging_frame(monkeypatch, tmp_path) + + assert ( + main(["capture", "--kind", "requirement", "an extra requirement", "--origin", "llm"]) == 0 + ) + frame = store.load(store.current_slug()) + extra_id = next(c.id for c in frame.claims if c.kind == "requirement") + assert main(["interrogate", extra_id, "--hard-question", "needed?", "--blocking"]) == 0 + + capsys.readouterr() + assert main(["converge", "--json"]) == 0 + verdict = _json_out(capsys) + assert verdict["ready_for_spec"] is False + assert any("blocking hard question" in b for b in verdict["blockers"]) + + assert main(["reject", extra_id]) == 0 + + capsys.readouterr() + assert main(["converge", "--json"]) == 0 + verdict = _json_out(capsys) + assert verdict["ready_for_spec"] is True + assert not any("blocking hard question" in b for b in verdict["blockers"]) diff --git a/tests/test_frame.py b/tests/test_frame.py index 303775c..0063420 100644 --- a/tests/test_frame.py +++ b/tests/test_frame.py @@ -253,3 +253,103 @@ def test_legacy_v3_vagueness_without_resolution_claim_id_defaults() -> None: assert f.open_vagueness[0].resolved is True assert f.open_vagueness[0].resolution == "done" assert f.open_vagueness[0].resolution_claim_id is None + + +# --- issue-backlog-sweep t4: HardQuestion resolution (schema v4, #48/#52) ----- + + +def test_hard_question_gains_resolution_default() -> None: + from devague.frame import HardQuestion + + q = HardQuestion(id="q1", text="what if empty?", blocking=True) + assert q.resolved is False + assert q.resolution == "" + + +def test_resolve_hard_question_marks_resolved_and_records_resolution() -> None: + f = Frame(slug="s", title="t") + c = f.add_claim("announcement", "x", origin="user") # c1 + f.add_hard_question(c, "is this real?", blocking=True) # q1 + resolved = f.resolve_hard_question("c1", "q1", "decided: yes, it is real") + assert resolved.resolved is True + assert resolved.resolution == "decided: yes, it is real" + assert c.hard_questions[0].resolved is True + assert c.hard_questions[0].resolution == "decided: yes, it is real" + + +def test_resolve_hard_question_decision_is_optional() -> None: + f = Frame(slug="s", title="t") + c = f.add_claim("announcement", "x", origin="user") # c1 + f.add_hard_question(c, "is this real?", blocking=True) # q1 + resolved = f.resolve_hard_question("c1", "q1") + assert resolved.resolved is True + assert resolved.resolution == "" + + +def test_resolve_hard_question_unknown_claim_raises() -> None: + f = Frame(slug="s", title="t") + with pytest.raises(ValueError, match="unknown claim"): + f.resolve_hard_question("c99", "q1", "decision") + + +def test_resolve_hard_question_unknown_qid_raises() -> None: + f = Frame(slug="s", title="t") + f.add_claim("announcement", "x", origin="user") # c1 + with pytest.raises(ValueError, match="no such hard question"): + f.resolve_hard_question("c1", "q99", "decision") + + +def test_resolve_hard_question_wrong_claim_raises() -> None: + # q1 belongs to c1, not c2 — the claim id disambiguates (decision c36), so + # naming the right qid on the wrong claim must fail closed, not silently + # resolve across claims. + f = Frame(slug="s", title="t") + c1 = f.add_claim("announcement", "x", origin="user") # c1 + f.add_claim("audience", "devs", origin="user") # c2 + f.add_hard_question(c1, "is this real?", blocking=True) # q1, owned by c1 + with pytest.raises(ValueError, match="no such hard question"): + f.resolve_hard_question("c2", "q1", "decision") + assert c1.hard_questions[0].resolved is False + + +def test_resolve_hard_question_already_resolved_raises() -> None: + f = Frame(slug="s", title="t") + c = f.add_claim("announcement", "x", origin="user") # c1 + f.add_hard_question(c, "is this real?", blocking=True) # q1 + f.resolve_hard_question("c1", "q1", "decision one") + with pytest.raises(ValueError, match="already"): + f.resolve_hard_question("c1", "q1", "decision two") + assert c.hard_questions[0].resolution == "decision one" # untouched + + +def test_roundtrip_preserves_hard_question_resolution() -> None: + f = Frame(slug="s", title="t") + c = f.add_claim("announcement", "x", origin="user") + f.add_hard_question(c, "is this real?", blocking=True) + f.resolve_hard_question("c1", "q1", "decided: yes") + f2 = from_dict(to_dict(f)) + assert to_dict(f2) == to_dict(f) + assert f2.claims[0].hard_questions[0].resolved is True + assert f2.claims[0].hard_questions[0].resolution == "decided: yes" + + +def test_legacy_v3_hard_question_without_resolution_defaults() -> None: + # A v3-or-older frame's hard_questions predate the resolution field. + d = { + "slug": "s", + "title": "t", + "schema_version": 3, + "claims": [ + { + "id": "c1", + "kind": "announcement", + "text": "x", + "hard_questions": [ + {"id": "q1", "text": "is this real?", "resolved": False, "blocking": True} + ], + } + ], + } + f = from_dict(d) + assert f.claims[0].hard_questions[0].resolved is False + assert f.claims[0].hard_questions[0].resolution == "" From f43953785bf6033b8e532f7b04d266d3f34202d3 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 10:41:49 +0300 Subject: [PATCH 09/25] fix(summary): scope Planned Work and Actual Delivery to confirmed tasks (#88, t13) devague summary rendered every task on the plan -- including rejected ones -- into both Planned Work and the Actual Delivery table, so a plan carrying 19 confirmed and 68 rejected tasks (rebuilt twice after scope changes) emitted 87 undifferentiated rows. A rejected task paired with `` invites recording a planning decision as a delivery failure, which is a different fact. Planned Work and Actual Delivery (and the --json summary_data equivalent) now iterate confirmed tasks only, with a single line preserving the rejected count without padding either list ("N tasks were rejected during planning -- see `devague plan show`"). A proposed task -- neither the confirmed contract nor an explicit rejection -- is excluded from both lists and from the rejected count, so an open decision is never folded into a closed one. The --pr wave map is unchanged (dependency_waves already excludes rejected tasks), pinned here with a dedicated regression test. Also applies the #87 md_safe_text escaper at every verbatim interpolation site in this renderer (task summaries, deviation what/reason, announcement/after-state text). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QtUixLUjgEt51PXS9TPVt3 --- devague/render/summary_md.py | 109 +++++++++++++++++++++++++----- tests/test_summary.py | 126 ++++++++++++++++++++++++++++++++++- 2 files changed, 215 insertions(+), 20 deletions(-) diff --git a/devague/render/summary_md.py b/devague/render/summary_md.py index c426372..765eaa1 100644 --- a/devague/render/summary_md.py +++ b/devague/render/summary_md.py @@ -31,8 +31,8 @@ from devague.delivery import Delivery, DeviationRecord from devague.frame import Frame -from devague.plan import Plan, dependency_waves -from devague.render._md_safety import autolink_urls, heading_safe +from devague.plan import Plan, Task, dependency_waves +from devague.render._md_safety import autolink_urls, heading_safe, md_safe_text RUN_STATUS_PLACEHOLDER = "" @@ -47,6 +47,51 @@ _UNDATED_PREFIX = "0000-00-00" +def _verbatim(text: str) -> str: + """Render-time markdown safety for one field of free-form verbatim prose + (task summaries, deviation ``what``/``reason``, announcement/after-state + claim text): autolink any bare URL (MD034), then escape/wrap markdown + control characters and underscore-bearing identifiers (MD037/MD050, #87's + ``md_safe_text``). Never applied to the JSON (``--json``) views + (:func:`summary_data`/:func:`pr_data`) — those mirror the underlying data + verbatim, the same way the frame/plan JSON stores are never touched by + rendering (#87's round-trip-safety rule).""" + return md_safe_text(autolink_urls(text)) + + +def _confirmed_tasks(plan: Plan) -> list[Task]: + """The tasks a delivery summary is scoped to (#88). + + A ``rejected`` task is planning history — visible in ``devague plan + show``, never padded into an accountability artifact about what shipped. + A ``proposed`` task is still under adjudication: neither the confirmed + contract nor an explicit rejection, so folding it into either list (or + into the rejected count below) would misrepresent an open decision as a + closed one. Both statuses are therefore silently excluded from Planned + Work / Actual Delivery / :func:`summary_data` — only a confirmed task is + part of the contract this artifact reports against. + """ + return [t for t in plan.tasks if t.status == "confirmed"] + + +def _rejected_task_ids(plan: Plan) -> list[str]: + return [t.id for t in plan.tasks if t.status == "rejected"] + + +def _rejected_count_line(plan: Plan) -> Optional[str]: + """A single line preserving the fact of rejection without padding the + Planned Work / Actual Delivery listings with it (#88's suggested + resolution). ``None`` when nothing was rejected — a clean plan gets no + noise line at all. + """ + n = len(_rejected_task_ids(plan)) + if not n: + return None + noun = "task" if n == 1 else "tasks" + verb = "was" if n == 1 else "were" + return f"{n} {noun} {verb} rejected during planning — see `devague plan show`." + + def _escape_table_cell(text: str) -> str: """Make ``text`` safe to interpolate into a single GFM markdown-table cell. @@ -105,39 +150,53 @@ def _intent_lines(plan: Plan, frame: Optional[Frame]) -> list[str]: return lines ann = _confirmed_claim_text(frame, "announcement") if ann: - lines.append("> " + autolink_urls(ann)) + lines.append("> " + _verbatim(ann)) else: lines.append("(no confirmed announcement recorded in the source frame)") afters = _confirmed_claim_texts(frame, "after_state") if afters: lines.append("") - lines.append("After: " + "; ".join(autolink_urls(a) for a in afters)) + lines.append("After: " + "; ".join(_verbatim(a) for a in afters)) lines.append("") return lines def _planned_work_lines(plan: Plan) -> list[str]: + """Confirmed tasks only (#88) — the confirmed contract, not planning + history (rejected) or open proposals (proposed). A single line preserves + the rejected count without padding the list; see :func:`_confirmed_tasks` + and :func:`_rejected_count_line`.""" lines = ["## Planned Work", ""] - if not plan.tasks: + confirmed = _confirmed_tasks(plan) + if not confirmed: lines.append(NO_TASKS_PLACEHOLDER) + else: + for t in confirmed: + lines.append(f"- `{t.id}` — {_verbatim(t.summary)}") + rejected_line = _rejected_count_line(plan) + if rejected_line: + # Blank line first: MD032 wants a list (when `confirmed` is non-empty) + # surrounded by blank lines, and this plain sentence is not itself a + # list item. lines.append("") - return lines - for t in plan.tasks: - mark = "" if t.status == "confirmed" else f" _({t.status})_" - lines.append(f"- `{t.id}` — {autolink_urls(t.summary)}{mark}") + lines.append(rejected_line) lines.append("") return lines def _actual_delivery_lines(plan: Plan) -> list[str]: + """One row per confirmed task (#88) — a rejected or still-proposed task is + never paired with a ```` placeholder, which would invite + recording a planning decision (or an open one) as a delivery failure.""" lines = ["## Actual Delivery", ""] - if not plan.tasks: + confirmed = _confirmed_tasks(plan) + if not confirmed: lines.append(NO_TASKS_PLACEHOLDER) lines.append("") return lines lines.append("| Plan task | Status | What actually landed |") lines.append("|-----------|--------|----------------------|") - for t in plan.tasks: + for t in confirmed: lines.append(f"| `{t.id}` | `` | `` |") lines.append("") return lines @@ -152,7 +211,7 @@ def _mid_work_lines(delivery: Delivery) -> list[str]: lines.append("") return lines for d in approved: - lines.append(f"- `{d.id}` — {autolink_urls(d.what)} — {autolink_urls(d.reason)}") + lines.append(f"- `{d.id}` — {_verbatim(d.what)} — {_verbatim(d.reason)}") if pending: ids = ", ".join(f"`{d.id}`" for d in pending) lines.append(f"- pending approval (not yet a decision): {ids}") @@ -177,7 +236,7 @@ def _drift_lines(delivery: Delivery) -> list[str]: # d.task_ref/d.id are backticked refs; d.reason is free-form prose -- # _escape_table_cell keeps a raw '|' or newline in it from corrupting # the row (#72 review, Q2). - reason = _escape_table_cell(autolink_urls(d.reason)) + reason = _escape_table_cell(_verbatim(d.reason)) task_ref = _escape_table_cell(d.task_ref) did = _escape_table_cell(d.id) lines.append(f"| `{task_ref}` (`{did}`) | {reason} | {classification} |") @@ -250,6 +309,7 @@ def summary_data(plan: Plan, frame: Optional[Frame], delivery: Delivery) -> dict """ approved = _approved(delivery) pending = _pending(delivery) + confirmed = _confirmed_tasks(plan) return { "plan": plan.slug, "title": plan.title, @@ -262,13 +322,21 @@ def summary_data(plan: Plan, frame: Optional[Frame], delivery: Delivery) -> dict "announcement": _confirmed_claim_text(frame, "announcement"), "after_state": _confirmed_claim_texts(frame, "after_state"), }, + # Confirmed tasks only (#88) — see _confirmed_tasks' docstring: a + # rejected task is planning history and a proposed one is still + # undecided, so neither belongs in the delivery contract's task + # lists. rejected_tasks (below) is the JSON parity for the + # markdown's single rejected-count line — the ids, not just a + # count, mirroring how pending_deviations carries ids rather than + # a bare number. "planned_work": [ - {"id": t.id, "summary": t.summary, "status": t.status} for t in plan.tasks + {"id": t.id, "summary": t.summary, "status": t.status} for t in confirmed ], "actual_delivery": [ {"id": t.id, "status": "", "what_landed": ""} - for t in plan.tasks + for t in confirmed ], + "rejected_tasks": _rejected_task_ids(plan), "mid_work_decisions": [ {"id": d.id, "what": d.what, "reason": d.reason} for d in approved ], @@ -291,6 +359,11 @@ def summary_data(plan: Plan, frame: Optional[Frame], delivery: Delivery) -> dict # ── --pr mode: condensed PR-body skeleton ──────────────────────────────────── def _wave_task_map_lines(plan: Plan) -> list[str]: + """``dependency_waves`` already excludes rejected tasks (plan.py) — this is + scheduling metadata (what *could* run), not the delivery contract, so it + intentionally keeps including ``proposed`` tasks unlike Planned Work / + Actual Delivery above (#88's instruction: "--pr needs no [filtering] + change").""" lines = ["## Wave / Task Map", ""] waves = dependency_waves(plan.tasks) if not waves: @@ -305,7 +378,7 @@ def _wave_task_map_lines(plan: Plan) -> list[str]: for tid in wave: t = by_id.get(tid) if t is not None: - lines.append(f"- `{t.id}` — {autolink_urls(t.summary)}") + lines.append(f"- `{t.id}` — {_verbatim(t.summary)}") lines.append("") return lines @@ -319,7 +392,7 @@ def _approved_deviations_lines(delivery: Delivery) -> list[str]: return lines for d in approved: lines.append( - f"- `{d.id}` (task `{d.task_ref}`) — {autolink_urls(d.what)}: {autolink_urls(d.reason)}" + f"- `{d.id}` (task `{d.task_ref}`) — {_verbatim(d.what)}: {_verbatim(d.reason)}" ) lines.append("") return lines @@ -332,7 +405,7 @@ def render_pr_summary(plan: Plan, frame: Optional[Frame], delivery: Delivery) -> out = [f"# {heading_safe(plan.title)}", ""] ann = _confirmed_claim_text(frame, "announcement") if ann: - out += ["> " + autolink_urls(ann), ""] + out += ["> " + _verbatim(ann), ""] out += _wave_task_map_lines(plan) out += _approved_deviations_lines(delivery) out += [f"Delivery summary: `{_deliveries_pointer(plan)}`", ""] diff --git a/tests/test_summary.py b/tests/test_summary.py index 5955a71..db7fbc2 100644 --- a/tests/test_summary.py +++ b/tests/test_summary.py @@ -125,20 +125,36 @@ def test_render_summary_run_status_is_placeholder() -> None: assert "run: `failed`" not in out -def test_planned_work_lists_every_task_id_and_summary_verbatim() -> None: +def test_planned_work_lists_only_confirmed_task_id_and_summary_verbatim() -> None: + # #88: Planned Work is scoped to confirmed tasks only — a rejected task + # (however it got there) must never appear, even though it still lives on + # plan.tasks. Flips the old "every task appears" assumption pinned here + # before the fix (bit #88). plan, frame = _bare_plan_and_frame() + dropped = plan.add_task("dropped task") + plan.set_status(dropped.id, "rejected") out = summary_md.render_summary(plan, frame, Delivery(plan_slug=plan.slug)) planned = out.split("## Planned Work")[1].split("## Actual Delivery")[0] assert "`t1` — first task" in planned assert "`t2` — second task" in planned + assert dropped.id not in planned + assert "dropped task" not in planned -def test_actual_delivery_has_one_row_per_task_with_fill_placeholders() -> None: +def test_actual_delivery_has_one_row_per_confirmed_task_with_fill_placeholders() -> None: + # #88: a rejected task is never paired with a `` row — that + # pairing is exactly the honesty hazard the issue names (it invites + # recording a planning decision as a delivery failure). Flips the old + # "one row per task regardless of status" assumption pinned here before + # the fix (bit #88). plan, frame = _bare_plan_and_frame() + dropped = plan.add_task("dropped task") + plan.set_status(dropped.id, "rejected") out = summary_md.render_summary(plan, frame, Delivery(plan_slug=plan.slug)) actual = out.split("## Actual Delivery")[1].split("## Mid-work Decisions")[0] for tid in ("t1", "t2"): assert f"| `{tid}` | `` | `` |" in actual + assert f"| `{dropped.id}` |" not in actual def test_no_placeholder_ever_looks_like_a_completed_claim() -> None: @@ -191,6 +207,112 @@ def test_no_tasks_placeholder_is_a_single_shared_constant() -> None: assert summary_md.NO_TASKS_PLACEHOLDER in pr_out # --pr wave/task map +# ── #88: summary scoped to confirmed tasks ─────────────────────────────────── +# +# Repro shape from the issue: a plan rebuilt after scope changes can carry far +# more rejected tasks than confirmed ones (19 confirmed / 68 rejected in the +# reporter's real plan). Planned Work / Actual Delivery must reflect only the +# confirmed contract; a single line preserves the rejected count without +# padding either list with 68 undifferentiated rows. A `proposed` task is +# neither the confirmed contract nor an explicit rejection (it is still under +# adjudication), so it is excluded from both lists AND from the rejected +# count — folding an open decision into "rejected" would misrepresent it as +# already decided against, the same honesty conflation the issue is about. + + +def test_mixed_status_plan_scopes_planned_work_and_actual_delivery_to_confirmed() -> None: + # #88 acceptance criteria, pinned literally: "a plan with N confirmed and M + # rejected tasks emits exactly N Actual Delivery rows and N Planned Work + # entries plus one line counting the M rejected". + n_confirmed, n_rejected = 2, 3 + plan, frame = _bare_plan_and_frame() # seeds t1, t2 confirmed (N=2) + rejected_ids = [] + for i in range(n_rejected): + t = plan.add_task(f"rejected task {i}") + plan.set_status(t.id, "rejected") + rejected_ids.append(t.id) + proposed = plan.add_task("still deciding", origin="llm") # proposed: neither list, no count + + out = summary_md.render_summary(plan, frame, Delivery(plan_slug=plan.slug)) + planned = out.split("## Planned Work")[1].split("## Actual Delivery")[0] + actual = out.split("## Actual Delivery")[1].split("## Mid-work Decisions")[0] + + planned_entries = [ln for ln in planned.splitlines() if ln.startswith("- `t")] + actual_rows = [ln for ln in actual.splitlines() if ln.startswith("| `t")] + assert len(planned_entries) == n_confirmed + assert len(actual_rows) == n_confirmed + + for excluded_id in rejected_ids + [proposed.id]: + assert excluded_id not in planned + assert excluded_id not in actual + + rejected_line = f"{n_rejected} tasks were rejected during planning — see `devague plan show`." + assert rejected_line in planned + # exactly one such line in the whole artifact — not per-section noise + assert out.count("rejected during planning") == 1 + + +def test_rejected_count_line_uses_singular_wording_for_exactly_one() -> None: + plan, frame = _bare_plan_and_frame() + t = plan.add_task("dropped") + plan.set_status(t.id, "rejected") + out = summary_md.render_summary(plan, frame, Delivery(plan_slug=plan.slug)) + assert "1 task was rejected during planning — see `devague plan show`." in out + + +def test_no_rejected_tasks_means_no_rejected_count_line() -> None: + plan, frame = _bare_plan_and_frame() + out = summary_md.render_summary(plan, frame, Delivery(plan_slug=plan.slug)) + assert "rejected during planning" not in out + + +def test_summary_data_scopes_planned_work_and_actual_delivery_to_confirmed() -> None: + plan, frame = _bare_plan_and_frame() + dropped = plan.add_task("dropped") + plan.set_status(dropped.id, "rejected") + proposed = plan.add_task("still deciding", origin="llm") + data = summary_md.summary_data(plan, frame, Delivery(plan_slug=plan.slug)) + planned_ids = [t["id"] for t in data["sections"]["planned_work"]] + actual_ids = [t["id"] for t in data["sections"]["actual_delivery"]] + assert planned_ids == ["t1", "t2"] + assert actual_ids == ["t1", "t2"] + assert dropped.id not in planned_ids + assert proposed.id not in planned_ids + # JSON parity for the markdown's single rejected-count line: the ids, not + # just a count (mirroring pending_deviations, which carries ids too). + assert data["sections"]["rejected_tasks"] == [dropped.id] + + +def test_pr_wave_and_task_map_stay_rejected_free_on_a_mixed_status_plan() -> None: + # #88 acceptance criteria: "the --pr wave map stays rejected-free". Pinned + # here (not in tests/test_plan.py, which already pins dependency_waves + # itself excluding rejected tasks) because this is the render-layer + # regression: summary_md must not reintroduce a rejected task via some + # other path (e.g. iterating plan.tasks directly instead of the waves). + plan, frame = _bare_plan_and_frame() + dead = plan.add_task("dead task") + plan.set_status(dead.id, "rejected") + delivery = Delivery(plan_slug=plan.slug) + + out = summary_md.render_pr_summary(plan, frame, delivery) + assert dead.id not in out + assert "dead task" not in out + + data = summary_md.pr_data(plan, frame, delivery) + assert all(dead.id not in wave for wave in data["waves"]) + assert dead.id not in data["tasks"] + + +def test_render_summary_with_rejected_tasks_is_markdownlint_clean_hand_rolled() -> None: + plan, frame = _bare_plan_and_frame() + t = plan.add_task("dropped") + plan.set_status(t.id, "rejected") + delivery = Delivery(plan_slug=plan.slug) + delivery.add_deviation("swap", "t1", "reason", origin="user", classification="acceptable") + out = summary_md.render_summary(plan, frame, delivery) + assert_markdownlint_clean(out) + + # ── deviation records: drift + mid-work ────────────────────────────────────── def test_approved_deviation_appears_in_drift_and_mid_work() -> None: plan, frame = _bare_plan_and_frame() From 22df59ab4081a02d8ec7d6e62043a7602cac65af Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 10:47:57 +0300 Subject: [PATCH 10/25] feat(plan): per-target deferral so milestone-scoped plans converge (#85, t9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `devague plan defer --reason ""` (+ `--undo` to reverse it) so a plan intentionally scoped to early milestones is not forced to fake coverage of a later-milestone target just to satisfy the gate — the exact perverse incentive #85 reported (a reviewer catching a task that "claims coverage ... but only installs and reports the counter"). - plan.py: CoverageTarget gains deferred/deferred_reason (schema v4, reserved by t2); Plan.defer_target/undefer_target mirror resolve_risk's fail-closed contract (unknown id / already-deferred / not-deferred all raise). - plan_convergence.py: _missing_coverage excludes deferred targets from blockers; _parked_items now also lists each deferred target labeled distinctly ("deferred: coverage target cN ... — ") so `plan status`/`converge --json` can tell "deliberately deferred" apart from "not yet covered". - cli/_commands/plan.py: new `defer` subcommand + PLAN_MOVES entry; _live/_live_frame_and_targets now carry persisted deferral state across every live-frame re-derive (targets_from_frame builds fresh CoverageTarget instances with no memory of prior deferrals — without this merge, converge/export/status would silently drop a recorded deferral on the next call). - cli/_status.py: emit_status now prints parked_items in text mode too (a pre-existing gap — JSON already carried them) so `plan status` visibly shows deferred targets, not just JSON. - render/plan_md.py: new "Deferred targets" section naming each with its reason; applies the t1 escaper (md_safe_text) at every verbatim site (task heading/instruction/acceptance, risk text, announcement, title) — closes the MD050 regression from the #87 comment for underscore-bearing task text. Verified against real markdownlint-cli2 (0 errors) and an end-to-end `devague` CLI smoke run in a scratch dir (defer -> converge -> export -> status -> undo), plus the full pytest suite (760 passed) and black/isort/flake8. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QtUixLUjgEt51PXS9TPVt3 --- devague/cli/_commands/plan.py | 113 ++++++++++++++++++++++- devague/cli/_status.py | 2 + devague/plan.py | 47 ++++++++++ devague/plan_convergence.py | 40 ++++++-- devague/render/plan_md.py | 55 +++++++++-- tests/test_cli_plan.py | 161 +++++++++++++++++++++++++++++++++ tests/test_plan.py | 83 +++++++++++++++++ tests/test_plan_convergence.py | 93 +++++++++++++++++++ tests/test_render_plan.py | 103 ++++++++++++++++++++- 9 files changed, 677 insertions(+), 20 deletions(-) diff --git a/devague/cli/_commands/plan.py b/devague/cli/_commands/plan.py index 7a9adcf..d30afe1 100644 --- a/devague/cli/_commands/plan.py +++ b/devague/cli/_commands/plan.py @@ -57,6 +57,10 @@ "(may flip confirmed -> proposed)." ), "cover": "Mark a task as covering a coverage target (c*/h*).", + "defer": ( + "Deliberately exclude a coverage target from this plan's gate " + "(--reason TEXT), or --undo to reverse it." + ), "confirm": "Confirm a task (user-only — no fabricated rigor).", "reject": "Reject a task.", "risk": ( @@ -105,8 +109,35 @@ def _load_source_frame(slug: str) -> Frame: ) from None +def _merge_deferred_state(old_targets: list, new_targets: list) -> list: + """Carry persisted per-target deferral state across a live-frame re-derive + (issue #85). + + :func:`targets_from_frame` builds brand-new ``CoverageTarget`` instances from + the frame every time — deriving them fresh is exactly what makes frame drift + detectable — but that also means the freshly derived objects know nothing + about deferral, which lives only on the plan's own record (``plan.targets``, + persisted via ``plan_store``). Without this, every ``converge``/``export``/ + ``status`` call would silently drop a recorded deferral the moment it + re-derives targets from the live frame. A deferred target the live re-derive + no longer contains (the underlying claim was rejected, say) simply has + nothing to carry forward — its deferral becomes moot, not resurrected as a + phantom target. + """ + deferred = {tg.id: (tg.deferred, tg.deferred_reason) for tg in old_targets if tg.deferred} + for tg in new_targets: + if tg.id in deferred: + tg.deferred, tg.deferred_reason = deferred[tg.id] + return new_targets + + def _live(plan: Plan): - """Re-load the source frame and re-derive targets; guard against frame drift.""" + """Re-load the source frame and re-derive targets; guard against frame drift. + + Re-derived targets are freshly built by :func:`targets_from_frame` and so + start with no deferral state; :func:`_merge_deferred_state` carries the + plan's persisted deferrals across the re-derive (issue #85) before returning. + """ frame = _load_source_frame(plan.frame_slug) fres = evaluate_frame(frame) if not fres.ready: @@ -115,7 +146,8 @@ def _live(plan: Plan): f"source frame '{frame.slug}' has regressed below convergence", f"re-converge the frame first: devague converge --frame {frame.slug}", ) - return frame, targets_from_frame(frame) + targets = _merge_deferred_state(plan.targets, targets_from_frame(frame)) + return frame, targets def _live_frame_and_targets(plan: Plan): @@ -130,9 +162,15 @@ def _live_frame_and_targets(plan: Plan): corrupt source frame still raises via :func:`_load_source_frame` — there is no state to synthesize from at all, which is a different failure than "not converged yet." + + Like :func:`_live`, deferred state is carried across the re-derive (issue + #85) — otherwise a previously deferred target would count as a fresh + blocker here, making the ``converged`` bool this feeds disagree with what + ``converge``/``status``/``export`` report for the exact same plan. """ frame = _load_source_frame(plan.frame_slug) - return frame, targets_from_frame(frame) + targets = _merge_deferred_state(plan.targets, targets_from_frame(frame)) + return frame, targets def _require_task(plan: Plan, tid: str): @@ -442,6 +480,64 @@ def cmd_plan_cover(args: argparse.Namespace) -> int: return 0 +def cmd_plan_defer(args: argparse.Namespace) -> int: + """``plan defer --reason ""`` — deliberately exclude a + coverage target from this plan's gate, or ``--undo`` to reverse a prior + deferral (issue #85: a milestone-scoped plan should not have to fake + coverage of a target that genuinely belongs to a later plan just to make + the gate go green). + + Mirrors ``plan risk --resolve``'s shape and house style: the target is + validated the same way ``cover`` validates one (:func:`_require_target` — + stored snapshot first, live frame fallback, refreshing the persisted + snapshot on a live hit), and the create/undo paths share one subcommand the + way risk's create/resolve paths do. + """ + plan = resolve_plan(args.plan) + _require_target(plan, args.id) # may refresh + persist plan.targets in-memory + if args.undo: + return _cmd_plan_defer_undo(args, plan) + if not args.reason: + raise DevagueError( + EXIT_USER_ERROR, + "--reason is required to defer a coverage target", + f'pass --reason "", ' + f"or --undo to reverse a prior defer for {args.id}", + ) + try: + target = plan.defer_target(args.id, args.reason) + except ValueError as exc: + raise DevagueError( + EXIT_USER_ERROR, str(exc), "run 'devague plan show' to see current targets" + ) from exc + plan_store.save(plan) + if getattr(args, "json", False): + emit_result( + {"id": target.id, "deferred": target.deferred, "reason": target.deferred_reason}, + json_mode=True, + ) + else: + emit_result(f"{target.id} -> deferred ({target.deferred_reason})", json_mode=False) + return 0 + + +def _cmd_plan_defer_undo(args: argparse.Namespace, plan: Plan) -> int: + """``plan defer --undo`` — reverse a prior deferral, returning + the target to the active coverage gate.""" + try: + target = plan.undefer_target(args.id) + except ValueError as exc: + raise DevagueError( + EXIT_USER_ERROR, str(exc), "run 'devague plan show' to see current targets" + ) from exc + plan_store.save(plan) + if getattr(args, "json", False): + emit_result({"id": target.id, "deferred": target.deferred}, json_mode=True) + else: + emit_result(f"{target.id} -> no longer deferred", json_mode=False) + return 0 + + def _transition(args: argparse.Namespace, status: str) -> int: plan = resolve_plan(args.plan) if not plan.set_status(args.id, status): @@ -857,6 +953,17 @@ def register(sub: argparse._SubParsersAction) -> None: _plan_opt(pc) pc.set_defaults(func=cmd_plan_cover) + pdf = psub.add_parser( + "defer", help="Deliberately exclude a coverage target from this plan's gate." + ) + pdf.add_argument("id", help="Coverage target id (c*/h*).") + pdf.add_argument("--reason", help="Why this target is out of scope for this plan.") + pdf.add_argument( + "--undo", action="store_true", help="Reverse a prior defer instead of creating one." + ) + _plan_opt(pdf) + pdf.set_defaults(func=cmd_plan_defer) + pcf = psub.add_parser("confirm", help="Confirm a task (user-only).") pcf.add_argument("id", help=_TASK_ID_HELP) _plan_opt(pcf) diff --git a/devague/cli/_status.py b/devague/cli/_status.py index 02fd56f..6201ea6 100644 --- a/devague/cli/_status.py +++ b/devague/cli/_status.py @@ -85,11 +85,13 @@ def emit_status( if result.ready: lines.append("convergence: PASSED ✓") lines += [f" ⚠ {w}" for w in result.warnings] + lines += [f" ~ {p}" for p in result.parked_items] lines.append(f"next move: {labels.export_move}") else: lines.append(f"convergence: NOT passed — {len(result.blockers)} gap(s):") lines += [f" - {b}" for b in result.blockers] lines += [f" ⚠ {w}" for w in result.warnings] + lines += [f" ~ {p}" for p in result.parked_items] if result.required_next_moves: lines += [ "", diff --git a/devague/plan.py b/devague/plan.py index d88940f..e56fa08 100644 --- a/devague/plan.py +++ b/devague/plan.py @@ -91,6 +91,14 @@ class CoverageTarget: id: str kind: str # a claim kind, or "honesty" for an honesty condition text: str + # Per-target deferral (issue #85, schema v4): a coverage target the operator + # has deliberately excluded from THIS plan's gate — typically because it + # belongs to a later milestone plan — with the reason recorded so the + # exclusion is visible in the exported artifact instead of silently implied + # by absence. False/"" means "not deferred"; never fabricated when absent. + # Set/cleared only via ``Plan.defer_target`` / ``Plan.undefer_target``. + deferred: bool = False + deferred_reason: str = "" @dataclass @@ -194,6 +202,45 @@ def resolve_risk(self, rid: str, resolution: str) -> PlanRisk: def find_target(self, target_id: str) -> Optional[CoverageTarget]: return next((tg for tg in self.targets if tg.id == target_id), None) + def defer_target(self, target_id: str, reason: str) -> CoverageTarget: + """Deliberately exclude ``target_id`` from this plan's coverage gate (issue + #85): a milestone-scoped plan should not have to fake coverage of a target + that genuinely belongs to a later plan just to satisfy the gate. + + Mirrors ``resolve_risk``'s / ``Frame.resolve_vagueness``'s fail-closed + contract: an unknown target id raises, and deferring an already-deferred + target raises rather than silently overwriting its recorded reason — call + ``undefer_target`` first to change your mind, so the reversal is itself an + explicit, auditable move rather than a quiet edit. The caller (the CLI + ``plan defer`` move) is responsible for validating ``target_id`` against + the live frame first (the same seam ``cover`` already uses), so by the + time this runs the target is guaranteed to exist in ``self.targets``. + """ + target = self.find_target(target_id) + if target is None: + raise ValueError(f"unknown coverage target: {target_id!r}") + if target.deferred: + raise ValueError(f"coverage target {target_id!r} is already deferred") + target.deferred = True + target.deferred_reason = reason + return target + + def undefer_target(self, target_id: str) -> CoverageTarget: + """Reverse a prior ``defer_target`` call, returning the target to the + active coverage gate. + + Fails closed on an unknown id and on a target that was never deferred — + the same "no silent no-op" contract as every other resolve/undo move here. + """ + target = self.find_target(target_id) + if target is None: + raise ValueError(f"unknown coverage target: {target_id!r}") + if not target.deferred: + raise ValueError(f"coverage target {target_id!r} is not deferred") + target.deferred = False + target.deferred_reason = "" + return target + @staticmethod def _validate_acceptance_index(task: Task, index: int) -> None: if not 1 <= index <= len(task.acceptance_criteria): diff --git a/devague/plan_convergence.py b/devague/plan_convergence.py index 2c865cb..5088d89 100644 --- a/devague/plan_convergence.py +++ b/devague/plan_convergence.py @@ -1,9 +1,10 @@ """The plan convergence gate: is a plan solid enough to export a buildable plan? The peer of :mod:`devague.convergence`. A plan converges when every coverage target -is covered by a confirmed task, every confirmed task carries acceptance criteria, the -dependency graph is sound (no dangling refs, no cycles), nothing is left proposed, and -no blocking risk remains. Reuses :class:`devague.convergence.ConvergenceResult` so both +is either covered by a confirmed task or deliberately deferred (``plan defer``, +issue #85), every confirmed task carries acceptance criteria, the dependency graph +is sound (no dangling refs, no cycles), nothing is left proposed, and no blocking +risk remains. Reuses :class:`devague.convergence.ConvergenceResult` so both engines report the same structured ``{ready, blockers, warnings, parked_items, required_next_moves}`` shape (the CLI serializes ``ready`` as ``ready_for_plan``). """ @@ -24,11 +25,21 @@ def _missing_tasks(plan: Plan) -> list[str]: def _missing_coverage(plan: Plan, targets: list[CoverageTarget]) -> list[str]: + """Targets with no confirmed covering task — excluding deliberately + deferred ones (issue #85). + + A deferred target is a documented, honest decision (``plan defer``) that a + milestone-scoped plan will not cover it here; it must not block convergence + the way a target nobody has decided about yet does. It still shows up + elsewhere — as a tracked item in :func:`_parked_items` and in the exported + plan's "Deferred targets" section — so the exclusion stays visible instead + of silently implied by the absence of a blocker. + """ covered = {tid for t in plan.tasks if t.status == "confirmed" for tid in t.covers} return [ f"coverage target {tg.id} ({tg.kind}) has no confirmed task" for tg in targets - if tg.id not in covered + if tg.id not in covered and not tg.deferred ] @@ -146,18 +157,29 @@ def _missing_risks(plan: Plan) -> list[str]: ] -def _parked_items(plan: Plan) -> list[str]: - """Tracked, non-blocking, *unresolved* risks (everything but unknown_blocking). +def _parked_items(plan: Plan, targets: list[CoverageTarget]) -> list[str]: + """Tracked, non-blocking items: unresolved risks (everything but + unknown_blocking) plus deliberately deferred coverage targets (issue #85). A resolved risk is no longer open vagueness — it stays on the plan's record for provenance (``Plan.resolve_risk``) but should stop being advertised as parked - (t4 AC3, mirrors t3's frame-side ``_parked_items``). + (t4 AC3, mirrors t3's frame-side ``_parked_items``). A deferred target is the + coverage-side peer: it is excluded from ``_missing_coverage``'s blockers, but + must still surface *somewhere* distinct from "not yet covered" — this is that + surface, labeled ``deferred:`` so it reads differently from a ``[kind] text`` + risk line at a glance. """ - return [ + items = [ f"[{r.kind}] {r.text}" for r in plan.risks if r.kind != "unknown_blocking" and not r.resolved ] + items += [ + f"deferred: coverage target {tg.id} ({tg.kind}) — {tg.deferred_reason}" + for tg in targets + if tg.deferred + ] + return items def suggest_move(blocker: str) -> str: @@ -283,6 +305,6 @@ def evaluate(plan: Plan, targets: Optional[list[CoverageTarget]] = None) -> Conv ready=not blockers, blockers=blockers, warnings=_tdd_fitness_warnings(plan), - parked_items=_parked_items(plan), + parked_items=_parked_items(plan, tgs), required_next_moves=[suggest_move(b) for b in blockers], ) diff --git a/devague/render/plan_md.py b/devague/render/plan_md.py index 3a8960e..5aa9e05 100644 --- a/devague/render/plan_md.py +++ b/devague/render/plan_md.py @@ -12,7 +12,28 @@ from devague.frame import Frame from devague.plan import Plan, Task -from devague.render._md_safety import autolink_urls, heading_safe +from devague.render._md_safety import autolink_urls, heading_safe, md_safe_text + + +def _verbatim(text: str) -> str: + """Render-only escaping for free-form verbatim body text (#64 + #87): wrap + bare URLs first (MD034), then apply the identifier/control-char escaper + (MD037/MD050) on top of the result. Plain prose and plain URLs — the common + cases, and the ones under test — are unaffected by the order; the one + combination this does not special-case is a URL whose host/path itself + contains an underscore, since ``autolink_urls``'s ``<...>`` wrapper is not + code-span-aware the way ``md_safe_text``'s own carving is. + """ + return md_safe_text(autolink_urls(text)) + + +def _verbatim_heading(text: str) -> str: + """Like :func:`_verbatim` but for heading text: autolink + strip + markdownlint's MD026 trailing punctuation (``heading_safe``), then wrap any + underscore/dunder identifiers in code spans on top (MD037/MD050) — the + #87-comment MD050 regression this task exists to close for task headings. + """ + return md_safe_text(heading_safe(text)) def _topo_order(tasks: list[Task]) -> list[Task]: @@ -59,15 +80,15 @@ def _task_lines(task: Task) -> list[str]: # t9), mirroring t6's nested ``- instruction:`` bullet in spec_md.py / # frame_md.py. A task is a heading rather than a claim bullet, so the # instruction renders as the first body bullet, immediately under it. - body.append(f"- instruction: {autolink_urls(task.instruction)}") + body.append(f"- instruction: {_verbatim(task.instruction)}") if task.deps: body.append(f"- depends on: {', '.join(task.deps)}") if task.covers: body.append(f"- covers: {', '.join(task.covers)}") if task.acceptance_criteria: body.append("- acceptance:") - body.extend(f" - {autolink_urls(a)}" for a in task.acceptance_criteria) - lines = [f"### {task.id} — {heading_safe(task.summary)}{mark}"] + body.extend(f" - {_verbatim(a)}" for a in task.acceptance_criteria) + lines = [f"### {task.id} — {_verbatim_heading(task.summary)}{mark}"] if body: # Blank line between the heading and its list (MD022/MD032). lines += ["", *body] @@ -75,16 +96,34 @@ def _task_lines(task: Task) -> list[str]: return lines +def _deferred_targets_lines(plan: Plan) -> list[str]: + """The ``## Deferred targets`` section (issue #85): every coverage target + deliberately excluded from this plan's gate (``plan defer``), named with its + reason so the exclusion is visible in the exported artifact rather than + implied by absence. Renders nothing when the plan has no deferred targets. + """ + deferred = [tg for tg in plan.targets if tg.deferred] + if not deferred: + return [] + out = ["## Deferred targets", ""] + for tg in deferred: + out.append( + f"- `{tg.id}` ({tg.kind}): {_verbatim(tg.text)}" + f" — deferred: {_verbatim(tg.deferred_reason)}" + ) + return out + [""] + + def render_plan(plan: Plan, frame: Optional[Frame]) -> str: out = [ - f"# Build Plan — {heading_safe(plan.title)}", + f"# Build Plan — {_verbatim_heading(plan.title)}", "", f"slug: `{plan.slug}` · status: `{plan.status}` · from frame: `{plan.frame_slug}`", "", ] ann = _announcement(frame) if ann: - out += ["> " + autolink_urls(ann), ""] + out += ["> " + _verbatim(ann), ""] tasks = [t for t in plan.tasks if t.status != "rejected"] if tasks: @@ -92,11 +131,13 @@ def render_plan(plan: Plan, frame: Optional[Frame]) -> str: for t in _topo_order(tasks): out.extend(_task_lines(t)) + out += _deferred_targets_lines(plan) + if plan.risks: out += ["## Risks", ""] for r in plan.risks: suffix = f" (task {r.task_id})" if r.task_id else "" - out.append(f"- [{r.kind}] {autolink_urls(r.text)}{suffix}") + out.append(f"- [{r.kind}] {_verbatim(r.text)}{suffix}") out.append("") return "\n".join(out).rstrip() + "\n" diff --git a/tests/test_cli_plan.py b/tests/test_cli_plan.py index 602b01b..25937ed 100644 --- a/tests/test_cli_plan.py +++ b/tests/test_cli_plan.py @@ -357,6 +357,167 @@ def test_risk_resolve_already_resolved_refused(tmp_path, monkeypatch, capsys) -> assert plan_store.load(slug).find_risk("r1").resolution == "first decision" +# ── defer (issue #85, t9) ───────────────────────────────────────────────────── +def test_defer_happy_path_and_json(tmp_path, monkeypatch, capsys) -> None: + slug = _converged_frame(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + capsys.readouterr() + rc = main(["plan", "defer", "c1", "--reason", "Milestone 3: worktree mechanics", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload == {"id": "c1", "deferred": True, "reason": "Milestone 3: worktree mechanics"} + tg = plan_store.load(slug).find_target("c1") + assert tg.deferred is True + assert tg.deferred_reason == "Milestone 3: worktree mechanics" + + +def test_defer_text_output(tmp_path, monkeypatch, capsys) -> None: + slug = _converged_frame(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + capsys.readouterr() + rc = main(["plan", "defer", "c1", "--reason", "Milestone 3"]) + assert rc == 0 + out = capsys.readouterr().out + assert "c1" in out and "deferred" in out and "Milestone 3" in out + + +def test_defer_unknown_target_errors(tmp_path, monkeypatch, capsys) -> None: + slug = _converged_frame(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + capsys.readouterr() + rc = main(["plan", "defer", "zzz", "--reason", "whatever"]) + assert rc == 1 + assert "unknown coverage target" in capsys.readouterr().err + + +def test_defer_missing_reason_errors(tmp_path, monkeypatch, capsys) -> None: + slug = _converged_frame(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + capsys.readouterr() + rc = main(["plan", "defer", "c1"]) + assert rc == 1 + assert "--reason" in capsys.readouterr().err + assert plan_store.load(slug).find_target("c1").deferred is False + + +def test_defer_already_deferred_errors(tmp_path, monkeypatch, capsys) -> None: + slug = _converged_frame(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + main(["plan", "defer", "c1", "--reason", "first"]) + capsys.readouterr() + rc = main(["plan", "defer", "c1", "--reason", "second"]) + assert rc == 1 + assert "already deferred" in capsys.readouterr().err + assert plan_store.load(slug).find_target("c1").deferred_reason == "first" + + +def test_defer_undo_happy_path(tmp_path, monkeypatch, capsys) -> None: + slug = _converged_frame(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + main(["plan", "defer", "c1", "--reason", "first"]) + capsys.readouterr() + rc = main(["plan", "defer", "c1", "--undo", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload == {"id": "c1", "deferred": False} + tg = plan_store.load(slug).find_target("c1") + assert tg.deferred is False + assert tg.deferred_reason == "" + + +def test_defer_undo_not_deferred_errors(tmp_path, monkeypatch, capsys) -> None: + slug = _converged_frame(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + capsys.readouterr() + rc = main(["plan", "defer", "c1", "--undo"]) + assert rc == 1 + assert "not deferred" in capsys.readouterr().err + + +def test_defer_undo_unknown_target_errors(tmp_path, monkeypatch, capsys) -> None: + slug = _converged_frame(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + capsys.readouterr() + rc = main(["plan", "defer", "zzz", "--undo"]) + assert rc == 1 + assert "unknown coverage target" in capsys.readouterr().err + + +def test_defer_validates_against_live_frame(tmp_path, monkeypatch, capsys) -> None: + """`defer` shares `_require_target`'s live-frame fallback (#90): a target + that only exists in the live frame (not yet in the stored snapshot) can be + deferred directly, without an intervening `plan converge`.""" + slug = _converged_frame(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + assert main(["plan", "converge"]) == 0 + capsys.readouterr() + assert main(["capture", "--kind", "requirement", "a new requirement"]) == 0 + new_claim_id = next(c.id for c in store.load(slug).claims if c.kind == "requirement") + main(["interrogate", new_claim_id, "--honesty", "must hold", "--origin", "user"]) + assert main(["converge"]) == 0 + capsys.readouterr() + + rc = main(["plan", "defer", new_claim_id, "--reason", "later"]) + assert rc == 0 + persisted = plan_store.load(slug) + assert persisted.find_target(new_claim_id).deferred is True + + +# ── end-to-end: converge, export, and status with deferred targets (issue #85) ── +def test_plan_with_deferred_targets_converges_exports_and_status_distinguishes( + tmp_path, monkeypatch, capsys +) -> None: + """Acceptance criterion 1: a plan with deferred targets converges and + exports; the export names every deferred target with its reason; status + distinguishes deferred from uncovered. (The exact "90 covered / 12 + deferred" shell-cli shape from #85 is exercised at the model layer in + tests/test_plan_convergence.py — this is the CLI-surface equivalent at a + smaller, CLI-practical scale.) + """ + slug = _converged_frame(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + covered = _ALL_TARGETS[:8] + deferred = _ALL_TARGETS[8:] + args = ["plan", "task", "in-scope work", "--accept", "covers in-scope targets"] + for tid in covered: + args += ["--covers", tid] + main(args) + for tid in deferred: + main(["plan", "defer", tid, "--reason", f"out of scope: {tid}"]) + capsys.readouterr() + + rc = main(["plan", "converge", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["ready_for_plan"] is True + assert payload["blockers"] == [] + deferred_parked = [item for item in payload["parked_items"] if "deferred" in item] + assert len(deferred_parked) == len(deferred) + for tid in deferred: + assert any(tid in item for item in deferred_parked) + + rc = main(["plan", "export"]) + assert rc == 0 + exported_plan = plan_store.load(slug) + text = (Path("docs/plans") / f"{exported_plan.created[:10]}-{slug}.md").read_text( + encoding="utf-8" + ) + assert "## Deferred targets" in text + for tid in deferred: + assert f"`{tid}`" in text + assert f"out of scope: {tid}" in text + + capsys.readouterr() + rc = main(["plan", "status"]) + assert rc == 0 + out = capsys.readouterr().out + assert "PASSED" in out + for tid in deferred: + assert tid in out and "deferred" in out + # Nothing about a deferred target is mislabeled as a plain uncovered gap. + assert "has no confirmed task" not in out + + # ── converge / export ─────────────────────────────────────────────────────── def test_converge_reports_then_passes(tmp_path, monkeypatch, capsys) -> None: slug = _converged_frame(monkeypatch, tmp_path) diff --git a/tests/test_plan.py b/tests/test_plan.py index c32a869..91de74d 100644 --- a/tests/test_plan.py +++ b/tests/test_plan.py @@ -5,6 +5,7 @@ from devague.frame import Frame from devague.plan import ( PLAN_SCHEMA_VERSION, + CoverageTarget, Plan, PlanRisk, Task, @@ -100,6 +101,88 @@ def test_resolve_risk_rejects_already_resolved() -> None: p.resolve_risk(r.id, "second decision") +# ── per-target deferral (issue #85, t9) ─────────────────────────────────────── + + +def test_coverage_target_deferred_defaults() -> None: + tg = CoverageTarget(id="c1", kind="requirement", text="x") + assert tg.deferred is False + assert tg.deferred_reason == "" + + +def test_defer_target_marks_deferred_with_reason() -> None: + p = _plan() + p.targets.append(CoverageTarget(id="c47", kind="requirement", text="worktree concurrency")) + tg = p.defer_target("c47", "Milestone 3: worktree mechanics") + assert tg.deferred is True + assert tg.deferred_reason == "Milestone 3: worktree mechanics" + # id/kind/text are untouched by deferring. + assert (tg.id, tg.kind, tg.text) == ("c47", "requirement", "worktree concurrency") + + +def test_defer_target_rejects_unknown_id() -> None: + p = _plan() + with pytest.raises(ValueError, match="unknown coverage target"): + p.defer_target("cX", "why") + + +def test_defer_target_rejects_already_deferred() -> None: + p = _plan() + p.targets.append(CoverageTarget(id="c1", kind="requirement", text="x")) + p.defer_target("c1", "first reason") + with pytest.raises(ValueError, match="already deferred"): + p.defer_target("c1", "second reason") + # The original reason survives the refused second call. + assert p.find_target("c1").deferred_reason == "first reason" + + +def test_undefer_target_clears_deferred_state() -> None: + p = _plan() + p.targets.append(CoverageTarget(id="c1", kind="requirement", text="x")) + p.defer_target("c1", "reason") + tg = p.undefer_target("c1") + assert tg.deferred is False + assert tg.deferred_reason == "" + + +def test_undefer_target_rejects_unknown_id() -> None: + p = _plan() + with pytest.raises(ValueError, match="unknown coverage target"): + p.undefer_target("cX") + + +def test_undefer_target_rejects_not_deferred() -> None: + p = _plan() + p.targets.append(CoverageTarget(id="c1", kind="requirement", text="x")) + with pytest.raises(ValueError, match="not deferred"): + p.undefer_target("c1") + + +def test_deferred_target_roundtrips_verbatim() -> None: + p = _plan() + p.targets.append(CoverageTarget(id="c1", kind="requirement", text="x")) + p.defer_target("c1", "decided: out of scope for this milestone") + restored = from_dict(to_dict(p)) + tg = restored.find_target("c1") + assert tg.deferred is True + assert tg.deferred_reason == "decided: out of scope for this milestone" + + +def test_legacy_v3_target_dict_without_deferred_fields_loads_with_defaults() -> None: + # A schema_version-3 plan's target dicts predate deferred/deferred_reason entirely. + legacy = { + "slug": "s", + "title": "t", + "frame_slug": "s", + "schema_version": 3, + "targets": [{"id": "c1", "kind": "requirement", "text": "x"}], + } + p = from_dict(legacy) + tg = p.find_target("c1") + assert tg.deferred is False + assert tg.deferred_reason == "" + + def test_set_status_transitions_and_reports_unknown() -> None: p = _plan() p.add_task("x") diff --git a/tests/test_plan_convergence.py b/tests/test_plan_convergence.py index 6bb880b..404922f 100644 --- a/tests/test_plan_convergence.py +++ b/tests/test_plan_convergence.py @@ -460,3 +460,96 @@ def test_resolved_risk_excluded_from_parked_items() -> None: p.resolve_risk(r.id, "decided: not an issue") res2 = evaluate(p) assert not any("a non-blocking risk" in item for item in res2.parked_items) + + +# ── per-target deferral (issue #85, t9) ─────────────────────────────────────── + + +def test_deferred_target_does_not_block_convergence() -> None: + """Baseline: an uncovered, deferred target is not a coverage blocker.""" + p = _converging() # one task covering c1 + p.targets.append(CoverageTarget(id="c47", kind="requirement", text="worktree concurrency")) + p.defer_target("c47", "Milestone 3: worktree mechanics") + res = evaluate(p) + assert res.ready is True, f"expected convergence; blockers: {res.blockers}" + assert not any("c47" in b for b in res.blockers) + + +def test_uncovered_undeferred_target_still_blocks() -> None: + """A target that is neither covered nor deferred remains a real blocker — + deferral must be an explicit decision, not a side effect of merely existing.""" + p = _converging() + p.targets.append(CoverageTarget(id="c47", kind="requirement", text="worktree concurrency")) + res = evaluate(p) + assert res.ready is False + assert any("coverage target c47" in b and "has no confirmed task" in b for b in res.blockers) + + +def test_deferred_target_appears_in_parked_items_distinctly() -> None: + """A deferred target surfaces in parked_items labeled distinctly from a plain + unresolved risk, so a reviewer can tell 'deliberately deferred' apart from + 'not yet covered' or 'an open risk' at a glance.""" + p = _converging() + p.targets.append(CoverageTarget(id="c47", kind="requirement", text="worktree concurrency")) + p.defer_target("c47", "Milestone 3: worktree mechanics") + res = evaluate(p) + matches = [item for item in res.parked_items if "c47" in item] + assert matches, f"expected a parked item naming c47, got: {res.parked_items}" + assert "deferred" in matches[0] + assert "Milestone 3: worktree mechanics" in matches[0] + + +def test_deferred_target_absent_from_required_next_moves() -> None: + """A deferred target generates no follow-up hint — it is not a blocker.""" + p = _converging() + p.targets.append(CoverageTarget(id="c47", kind="requirement", text="worktree concurrency")) + p.defer_target("c47", "Milestone 3") + res = evaluate(p) + assert not any("c47" in m for m in res.required_next_moves) + + +def test_undeferred_target_blocks_again() -> None: + """Reversing a deferral (``undefer_target``) restores the target as a real + blocker — deferral is not a permanent write-off once undone.""" + p = _converging() + p.targets.append(CoverageTarget(id="c47", kind="requirement", text="worktree concurrency")) + p.defer_target("c47", "Milestone 3") + p.undefer_target("c47") + res = evaluate(p) + assert res.ready is False + assert any("coverage target c47" in b for b in res.blockers) + + +def test_shell_cli_shape_90_covered_12_deferred_converges() -> None: + """The exact repro shape from issue #85's comment thread: a plan confirmed + and complete — 19 tasks / 9 waves in the real repro, simplified here to one + covering task per covered target — with 90 of 102 coverage targets covered + and the remaining 12 deliberately deferred to a later milestone. It must + converge cleanly, with zero coverage blockers and every deferred target + named in parked_items. + """ + p = Plan(slug="milestone-scoped", title="Milestone-scoped plan", frame_slug="milestone-scoped") + covered_ids = [f"c{i}" for i in range(1, 91)] + deferred_ids = [f"c{i}" for i in range(91, 103)] + for tid in covered_ids: + p.targets.append(CoverageTarget(id=tid, kind="requirement", text=f"target {tid}")) + for tid in deferred_ids: + p.targets.append(CoverageTarget(id=tid, kind="requirement", text=f"target {tid}")) + assert len(p.targets) == 102 + + t = p.add_task("deliver the in-scope milestones") + t.instruction = "implement every in-scope target" + p.add_acceptance(t, "all 90 in-scope targets pass their own acceptance checks") + for tid in covered_ids: + p.add_cover(t, tid) + + for tid in deferred_ids: + p.defer_target(tid, "belongs to a later milestone plan") + + res = evaluate(p) + assert res.ready is True, f"expected convergence; blockers: {res.blockers}" + assert res.blockers == [] + deferred_parked = [item for item in res.parked_items if "deferred" in item] + assert len(deferred_parked) == 12 + for tid in deferred_ids: + assert any(tid in item for item in deferred_parked) diff --git a/tests/test_render_plan.py b/tests/test_render_plan.py index 8dd9dbd..1e283f5 100644 --- a/tests/test_render_plan.py +++ b/tests/test_render_plan.py @@ -1,7 +1,7 @@ from __future__ import annotations from devague.frame import Frame -from devague.plan import Plan +from devague.plan import CoverageTarget, Plan from devague.render.plan_md import render_plan from tests.test_render import assert_blanks_around_headings_and_lists @@ -149,3 +149,104 @@ def test_plan_md_does_not_mutate_plan_or_task_text() -> None: render_plan(plan, frame) assert plan.title == before_title assert plan.tasks[0].summary == before_summary + + +# ── #85: Deferred targets section ──────────────────────────────────────────── + + +def test_deferred_targets_section_names_target_and_reason() -> None: + p = _plan() + p.targets.append(CoverageTarget(id="c47", kind="requirement", text="worktree concurrency")) + p.defer_target("c47", "Milestone 3: worktree mechanics") + out = render_plan(p, _frame()) + assert "## Deferred targets" in out + assert "`c47`" in out + assert "worktree concurrency" in out + assert "deferred: Milestone 3: worktree mechanics" in out + + +def test_no_deferred_targets_section_when_nothing_deferred() -> None: + out = render_plan(_plan(), _frame()) + assert "## Deferred targets" not in out + + +def test_deferred_targets_section_lists_every_deferred_target() -> None: + p = _plan() + p.targets.append(CoverageTarget(id="c47", kind="requirement", text="target A")) + p.targets.append(CoverageTarget(id="h35", kind="honesty", text="target B")) + p.defer_target("c47", "reason A") + p.defer_target("h35", "reason B") + out = render_plan(p, _frame()) + assert "`c47`" in out and "reason A" in out + assert "`h35`" in out and "reason B" in out + + +def test_deferred_targets_section_blanks_around_headings_and_lists() -> None: + p = _plan() + p.targets.append(CoverageTarget(id="c47", kind="requirement", text="target A")) + p.defer_target("c47", "reason A") + assert_blanks_around_headings_and_lists(render_plan(p, _frame())) + + +# ── #87 MD050 regression: underscore-bearing verbatim text (t9) ───────────── + + +def test_task_heading_wraps_underscore_identifier() -> None: + p = Plan(slug="demo", title="Demo", frame_slug="demo") + p.add_task("no functional export is added to shell/fs/__init__.py") + out = render_plan(p, None) + heading_line = next(ln for ln in out.split("\n") if ln.startswith("### t1")) + assert "shell/fs/`__init__.py`" in heading_line + # never a bare, unwrapped dunder in the heading (the MD050 trigger). + assert "__init__.py" not in heading_line.replace("`__init__.py`", "") + + +def test_task_instruction_wraps_underscore_identifier() -> None: + p = Plan(slug="demo", title="Demo", frame_slug="demo") + t = p.add_task("core") + t.instruction = "calls _read_file directly" + out = render_plan(p, None) + assert "- instruction: calls `_read_file` directly" in out + + +def test_acceptance_criterion_wraps_underscore_identifier() -> None: + p = Plan(slug="demo", title="Demo", frame_slug="demo") + t = p.add_task("core") + p.add_acceptance(t, "_read_file and __init__.py both matter") + out = render_plan(p, None) + assert " - `_read_file` and `__init__.py` both matter" in out + + +def test_risk_text_wraps_underscore_identifier() -> None: + p = Plan(slug="demo", title="Demo", frame_slug="demo") + p.add_risk("touches shell/fs/__init__.py directly", "unknown_nonblocking") + out = render_plan(p, None) + assert "shell/fs/`__init__.py`" in out + + +def test_deferred_target_reason_wraps_underscore_identifier() -> None: + p = Plan(slug="demo", title="Demo", frame_slug="demo") + p.targets.append(CoverageTarget(id="c1", kind="requirement", text="x")) + p.defer_target("c1", "belongs to shell/fs/__init__.py migration") + out = render_plan(p, None) + assert "shell/fs/`__init__.py`" in out + + +def test_plan_title_wraps_underscore_identifier() -> None: + p = Plan(slug="demo", title="Ship the __init__.py rewrite", frame_slug="demo") + out = render_plan(p, None) + assert out.startswith("# Build Plan — Ship the `__init__.py` rewrite") + + +def test_underscore_bearing_plan_blanks_around_headings_and_lists() -> None: + """The full MD050-regression shape in one plan, run through the same blank-line + structural check the hostile-URL tests already use — the closest in-repo proxy + for "passes markdownlint-cli2" without shelling out to the linter itself.""" + p = Plan(slug="demo", title="Ship the __init__.py rewrite", frame_slug="demo") + t = p.add_task("no functional export is added to shell/fs/__init__.py") + t.instruction = "calls _read_file directly" + p.add_acceptance(t, "_read_file and __init__.py both matter") + p.targets.append(CoverageTarget(id="c1", kind="requirement", text="x")) + p.defer_target("c1", "belongs to shell/fs/__init__.py migration") + p.add_risk("touches shell/fs/__init__.py directly", "unknown_nonblocking") + assert_blanks_around_headings_and_lists(render_plan(p, None)) From 35932e42dfad38dfef3d557d8f464a52d1088401 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 10:48:40 +0300 Subject: [PATCH 11/25] fix(export): render all parks, resolved questions, dead seeds; escape verbatim text (#93, #49, #83, #87, t3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spec_md.py's park-kind filter only ever surfaced follow_up/out_of_scope, silently dropping every open unknown_nonblocking/unknown_blocking park — exactly the residual-risk kind that legitimately coexists with a converged frame (#93, #49). It now renders every open park kind, grouped and labeled. Hard questions previously carried no resolved marker and rendered regardless of parent-claim status, so a resolved question read as an open blocker and a rejected claim's question still leaked into the artifact (#49, #83). Both are fixed: resolved questions get a "(resolved)" marker, and any claim.status == "rejected" drops its hard questions entirely. A scope entry whose seeds cite a claim that was later rejected now renders a visible "(rejected)" marker instead of a bare dead id (#84's fourth acceptance criterion, c33/h26). Every verbatim interpolation site now composes the t1 escaper (md_safe_text) with the existing autolink_urls/heading_safe passes via new _safe()/_safe_heading() helpers, fixing MD037/MD050 on underscore/dunder identifiers throughout the exported spec (#87). Flips the pinned test_render.py:301-306 (nonblocking parks now render) and adds regression coverage for all four acceptance criteria, including a real markdownlint-cli2 double-export byte-stability check. Re-exporting docs/specs/2026-07-28-issue-backlog-sweep.md (a live corpus mixing backticked and bare identifiers) produces the expected one-time presentational diff — committed alongside, per park v5. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QtUixLUjgEt51PXS9TPVt3 --- .devague/frames/issue-backlog-sweep.json | 4 +- devague/render/spec_md.py | 155 ++++++++++++------ docs/specs/2026-07-28-issue-backlog-sweep.md | 30 ++-- tests/goldens/resolved_vagueness_spec.md | 4 + tests/goldens/sharper_spec.md | 2 +- tests/test_export_markdownlint_integration.py | 137 ++++++++++++++++ tests/test_render.py | 137 ++++++++++++++-- 7 files changed, 397 insertions(+), 72 deletions(-) diff --git a/.devague/frames/issue-backlog-sweep.json b/.devague/frames/issue-backlog-sweep.json index e9e9117..d0632ad 100644 --- a/.devague/frames/issue-backlog-sweep.json +++ b/.devague/frames/issue-backlog-sweep.json @@ -1,10 +1,10 @@ { "slug": "issue-backlog-sweep", "title": "issue-backlog-sweep", - "schema_version": 3, + "schema_version": 4, "status": "exported", "created": "2026-07-28T06:49:19Z", - "updated": "2026-07-28T07:02:03Z", + "updated": "2026-07-28T07:47:39Z", "claims": [ { "id": "c1", diff --git a/devague/render/spec_md.py b/devague/render/spec_md.py index a4c405d..ca8daec 100644 --- a/devague/render/spec_md.py +++ b/devague/render/spec_md.py @@ -2,8 +2,37 @@ from __future__ import annotations -from devague.frame import Claim, Frame, HonestyCondition, Vagueness -from devague.render._md_safety import autolink_urls, heading_safe +from devague.frame import ( + VAGUENESS_KINDS, + Claim, + Frame, + HardQuestion, + HonestyCondition, + Vagueness, +) +from devague.render._md_safety import autolink_urls, heading_safe, md_safe_text + + +def _safe(text: str) -> str: + """Compose both render-time verbatim-text passes for one field (#64, #87): + identifier/control-character escaping first, then bare-URL autolinking. + + Order matters: ``autolink_urls`` already treats a backtick-delimited code + span as untouchable, so running ``md_safe_text`` first means any fresh + code span it introduces (wrapping an underscore-bearing identifier) is + then correctly skipped by the autolink pass. Running them in the other + order risks an autolinked ``<...>`` URL being reopened by an identifier + match landing inside it — ``md_safe_text`` has no notion of + angle-bracket protection, only of existing backtick code spans. + """ + return autolink_urls(md_safe_text(text)) + + +def _safe_heading(text: str) -> str: + """Like ``_safe`` but for heading text: composes with ``heading_safe`` + (MD026 trailing-punctuation stripping) instead of a bare ``autolink_urls``. + """ + return heading_safe(md_safe_text(text)) def _claims(frame: Frame, kind: str) -> list[Claim]: @@ -15,27 +44,17 @@ def _instruction_lines(instruction: str, indent: str = " ") -> list[str]: when the item carries no instruction — never fabricated filler (#53 t1/t6, c10/h3). """ - return [f"{indent}- instruction: {autolink_urls(instruction)}"] if instruction else [] + return [f"{indent}- instruction: {_safe(instruction)}"] if instruction else [] def _claim_bullets(claims: list[Claim], prefix: str = "") -> list[str]: out: list[str] = [] for c in claims: - out.append(f"- {prefix}{autolink_urls(c.text)}") + out.append(f"- {prefix}{_safe(c.text)}") out += _instruction_lines(c.instruction) return out -def _text_section(heading: str, texts: list[str]) -> list[str]: - """A standard ``## heading`` + plain bullet-list block, or nothing when empty. - - For text-only items with no instruction field (e.g. open vagueness). - """ - if not texts: - return [] - return [f"## {heading}", "", *[f"- {autolink_urls(t)}" for t in texts], ""] - - def _claim_section(heading: str, claims: list[Claim]) -> list[str]: """A ``## heading`` + bullet-list block of claims, each with its own nested instruction bullet when it carries one, or nothing when empty. @@ -63,12 +82,12 @@ def _requirements_block(frame: Frame) -> list[str]: return [] out = ["## Requirements", ""] for c in reqs: - out.append(f"- {autolink_urls(c.text)}") + out.append(f"- {_safe(c.text)}") out += _instruction_lines(c.instruction) for h in c.honesty_conditions: if h.status != "confirmed": continue - out.append(f" - honesty: {autolink_urls(h.text)}") + out.append(f" - honesty: {_safe(h.text)}") out += _instruction_lines(h.instruction, indent=" ") return out + [""] @@ -96,38 +115,73 @@ def _honesty_section(heading: str, honesties: list[HonestyCondition]) -> list[st return [] out = [f"## {heading}", ""] for h in honesties: - out.append(f"- {autolink_urls(h.text)}") + out.append(f"- {_safe(h.text)}") out += _instruction_lines(h.instruction) return out + [""] -def _hard_questions(frame: Frame) -> list[str]: - hqs = [q for c in frame.claims for q in c.hard_questions] - bullets = [f"- {autolink_urls(q.text)}" + (" (blocking)" if q.blocking else "") for q in hqs] - return ["## Hard questions", "", *bullets, ""] if hqs else [] +def _hard_question_marker(q: HardQuestion) -> str: + """A trailing marker for one hard question: ``(resolved)`` once answered, + else ``(blocking)`` while it still blocks convergence, else nothing. + Resolved takes priority over blocking — an answered question is no longer + an open blocker regardless of the flag it was created with (#49). + """ + if q.resolved: + return " (resolved)" + if q.blocking: + return " (blocking)" + return "" -def _follow_up(frame: Frame) -> list[str]: - # A resolved follow_up/out_of_scope item is no longer open — it moves into - # ``_resolved_vagueness_section`` instead (resolve-parked-vagueness t7); - # counting it here too would fabricate it as still-open. - return [ - v.text - for v in frame.open_vagueness - if v.kind in ("follow_up", "out_of_scope") and not v.resolved +def _hard_questions(frame: Frame) -> list[str]: + """Hard questions attached to non-rejected claims, each carrying a + resolved/blocking marker. + + Two independent fidelity fixes (#49, #83): a resolved question no longer + renders as if it were still an open blocker, and a question whose parent + claim was rejected is dropped entirely — rejected content must never + reach the exported spec, regardless of the question's own state. + """ + bullets = [ + f"- {_safe(q.text)}{_hard_question_marker(q)}" + for c in frame.claims + if c.status != "rejected" + for q in c.hard_questions ] + return ["## Hard questions", "", *bullets, ""] if bullets else [] + + +def _open_parks(frame: Frame) -> list[str]: + """All still-open (unresolved) parked vagueness, of every kind, grouped by + kind and labeled with it. + + Replaces the old filter that only ever surfaced ``follow_up``/ + ``out_of_scope`` and silently dropped every open ``unknown_nonblocking``/ + ``unknown_blocking`` park (#93, #49) — exactly ``unknown_nonblocking`` is + the kind that legitimately coexists with a converged frame, which made it + the kind most worth rendering. A resolved item moves into + ``_resolved_vagueness_section`` instead (resolve-parked-vagueness t7); + counting it here too would fabricate it as still open. + """ + items = [v for v in frame.open_vagueness if not v.resolved] + if not items: + return [] + ordered = sorted(items, key=lambda v: VAGUENESS_KINDS.index(v.kind)) + out = ["## Open parks", ""] + out.extend(f"- [{v.kind}] {_safe(v.text)}" for v in ordered) + return out + [""] def _resolved_vagueness(frame: Frame) -> list[Vagueness]: """Resolved open-vagueness items, of any kind. - Unlike ``_follow_up`` (which only ever surfaced follow_up/out_of_scope - kinds), a decided item — including a resolved ``unknown_blocking`` or - ``unknown_nonblocking`` park — belongs in the exported spec once it carries - a resolution: the whole point of resolving a parked unknown is that the - answer ships with the spec (issue 45's provenance ask). An item marked - resolved with no resolution text renders nothing here — never fabricated - filler (mirrors ``_instruction_lines``). + Unlike the old open-parks filter (which only ever surfaced follow_up/ + out_of_scope kinds), a decided item — including a resolved + ``unknown_blocking`` or ``unknown_nonblocking`` park — belongs in the + exported spec once it carries a resolution: the whole point of resolving + a parked unknown is that the answer ships with the spec (issue 45's + provenance ask). An item marked resolved with no resolution text renders + nothing here — never fabricated filler (mirrors ``_instruction_lines``). """ return [v for v in frame.open_vagueness if v.resolved and v.resolution] @@ -137,13 +191,22 @@ def _resolved_vagueness_section(frame: Frame) -> list[str]: if not items: return [] out = ["## Resolved vagueness", ""] - out.extend( - f"- [{v.kind}] {autolink_urls(v.text)} — resolved: {autolink_urls(v.resolution)}" - for v in items - ) + out.extend(f"- [{v.kind}] {_safe(v.text)} — resolved: {_safe(v.resolution)}" for v in items) return out + [""] +def _seed_label(frame: Frame, seed_id: str) -> str: + """A scope-entry seed id, flagged when it cites a rejected claim (the + fourth #84 acceptance criterion, c33/h26) instead of rendering a bare + dead reference. An id that resolves to no claim at all (or to a + confirmed/proposed one) renders as the plain backticked id, unchanged. + """ + claim = frame.find_claim(seed_id) + if claim is not None and claim.status == "rejected": + return f"`{seed_id}` (rejected)" + return f"`{seed_id}`" + + def _scope_section(frame: Frame) -> list[str]: """Scope-exploration provenance: each recorded surface + finding, with the claim ids it seeded — citing what was actually explored, not a generic @@ -153,20 +216,20 @@ def _scope_section(frame: Frame) -> list[str]: return [] out = ["## Scope exploration", ""] for e in frame.scope_entries: - out.append(f"- `{e.id}` — `{e.surface}`: {autolink_urls(e.finding)}") + out.append(f"- `{e.id}` — `{e.surface}`: {_safe(e.finding)}") if e.seeds: - out.append(f" - seeds: {', '.join(f'`{s}`' for s in e.seeds)}") + out.append(f" - seeds: {', '.join(_seed_label(frame, s) for s in e.seeds)}") return out + [""] def render_spec(frame: Frame) -> str: - out: list[str] = [f"# {heading_safe(frame.title)}", ""] + out: list[str] = [f"# {_safe_heading(frame.title)}", ""] ann_claims = _claims(frame, "announcement") if ann_claims: ann = ann_claims[0] - out.append("> " + autolink_urls(ann.text)) + out.append("> " + _safe(ann.text)) if ann.instruction: - out.append(f"> instruction: {autolink_urls(ann.instruction)}") + out.append(f"> instruction: {_safe(ann.instruction)}") out.append("") out += _claim_section("Audience", _claims(frame, "audience")) out += _before_after(frame) @@ -181,6 +244,6 @@ def render_spec(frame: Frame) -> str: out += _claim_section("Decisions", _claims(frame, "decision")) out += _hard_questions(frame) out += _claim_section("Open questions", _claims(frame, "open_question")) - out += _text_section("Open / follow-up", _follow_up(frame)) + out += _open_parks(frame) out += _resolved_vagueness_section(frame) return "\n".join(out).rstrip() + "\n" diff --git a/docs/specs/2026-07-28-issue-backlog-sweep.md b/docs/specs/2026-07-28-issue-backlog-sweep.md index 1d0c7e0..b7693f7 100644 --- a/docs/specs/2026-07-28-issue-backlog-sweep.md +++ b/docs/specs/2026-07-28-issue-backlog-sweep.md @@ -14,15 +14,15 @@ ## Requirements - export renders every parked kind: `render/spec_md.py:110-118` `_follow_up` filters to `follow_up`/`out_of_scope` only, so an unresolved `unknown_nonblocking` park renders nowhere in the spec; all open parks must render grouped by kind, and the pinning test `tests/test_render.py:301-306` flips from asserting absence to asserting presence (#93, #49) - - honesty: honest only if a converged frame carrying open parks of all four kinds exports a spec where every open park appears, labeled by kind — verified by flipping tests/test_render.py:301-306 from asserting absence to asserting presence + - honesty: honest only if a converged frame carrying open parks of all four kinds exports a spec where every open park appears, labeled by kind — verified by flipping tests/`test_render.py`:301-306 from asserting absence to asserting presence - hard questions render honestly: `render/spec_md.py:104-107` shows no resolved marker and iterates all claims regardless of status — resolved questions render as resolved (or are omitted) and questions on rejected claims never render (#49, #83) - honesty: honest only if a resolved hard question renders with a resolved marker (or is omitted by documented choice) and a hard question on a rejected claim never reaches the export — regression covering the #83 repro shape: capture, interrogate --risk, reject, converge, export -- a user move resolves claim-attached hard questions: nothing in the codebase sets `HardQuestion.resolved` (only vagueness `frame.py:243` and plan-risk `plan.py:187` resolves exist), `set_status` (`frame.py:264-273`) routes only c*/h* ids, so the gate at `convergence.py:111-116` blocks forever; `suggest_move` (`convergence.py:206-211`) must name the real move (#48, #52) - - honesty: honest only if the block-resolve-converge sequence completes through CLI moves alone (no state-JSON hand-edit), suggest_move names the shipped move verbatim, and resolved state survives a save/load round-trip +- a user move resolves claim-attached hard questions: nothing in the codebase sets `HardQuestion.resolved` (only vagueness `frame.py:243` and plan-risk `plan.py:187` resolves exist), `set_status` (`frame.py:264-273`) routes only c\*/h\* ids, so the gate at `convergence.py:111-116` blocks forever; `suggest_move` (`convergence.py:206-211`) must name the real move (#48, #52) + - honesty: honest only if the block-resolve-converge sequence completes through CLI moves alone (no state-JSON hand-edit), `suggest_move` names the shipped move verbatim, and resolved state survives a save/load round-trip - reject cascades over attachments: rejecting a claim cascades to (or refuses over) its honesty conditions and hard questions, reporting what it took; `_assumption_warnings` (`convergence.py:120-126`) and the blocking-question gate skip rejected claims; `devague review` stops listing orphaned conditions (#83) - honesty: honest only if rejecting a claim with attachments reports exactly what it cascaded over (or refuses with an actionable hint), post-reject converge emits zero warnings about the rejected claim, and review lists zero orphaned conditions - markdown escaping lands at the single verbatim seam: `render/_md_safety.py` (today only `autolink_urls`/`heading_safe`) gains identifier-aware escaping — code-span wrapping for underscore and dunder tokens per the #87 comment, fixing MD037 and MD050 — applied at every verbatim site in `spec_md.py`, `plan_md.py`, and `summary_md.py` (#87) - - honesty: honest only if a frame whose text contains `_read_file`, `__init__.py`, `*`, `[`, a backtick, and a leading `#` exports spec-md and plan-md passing markdownlint-cli2 default config — the integration test extends tests/test_export_markdownlint_integration.py + - honesty: honest only if a frame whose text contains `_read_file`, `__init__.py`, `*`, `[`, a backtick, and a leading `#` exports spec-md and plan-md passing markdownlint-cli2 default config — the integration test extends tests/`test_export_markdownlint_integration.py` - an amend move preserves identity: claims (`--text`/`--kind`, keeping id, honesty conditions, instruction, and inbound seeds), scope entries (`scope --amend sN --finding`), and plan risks (`plan risk --amend rN --text`); amending a confirmed item flips it to proposed with an echoed flip, matching the `interrogate.py:58-68` and plan `_FLIP_SUFFIX` precedent (#84) - honesty: honest only if amending a confirmed claim keeps its id, honesty conditions, instruction, and inbound scope seeds, flips it to proposed with an echoed flip, and correcting one number costs exactly one move — with the same holding for scope --amend and plan risk --amend - `scope --seeds` accepts question ids: `Frame.add_scope_entry` (`frame.py:252-254`) validates seeds via `find_claim` only, so `q*` is refused today — yet the /scope routing table sends needs-a-user-decision findings to `question`, so that branch must be seedable (#84) @@ -32,7 +32,7 @@ - a per-target deferral move (for example `plan defer --reason`): deferred targets stop blocking `_missing_coverage` (`plan_convergence.py:26-32`), render as a Deferred targets section in plan-md (no such section exists, `render/plan_md.py:78-102`), and `plan status` distinguishes deliberately-deferred from not-yet-covered; today an `out_of_scope` risk has zero gate effect (#85) - honesty: honest only if a plan with deferred targets converges and exports, the export names every deferred target with its reason in a Deferred targets section, and plan status reports deliberately-deferred distinctly from not-yet-covered — exercising the shell-cli shape of 90 covered plus 12 deferred - `plan task --dep` validates at creation: `add_dep` (`plan.py:134-136`) is a bare append, so self-dependencies and dangling deps are accepted silently (pinned by `tests/test_cli_plan.py:87-96`, which flips); refuse self-reference and unknown ids with actionable hints (#86) - - honesty: honest only if plan task --dep naming the about-to-be-assigned id or an unknown id refuses at creation with an actionable hint, and tests/test_cli_plan.py:87-96 is flipped rather than deleted + - honesty: honest only if plan task --dep naming the about-to-be-assigned id or an unknown id refuses at creation with an actionable hint, and tests/`test_cli_plan.py`:87-96 is flipped rather than deleted - `plan confirm` and `plan reject` go multi-id transactional: today single-id (`cli/_commands/plan.py:827-835`) versus the frame-side batched `nargs` surface (`confirm.py:90-97`, `reject.py:10-15`) (#86) - honesty: honest only if plan confirm and plan reject with N ids apply transactionally — all valid or none applied — matching the frame-side contract in behavior and error text - `devague summary` scopes to confirmed tasks: `_planned_work_lines`, `_actual_delivery_lines`, and `summary_data` (`render/summary_md.py:119-143, 265-271`) iterate every task today; rejected tasks leave Planned Work and the Actual Delivery table, replaced by a one-line rejected-count note; `dependency_waves` already excludes rejected tasks (`plan.py:285`) so the --pr wave map is safe (#88) @@ -42,7 +42,7 @@ - split-plan writes a durable gate-2 artifact: `docs/plans/--split.md` beside the exported plan-md, overwrite-in-place on re-run; today the script has no write step at all (`assign-to-workforce.sh:262-283` — subcommands split-plan, waves, help only) (#82) - honesty: honest only if the written split artifact carries real per-task summaries, acceptance criteria, instructions, the owner/model annotation block, and the End state section; re-running overwrites the same dated path; the file lints clean - the /scope skill fans exploration out to smaller-tier read-only subagents (sonnet default): method-only change to SKILL.md steps 1-2 (`scope/SKILL.md:43-50`, today serial first-person), sweeping `learn.py` `SCOPE_STAGE` (`learn.py:153-171`), `docs/skills.md:156-176`, and the `docs/skill-sources.md` version-stamp row (#79, #91) - - honesty: honest only if scope SKILL.md, learn.py SCOPE_STAGE, docs/skills.md, and the skill-sources ledger all describe the same subagent fan-out with the same default tier — a doc-alignment check, not four diverging texts + - honesty: honest only if scope SKILL.md, learn.py `SCOPE_STAGE`, docs/skills.md, and the skill-sources ledger all describe the same subagent fan-out with the same default tier — a doc-alignment check, not four diverging texts - every new or changed CLI surface in the sweep — hard-question resolve, claim/scope/risk amend, `plan defer`, multi-id `plan confirm`/`reject`, live-target cover — is documented in the `learn` and `explain` recipes in the same release; issue #52 makes this an explicit acceptance criterion for the resolve move - honesty: honest only if devague learn and devague explain output name every new verb/flag shipped in the sweep, checked by tests that grep the recipe text for each surface - the render-time escaper never alters text inside existing code spans: claim text already mixes prose and backticked tokens (this frame is itself the counter-evidence corpus), so identifier wrapping must skip spans that are already code and stay stable across repeated exports @@ -64,7 +64,7 @@ ## Success signals -- all 15 issues closed by the release with a regression test each; exported spec-md, plan-md, and split artifacts produce 0 markdownlint-cli2 errors under the default config; the full pytest suite stays green with coverage >= 95%; the 3 downstream workarounds named in the issues (render_plan.py projection, docs/specs lint ignores, hand-edited frame JSON) become deletable +- all 15 issues closed by the release with a regression test each; exported spec-md, plan-md, and split artifacts produce 0 markdownlint-cli2 errors under the default config; the full pytest suite stays green with coverage >= 95%; the 3 downstream workarounds named in the issues (`render_plan.py` projection, docs/specs lint ignores, hand-edited frame JSON) become deletable ## Scope / boundaries @@ -81,7 +81,7 @@ - issue #82 first ask is already shipped and its symptom was a stale vendored copy: since 0.16.0 the script renders real task content from `plan waves --json` (`assign-to-workforce.sh:202`; the JSON carries summary, instruction, acceptance criteria, and covers per `cli/_commands/plan.py:569-584`), so the live gaps are only the durable artifact and owner/model recording - schema bumps are needed and currently hazardous: `HardQuestion(**q)` and `Vagueness(**v)` raw unpacking (`frame.py:315,322`) plus the version gate running after `from_dict` (`store.py:136-147`) means any new persisted field crashes older binaries with a raw TypeError instead of the fail-closed IncompatibleSchemaError; resolve and amend fields need a frame schema bump with the load order fixed - the deviation-to-claim join stays derived, not stored: `Delivery` has no frame slug (`delivery.py:57` keys by plan slug only) and Frame has no reverse plan index, so #92 rendering derives the contested set at render time instead of adding reverse-pointer state -- decision c25 (no plan schema change) is scoped to the #82 owner/model recording only — the #85 defer move requires persisting deferred-target state, which means a PLAN_SCHEMA_VERSION bump, and `plan_store.load` (line 54 vs 62) has the same late-version-gate hazard c18 records for frames, so the load-order fix must cover both stores +- decision c25 (no plan schema change) is scoped to the #82 owner/model recording only — the #85 defer move requires persisting deferred-target state, which means a `PLAN_SCHEMA_VERSION` bump, and `plan_store.load` (line 54 vs 62) has the same late-version-gate hazard c18 records for frames, so the load-order fix must cover both stores ## Scope exploration @@ -89,7 +89,7 @@ - seeds: `c2`, `c3`, `c6` - `s2` — `devague/convergence.py`: blocking-question gate (111-116) and assumption warnings (120-126) iterate claims status-agnostically, so rejected claims still block and warn; `suggest_move` (206-211) advertises a resolve path no CLI move implements - seeds: `c4`, `c5` -- `s3` — `devague/frame.py`: `set_status` (264-273) routes only c*/h*; `HardQuestion.resolved` exists but nothing sets it; `add_scope_entry` (252-254) validates seeds via `find_claim` only; no amend machinery exists for claim text or scope entries; `HardQuestion(**q)`/`Vagueness(**v)` unpacking (315, 322) rejects unknown keys +- `s3` — `devague/frame.py`: `set_status` (264-273) routes only c\*/h\*; `HardQuestion.resolved` exists but nothing sets it; `add_scope_entry` (252-254) validates seeds via `find_claim` only; no amend machinery exists for claim text or scope entries; `HardQuestion(**q)`/`Vagueness(**v)` unpacking (315, 322) rejects unknown keys - seeds: `c4`, `c7`, `c8`, `c18` - `s4` — `devague/store.py`: the schema-version gate (143-147) runs after `from_dict` (136), so a newer-schema frame with new nested fields dies with a raw TypeError before reaching the fail-closed IncompatibleSchemaError - seeds: `c18` @@ -105,7 +105,7 @@ - seeds: `c14`, `c19` - `s10` — `devague/cli/_commands/export.py, show.py, status.py`: zero references to the delivery store in any of the five frame-side view/export modules (grep confirmed) — no path exists today to mark a confirmed claim contested by an approved deviation - seeds: `c14` -- `s11` — `.claude/skills/assign-to-workforce/SKILL.md and scripts/assign-to-workforce.sh`: the split-plan table renders real task content since 0.16.0 (script line 202 reads summaries from the `plan waves --json` payload); the Model column is a presentation-only hardcoded sonnet default (156-159); no --write flag and no *-split.md artifact exists anywhere in the repo (subcommands: split-plan, waves, help at 262-283) +- `s11` — `.claude/skills/assign-to-workforce/SKILL.md and scripts/assign-to-workforce.sh`: the split-plan table renders real task content since 0.16.0 (script line 202 reads summaries from the `plan waves --json` payload); the Model column is a presentation-only hardcoded sonnet default (156-159); no --write flag and no \*-split.md artifact exists anywhere in the repo (subcommands: split-plan, waves, help at 262-283) - seeds: `c15`, `c17`, `c20` - `s12` — `.claude/skills/scope/SKILL.md`: steps 1-2 (lines 43-50) describe serial first-person exploration; no subagent, fan-out, or model-tier language anywhere in the skill, and no precedent in think or spec-to-plan — the only fan-out pattern in the kit is assign-to-workforce worktree orchestration, which is the wrong shape for read-only exploration - seeds: `c16` @@ -123,7 +123,7 @@ - seeds: `c33` - `s19` — `challenge pass / containment lens: delivery-store reads from export/show/status`: the contested-marker join adds the first delivery-store read on the frame side; a corrupt or newer-schema delivery file must degrade to markerless rendering, never a crashed export - seeds: `c34` -- `s20` — `challenge pass / migration lens: plan.py PLAN_SCHEMA_VERSION + plan_store.py load order`: probe confirmed plan_store.load parses via from_dict (line 54) before the version gate (line 62) — the same hazard c18 records for frames; defer state forces a plan schema bump, so the fix must cover both stores +- `s20` — `challenge pass / migration lens: plan.py PLAN_SCHEMA_VERSION + plan_store.py load order`: probe confirmed `plan_store`.load parses via `from_dict` (line 54) before the version gate (line 62) — the same hazard c18 records for frames; defer state forces a plan schema bump, so the fix must cover both stores - seeds: `c35`, `c18` - `s21` — `challenge pass / actors lens: qN id namespace across HardQuestion and the questions file`: probe confirmed both hard questions (frame.py:201) and durable questions assign qN ids independently — recorded as pending decision q4 (seeds not linkable: --seeds refuses q ids until c8 lands, which is itself finding evidence for c8) - `s22` — `challenge pass / concurrency lens: worktree-copied .devague state during fan-out`: clean pass: each worktree owns a full checkout copy of .devague, conflicts surface at reconcile per the documented worktree-contention convention; residual risk only if two agents ever share one checkout — no new claim seeded @@ -136,6 +136,10 @@ - the sweep ships as one combined release: single branch, single PR closing all fifteen issues, one version bump (resolves q3) - the #48/#52 resolve move ships as `devague interrogate --resolve ` with an optional `--decision` recording how it was answered — the claim id disambiguates the shared qN namespace (resolves q4) -## Open / follow-up +## Open parks -- downstream repos carry stale vendored copies and workarounds to retire once fixes land: reachy-mini-cli split placeholder table and render_plan.py projection (issues 82/85), shell-cli markdownlint ignores for docs/specs (issue 87); notify via guildmaster re-broadcast +- [unknown_nonblocking] issue 86 reports plan converge missed a self-cycle on 0.20.0 but the code shows evaluate includes `_missing_dep_integrity` — unverified which is true for the installed 0.20.0; creation-time validation fixes it either way +- [unknown_nonblocking] whether issue 85 secondary ask — warn when a task covers many targets with few acceptance criteria — ships in this sweep or as a later plan-convergence warning heuristic +- [unknown_nonblocking] behavior of live-target cover when the source frame has regressed below convergence: `_live` refuses in that state — whether cover falls back to the stored snapshot or refuses with the reconverge hint is an implementation-time decision +- [unknown_nonblocking] re-exporting any historical committed frame after the escaping change rewrites its dated spec file with code-span wrapping — expected and presentational-only, but the first re-export after upgrade produces a large diff reviewers should anticipate +- [follow_up] downstream repos carry stale vendored copies and workarounds to retire once fixes land: reachy-mini-cli split placeholder table and `render_plan.py` projection (issues 82/85), shell-cli markdownlint ignores for docs/specs (issue 87); notify via guildmaster re-broadcast diff --git a/tests/goldens/resolved_vagueness_spec.md b/tests/goldens/resolved_vagueness_spec.md index 6d69cb8..c473d49 100644 --- a/tests/goldens/resolved_vagueness_spec.md +++ b/tests/goldens/resolved_vagueness_spec.md @@ -2,6 +2,10 @@ > Shipped the resolve move +## Open parks + +- [unknown_nonblocking] scale unknown + ## Resolved vagueness - [unknown_blocking] what happens on double resolve — resolved: refuse with a hint, exit 1 diff --git a/tests/goldens/sharper_spec.md b/tests/goldens/sharper_spec.md index 391badb..f75e3f3 100644 --- a/tests/goldens/sharper_spec.md +++ b/tests/goldens/sharper_spec.md @@ -35,7 +35,7 @@ - `s1` — `devague render spec_md dot py`: no instruction or scope rendering existed before t6 - seeds: `c3` -- `s2` — `devague render frame_md dot py`: same renderer gap as spec_md.py +- `s2` — `devague render frame_md dot py`: same renderer gap as `spec_md.py` ## Decisions diff --git a/tests/test_export_markdownlint_integration.py b/tests/test_export_markdownlint_integration.py index 626e4a4..bfa8fc6 100644 --- a/tests/test_export_markdownlint_integration.py +++ b/tests/test_export_markdownlint_integration.py @@ -18,6 +18,7 @@ from __future__ import annotations +import json import shutil import subprocess # noqa: S404 - dev-tooling integration check, not shipped code from pathlib import Path @@ -124,3 +125,139 @@ def test_hostile_plan_export_passes_markdownlint_cli2(tmp_path, monkeypatch) -> assert plan_path.exists() result = _run_markdownlint(spec_path, plan_path) assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + + +# ── issue-backlog-sweep t3 (#93, #49, #83, #87 c6/h6, c22/h18): a frame ────── +# mixing bare and already-backticked underscore identifiers, open parks of +# two kinds, and a rejected claim that also carried a hard question and +# seeded a scope entry — the real counter-evidence corpus shape ───────────── + + +def _build_and_export_mixed_identifier_frame(monkeypatch, tmp_path) -> Path: + monkeypatch.chdir(tmp_path) + main(["new", "Sweep closes the issue backlog"]) + main( + [ + "capture", + "--kind", + "audience", + "operators driving _think and challenge", + "--origin", + "user", + ] + ) + main( + [ + "capture", + "--kind", + "after_state", + "specs escape `_read_file` and __init__.py safely", + "--origin", + "user", + ] + ) + main( + [ + "capture", + "--kind", + "before_state", + "exports failed markdownlint on _read_file identifiers", + "--origin", + "user", + ] + ) + main(["capture", "--kind", "boundary", "escaping never mutates frame JSON", "--origin", "user"]) + main(["capture", "--kind", "success_signal", "0 markdownlint-cli2 errors", "--origin", "user"]) + frame = store.load(store.current_slug()) + for c in frame.claims: + main(["interrogate", c.id, "--honesty", "must hold.", "--origin", "user"]) + main(["park", "residual risk about scale", "--kind", "unknown_nonblocking"]) + main(["park", "later docs follow-up", "--kind", "follow_up"]) + + # The #83 repro shape: capture (proposed), interrogate --risk, reject — + # the hard question and the scope seed below must both vanish on export. + main( + [ + "capture", + "--kind", + "boundary", + "the policy gate must receive rewritten args", + "--origin", + "llm", + ] + ) + frame = store.load(store.current_slug()) + contested = next(c for c in frame.claims if c.text.startswith("the policy gate")) + main(["interrogate", contested.id, "--risk", "a hook could launder a denied command"]) + main( + [ + "scope", + "devague/render/spec_md.py", + "--finding", + "`_follow_up` no longer drops parks", + "--seeds", + contested.id, + ] + ) + main(["reject", contested.id]) + + main(["converge"]) + main(["export"]) + frame = store.load(store.current_slug()) + return Path("docs/specs") / f"{frame.created[:10]}-{frame.slug}.md" + + +def test_mixed_identifier_export_passes_markdownlint_cli2(tmp_path, monkeypatch) -> None: + spec_path = _build_and_export_mixed_identifier_frame(monkeypatch, tmp_path) + assert spec_path.exists() + result = _run_markdownlint(spec_path) + assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + + +def test_mixed_identifier_export_omits_rejected_content(tmp_path, monkeypatch) -> None: + spec_path = _build_and_export_mixed_identifier_frame(monkeypatch, tmp_path) + out = spec_path.read_text(encoding="utf-8") + assert "launder a denied command" not in out + assert "policy gate must receive rewritten args" not in out + assert "(rejected)" in out # the dead scope seed is flagged, not silently dropped + + +def test_mixed_identifier_export_lists_both_open_park_kinds(tmp_path, monkeypatch) -> None: + spec_path = _build_and_export_mixed_identifier_frame(monkeypatch, tmp_path) + out = spec_path.read_text(encoding="utf-8") + assert "## Open parks" in out + assert "[unknown_nonblocking] residual risk about scale" in out + assert "[follow_up] later docs follow-up" in out + + +def _content_only(raw_json: str) -> dict: + """Parse a frame JSON file, dropping the ``updated`` timestamp — the one + field ``store.save`` always bumps on every write, unrelated to the + escaping fix under test here. + """ + d = json.loads(raw_json) + d.pop("updated", None) + return d + + +def test_repeated_export_is_byte_stable_and_frame_json_content_unchanged( + tmp_path, monkeypatch +) -> None: + # #87 acceptance (c6/h6, c22/h18): escaping is presentational only — the + # rendered spec-md is byte-stable across repeated exports of the same + # frame, and the frame JSON's content (everything `show --json` reads, + # modulo the `updated` timestamp every save bumps) is untouched. + spec_path = _build_and_export_mixed_identifier_frame(monkeypatch, tmp_path) + frame_json_path = store.path_for(store.current_slug()) + first_spec = spec_path.read_text(encoding="utf-8") + first_json = frame_json_path.read_text(encoding="utf-8") + + main(["export"]) + + second_spec = spec_path.read_text(encoding="utf-8") + second_json = frame_json_path.read_text(encoding="utf-8") + assert first_spec == second_spec + assert _content_only(first_json) == _content_only(second_json) + + result = _run_markdownlint(spec_path) + assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" diff --git a/tests/test_render.py b/tests/test_render.py index 8429bac..df45b57 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -219,7 +219,7 @@ def test_spec_md_wraps_bare_url_in_claim_bullet() -> None: def test_spec_md_wraps_bare_url_in_follow_up_text() -> None: out = render.render(_hostile_frame(), "spec-md") - assert "- follow up at " in out + assert "- [follow_up] follow up at " in out def test_spec_md_hostile_input_is_markdownlint_clean() -> None: @@ -291,19 +291,25 @@ def test_spec_md_renders_resolved_vagueness_section_with_resolution_verbatim() - assert "- [follow_up] follow up on docs — resolved: docs updated in t8" in out -def test_spec_md_resolved_follow_up_is_excluded_from_open_follow_up_section() -> None: - # The only vagueness item of kind follow_up/out_of_scope is resolved — the - # "Open / follow-up" section must not fabricate it as still open. +def test_spec_md_resolved_follow_up_is_excluded_from_open_parks_section() -> None: + # The only vagueness item of kind follow_up is resolved — the open-parks + # section must not fabricate it as still open. Its resolved form (with + # the "— resolved:" suffix) belongs only in "## Resolved vagueness", so + # checking the bare (unsuffixed) bullet form is absent distinguishes the + # two without depending on any particular heading name. out = render.render(_resolved_vagueness_frame(), "spec-md") - assert "## Open / follow-up" not in out + assert "- [follow_up] follow up on docs\n" not in out -def test_spec_md_still_open_nonblocking_park_stays_unlisted() -> None: - # Unchanged pre-existing behavior: spec_md never rendered unknown_blocking/ - # unknown_nonblocking kinds unless resolved — the still-open nonblocking - # item here has no resolution, so it stays absent from the exported spec. +def test_spec_md_open_nonblocking_park_renders_labeled_by_kind() -> None: + # #93/#49 (flipped): spec_md previously never rendered unknown_blocking/ + # unknown_nonblocking kinds unless resolved, so this exact still-open, + # unresolved nonblocking park silently vanished from the exported spec — + # precisely the residual-risk kind that legitimately coexists with a + # converged frame. It now renders, grouped/labeled by kind. out = render.render(_resolved_vagueness_frame(), "spec-md") - assert "scale unknown" not in out + assert "## Open parks" in out + assert "- [unknown_nonblocking] scale unknown" in out def test_spec_md_resolved_vagueness_is_markdownlint_clean() -> None: @@ -383,3 +389,114 @@ def test_resolved_blocking_park_spec_passes_real_markdownlint_cli2(tmp_path: Pat check=False, ) assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + + +# ── issue-backlog-sweep t3: all four park kinds, resolved hard questions, ──── +# rejected-claim exclusion, dead-seed flagging (#93, #49, #83, #84 c33) ─────── + + +def _all_four_park_kinds_frame() -> Frame: + f = Frame(slug="allkinds", title="All Park Kinds") + f.add_claim("announcement", "Shipped all four park kinds", origin="user") + f.add_vagueness("residual risk one", "unknown_nonblocking") + f.add_vagueness("residual risk two", "unknown_blocking") + f.add_vagueness("explicitly out of scope", "out_of_scope") + f.add_vagueness("later follow-up", "follow_up") + return f + + +def test_spec_md_lists_every_open_park_kind_labeled_by_kind() -> None: + # Acceptance criterion 1: a frame carrying open parks of all four kinds + # exports a spec listing each park, labeled by kind. + out = render.render(_all_four_park_kinds_frame(), "spec-md") + assert "## Open parks" in out + assert "- [unknown_nonblocking] residual risk one" in out + assert "- [unknown_blocking] residual risk two" in out + assert "- [out_of_scope] explicitly out of scope" in out + assert "- [follow_up] later follow-up" in out + + +def test_spec_md_all_four_park_kinds_is_markdownlint_clean() -> None: + assert_markdownlint_clean(render.render(_all_four_park_kinds_frame(), "spec-md")) + + +def test_spec_md_resolved_hard_question_renders_with_resolved_marker() -> None: + # Acceptance criterion 2 (part 1, #49): a converged frame has no + # unresolved blocking hard question, yet the pre-fix renderer showed every + # hard question as if still open. A resolved one now carries a marker + # instead of reading as a live blocker. + f = Frame(slug="resolvedhq", title="Resolved HQ") + ann = f.add_claim("announcement", "Shipped", origin="user") + q = f.add_hard_question(ann, "will this scale?", blocking=True) + q.resolved = True + out = render.render(f, "spec-md") + assert "## Hard questions" in out + assert "- will this scale? (resolved)" in out + assert "(blocking)" not in out + + +def test_spec_md_hard_question_on_rejected_claim_is_absent_issue_83_repro() -> None: + # Acceptance criterion 2 (part 2): the #83 repro shape — capture, + # interrogate --risk, reject, converge, export — must never leak the + # rejected claim's hard question into the exported spec. + f = Frame(slug="repro83", title="Repro 83") + f.add_claim("announcement", "Shipped", origin="user") + c = f.add_claim("boundary", "the policy gate must receive rewritten args", origin="llm") + f.set_status(c.id, "confirmed") + f.add_hard_question(c, "risk: a hook could launder a denied command", blocking=False) + f.set_status(c.id, "rejected") + out = render.render(f, "spec-md") + assert "## Hard questions" not in out + assert "launder a denied command" not in out + + +def test_spec_md_hard_question_on_non_rejected_claim_still_renders() -> None: + # Control for the #83 fix: only the rejected claim's questions are + # dropped — a confirmed claim's still keep rendering. + f = Frame(slug="control83", title="Control 83") + ann = f.add_claim("announcement", "Shipped", origin="user") + f.add_hard_question(ann, "an ordinary open question", blocking=False) + out = render.render(f, "spec-md") + assert "## Hard questions" in out + assert "- an ordinary open question" in out + + +def test_spec_md_scope_seed_citing_rejected_claim_renders_rejected_marker() -> None: + # Acceptance criterion 3 (#84's fourth criterion, c33/h26): a scope entry + # whose seeds cite a claim that was later rejected must render a visible + # rejected marker, not a bare dead id. + f = Frame(slug="deadseed", title="Dead Seed") + f.add_claim("announcement", "Shipped", origin="user") + c = f.add_claim("boundary", "will be rejected", origin="user") + f.add_scope_entry("some/surface.py", "a finding", seeds=[c.id]) + f.set_status(c.id, "rejected") + out = render.render(f, "spec-md") + assert f"`{c.id}` (rejected)" in out + + +def test_spec_md_scope_seed_citing_live_claim_stays_a_bare_id() -> None: + # Control for criterion 3: a seed citing a claim that is still confirmed + # (never rejected) keeps rendering as the plain backticked id. + f = Frame(slug="liveseed", title="Live Seed") + f.add_claim("announcement", "Shipped", origin="user") + c = f.add_claim("boundary", "stays confirmed", origin="user") + f.add_scope_entry("some/surface.py", "a finding", seeds=[c.id]) + out = render.render(f, "spec-md") + assert f"`{c.id}`" in out + assert f"`{c.id}` (rejected)" not in out + + +def test_spec_md_rendering_never_mutates_park_or_hard_question_state() -> None: + # Acceptance criterion 4 (#87 h18/c22): escaping and the new park/hard- + # question rendering are presentational only — the frame's own fields + # (what `show --json` reads) must be untouched by a spec-md render. + f = _all_four_park_kinds_frame() + ann = f.claims[0] + f.add_hard_question(ann, "_underscored_risk_ with a leading # too", blocking=True) + before_vagueness = [(v.kind, v.text, v.resolved) for v in f.open_vagueness] + before_hq = [(q.text, q.resolved, q.blocking) for c in f.claims for q in c.hard_questions] + render.render(f, "spec-md") + after_vagueness = [(v.kind, v.text, v.resolved) for v in f.open_vagueness] + after_hq = [(q.text, q.resolved, q.blocking) for c in f.claims for q in c.hard_questions] + assert before_vagueness == after_vagueness + assert before_hq == after_hq From 7ccf4cae48553b29bb7e9ac54f238d975706a912 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 10:50:12 +0300 Subject: [PATCH 12/25] chore(export): re-export the 2026-07-17 plan artifact through the new escaper (#87) Proves the t1/t9 escaping fix end-to-end: three pre-existing MD037 errors in a committed artifact from PR #81 clear on re-export, with no source text change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QtUixLUjgEt51PXS9TPVt3 --- .devague/plans/resolve-parked-vagueness.json | 120 +++++++++++++----- .../2026-07-17-resolve-parked-vagueness.md | 56 ++++---- 2 files changed, 117 insertions(+), 59 deletions(-) diff --git a/.devague/plans/resolve-parked-vagueness.json b/.devague/plans/resolve-parked-vagueness.json index dd8c75b..5bb374c 100644 --- a/.devague/plans/resolve-parked-vagueness.json +++ b/.devague/plans/resolve-parked-vagueness.json @@ -2,150 +2,206 @@ "slug": "resolve-parked-vagueness", "title": "resolve parked vagueness", "frame_slug": "resolve-parked-vagueness", - "schema_version": 2, + "schema_version": 4, "status": "exported", "created": "2026-07-17T10:18:49Z", - "updated": "2026-07-17T10:26:48Z", + "updated": "2026-07-28T07:49:59Z", "targets": [ { "id": "c1", "kind": "announcement", - "text": "devague ships a resolve move for parked vagueness: a blocking park can now be resolved or reclassified through CLI moves alone, closing issues 45, 55, 57, and 60" + "text": "devague ships a resolve move for parked vagueness: a blocking park can now be resolved or reclassified through CLI moves alone, closing issues 45, 55, 57, and 60", + "deferred": false, + "deferred_reason": "" }, { "id": "h1", "kind": "honesty", - "text": "the shipped release clears a decided blocking park via CLI moves alone \u2014 issue 57's repro converges with zero .devague JSON edits" + "text": "the shipped release clears a decided blocking park via CLI moves alone \u2014 issue 57's repro converges with zero .devague JSON edits", + "deferred": false, + "deferred_reason": "" }, { "id": "c2", "kind": "requirement", - "text": "the Vagueness dataclass (devague/frame.py:106) gains resolution state \u2014 e.g. a resolved flag plus resolution text \u2014 kept in frame state for the evidence trail rather than deleted; add_vagueness today only appends and set_status (frame.py:226) routes only claim and honesty ids, so v-ids are unaddressable by any move" + "text": "the Vagueness dataclass (devague/frame.py:106) gains resolution state \u2014 e.g. a resolved flag plus resolution text \u2014 kept in frame state for the evidence trail rather than deleted; add_vagueness today only appends and set_status (frame.py:226) routes only claim and honesty ids, so v-ids are unaddressable by any move", + "deferred": false, + "deferred_reason": "" }, { "id": "h2", "kind": "honesty", - "text": "a resolved Vagueness keeps its text, kind, and claim_id plus the resolution on the record, and round-trips save-load identical" + "text": "a resolved Vagueness keeps its text, kind, and claim_id plus the resolution on the record, and round-trips save-load identical", + "deferred": false, + "deferred_reason": "" }, { "id": "c3", "kind": "requirement", - "text": "the convergence gate (devague/convergence.py:100-102) stops counting a resolved vagueness as a blocker, and suggest_move (convergence.py:188) names an actually executable move instead of today's 're-park it as non-blocking' hint, which only appends a second item while the first keeps blocking \u2014 the acceptance bar issue 45 sets explicitly" + "text": "the convergence gate (devague/convergence.py:100-102) stops counting a resolved vagueness as a blocker, and suggest_move (convergence.py:188) names an actually executable move instead of today's 're-park it as non-blocking' hint, which only appends a second item while the first keeps blocking \u2014 the acceptance bar issue 45 sets explicitly", + "deferred": false, + "deferred_reason": "" }, { "id": "h3", "kind": "honesty", - "text": "after park --resolve, converge no longer lists the item as a blocker and the blocking-vagueness hint names park --resolve verbatim \u2014 an executable move, issue 45's acceptance bar" + "text": "after park --resolve, converge no longer lists the item as a blocker and the blocking-vagueness hint names park --resolve verbatim \u2014 an executable move, issue 45's acceptance bar", + "deferred": false, + "deferred_reason": "" }, { "id": "c4", "kind": "requirement", - "text": "a new user-only resolve surface for v-ids that mirrors the existing `question --resolve --decision \"\"` shape (devague/cli/_commands/question.py) \u2014 resolving is a user decision like confirm (issue 55), validated fail-closed with a hint on an unknown v-id, with stdout result, --json, and the existing exit-code contract" + "text": "a new user-only resolve surface for v-ids that mirrors the existing `question --resolve --decision \"\"` shape (devague/cli/_commands/question.py) \u2014 resolving is a user decision like confirm (issue 55), validated fail-closed with a hint on an unknown v-id, with stdout result, --json, and the existing exit-code contract", + "deferred": false, + "deferred_reason": "" }, { "id": "h4", "kind": "honesty", - "text": "an unknown v-id is refused with a hint and nothing is persisted; output follows the existing stdout/--json/exit-code contract; resolving stays a user-decided move like confirm" + "text": "an unknown v-id is refused with a hint and nothing is persisted; output follows the existing stdout/--json/exit-code contract; resolving stays a user-decided move like confirm", + "deferred": false, + "deferred_reason": "" }, { "id": "c5", "kind": "requirement", - "text": "SCHEMA_VERSION bumps 2 to 3 (devague/frame.py:15) because adding fields to Vagueness breaks old loaders \u2014 from_dict does Vagueness(**v) (frame.py:284), so an old CLI reading a new frame raises TypeError; the documented fail-closed policy and the v2 scope_entries precedent both say bump; docs/spec-contract.md's Vagueness entity section gains the resolved state" + "text": "SCHEMA_VERSION bumps 2 to 3 (devague/frame.py:15) because adding fields to Vagueness breaks old loaders \u2014 from_dict does Vagueness(**v) (frame.py:284), so an old CLI reading a new frame raises TypeError; the documented fail-closed policy and the v2 scope_entries precedent both say bump; docs/spec-contract.md's Vagueness entity section gains the resolved state", + "deferred": false, + "deferred_reason": "" }, { "id": "h5", "kind": "honesty", - "text": "a v3 frame on a v2-only binary fails closed with the schema_version error, never a Vagueness(**v) TypeError; v2 frames without the new fields still load with defaults" + "text": "a v3 frame on a v2-only binary fails closed with the schema_version error, never a Vagueness(**v) TypeError; v2 frames without the new fields still load with defaults", + "deferred": false, + "deferred_reason": "" }, { "id": "c6", "kind": "requirement", - "text": "renderers keep a resolved vagueness on the record as provenance instead of dropping it \u2014 render/frame_md.py:56-60 lists every open_vagueness item flat, and render/spec_md.py:111 routes only follow_up/out_of_scope kinds into the spec; a resolved item should render with its resolution text so the exported spec shows the answered unknown (the provenance issue 45's comment asks for)" + "text": "renderers keep a resolved vagueness on the record as provenance instead of dropping it \u2014 render/frame_md.py:56-60 lists every open_vagueness item flat, and render/spec_md.py:111 routes only follow_up/out_of_scope kinds into the spec; a resolved item should render with its resolution text so the exported spec shows the answered unknown (the provenance issue 45's comment asks for)", + "deferred": false, + "deferred_reason": "" }, { "id": "h6", "kind": "honesty", - "text": "an exported spec from a frame with a resolved blocking park renders the unknown together with its resolution text, and the export passes markdownlint" + "text": "an exported spec from a frame with a resolved blocking park renders the unknown together with its resolution text, and the export passes markdownlint", + "deferred": false, + "deferred_reason": "" }, { "id": "c7", "kind": "requirement", - "text": "every surface that teaches park also teaches the resolve close-out: devague/cli/_commands/learn.py (move table line 26, operating rules line 107), docs/llm-guidance.md (park rows in the fabrication table, lines 109-130), and .claude/skills/think/SKILL.md (move table line 82, rules lines 209-210) \u2014 otherwise operators keep learning the trap issues 45/55/57/60 all fell into" + "text": "every surface that teaches park also teaches the resolve close-out: devague/cli/_commands/learn.py (move table line 26, operating rules line 107), docs/llm-guidance.md (park rows in the fabrication table, lines 109-130), and .claude/skills/think/SKILL.md (move table line 82, rules lines 209-210) \u2014 otherwise operators keep learning the trap issues 45/55/57/60 all fell into", + "deferred": false, + "deferred_reason": "" }, { "id": "h7", "kind": "honesty", - "text": "learn, docs/llm-guidance.md, and the think skill each name park --resolve wherever they teach park; issue 57's prefer-question workaround guidance is retired" + "text": "learn, docs/llm-guidance.md, and the think skill each name park --resolve wherever they teach park; issue 57's prefer-question workaround guidance is retired", + "deferred": false, + "deferred_reason": "" }, { "id": "c9", "kind": "boundary", - "text": "the resolve move is deterministic recording only \u2014 no LLM calls in the CLI (issue 20), and hand-editing .devague state stays forbidden; the fix removes the last reason operators had to hand-edit (the workaround all four issues document), it does not legitimize state edits" + "text": "the resolve move is deterministic recording only \u2014 no LLM calls in the CLI (issue 20), and hand-editing .devague state stays forbidden; the fix removes the last reason operators had to hand-edit (the workaround all four issues document), it does not legitimize state edits", + "deferred": false, + "deferred_reason": "" }, { "id": "h8", "kind": "honesty", - "text": "the diff adds no LLM call and no subprocess to the CLI; resolution text is authored by the user/operator, never generated in-CLI" + "text": "the diff adds no LLM call and no subprocess to the CLI; resolution text is authored by the user/operator, never generated in-CLI", + "deferred": false, + "deferred_reason": "" }, { "id": "c13", "kind": "requirement", - "text": "the plan engine ships the twin in the same PR: PlanRisk (devague/plan.py:60) gains the same resolution state, `plan risk --resolve ` the same user-only surface, plan_convergence.py:136 skips resolved risks, the hint at plan_convergence.py:172 names the executable move, and PLAN_SCHEMA_VERSION bumps 2 to 3 (PlanRisk(**r) at plan.py:310 has the same old-loader crash shape as the frame side)" + "text": "the plan engine ships the twin in the same PR: PlanRisk (devague/plan.py:60) gains the same resolution state, `plan risk --resolve ` the same user-only surface, plan_convergence.py:136 skips resolved risks, the hint at plan_convergence.py:172 names the executable move, and PLAN_SCHEMA_VERSION bumps 2 to 3 (PlanRisk(**r) at plan.py:310 has the same old-loader crash shape as the frame side)", + "deferred": false, + "deferred_reason": "" }, { "id": "h9", "kind": "honesty", - "text": "plan converge drops a resolved blocking risk; the plan hint at plan_convergence.py:172 names plan risk --resolve verbatim; a v3 plan fails closed on a v2-only binary" + "text": "plan converge drops a resolved blocking risk; the plan hint at plan_convergence.py:172 names plan risk --resolve verbatim; a v3 plan fails closed on a v2-only binary", + "deferred": false, + "deferred_reason": "" }, { "id": "c14", "kind": "audience", - "text": "operators driving /think and /spec-to-plan (the agents making CLI moves), the humans who own confirms, and the downstream AgentCulture repos that filed the four reports \u2014 colleague, lobes-cli, league-of-agents, and devague's own dogfooding" + "text": "operators driving /think and /spec-to-plan (the agents making CLI moves), the humans who own confirms, and the downstream AgentCulture repos that filed the four reports \u2014 colleague, lobes-cli, league-of-agents, and devague's own dogfooding", + "deferred": false, + "deferred_reason": "" }, { "id": "h10", "kind": "honesty", - "text": "devague learn output alone teaches the close-out (park --resolve and plan risk --resolve) \u2014 an operator never needs to read source to escape a decided blocking park" + "text": "devague learn output alone teaches the close-out (park --resolve and plan risk --resolve) \u2014 an operator never needs to read source to escape a decided blocking park", + "deferred": false, + "deferred_reason": "" }, { "id": "c15", "kind": "after_state", - "text": "a parked blocking unknown, once decided, is closed through `park --resolve` (frames) or `plan risk --resolve` (plans): the item stays on record with its resolution text, drops out of the convergence gate, the converge hints name executable moves, and no operator ever hand-edits .devague state again" + "text": "a parked blocking unknown, once decided, is closed through `park --resolve` (frames) or `plan risk --resolve` (plans): the item stays on record with its resolution text, drops out of the convergence gate, the converge hints name executable moves, and no operator ever hand-edits .devague state again", + "deferred": false, + "deferred_reason": "" }, { "id": "h11", "kind": "honesty", - "text": "no .devague hand-edit remains necessary anywhere in the vagueness lifecycle: park, decide, resolve, converge, export all work through moves" + "text": "no .devague hand-edit remains necessary anywhere in the vagueness lifecycle: park, decide, resolve, converge, export all work through moves", + "deferred": false, + "deferred_reason": "" }, { "id": "c16", "kind": "why_it_matters", - "text": "four independent reports in five weeks (issues 45, 55, 57, 60) show the method punishing correct behavior: honest parking leads to a permanently blocked frame, and the only escape is the exact out-of-band state mutation the move-driven design exists to prevent \u2014 one repo has the hand-edit in committed history (league-of-agents 0c71282)" + "text": "four independent reports in five weeks (issues 45, 55, 57, 60) show the method punishing correct behavior: honest parking leads to a permanently blocked frame, and the only escape is the exact out-of-band state mutation the move-driven design exists to prevent \u2014 one repo has the hand-edit in committed history (league-of-agents 0c71282)", + "deferred": false, + "deferred_reason": "" }, { "id": "h12", "kind": "honesty", - "text": "each of the four issues is closed with a comment naming the shipped release and the move that replaces its documented workaround" + "text": "each of the four issues is closed with a comment naming the shipped release and the move that replaces its documented workaround", + "deferred": false, + "deferred_reason": "" }, { "id": "c17", "kind": "success_signal", - "text": "all 4 issues close against the shipped release; issue 57's repro script (park blocking, decide, resolve, converge) passes through moves alone with 0 hand-edits of .devague state; test coverage stays >= 95%" + "text": "all 4 issues close against the shipped release; issue 57's repro script (park blocking, decide, resolve, converge) passes through moves alone with 0 hand-edits of .devague state; test coverage stays >= 95%", + "deferred": false, + "deferred_reason": "" }, { "id": "h13", "kind": "honesty", - "text": "the stated numbers are verified before close: 4 issues closed, the repro converges with 0 hand-edits, coverage >= 95% in CI" + "text": "the stated numbers are verified before close: 4 issues closed, the repro converges with 0 hand-edits, coverage >= 95% in CI", + "deferred": false, + "deferred_reason": "" }, { "id": "c18", "kind": "requirement", - "text": "every secondary reader of open vagueness and plan risks handles resolved items consistently, not just the gate: _parked_items in convergence.py:151 and plan_convergence.py:141 (whose output feeds converge/status parked_items), and render/deliverables_md.py:72-81 (plan deliverables' surviving-open-items section, which reads frame.open_vagueness and plan risks directly) \u2014 a resolved item must stop being advertised as open everywhere, or status and deliverables contradict the spec" + "text": "every secondary reader of open vagueness and plan risks handles resolved items consistently, not just the gate: _parked_items in convergence.py:151 and plan_convergence.py:141 (whose output feeds converge/status parked_items), and render/deliverables_md.py:72-81 (plan deliverables' surviving-open-items section, which reads frame.open_vagueness and plan risks directly) \u2014 a resolved item must stop being advertised as open everywhere, or status and deliverables contradict the spec", + "deferred": false, + "deferred_reason": "" }, { "id": "h14", "kind": "honesty", - "text": "with a resolved blocking item on a frame and a resolved blocking risk on a plan: converge/status parked_items and plan deliverables' open-items section show neither as open \u2014 verified by tests against those exact surfaces" + "text": "with a resolved blocking item on a frame and a resolved blocking risk on a plan: converge/status parked_items and plan deliverables' open-items section show neither as open \u2014 verified by tests against those exact surfaces", + "deferred": false, + "deferred_reason": "" } ], "tasks": [ @@ -363,7 +419,9 @@ "id": "r1", "text": "the updated think skill reaches downstream mesh repos only on guildmaster's next re-broadcast sync \u2014 until then vendored copies still teach the one-way park; known cadence cost, not a blocker", "kind": "follow_up", - "task_id": null + "task_id": null, + "resolved": false, + "resolution": "" } ] } diff --git a/docs/plans/2026-07-17-resolve-parked-vagueness.md b/docs/plans/2026-07-17-resolve-parked-vagueness.md index f43b68c..b1cc72f 100644 --- a/docs/plans/2026-07-17-resolve-parked-vagueness.md +++ b/docs/plans/2026-07-17-resolve-parked-vagueness.md @@ -8,82 +8,82 @@ slug: `resolve-parked-vagueness` · status: `exported` · from frame: `resolve-p ### t1 — Frame-side model: Vagueness resolution state + schema v3 -- instruction: extend Vagueness in devague/frame.py with resolved: bool = False and resolution: str = ''; add Frame.resolve_vagueness(vid: str, resolution: str); bump SCHEMA_VERSION to 3; default the new keys in from_dict for v2 artifacts; keep set_status untouched — v-ids stay out of confirm/reject per decision c11; field names are pinned (resolved, resolution) — t2 and t7 read them verbatim +- instruction: extend Vagueness in devague/frame.py with resolved: bool = False and resolution: str = ''; add Frame.`resolve_vagueness`(vid: str, resolution: str); bump `SCHEMA_VERSION` to 3; default the new keys in `from_dict` for v2 artifacts; keep `set_status` untouched — v-ids stay out of confirm/reject per decision c11; field names are pinned (resolved, resolution) — t2 and t7 read them verbatim - covers: c2, h2, c5, h5 - acceptance: - - Vagueness gains resolved: bool = False and resolution: str = '' with id/text/kind/claim_id unchanged; Frame.resolve_vagueness(vid, resolution) marks it resolved and raises ValueError on an unknown or already-resolved id - - SCHEMA_VERSION == 3; a v2 frame JSON without the new keys loads with defaults; save-load round-trips a resolved item identical (tests/test_frame.py, tests/test_store.py) - - store.load still fails closed with the upgrade hint when schema_version exceeds 3 + - Vagueness gains resolved: bool = False and resolution: str = '' with id/text/kind/`claim_id` unchanged; Frame.`resolve_vagueness`(vid, resolution) marks it resolved and raises ValueError on an unknown or already-resolved id + - `SCHEMA_VERSION` == 3; a v2 frame JSON without the new keys loads with defaults; save-load round-trips a resolved item identical (tests/`test_frame.py`, tests/`test_store.py`) + - store.load still fails closed with the upgrade hint when `schema_version` exceeds 3 ### t2 — Plan-side model: PlanRisk resolution state + plan schema v3 -- instruction: mirror t1 exactly in devague/plan.py: PlanRisk.resolved/resolution, Plan.resolve_risk, PLAN_SCHEMA_VERSION = 3, from_dict defaults for v2 artifacts; field names must match t1 verbatim (resolved, resolution) — deliverables_md reads both models +- instruction: mirror t1 exactly in devague/plan.py: PlanRisk.resolved/resolution, Plan.`resolve_risk`, `PLAN_SCHEMA_VERSION` = 3, `from_dict` defaults for v2 artifacts; field names must match t1 verbatim (resolved, resolution) — `deliverables_md` reads both models - covers: c13, h9 - acceptance: - - PlanRisk gains resolved: bool = False and resolution: str = '' with id/text/kind/task_id unchanged; Plan.resolve_risk(rid, resolution) mirrors the frame-side error contract (ValueError on unknown or already-resolved) - - PLAN_SCHEMA_VERSION == 3; a v2 plan JSON loads with defaults; save-load round-trips a resolved risk identical (tests/test_plan.py, tests/test_plan_store.py) + - PlanRisk gains resolved: bool = False and resolution: str = '' with id/text/kind/`task_id` unchanged; Plan.`resolve_risk`(rid, resolution) mirrors the frame-side error contract (ValueError on unknown or already-resolved) + - `PLAN_SCHEMA_VERSION` == 3; a v2 plan JSON loads with defaults; save-load round-trips a resolved risk identical (tests/`test_plan.py`, tests/`test_plan_store.py`) -### t3 — Frame gate: skip resolved vagueness, executable hint, parked_items +### t3 — Frame gate: skip resolved vagueness, executable hint, `parked_items` -- instruction: in devague/convergence.py: filter v.resolved in _missing_open_uncertainty and _parked_items; rewrite the blocking-vagueness branch of suggest_move (line 188) to emit the park --resolve move; plain CLI text, stdout only — renderer changes are t7, not here +- instruction: in devague/convergence.py: filter v.resolved in `_missing_open_uncertainty` and `_parked_items`; rewrite the blocking-vagueness branch of `suggest_move` (line 188) to emit the park --resolve move; plain CLI text, stdout only — renderer changes are t7, not here - depends on: t1 - covers: c3, h3, c18, h14 - acceptance: - - _missing_open_uncertainty skips resolved items: a resolved unknown_blocking no longer appears in blockers (tests/test_convergence.py) - - suggest_move for a blocking-vagueness blocker names the executable syntax verbatim: park --resolve VID --decision TEXT - - _parked_items excludes resolved items, so converge/status parked_items stops advertising a closed item as open + - `_missing_open_uncertainty` skips resolved items: a resolved `unknown_blocking` no longer appears in blockers (tests/`test_convergence.py`) + - `suggest_move` for a blocking-vagueness blocker names the executable syntax verbatim: park --resolve VID --decision TEXT + - `_parked_items` excludes resolved items, so converge/status `parked_items` stops advertising a closed item as open -### t4 — Plan gate: skip resolved risks, executable hint, parked_items +### t4 — Plan gate: skip resolved risks, executable hint, `parked_items` -- instruction: mirror t3 in devague/plan_convergence.py: filter r.resolved in _missing_risks and _parked_items; rewrite the blocking-risk hint branch to emit the plan risk --resolve move +- instruction: mirror t3 in devague/`plan_convergence.py`: filter r.resolved in `_missing_risks` and `_parked_items`; rewrite the blocking-risk hint branch to emit the plan risk --resolve move - depends on: t2 - covers: c13, h9, c18, h14 - acceptance: - - a resolved unknown_blocking risk no longer blocks plan convergence (tests/test_plan_convergence.py) - - the blocking-risk hint at plan_convergence.py:172 names the executable syntax verbatim: plan risk --resolve RID --decision TEXT - - plan-side _parked_items excludes resolved risks + - a resolved `unknown_blocking` risk no longer blocks plan convergence (tests/`test_plan_convergence.py`) + - the blocking-risk hint at `plan_convergence.py`:172 names the executable syntax verbatim: plan risk --resolve RID --decision TEXT + - plan-side `_parked_items` excludes resolved risks ### t5 — CLI: park --resolve VID --decision TEXT -- instruction: in devague/cli/_commands/park.py make the positional text optional (nargs='?') the way question.py does; add --resolve VID, --decision TEXT, --claim CN; require --decision whenever --resolve is passed; route through Frame.resolve_vagueness and translate ValueError into DevagueError with a run-devague-show hint; fail-closed refusal for already-resolved ids, consistent with the store posture +- instruction: in devague/cli/`_commands`/park.py make the positional text optional (nargs='?') the way question.py does; add --resolve VID, --decision TEXT, --claim CN; require --decision whenever --resolve is passed; route through Frame.`resolve_vagueness` and translate ValueError into DevagueError with a run-devague-show hint; fail-closed refusal for already-resolved ids, consistent with the store posture - depends on: t1 - covers: c4, h4 - acceptance: - park --resolve VID --decision TEXT marks the item resolved, echoes the transition on stdout, and has --json parity - a bare park --resolve VID without --decision is refused with a hint and persists nothing (decision c21); unknown and already-resolved ids are refused with a hint, exit 1 (answers the frame hard question: refuse, not no-op) - - --claim CN links the deciding claim and an unknown claim id is refused; passing positional text together with --resolve is refused; the park-create path is unchanged (tests/test_cli_moves.py) + - --claim CN links the deciding claim and an unknown claim id is refused; passing positional text together with --resolve is refused; the park-create path is unchanged (tests/`test_cli_moves.py`) ### t6 — CLI: plan risk --resolve RID --decision TEXT -- instruction: mirror t5 on the risk subcommand in devague/cli/_commands/plan.py: positional text becomes optional, add --resolve RID and --decision TEXT with the same refusal semantics; no --claim analog (risks link tasks via --task); route through Plan.resolve_risk +- instruction: mirror t5 on the risk subcommand in devague/cli/`_commands`/plan.py: positional text becomes optional, add --resolve RID and --decision TEXT with the same refusal semantics; no --claim analog (risks link tasks via --task); route through Plan.`resolve_risk` - depends on: t2 - covers: c13, h9 - acceptance: - - plan risk --resolve RID --decision TEXT resolves the risk with stdout echo and --json parity; bare resolve without --decision refused; unknown and already-resolved ids refused with hint, exit 1 (tests/test_cli_plan.py) + - plan risk --resolve RID --decision TEXT resolves the risk with stdout echo and --json parity; bare resolve without --decision refused; unknown and already-resolved ids refused with hint, exit 1 (tests/`test_cli_plan.py`) - the risk-create path (positional text --kind K --task TN) is unchanged ### t7 — Renderers: resolved items render with resolution; deliverables excludes them -- instruction: frame_md: keep the flat Open vagueness list for open items and render resolved ones as '- [kind] text — resolved: TEXT'; spec_md: render resolved items of any kind with their resolution under the existing structure (a Resolved vagueness subsection is acceptable); deliverables_md: filter on the resolved flag from both models; update tests/goldens accordingly +- instruction: `frame_md`: keep the flat Open vagueness list for open items and render resolved ones as '- \[kind\] text — resolved: TEXT'; `spec_md`: render resolved items of any kind with their resolution under the existing structure (a Resolved vagueness subsection is acceptable); `deliverables_md`: filter on the resolved flag from both models; update tests/goldens accordingly - depends on: t1, t2 - covers: c6, h6, c18, h14 - acceptance: - - frame_md and spec_md render a resolved item with its resolution text verbatim; an exported spec from a frame with a resolved blocking park passes markdownlint (integration test + goldens) - - deliverables_md surviving-open-items excludes resolved frame vagueness and resolved plan risks (tests/test_plan_deliverables.py) + - `frame_md` and `spec_md` render a resolved item with its resolution text verbatim; an exported spec from a frame with a resolved blocking park passes markdownlint (integration test + goldens) + - `deliverables_md` surviving-open-items excludes resolved frame vagueness and resolved plan risks (tests/`test_plan_deliverables.py`) ### t8 — Teaching + contract docs sweep: the close-out loop everywhere park is taught -- instruction: sweep devague/cli/_commands/learn.py (move table + operating rules), docs/llm-guidance.md park rows, .claude/skills/think/SKILL.md move table and rules, and docs/spec-contract.md (entities, move tables, Versioning); match the exact flag names t5/t6 shipped; markdownlint everything touched +- instruction: sweep devague/cli/`_commands`/learn.py (move table + operating rules), docs/llm-guidance.md park rows, .claude/skills/think/SKILL.md move table and rules, and docs/spec-contract.md (entities, move tables, Versioning); match the exact flag names t5/t6 shipped; markdownlint everything touched - depends on: t5, t6 - covers: c7, h7, c14, h10, c5 - acceptance: - - devague learn and devague plan learn name park --resolve / plan risk --resolve wherever park/risk are taught (tests/test_cli_learn.py asserts the strings) - - docs/spec-contract.md documents resolved/resolution on Vagueness and PlanRisk, the resolve move rows, and schema_version 3 for both engines + - devague learn and devague plan learn name park --resolve / plan risk --resolve wherever park/risk are taught (tests/`test_cli_learn.py` asserts the strings) + - docs/spec-contract.md documents resolved/resolution on Vagueness and PlanRisk, the resolve move rows, and `schema_version` 3 for both engines - docs/llm-guidance.md and .claude/skills/think/SKILL.md teach the close-out loop; the taught prefer-question workaround framing is retired ### t9 — E2E repro + quality gates: issue 57 lifecycle through the real CLI, both engines -- instruction: new tests/test_e2e_resolve.py exercising devague.cli main() end to end for both engines; keep it hermetic in tmp_path working dirs; verify the exported spec passes the existing markdownlint integration harness +- instruction: new tests/`test_e2e_resolve.py` exercising devague.cli main() end to end for both engines; keep it hermetic in `tmp_path` working dirs; verify the exported spec passes the existing markdownlint integration harness - depends on: t3, t4, t5, t6, t7 - covers: c1, h1, c15, h11, c9, h8, c17, h13 - acceptance: From 24b1837c7bd7dc8eaec9e99c3f389f6b55d86daf Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 11:01:47 +0300 Subject: [PATCH 13/25] fix(reject): cascade over honesty conditions and hard questions (#83, t5) Frame.reject cascades a claim's rejection onto its still-live honesty conditions (status -> rejected) and reports both cascaded honesty and hard-question ids so the CLI can echo what it took with it (`c21 -> rejected (also rejected: h3, q1)`). The cascade fires only on the transition into rejected, so re-rejecting an already-rejected claim reports nothing (idempotent, no double-reporting). _assumption_warnings now skips rejected assumptions (only a still-proposed one is actionable "unconfirmed"), and devague review naturally stops listing an honesty condition once its parent claim's rejection cascades over it -- no renderer changes needed, since proposed_honesty() already filters on status == "proposed". --- devague/cli/_commands/confirm.py | 22 ++++++++-- devague/convergence.py | 12 +++++- devague/frame.py | 55 ++++++++++++++++++++++++ tests/test_cli_converge_export.py | 70 +++++++++++++++++++++++++++++++ tests/test_cli_moves.py | 62 +++++++++++++++++++++++++++ tests/test_cli_review.py | 25 +++++++++++ tests/test_convergence.py | 13 ++++++ tests/test_frame.py | 65 ++++++++++++++++++++++++++++ 8 files changed, 318 insertions(+), 6 deletions(-) diff --git a/devague/cli/_commands/confirm.py b/devague/cli/_commands/confirm.py index 459cf9a..5fb8bf8 100644 --- a/devague/cli/_commands/confirm.py +++ b/devague/cli/_commands/confirm.py @@ -6,6 +6,10 @@ id is validated first, and if any is unknown nothing is changed. Confirmation stays a user-only action, and ``--from-review`` applies only what the file explicitly marks — ``pending`` lines are never auto-confirmed. See issue #17. + +Rejecting a claim cascades onto its still-live honesty conditions/hard +questions via ``Frame.reject`` (issue #83) — confirm has no mirrored +"un-reject" cascade; see that method's docstring for why. """ from __future__ import annotations @@ -38,14 +42,24 @@ def _run(args: argparse.Namespace, confirm_ids: list[str], reject_ids: list[str] ) for item_id in confirm_ids: frame.set_status(item_id, "confirmed") - for item_id in reject_ids: - frame.set_status(item_id, "rejected") + # Rejecting a CLAIM cascades onto its still-live honesty conditions/hard + # questions (Frame.reject, issue #83); collect what each id took with it + # so the echo can report it (`c21 -> rejected (also rejected: h3, q1)`). + cascaded: dict[str, list[str]] = {item_id: frame.reject(item_id) for item_id in reject_ids} store.save(frame) if getattr(args, "json", False): - emit_result({"confirmed": confirm_ids, "rejected": reject_ids}, json_mode=True) + emit_result( + {"confirmed": confirm_ids, "rejected": reject_ids, "cascaded": cascaded}, + json_mode=True, + ) else: lines = [f"{i} -> confirmed" for i in confirm_ids] - lines += [f"{i} -> rejected" for i in reject_ids] + for item_id in reject_ids: + extra = cascaded[item_id] + if extra: + lines.append(f"{item_id} -> rejected (also rejected: {', '.join(extra)})") + else: + lines.append(f"{item_id} -> rejected") emit_result("\n".join(lines), json_mode=False) return 0 diff --git a/devague/convergence.py b/devague/convergence.py index 8581452..35d44b7 100644 --- a/devague/convergence.py +++ b/devague/convergence.py @@ -127,11 +127,19 @@ def _missing_open_uncertainty(frame: Frame) -> list[str]: def _assumption_warnings(frame: Frame) -> list[str]: - """Unconfirmed assumptions are soft: a warning, never a blocker (#5, h14).""" + """Unconfirmed assumptions are soft: a warning, never a blocker (#5, h14). + + Only a still-*proposed* assumption is "unconfirmed" in the actionable + sense this warning describes (confirm it, or it ships as a stated + assumption). A *rejected* assumption was explicitly decided against — + confirming it would reverse that decision and re-rejecting it is a + no-op, so the warning would be pure noise with no useful next move; + skip it (issue #83). + """ return [ f"assumption {c.id} is unconfirmed — confirm it or it ships as a stated assumption" for c in frame.claims - if c.kind == "assumption" and c.status != "confirmed" + if c.kind == "assumption" and c.status == "proposed" ] diff --git a/devague/frame.py b/devague/frame.py index f26ae36..6601ddb 100644 --- a/devague/frame.py +++ b/devague/frame.py @@ -306,6 +306,61 @@ def set_status(self, item_id: str, status: str) -> bool: return True return False + def reject(self, item_id: str) -> list[str]: + """Reject a claim or honesty condition, cascading a claim's rejection + onto its still-live attachments (issue #83). + + Rejected content must never keep looking "live": rendering already + excludes a rejected claim's attachments (spec-md), and the + convergence gate already stops treating a rejected claim's unresolved + blocking hard question as an open blocker. This method closes the + remaining gap — the review pool and the honesty condition's own + recorded status — by flipping every honesty condition still attached + to the claim (``status != "rejected"``) to ``"rejected"`` too, so + ``devague review`` (which only lists ``proposed`` items) stops + surfacing them as awaiting a decision that no longer matters. + + Hard questions have no independent status field to flip (only + ``resolved``/``resolution``, a genuine answer — not the same thing as + "the parent claim was rejected"), so this leaves them structurally + untouched; every call site that matters already keys off the parent + claim's own ``status`` (``convergence._missing_open_uncertainty``, + ``render.spec_md._hard_questions``). + + Returns the ids of honesty conditions and hard questions this call + swept along, in "what it took with it" order (honesty ids first, + then hard-question ids, each in their claim's own attachment order) — + for the caller to echo (e.g. ``c21 -> rejected (also rejected: h3, + q1)``). The cascade fires only on the transition *into* ``rejected``: + rejecting an already-rejected claim again returns ``[]`` rather than + re-claiming credit for a cascade a prior call already performed + (idempotent, no double-reporting — the ids "already rejected" stay + that way whether or not this call runs). Rejecting a bare honesty + condition id is a plain status flip with no cascade (honesty + conditions carry no sub-attachments of their own) and also returns + ``[]``. Raises ``ValueError`` if ``item_id`` names neither a claim + nor a honesty condition — callers are expected to pre-validate the id + first (mirrors ``set_status``'s bool-return contract; see + ``cli/_commands/confirm.py``'s ``_exists`` pre-check, which every + current caller already runs before touching the frame). + """ + claim = self.find_claim(item_id) + if claim is not None: + cascaded: list[str] = [] + if claim.status != "rejected": + for h in claim.honesty_conditions: + if h.status != "rejected": + h.status = "rejected" + cascaded.append(h.id) + cascaded += [q.id for q in claim.hard_questions if not q.resolved] + claim.status = "rejected" + return cascaded + honesty = self.find_honesty(item_id) + if honesty is not None: + honesty.status = "rejected" + return [] + raise ValueError(f"unknown claim or honesty id: {item_id!r}") + def to_dict(frame: Frame) -> dict: return dataclasses.asdict(frame) diff --git a/tests/test_cli_converge_export.py b/tests/test_cli_converge_export.py index 624dc9c..3aba502 100644 --- a/tests/test_cli_converge_export.py +++ b/tests/test_cli_converge_export.py @@ -91,6 +91,76 @@ def test_export_rejects_non_spec_format(tmp_path, monkeypatch, capsys) -> None: assert exc.value.code == 1 +def test_export_never_leaks_a_rejected_claims_risk_text_issue_83( + tmp_path, monkeypatch, capsys +) -> None: + # Issue #83's exact repro shape: capture -> interrogate --risk -> reject -> + # converge -> export. Before the fix, the hard question created by --risk + # survived its parent claim's rejection and rendered into the exported + # spec under "## Hard questions" — this asserts the risk text never + # reaches the exported markdown, and that convergence emits no leftover + # "unconfirmed" noise about the rejected claim either. + _converged(monkeypatch, tmp_path) + capsys.readouterr() + main( + [ + "capture", + "--kind", + "boundary", + "the policy gate must receive the REWRITTEN arguments", + "--origin", + "llm", + ] + ) + f = store.load(store.current_slug()) + original = f.claims[-1] + main(["confirm", original.id]) + main( + [ + "interrogate", + original.id, + "--risk", + "a hook can launder a denied command into an allowed shape", + ] + ) + main(["interrogate", original.id, "--honesty", "the ordering holds", "--origin", "user"]) + capsys.readouterr() + rc = main(["reject", original.id]) + assert rc == 0 + echo = capsys.readouterr().out + assert f"{original.id} -> rejected" in echo + + main( + [ + "capture", + "--kind", + "boundary", + "an ALLOWED original is rewritten into a DENIED command", + "--origin", + "user", + ] + ) + f2 = store.load(store.current_slug()) + corrected = f2.claims[-1] + main(["interrogate", corrected.id, "--honesty", "must hold", "--origin", "user"]) + capsys.readouterr() + + rc = main(["converge", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["ready_for_spec"] is True + assert not any("unconfirmed" in w for w in payload["warnings"]) + + rc = main(["export"]) + assert rc == 0 + frame = store.load(store.current_slug()) + out = Path("docs/specs") / f"{frame.created[:10]}-{frame.slug}.md" + text = out.read_text(encoding="utf-8") + assert "launder a denied command" not in text + assert "## Hard questions" not in text + assert "an ALLOWED original is rewritten into a DENIED command" in text + + def test_converge_llm_honesty_blocks_until_confirmed(tmp_path, monkeypatch, capsys) -> None: """Fix 5: an llm-origin honesty condition stays proposed and blocks convergence.""" monkeypatch.chdir(tmp_path) diff --git a/tests/test_cli_moves.py b/tests/test_cli_moves.py index c85c31e..67df9cb 100644 --- a/tests/test_cli_moves.py +++ b/tests/test_cli_moves.py @@ -140,6 +140,68 @@ def test_reject_multiple_ids_in_one_call(tmp_path, monkeypatch) -> None: assert f.find_claim("c3").status == "rejected" +# --- reject cascade (issue #83): echo + JSON + idempotence ------------------- + + +def test_reject_echoes_cascaded_honesty_and_hard_question(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + main(["capture", "--kind", "boundary", "risky ordering contract", "--origin", "llm"]) # c2 + main(["interrogate", "c2", "--honesty", "the ordering holds"]) # h1, proposed + main(["interrogate", "c2", "--risk", "a hook could launder a command"]) # q1 + capsys.readouterr() + rc = main(["reject", "c2"]) + assert rc == 0 + out = capsys.readouterr().out + assert "c2 -> rejected (also rejected: h1, q1)" in out + f = store.load(store.current_slug()) + assert f.find_claim("c2").status == "rejected" + assert f.find_honesty("h1").status == "rejected" + + +def test_reject_with_no_attachments_echoes_plain_line(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + main(["capture", "--kind", "audience", "devs", "--origin", "llm"]) # c2, no attachments + capsys.readouterr() + assert main(["reject", "c2"]) == 0 + out = capsys.readouterr().out + assert out.strip() == "c2 -> rejected" + + +def test_reject_json_reports_cascaded_ids(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + main(["capture", "--kind", "boundary", "risky ordering contract", "--origin", "llm"]) # c2 + main(["interrogate", "c2", "--honesty", "the ordering holds"]) # h1 + capsys.readouterr() + rc = main(["reject", "c2", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["rejected"] == ["c2"] + assert payload["cascaded"] == {"c2": ["h1"]} + + +def test_reject_already_rejected_claim_does_not_double_report_cascade( + tmp_path, monkeypatch, capsys +) -> None: + _seed(monkeypatch, tmp_path) + main(["capture", "--kind", "boundary", "risky ordering contract", "--origin", "llm"]) # c2 + main(["interrogate", "c2", "--honesty", "the ordering holds"]) # h1 + main(["reject", "c2"]) # first reject cascades over h1 + capsys.readouterr() + rc = main(["reject", "c2", "--json"]) # rejecting again is idempotent + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["cascaded"] == {"c2": []} + + +def test_reject_bare_honesty_id_does_not_touch_parent_claim(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + main(["interrogate", "c1", "--honesty", "must hold"]) # h1, on c1 (confirmed) + assert main(["reject", "h1"]) == 0 + f = store.load(store.current_slug()) + assert f.find_honesty("h1").status == "rejected" + assert f.find_claim("c1").status == "confirmed" # untouched — no reverse cascade + + def test_batch_confirm_is_transactional(tmp_path, monkeypatch, capsys) -> None: # One unknown id in the batch => resolve NONE (no half-applied state). _seed(monkeypatch, tmp_path) diff --git a/tests/test_cli_review.py b/tests/test_cli_review.py index 40ff569..37dc2ce 100644 --- a/tests/test_cli_review.py +++ b/tests/test_cli_review.py @@ -151,3 +151,28 @@ def test_from_review_rejects_ids_and_file_together(tmp_path, monkeypatch, capsys rc = main(["confirm", "c2", "--from-review", str(store.review_path(slug))]) assert rc == 1 assert "not both" in capsys.readouterr().err + + +# --- reject cascade (issue #83): review stops listing orphaned attachments --- + + +def test_review_lists_zero_orphans_after_reject_cascades(tmp_path, monkeypatch, capsys) -> None: + # Before the #83 fix: rejecting c2 left h1 (its honesty condition) still + # `proposed`, so it kept showing up in `devague review` as an item + # awaiting a decision, attached to a claim that no longer exists. The + # cascade in Frame.reject now flips h1 to `rejected` too, so review's + # proposed-only listing (h.status == "proposed") naturally excludes it. + _seed_proposed(monkeypatch, tmp_path) # c2 proposed, h1 proposed (on c1) + main(["capture", "--kind", "boundary", "risky boundary", "--origin", "llm"]) # c3 + main(["interrogate", "c3", "--honesty", "must hold"]) # h2, proposed, on c3 + capsys.readouterr() + assert main(["reject", "c3"]) == 0 + echo = capsys.readouterr().out + assert "h2" in echo # the cascade was echoed + capsys.readouterr() + assert main(["review", "--json"]) == 0 + payload = json.loads(capsys.readouterr().out) + orphan_ids = [h["id"] for h in payload["proposed_honesty"]] + assert "h2" not in orphan_ids + # h1 (attached to c1, which was never rejected) is untouched and unrelated. + assert "h1" in orphan_ids diff --git a/tests/test_convergence.py b/tests/test_convergence.py index cc141bc..8928251 100644 --- a/tests/test_convergence.py +++ b/tests/test_convergence.py @@ -73,6 +73,19 @@ def test_unconfirmed_assumption_is_warning_not_blocker() -> None: assert any("assumption" in w for w in res.warnings) +def test_rejected_assumption_does_not_warn() -> None: + # Issue #83: a rejected assumption was explicitly decided against — it is + # not "unconfirmed" in the actionable sense the warning describes, and + # the warning has no useful next move for it (confirming would reverse + # the rejection; re-rejecting is a no-op). Must not warn. + f = _full_frame() + c = f.add_claim("assumption", "frames stay small", origin="llm") + f.reject(c.id) + res = evaluate(f) + assert res.ready is True + assert not any("assumption" in w for w in res.warnings) + + def test_requirement_is_spec_affecting() -> None: f = _full_frame() r = f.add_claim("requirement", "must round-trip", origin="user") # confirmed, no honesty diff --git a/tests/test_frame.py b/tests/test_frame.py index 0063420..50be3df 100644 --- a/tests/test_frame.py +++ b/tests/test_frame.py @@ -353,3 +353,68 @@ def test_legacy_v3_hard_question_without_resolution_defaults() -> None: f = from_dict(d) assert f.claims[0].hard_questions[0].resolved is False assert f.claims[0].hard_questions[0].resolution == "" + + +# --- reject cascade (issue #83): rejecting a claim sweeps its attachments ---- + + +def test_reject_claim_cascades_over_honesty_and_hard_question() -> None: + f = Frame(slug="s", title="t") + c = f.add_claim("boundary", "policy gate must receive rewritten args", origin="llm") + h = f.add_honesty(c, "the ordering holds", origin="llm") # h1, proposed + q = f.add_hard_question(c, "risk: a hook could launder a command", blocking=False) # q1 + cascaded = f.reject(c.id) + assert c.status == "rejected" + assert h.status == "rejected" + assert cascaded == [h.id, q.id] # honesty ids first, then hard-question ids + + +def test_reject_claim_cascade_skips_already_rejected_honesty() -> None: + f = Frame(slug="s", title="t") + c = f.add_claim("boundary", "x", origin="llm") + h1 = f.add_honesty(c, "cond one", origin="llm") + h2 = f.add_honesty(c, "cond two", origin="llm") + f.set_status(h1.id, "rejected") # already decided independently + cascaded = f.reject(c.id) + assert h1.status == "rejected" and h2.status == "rejected" + assert cascaded == [h2.id] # h1 wasn't newly cascaded — it was already rejected + + +def test_reject_claim_cascade_skips_resolved_hard_question() -> None: + f = Frame(slug="s", title="t") + c = f.add_claim("boundary", "x", origin="llm") + q1 = f.add_hard_question(c, "still open", blocking=True) + q2 = f.add_hard_question(c, "already answered", blocking=True) + f.resolve_hard_question(c.id, q2.id, "decided: yes") + cascaded = f.reject(c.id) + assert cascaded == [q1.id] # the already-resolved question is not "swept" + + +def test_reject_already_rejected_claim_reports_no_cascade_again() -> None: + # Idempotence / no double-reporting: a second reject of the same claim + # must not re-claim credit for the cascade the first call performed. + f = Frame(slug="s", title="t") + c = f.add_claim("boundary", "x", origin="llm") + f.add_honesty(c, "cond", origin="llm") + f.add_hard_question(c, "risk", blocking=False) + first = f.reject(c.id) + assert first != [] + second = f.reject(c.id) + assert second == [] + assert c.status == "rejected" + + +def test_reject_bare_honesty_id_is_a_plain_flip_no_cascade() -> None: + f = Frame(slug="s", title="t") + c = f.add_claim("boundary", "x", origin="llm") + h = f.add_honesty(c, "cond", origin="llm") + cascaded = f.reject(h.id) + assert h.status == "rejected" + assert c.status == "proposed" # the parent claim is untouched + assert cascaded == [] + + +def test_reject_unknown_id_raises() -> None: + f = Frame(slug="s", title="t") + with pytest.raises(ValueError, match="unknown"): + f.reject("zzz") From 95a5c2ac29c486c870e240628462ca58fbfa5965 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 11:05:23 +0300 Subject: [PATCH 14/25] fix(plan): validate --dep at task creation (#86, t10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `plan task --dep` and `depend --on ` now refuse a self-cycle (the about-to-be-assigned/naming-itself task id) and an unknown task id at authoring time, with an actionable hint, instead of silently recording a broken edge that only surfaces much later as a bare `dependency cycle: tN -> tN` at `plan converge`/`plan waves`. `depend --remove` is untouched by the new checks (it must still repair a dangling dep predating this fix), and the plan_convergence gate for multi-task cycles is left alone — this is creation-time feedback, not a replacement for it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QtUixLUjgEt51PXS9TPVt3 --- devague/cli/_commands/plan.py | 54 ++++++++++++++ tests/test_cli_plan.py | 117 +++++++++++++++++++++++++++++- tests/test_plan_escape_hatches.py | 11 ++- 3 files changed, 178 insertions(+), 4 deletions(-) diff --git a/devague/cli/_commands/plan.py b/devague/cli/_commands/plan.py index d30afe1..c979d38 100644 --- a/devague/cli/_commands/plan.py +++ b/devague/cli/_commands/plan.py @@ -222,6 +222,46 @@ def _require_target(plan: Plan, target_id: str) -> None: plan.targets = live_targets # persist the refreshed snapshot on success +def _require_dep_target( + plan: Plan, subject_id: str, dep_id: str, *, flag: str, self_hint: str +) -> None: + """Validate a dependency edge at authoring time — a self-cycle or an unknown + task id is refused the moment ``--dep``/``--on`` is given (issue #86), + instead of surfacing only much later as a ``plan converge``/``plan waves`` + dependency-graph blocker, after the authoring context (which id was + actually meant) is long gone. The reporter hit the self-cycle case twice in + one session: the task id is assigned BY ``plan task``, so an author who + predicts the next id wrongly in ``--dep`` records a task that depends on + itself, invisible until ``plan waves`` reports a bare ``dependency cycle: + tN -> tN``. + + Shared by both add paths: ``plan task --dep`` (``subject_id`` is the + about-to-be-assigned id, predicted via ``Plan._next`` before the task + exists) and ``plan depend --on ``'s add path (``subject_id`` is + the already-assigned ````). Neither call site invokes this on the + ``depend --remove`` path — removing an edge must keep working on an + already-broken graph (a self-cycle or dangling dep recorded before this + check existed, e.g. by an older devague or by hand-edited JSON), so a plan + upgrading with pre-existing damage stays repairable. + + Deliberately narrow: a two-or-more-task cycle (a depends on b depends on + a) is NOT caught here — both tasks already exist and neither equals the + other, so this passes them through. That check already lives in + :mod:`devague.plan_convergence` (``converge``/``waves``) and is left + alone; this is creation-time feedback for the two mistakes a human can + make in a single keystroke, not a replacement for the gate. + """ + if dep_id == subject_id: + raise DevagueError(EXIT_USER_ERROR, "task cannot depend on itself", self_hint) + if plan.find_task(dep_id) is None: + raise DevagueError( + EXIT_USER_ERROR, + f"unknown task: {dep_id}", + f"{flag} {dep_id} does not match any existing task; " + "run 'devague plan show' to see current tasks", + ) + + # ── the re-confirm rule (#53 t5, sharpened in #53-esd t1) ──────────────────── # A demoting change — ``instruct``, ``amend``, ``depend --remove`` — alters something # the user already confirmed, so it must go back through the user: setting/changing it @@ -292,6 +332,13 @@ def cmd_plan_task(args: argparse.Namespace) -> int: plan = resolve_plan(args.plan) for tid in args.covers or []: _require_target(plan, tid) + # The task id is assigned BY this call (`add_task` below); validate every + # `--dep` against the id it is *about* to receive, before creating anything + # (issue #86) — mirrors how `--covers` is validated above, before mutation. + next_id = Plan._next(plan.tasks, "t") + for dep in args.dep or []: + self_hint = f"--dep {dep} names the id this task will receive; depend on an existing task" + _require_dep_target(plan, next_id, dep, flag="--dep", self_hint=self_hint) task = plan.add_task(args.summary, origin=args.origin) task.instruction = args.instruction or "" for crit in args.accept or []: @@ -429,6 +476,13 @@ def cmd_plan_depend(args: argparse.Namespace) -> int: task = _require_task(plan, args.id) if args.remove: return _cmd_plan_depend_remove(args, plan, task) + _require_dep_target( + plan, + args.id, + args.on, + flag="--on", + self_hint=f"--on {args.on} is the same task; depend on a different, existing task", + ) plan.add_dep(task, args.on) plan_store.save(plan) if getattr(args, "json", False): diff --git a/tests/test_cli_plan.py b/tests/test_cli_plan.py index 25937ed..efd22ae 100644 --- a/tests/test_cli_plan.py +++ b/tests/test_cli_plan.py @@ -85,15 +85,66 @@ def test_plan_new_happy_and_collision(tmp_path, monkeypatch, capsys) -> None: # ── task / accept / depend / cover ────────────────────────────────────────── def test_task_inline_flags_and_json(tmp_path, monkeypatch, capsys) -> None: + """`--dep` must reference an EXISTING task (issue #86) — this test used to + pin the broken behavior (`--dep t9` where t9 never existed, asserting + rc == 0). Flipped to depend on a real task (t1) instead, still exercising + every other inline flag (`--accept`/`--covers`/`--json`) together.""" slug = _converged_frame(monkeypatch, tmp_path) main(["plan", "new", "--frame", slug]) + main(["plan", "task", "dep target"]) # t1 — a real task to depend on capsys.readouterr() rc = main( - ["plan", "task", "core", "--accept", "works", "--covers", "c1", "--dep", "t9", "--json"] + ["plan", "task", "core", "--accept", "works", "--covers", "c1", "--dep", "t1", "--json"] ) assert rc == 0 payload = json.loads(capsys.readouterr().out) - assert payload["id"] == "t1" and payload["covers"] == ["c1"] and payload["deps"] == ["t9"] + assert payload["id"] == "t2" and payload["covers"] == ["c1"] and payload["deps"] == ["t1"] + + +def test_task_dep_self_cycle_errors(tmp_path, monkeypatch, capsys) -> None: + """Issue #86: `--dep` naming the id the about-to-be-created task will + itself receive (a batch-authoring mispredict) must refuse at creation + time, with the reporter's suggested error shape, not silently record a + self-cycle that only surfaces later at `plan waves`.""" + slug = _converged_frame(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + capsys.readouterr() + rc = main(["plan", "task", "core", "--dep", "t1"]) # this call would BE t1 + assert rc == 1 + err = capsys.readouterr().err + assert "error: task cannot depend on itself" in err + assert "hint: --dep t1 names the id this task will receive" in err + assert plan_store.load(slug).tasks == [] # nothing was persisted + + +def test_task_dep_unknown_id_errors(tmp_path, monkeypatch, capsys) -> None: + """Issue #86: a typo'd `--dep` (naming a task id that does not exist at + all) must refuse at creation time with an actionable hint, rather than + silently recording a dangling dep that only surfaces later at `plan + waves`.""" + slug = _converged_frame(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + capsys.readouterr() + rc = main(["plan", "task", "core", "--dep", "t4"]) + assert rc == 1 + err = capsys.readouterr().err + assert "error: unknown task: t4" in err + assert "hint: --dep t4 does not match any existing task" in err + assert plan_store.load(slug).tasks == [] # nothing was persisted + + +def test_task_dep_multiple_valid_and_invalid(tmp_path, monkeypatch, capsys) -> None: + """A valid `--dep` earlier in the list does not mask a later bad one, and + nothing is left half-created when one entry in the batch is invalid.""" + slug = _converged_frame(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + main(["plan", "task", "root"]) # t1 + capsys.readouterr() + rc = main(["plan", "task", "core", "--dep", "t1", "--dep", "ghost"]) + assert rc == 1 + assert "unknown task: ghost" in capsys.readouterr().err + plan = plan_store.load(slug) + assert len(plan.tasks) == 1 # only t1 exists — the second task was never created def test_task_unknown_cover_target_errors(tmp_path, monkeypatch, capsys) -> None: @@ -119,6 +170,57 @@ def test_accept_depend_cover_moves(tmp_path, monkeypatch, capsys) -> None: assert payload["covers"] == ["c1"] +def test_depend_self_cycle_errors(tmp_path, monkeypatch, capsys) -> None: + """Issue #86: `depend --on ` (an already-assigned task naming + itself) must refuse the same way the `plan task --dep` self-cycle does.""" + slug = _converged_frame(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + main(["plan", "task", "a"]) # t1 + capsys.readouterr() + rc = main(["plan", "depend", "t1", "--on", "t1"]) + assert rc == 1 + err = capsys.readouterr().err + assert "error: task cannot depend on itself" in err + assert "hint: --on t1 is the same task" in err + assert plan_store.load(slug).find_task("t1").deps == [] + + +def test_depend_unknown_target_errors(tmp_path, monkeypatch, capsys) -> None: + """Issue #86: `depend --on ` where does not exist must refuse + at authoring time with an actionable hint, rather than recording a dangling + dep that only surfaces later at `plan waves`/`plan converge`.""" + slug = _converged_frame(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + main(["plan", "task", "a"]) # t1 + capsys.readouterr() + rc = main(["plan", "depend", "t1", "--on", "t99"]) + assert rc == 1 + err = capsys.readouterr().err + assert "error: unknown task: t99" in err + assert "hint: --on t99 does not match any existing task" in err + assert plan_store.load(slug).find_task("t1").deps == [] + + +def test_depend_remove_repairs_preexisting_dangling_dep(tmp_path, monkeypatch, capsys) -> None: + """Design note: a plan authored before this fix existed (or hand-edited + JSON) can already carry a dangling dep that could no longer be CREATED + through the CLI today — `depend --remove` must still repair it. The + dangling dep is injected directly into the store to simulate that + pre-existing damage, since `plan task --dep`/`depend --on` now refuse to + create it.""" + slug = _converged_frame(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + main(["plan", "task", "a"]) # t1 + capsys.readouterr() + plan = plan_store.load(slug) + plan.find_task("t1").deps.append("ghost") # simulate pre-existing damage + plan_store.save(plan) + + rc = main(["plan", "depend", "t1", "--on", "ghost", "--remove"]) + assert rc == 0 + assert plan_store.load(slug).find_task("t1").deps == [] + + def test_moves_on_unknown_task_error(tmp_path, monkeypatch, capsys) -> None: slug = _converged_frame(monkeypatch, tmp_path) main(["plan", "new", "--frame", slug]) @@ -713,7 +815,16 @@ def test_waves_does_not_mutate_plan_state(tmp_path, monkeypatch, capsys) -> None def test_waves_dangling_dep_errors(tmp_path, monkeypatch, capsys) -> None: - _plan_with_deps(monkeypatch, tmp_path, capsys, [["t99"]]) + """A dangling dep on a task id that never existed is still caught by + `plan waves`'s dependency-graph gate — but issue #86 means `plan task + --dep`/`depend --on` now refuse to CREATE one, so it is injected directly + into the store here to simulate a plan carrying pre-existing damage (from + before this fix, or hand-edited JSON).""" + slug = _plan_with_deps(monkeypatch, tmp_path, capsys, [[]]) + plan = plan_store.load(slug) + plan.find_task("t1").deps.append("t99") + plan_store.save(plan) + assert main(["plan", "waves", "--json"]) == 1 assert "unknown task" in capsys.readouterr().err diff --git a/tests/test_plan_escape_hatches.py b/tests/test_plan_escape_hatches.py index a1c5f76..0951449 100644 --- a/tests/test_plan_escape_hatches.py +++ b/tests/test_plan_escape_hatches.py @@ -163,10 +163,19 @@ def test_depend_remove_flip_emits_stderr_note_in_text_mode(tmp_path, monkeypatch def test_converge_stops_reporting_removed_edge(tmp_path, monkeypatch, capsys) -> None: + """A dangling dep on an unknown task id used to be creatable through + `plan task --dep` directly; issue #86 makes the CLI refuse that at + creation time now, so the dangling dep is injected straight into the + store here — simulating a plan that already carries this damage from + before the fix (or from hand-edited JSON) — to verify `depend --remove` + still repairs it.""" slug = _seeded_plan(monkeypatch, tmp_path, capsys) main(["plan", "task", "a"]) # t1 - main(["plan", "task", "b", "--dep", "ghost"]) # t2, dangles on an unknown task + main(["plan", "task", "b"]) # t2 capsys.readouterr() + plan = plan_store.load(slug) + plan.find_task("t2").deps.append("ghost") # simulate pre-existing damage + plan_store.save(plan) rc = main(["plan", "converge", "--json"]) assert rc == 0 From a7eda7a9059ee2c5fe7c5dc205015b0cedf50f54 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 11:14:49 +0300 Subject: [PATCH 15/25] feat(export): mark confirmed claims contested by an approved deviation (#92, t14) Adds a read-only derivation (devague/contested.py) joining a frame's confirmed claims to any approved `devague deviate` record naming them in --affects, since the deviation ledger knew a claim had been contradicted by execution but nothing rendered that back-reference (the spec, `show`, and `status` all stayed silent). Per the #92 maintainer ruling the spec itself is never rewritten -- this only ever derives a pointer at render time: `export` gets a rich per-claim marker (announcement blockquote, Requirements, and every generic claim section), `show`/`status` gain a summary "contested: ..." line in both text and --json, and the plan engine's own status is untouched (no "contested" key at all). The join crosses the frame/plan boundary the instruction called out: frames carry no reverse pointer to the plans seeded from them, so every plan slug is enumerated and filtered on frame_slug before its delivery ledger is read. Fails open throughout -- a plan or delivery file that is missing, truncated, or declares a newer schema degrades to "no markers from that source" plus a stderr diagnostic, never a crash or a refused export; zero frame/plan/ delivery mutation. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QtUixLUjgEt51PXS9TPVt3 --- devague/cli/_commands/export.py | 16 +- devague/cli/_commands/show.py | 32 +- devague/cli/_commands/status.py | 16 +- devague/cli/_status.py | 55 +- devague/contested.py | 206 ++++++ devague/render/spec_md.py | 90 ++- tests/test_contested.py | 626 ++++++++++++++++++ tests/test_export_markdownlint_integration.py | 58 ++ 8 files changed, 1061 insertions(+), 38 deletions(-) create mode 100644 devague/contested.py create mode 100644 tests/test_contested.py diff --git a/devague/cli/_commands/export.py b/devague/cli/_commands/export.py index a073312..a8a8482 100644 --- a/devague/cli/_commands/export.py +++ b/devague/cli/_commands/export.py @@ -8,9 +8,11 @@ from devague import render, store from devague.cli._errors import EXIT_USER_ERROR, DevagueError from devague.cli._frames import resolve -from devague.cli._output import emit_result +from devague.cli._output import emit_diagnostic, emit_result from devague.cli._paths import dated_name +from devague.contested import find_contested_markers from devague.convergence import evaluate +from devague.render import spec_md SPECS_DIR = Path("docs/specs") @@ -24,7 +26,17 @@ def cmd_export(args: argparse.Namespace) -> int: "frame has not converged; cannot export", "resolve: " + "; ".join(result.blockers), ) - text = render.render(frame, args.format) + # Contested-by-deviation derivation (#92): a read-only cross-store join + # (frame claims × every plan's delivery ledger). Fails open — a broken + # plan or delivery file degrades to "no markers from that source" plus a + # stderr diagnostic, never a refused export (claim c34). + markers, diagnostics = find_contested_markers(frame) + for diag in diagnostics: + emit_diagnostic(diag) + if args.format == "spec-md": + text = spec_md.render_spec(frame, contested=markers) + else: + text = render.render(frame, args.format) SPECS_DIR.mkdir(parents=True, exist_ok=True) out_path = SPECS_DIR / dated_name(frame.created, frame.slug) out_path.write_text(text, encoding="utf-8") diff --git a/devague/cli/_commands/show.py b/devague/cli/_commands/show.py index 15ac7b7..9c8bad2 100644 --- a/devague/cli/_commands/show.py +++ b/devague/cli/_commands/show.py @@ -6,16 +6,36 @@ from devague import render from devague.cli._frames import resolve -from devague.cli._output import emit_result +from devague.cli._output import emit_diagnostic, emit_result +from devague.contested import find_contested_markers, marker_to_dict, sorted_markers from devague.frame import to_dict +def _contested_line(m) -> str: + line = f"contested: {m.claim_id} by {m.deviation_id}" + if m.classification: + line += f" ({m.classification})" + return f"{line}: {m.reason}" + + def cmd_show(args: argparse.Namespace) -> int: frame = resolve(args.frame) + # Contested-by-deviation derivation (#92): read-only, fails open — a + # broken plan/delivery ledger anywhere in the join degrades to "no + # markers from that source" plus a stderr diagnostic, never a crash. + markers, diagnostics = find_contested_markers(frame) + for diag in diagnostics: + emit_diagnostic(diag) + flat = sorted_markers(markers) if getattr(args, "json", False): - emit_result(to_dict(frame), json_mode=True) + payload = to_dict(frame) + payload["contested"] = [marker_to_dict(m) for m in flat] + emit_result(payload, json_mode=True) else: - emit_result(render.render(frame, args.format), json_mode=False) + text = render.render(frame, args.format) + if flat: + text = text.rstrip("\n") + "\n\n" + "\n".join(_contested_line(m) for m in flat) + "\n" + emit_result(text, json_mode=False) return 0 @@ -23,5 +43,9 @@ def register(sub: argparse._SubParsersAction) -> None: p = sub.add_parser("show", help="Render the current frame.") p.add_argument("--format", default="frame-md", help="Renderer format (default: frame-md).") p.add_argument("--frame", help="Frame slug (default: current).") - p.add_argument("--json", action="store_true", help="Emit the raw frame as JSON.") + p.add_argument( + "--json", + action="store_true", + help="Emit the raw frame as JSON, plus a derived 'contested' list (#92).", + ) p.set_defaults(func=cmd_show) diff --git a/devague/cli/_commands/status.py b/devague/cli/_commands/status.py index a5af927..00ab152 100644 --- a/devague/cli/_commands/status.py +++ b/devague/cli/_commands/status.py @@ -15,7 +15,9 @@ from devague import store from devague.cli._frames import resolve +from devague.cli._output import emit_diagnostic from devague.cli._status import StatusLabels, emit_empty, emit_status +from devague.contested import find_contested_markers, marker_to_dict, sorted_markers from devague.convergence import evaluate _LABELS = StatusLabels( @@ -40,8 +42,20 @@ def cmd_status(args: argparse.Namespace) -> int: # A bad --frame raises here (before any stdout) so the error reaches stderr. frame = resolve(args.frame) result = evaluate(frame) + # Contested-by-deviation derivation (#92): read-only, fails open — a + # broken plan/delivery ledger anywhere in the join degrades to "no + # markers from that source" plus a stderr diagnostic, never a crash. + markers, diagnostics = find_contested_markers(frame) + for diag in diagnostics: + emit_diagnostic(diag) + contested = [marker_to_dict(m) for m in sorted_markers(markers)] emit_status( - _LABELS, selected=frame.slug, total=len(slugs), result=result, json_mode=json_mode + _LABELS, + selected=frame.slug, + total=len(slugs), + result=result, + json_mode=json_mode, + contested=contested, ) return 0 diff --git a/devague/cli/_status.py b/devague/cli/_status.py index 6201ea6..5da9f79 100644 --- a/devague/cli/_status.py +++ b/devague/cli/_status.py @@ -16,6 +16,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Optional from devague.cli._output import emit_result from devague.convergence import ConvergenceResult @@ -56,6 +57,20 @@ def emit_empty(labels: StatusLabels, *, json_mode: bool) -> None: emit_result(labels.empty_text, json_mode=False) +def _contested_line(entry: dict) -> str: + """Render one derived contested-by-deviation entry (#92) as a single + ``contested: by (): `` line. + Takes the plain JSON-shaped dict (:func:`devague.contested.marker_to_dict`) + rather than the ``ContestedMarker`` dataclass itself, so this shared + frame/plan status renderer stays decoupled from the contested module — + it only needs to agree on a dict shape, not import a domain type. + """ + line = f"contested: {entry['claim']} by {entry['deviation']}" + if entry.get("classification"): + line += f" ({entry['classification']})" + return f"{line}: {entry['reason']}" + + def emit_status( labels: StatusLabels, *, @@ -63,25 +78,39 @@ def emit_status( total: int, result: ConvergenceResult, json_mode: bool, + contested: Optional[list[dict]] = None, ) -> None: - """Render the convergence verdict + recommended next move for one artifact.""" + """Render the convergence verdict + recommended next move for one artifact. + + ``contested`` (#92) is the frame engine's derived contested-by-deviation + list (see :mod:`devague.contested`) — a list of + :func:`devague.contested.marker_to_dict`-shaped dicts, or ``None`` when + the caller has no notion of it (the plan engine's ``status`` never passes + this; only the frame engine's does). ``None`` means "not applicable" and + omits the JSON key entirely; an empty list means "checked, nothing + contested" and still renders the key (JSON) but no lines (text) — the + same never-fabricate-an-empty-section convention every other renderer + here follows. + """ if json_mode: - emit_result( - { - labels.noun: selected, - "total": total, - labels.ready_key: result.ready, - "blockers": result.blockers, - "warnings": result.warnings, - "parked_items": result.parked_items, - "required_next_moves": result.required_next_moves, - }, - json_mode=True, - ) + payload = { + labels.noun: selected, + "total": total, + labels.ready_key: result.ready, + "blockers": result.blockers, + "warnings": result.warnings, + "parked_items": result.parked_items, + "required_next_moves": result.required_next_moves, + } + if contested is not None: + payload["contested"] = contested + emit_result(payload, json_mode=True) return plural = "s" if total != 1 else "" lines = [f"{labels.noun}: {selected} ({total} {labels.noun}{plural} total)"] + if contested: + lines += [_contested_line(c) for c in contested] if result.ready: lines.append("convergence: PASSED ✓") lines += [f" ⚠ {w}" for w in result.warnings] diff --git a/devague/contested.py b/devague/contested.py new file mode 100644 index 0000000..d9bdc8f --- /dev/null +++ b/devague/contested.py @@ -0,0 +1,206 @@ +"""Contested-by-deviation derivation (#92, t14). + +An approved ``devague deviate`` record can name a confirmed claim in its +``--affects`` list — the deviation ledger knows a claim has been contested by +execution, but until now nothing surfaced that back-reference: the exported +spec, ``devague show``, and ``devague status`` all rendered the claim as if +nothing had happened. The maintainer ruling on #92 is explicit: **the spec is +not editable to fix this** ("don't change the spec, this is part of the +ledger — deviate is the marking of the change"). So this module derives the +back-reference read-only, at render time, from state that already exists — +it never mutates a claim, a plan, or a delivery ledger, and it never invents +an id. + +The join (decisions c24/c19, honesty conditions h14/h17/h27): a frame outlives +the plan(s) seeded from it and carries no reverse pointer to them, so finding +"every delivery ledger whose deviations might affect this frame's claims" +means enumerating :func:`devague.plan_store.list_slugs`, loading each plan, +keeping the ones whose ``frame_slug`` matches, and then loading +:mod:`devague.delivery_store` per matching plan slug — exactly the shape +:mod:`devague.cli._commands.deviate`'s own ``--affects`` validation already +established (loading a plan's live source frame to check a ref against it), +just walked in the opposite direction. + +Fail-open is a hard requirement (claim c34/h27): this is the FIRST time +frame-side code (``export``/``show``/``status`` — the tool's core read paths) +reaches across to the plan/delivery stores at all. A plan file or delivery +ledger that is missing, truncated, or declares a schema newer than this +binary understands must never crash or block those commands — it degrades to +"no markers derived from that source" plus a human-readable diagnostic string +the caller is responsible for routing to stderr (typically via +:func:`devague.cli._output.emit_diagnostic`). A delivery ledger that simply +does not exist yet (a plan with no recorded deviations — the common case) is +not a diagnostic at all: :mod:`devague.delivery_store` itself treats that as +normal, and so does this module. + +Pure and read-only throughout: every function here only ever calls ``load``/ +``list_slugs`` on the plan and delivery stores, never ``save``. Nothing here +mutates ``frame`` either. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +from devague import delivery_store, plan_store +from devague.delivery import Delivery +from devague.frame import Frame +from devague.plan import Plan + +__all__ = [ + "ContestedMarker", + "find_contested_markers", + "sorted_markers", + "marker_to_dict", +] + + +@dataclass(frozen=True) +class ContestedMarker: + """One approved deviation naming a confirmed claim in its ``--affects``.""" + + claim_id: str + deviation_id: str + what: str + reason: str + classification: Optional[str] + plan_slug: str + + +def _numeric_suffix(item_id: str) -> int: + """The trailing digits of an id (``c14`` -> 14), for a numeric-aware sort + that orders ``c2`` before ``c10`` — a plain lexicographic sort would not. + Non-numeric/empty input sorts as 0 rather than raising; this only ever + feeds a display order, never an identity check. + """ + digits = "".join(ch for ch in item_id if ch.isdigit()) + return int(digits) if digits else 0 + + +def _load_plan_safely(slug: str) -> tuple[Optional[Plan], Optional[str]]: + """Best-effort plan load: ``(plan, diagnostic)``, never raises. + + ``diagnostic`` is ``None`` on success. A plan that disappeared between + ``list_slugs()`` and this load (a narrow race, not a corruption shape) is + treated the same as "not found" and silently skipped — it is not one of + the three corruption shapes the fail-open contract is about. + """ + try: + return plan_store.load(slug), None + except FileNotFoundError: + return None, None + except plan_store.IncompatiblePlanSchemaError as exc: + return ( + None, + f"contested: plan {slug!r} uses a schema this devague can't read, skipped ({exc})", + ) + except (ValueError, KeyError, TypeError, OSError) as exc: + return None, f"contested: plan {slug!r} is unreadable, skipped ({exc})" + + +def _load_delivery_safely(slug: str) -> tuple[Optional[Delivery], Optional[str]]: + """Best-effort delivery-ledger load: ``(delivery, diagnostic)``, never raises. + + A missing ledger (no deviation ever recorded against this plan) is the + normal, silent case — mirrors :func:`devague.delivery_store.load_or_new`'s + own treatment of "no file yet". A truncated/malformed file or a + newer-than-supported ``schema_version`` is a real corruption shape (claim + c34): both degrade to "no markers from this ledger" plus a diagnostic. + """ + try: + return delivery_store.load(slug), None + except FileNotFoundError: + return None, None + except delivery_store.IncompatibleDeliverySchemaError as exc: + return None, ( + f"contested: delivery ledger for plan {slug!r} uses a schema this " + f"devague can't read, skipped ({exc})" + ) + except (ValueError, KeyError, TypeError, OSError) as exc: + return None, ( + f"contested: delivery ledger for plan {slug!r} is unreadable, skipped ({exc})" + ) + + +def find_contested_markers( + frame: Frame, +) -> tuple[dict[str, list[ContestedMarker]], list[str]]: + """Derive contested markers for ``frame``'s confirmed claims. + + Returns ``(markers, diagnostics)``. ``markers`` maps a confirmed claim id + to every :class:`ContestedMarker` naming it, sorted deterministically + (:func:`sorted_markers`'s order); a claim with nothing contesting it is + simply absent from the dict (``markers.get(cid, [])`` is the intended + read). ``diagnostics`` is zero or more human-readable strings describing + a plan or delivery ledger that could not be read — never fatal, the + caller decides whether/where to surface them. + + Never raises (fail-open, claim c34) and never mutates ``frame``, any + plan, or any delivery ledger (pure derivation, claim c21/h17). + """ + confirmed_ids = {c.id for c in frame.claims if c.status == "confirmed"} + markers: dict[str, list[ContestedMarker]] = {} + diagnostics: list[str] = [] + + try: + slugs = plan_store.list_slugs() + except OSError as exc: + diagnostics.append(f"contested: could not list plans ({exc}); no markers derived") + return markers, diagnostics + + for slug in slugs: + plan, plan_diag = _load_plan_safely(slug) + if plan_diag: + diagnostics.append(plan_diag) + if plan is None or plan.frame_slug != frame.slug: + continue + + delivery, delivery_diag = _load_delivery_safely(slug) + if delivery_diag: + diagnostics.append(delivery_diag) + if delivery is None: + continue + + for dev in delivery.deviations: + if dev.status != "approved": + continue + for ref in dev.affects: + if ref in confirmed_ids: + markers.setdefault(ref, []).append( + ContestedMarker( + claim_id=ref, + deviation_id=dev.id, + what=dev.what, + reason=dev.reason, + classification=dev.classification, + plan_slug=slug, + ) + ) + + for entries in markers.values(): + entries.sort(key=lambda m: (m.plan_slug, _numeric_suffix(m.deviation_id))) + return markers, diagnostics + + +def sorted_markers(markers: dict[str, list[ContestedMarker]]) -> list[ContestedMarker]: + """Flatten a claim-id-keyed marker map into one deterministically ordered + list — claim id first (numeric-aware), then plan slug, then deviation id. + """ + flat = [m for entries in markers.values() for m in entries] + flat.sort( + key=lambda m: (_numeric_suffix(m.claim_id), m.plan_slug, _numeric_suffix(m.deviation_id)) + ) + return flat + + +def marker_to_dict(marker: ContestedMarker) -> dict: + """The JSON-friendly shape of one marker, for ``--json`` output.""" + return { + "claim": marker.claim_id, + "deviation": marker.deviation_id, + "what": marker.what, + "reason": marker.reason, + "classification": marker.classification, + "plan": marker.plan_slug, + } diff --git a/devague/render/spec_md.py b/devague/render/spec_md.py index ca8daec..0d45402 100644 --- a/devague/render/spec_md.py +++ b/devague/render/spec_md.py @@ -2,6 +2,9 @@ from __future__ import annotations +from typing import Optional + +from devague.contested import ContestedMarker from devague.frame import ( VAGUENESS_KINDS, Claim, @@ -12,6 +15,14 @@ ) from devague.render._md_safety import autolink_urls, heading_safe, md_safe_text +# Keyed by confirmed claim id -> the approved deviations naming it in their +# ``--affects`` (#92). Pure input data: this module never derives it itself +# (that would mean I/O inside a renderer) — the caller (``devague export``) +# computes it via ``devague.contested.find_contested_markers`` and passes it +# in, the same way ``render.deliverables_md`` takes a already-loaded ``Plan`` +# rather than loading one itself. +ContestedMap = dict[str, list[ContestedMarker]] + def _safe(text: str) -> str: """Compose both render-time verbatim-text passes for one field (#64, #87): @@ -47,35 +58,65 @@ def _instruction_lines(instruction: str, indent: str = " ") -> list[str]: return [f"{indent}- instruction: {_safe(instruction)}"] if instruction else [] -def _claim_bullets(claims: list[Claim], prefix: str = "") -> list[str]: +def _contested_marker_text(m: ContestedMarker) -> str: + """The shared ``contested by `dN` (classification): reason`` fragment — + used both as a nested bullet under an affected claim and (prefixed with + ``>``) under the announcement blockquote. + """ + text = f"contested by `{m.deviation_id}`" + if m.classification: + text += f" ({m.classification})" + return f"{text}: {_safe(m.reason)}" + + +def _contested_lines( + claim_id: str, contested: Optional[ContestedMap], indent: str = " " +) -> list[str]: + """Nested ``- ⚠ contested by ...`` bullets for a confirmed claim named in an + approved deviation's ``--affects`` (#92), or nothing when the claim isn't + contested — never fabricated filler, mirrors ``_instruction_lines``. The + spec is never rewritten to match what execution later learned (the #92 + maintainer ruling); this only ever adds a read-only pointer to the ledger + entry that superseded the claim. + """ + entries = (contested or {}).get(claim_id, []) + return [f"{indent}- ⚠ {_contested_marker_text(m)}" for m in entries] + + +def _claim_bullets( + claims: list[Claim], prefix: str = "", contested: Optional[ContestedMap] = None +) -> list[str]: out: list[str] = [] for c in claims: out.append(f"- {prefix}{_safe(c.text)}") out += _instruction_lines(c.instruction) + out += _contested_lines(c.id, contested) return out -def _claim_section(heading: str, claims: list[Claim]) -> list[str]: +def _claim_section( + heading: str, claims: list[Claim], contested: Optional[ContestedMap] = None +) -> list[str]: """A ``## heading`` + bullet-list block of claims, each with its own nested instruction bullet when it carries one, or nothing when empty. """ if not claims: return [] - return [f"## {heading}", "", *_claim_bullets(claims), ""] + return [f"## {heading}", "", *_claim_bullets(claims, contested=contested), ""] -def _before_after(frame: Frame) -> list[str]: +def _before_after(frame: Frame, contested: Optional[ContestedMap] = None) -> list[str]: befores = _claims(frame, "before_state") afters = _claims(frame, "after_state") if not (befores or afters): return [] lines = ["## Before → After", ""] - lines += _claim_bullets(befores, prefix="Before: ") - lines += _claim_bullets(afters, prefix="After: ") + lines += _claim_bullets(befores, prefix="Before: ", contested=contested) + lines += _claim_bullets(afters, prefix="After: ", contested=contested) return lines + [""] -def _requirements_block(frame: Frame) -> list[str]: +def _requirements_block(frame: Frame, contested: Optional[ContestedMap] = None) -> list[str]: """Requirement claims (confirmed) with their confirmed honesty conditions nested.""" reqs = _claims(frame, "requirement") if not reqs: @@ -84,6 +125,7 @@ def _requirements_block(frame: Frame) -> list[str]: for c in reqs: out.append(f"- {_safe(c.text)}") out += _instruction_lines(c.instruction) + out += _contested_lines(c.id, contested) for h in c.honesty_conditions: if h.status != "confirmed": continue @@ -222,7 +264,17 @@ def _scope_section(frame: Frame) -> list[str]: return out + [""] -def render_spec(frame: Frame) -> str: +def render_spec(frame: Frame, contested: Optional[ContestedMap] = None) -> str: + """Render the buildable spec. + + ``contested`` (#92) maps a confirmed claim id to the approved deviations + naming it — computed by the caller via + :func:`devague.contested.find_contested_markers` and passed in verbatim; + this function stays a pure function of its two inputs, no I/O of its own + (the same layering ``render.deliverables_md`` uses for a ``Plan``). + Omitted/``None`` means "no contested markers" — every existing caller + that renders a frame with no notion of deviations is unaffected. + """ out: list[str] = [f"# {_safe_heading(frame.title)}", ""] ann_claims = _claims(frame, "announcement") if ann_claims: @@ -230,20 +282,22 @@ def render_spec(frame: Frame) -> str: out.append("> " + _safe(ann.text)) if ann.instruction: out.append(f"> instruction: {_safe(ann.instruction)}") + for m in (contested or {}).get(ann.id, []): + out.append(f"> ⚠ {_contested_marker_text(m)}") out.append("") - out += _claim_section("Audience", _claims(frame, "audience")) - out += _before_after(frame) - out += _claim_section("Why it matters", _claims(frame, "why_it_matters")) - out += _requirements_block(frame) + out += _claim_section("Audience", _claims(frame, "audience"), contested) + out += _before_after(frame, contested) + out += _claim_section("Why it matters", _claims(frame, "why_it_matters"), contested) + out += _requirements_block(frame, contested) out += _honesty_section("Honesty conditions", _other_honesty(frame)) - out += _claim_section("Success signals", _claims(frame, "success_signal")) - out += _claim_section("Scope / boundaries", _claims(frame, "boundary")) - out += _claim_section("Non-goals", _claims(frame, "non_goal")) - out += _claim_section("Assumptions", _claims(frame, "assumption")) + out += _claim_section("Success signals", _claims(frame, "success_signal"), contested) + out += _claim_section("Scope / boundaries", _claims(frame, "boundary"), contested) + out += _claim_section("Non-goals", _claims(frame, "non_goal"), contested) + out += _claim_section("Assumptions", _claims(frame, "assumption"), contested) out += _scope_section(frame) - out += _claim_section("Decisions", _claims(frame, "decision")) + out += _claim_section("Decisions", _claims(frame, "decision"), contested) out += _hard_questions(frame) - out += _claim_section("Open questions", _claims(frame, "open_question")) + out += _claim_section("Open questions", _claims(frame, "open_question"), contested) out += _open_parks(frame) out += _resolved_vagueness_section(frame) return "\n".join(out).rstrip() + "\n" diff --git a/tests/test_contested.py b/tests/test_contested.py new file mode 100644 index 0000000..453af27 --- /dev/null +++ b/tests/test_contested.py @@ -0,0 +1,626 @@ +"""Tests for the contested-by-deviation derivation (#92, t14). + +An approved ``devague deviate`` record can name a confirmed claim in its +``--affects`` list, but until now nothing surfaced that back-reference: the +exported spec, ``devague show``, and ``devague status`` all rendered the +named claim as if execution had never disproved it. Per the #92 maintainer +ruling ("don't change the spec, this is part of the ledger — deviate is the +marking of the change"), the fix is a pure, read-only derivation — never a +rewrite of the claim itself. Acceptance criteria: + +1. an approved deviation whose --affects names a confirmed claim yields a + contested marker on re-export and a contested line in show and status +2. export, show, and status succeed on a frame whose delivery store is + missing, truncated, or declares a newer schema (three corruption-shape + tests); frame JSON is byte-identical before and after + +Covers claims c14/c21/c34, honesty conditions h14/h17/h27. +""" + +from __future__ import annotations + +import json + +import pytest + +from devague import delivery_store, plan_store, store +from devague.cli import main +from devague.contested import ( + ContestedMarker, + find_contested_markers, + marker_to_dict, + sorted_markers, +) +from devague.delivery import DELIVERY_SCHEMA_VERSION, Delivery +from devague.frame import Frame +from devague.plan import Plan, targets_from_frame +from devague.render.spec_md import render_spec + +_KINDS = ("audience", "after_state", "before_state", "boundary", "success_signal") + + +# ── CLI-level fixtures ──────────────────────────────────────────────────────── + + +def _converged_frame_with_requirement(monkeypatch, tmp_path) -> tuple[str, str]: + """Seed a frame that passes the frame gate and also carries a confirmed + ``requirement`` claim (Requirements has its own render code path, + distinct from the generic ``_claim_section``/announcement blockquote). + Returns ``(slug, requirement_claim_id)``. + """ + monkeypatch.chdir(tmp_path) + main(["new", "Ship the contested-by-deviation derivation"]) # c1 announcement + for kind in _KINDS: + main(["capture", "--kind", kind, f"{kind} text", "--origin", "user"]) + main( + [ + "capture", + "--kind", + "requirement", + "native transcripts live in a flat layout", + "--origin", + "user", + ] + ) + f = store.load(store.current_slug()) + for c in f.claims: + main(["interrogate", c.id, "--honesty", "must hold", "--origin", "user"]) + slug = store.current_slug() + req = next(c for c in store.load(slug).claims if c.kind == "requirement") + return slug, req.id + + +def _converged_plan_covering_all_targets(monkeypatch, tmp_path) -> tuple[str, str]: + """Seed a converged frame + a converged plan (single task ``t1`` covering + every target). Returns ``(slug, requirement_claim_id)``. + """ + slug, req_id = _converged_frame_with_requirement(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + p = plan_store.load(slug) + args = ["plan", "task", "cover everything", "--accept", "all targets satisfied"] + for tg in p.targets: + args += ["--covers", tg.id] + main(args) + return slug, req_id + + +# ── acceptance criterion 1: end-to-end across export/show/status ──────────── + + +def test_export_show_status_render_contested_marker_end_to_end( + tmp_path, monkeypatch, capsys +) -> None: + slug, req_id = _converged_plan_covering_all_targets(monkeypatch, tmp_path) + reason = "measured 408 of 695 files (59%) below the depth the walker searches" + rc = main( + [ + "deviate", + "walk transcripts recursively", + "--task", + "t1", + "--reason", + reason, + "--affects", + "c1", + "--affects", + req_id, + "--classification", + "risky", + ] + ) + assert rc == 0 + capsys.readouterr() + + # export: a rich per-claim marker under BOTH the announcement blockquote + # (c1) and the Requirements block (its own separate render code path). + rc = main(["export"]) + assert rc == 0 + capsys.readouterr() + spec_files = list((tmp_path / "docs" / "specs").glob("*.md")) + assert len(spec_files) == 1 + spec_text = spec_files[0].read_text(encoding="utf-8") + assert f"> ⚠ contested by `d1` (risky): {reason}" in spec_text + assert f" - ⚠ contested by `d1` (risky): {reason}" in spec_text + + # show (text): a summary contested line, one per (claim, deviation). + rc = main(["show"]) + assert rc == 0 + out = capsys.readouterr().out + assert f"contested: c1 by d1 (risky): {reason}" in out + assert f"contested: {req_id} by d1 (risky): {reason}" in out + + # show --json: the same information, structured. + rc = main(["show", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["contested"] == [ + { + "claim": "c1", + "deviation": "d1", + "what": "walk transcripts recursively", + "reason": reason, + "classification": "risky", + "plan": slug, + }, + { + "claim": req_id, + "deviation": "d1", + "what": "walk transcripts recursively", + "reason": reason, + "classification": "risky", + "plan": slug, + }, + ] + + # status (text + json): same signal, different surface. + rc = main(["status"]) + assert rc == 0 + out = capsys.readouterr().out + assert f"contested: c1 by d1 (risky): {reason}" in out + + rc = main(["status", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert {"claim": "c1", "deviation": "d1"}.items() <= payload["contested"][0].items() + assert len(payload["contested"]) == 2 + + +def test_plan_status_never_carries_a_contested_key(tmp_path, monkeypatch, capsys) -> None: + # Scope discipline: only the FRAME engine's status/show derive this — the + # plan engine's own status must stay exactly as it was (no "contested" key + # at all, not even an empty one), so plan-status consumers are never told + # about a feature that doesn't apply to them. + slug, req_id = _converged_plan_covering_all_targets(monkeypatch, tmp_path) + main( + [ + "deviate", + "swap", + "--task", + "t1", + "--reason", + "why", + "--affects", + "c1", + ] + ) + capsys.readouterr() + rc = main(["plan", "status", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert "contested" not in payload + + +def test_a_deviation_affecting_only_task_ids_yields_no_markers_end_to_end( + tmp_path, monkeypatch, capsys +) -> None: + # Mirrors this repo's own committed `.devague/deliveries/issue-backlog-sweep.json` + # d1: a real approved deviation whose --affects names only task ids. It must + # never produce a contested marker anywhere. + slug, _ = _converged_plan_covering_all_targets(monkeypatch, tmp_path) + p = plan_store.load(slug) + p.add_task("second task") # t2 + plan_store.save(p) + rc = main( + [ + "deviate", + "reorder waves", + "--task", + "t1", + "--reason", + "dependency graph under-specified execution order", + "--affects", + "t1", + "--affects", + "t2", + ] + ) + assert rc == 0 + capsys.readouterr() + + rc = main(["show", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["contested"] == [] + + rc = main(["status", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["contested"] == [] + + +# ── acceptance criterion 2: fail-open on a corrupt delivery store ──────────── + + +def _corrupt_missing(slug: str) -> None: + pass # never create a delivery file at all -- the common, silent case + + +def _corrupt_truncated(slug: str) -> None: + delivery_store.save(Delivery(plan_slug=slug)) + p = delivery_store.path_for(slug) + p.write_text("{not valid json", encoding="utf-8") + + +def _corrupt_newer_schema(slug: str) -> None: + delivery_store.save(Delivery(plan_slug=slug)) + p = delivery_store.path_for(slug) + raw = json.loads(p.read_text(encoding="utf-8")) + raw["schema_version"] = DELIVERY_SCHEMA_VERSION + 99 + p.write_text(json.dumps(raw), encoding="utf-8") + + +@pytest.mark.parametrize( + "corrupt, diag_snippet", + [ + pytest.param(_corrupt_missing, None, id="missing"), + pytest.param(_corrupt_truncated, "unreadable", id="truncated"), + pytest.param(_corrupt_newer_schema, "schema", id="newer-schema"), + ], +) +def test_export_show_status_fail_open_on_corrupt_delivery_store( + tmp_path, monkeypatch, capsys, corrupt, diag_snippet +) -> None: + slug, _ = _converged_plan_covering_all_targets(monkeypatch, tmp_path) + corrupt(slug) + frame_path = store.path_for(slug) + before_bytes = frame_path.read_bytes() + capsys.readouterr() + + # show: read-only -- frame JSON must be byte-identical before and after. + rc = main(["show"]) + assert rc == 0 + err = capsys.readouterr().err + if diag_snippet: + assert diag_snippet in err + assert "contested" in err + assert frame_path.read_bytes() == before_bytes + + # status: same contract. + rc = main(["status"]) + assert rc == 0 + err = capsys.readouterr().err + if diag_snippet: + assert diag_snippet in err + assert frame_path.read_bytes() == before_bytes + + # export: must still succeed (never a refused export, never a traceback). + before_claims = [(c.id, c.kind, c.text, c.status) for c in store.load(slug).claims] + rc = main(["export"]) + assert rc == 0 + err = capsys.readouterr().err + if diag_snippet: + assert diag_snippet in err + # export legitimately flips frame.status/updated on every successful + # export (pre-existing contract, unrelated to #92) -- what must stay + # unchanged is the claim content itself: no id churn, no text mutation. + after_claims = [(c.id, c.kind, c.text, c.status) for c in store.load(slug).claims] + assert after_claims == before_claims + + +# ── module-level unit tests: devague.contested ─────────────────────────────── + + +def _bare_frame_and_plan(monkeypatch, tmp_path, slug: str = "demo") -> Frame: + monkeypatch.chdir(tmp_path) + frame = Frame(slug=slug, title="Demo") + frame.add_claim("announcement", "ship it", origin="user") # c1 + store.save(frame) + plan = Plan(slug=slug, title="Demo Plan", frame_slug=slug) + plan.targets = targets_from_frame(frame) + plan.add_task("first task") # t1 + plan_store.save(plan) + return frame + + +def test_find_contested_markers_no_plans_at_all(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + frame = Frame(slug="demo", title="Demo") + frame.add_claim("announcement", "ship it", origin="user") + markers, diagnostics = find_contested_markers(frame) + assert markers == {} + assert diagnostics == [] + + +def test_find_contested_markers_ignores_plan_with_different_frame_slug( + tmp_path, monkeypatch +) -> None: + monkeypatch.chdir(tmp_path) + frame = _bare_frame_and_plan(monkeypatch, tmp_path, slug="demo") + other = Plan(slug="unrelated", title="Other", frame_slug="other-frame") + other.add_task("t1") + plan_store.save(other) + d = Delivery(plan_slug="unrelated") + d.add_deviation("x", "t1", "why", affects=["c1"], origin="user") + delivery_store.save(d) + + markers, diagnostics = find_contested_markers(frame) + assert markers == {} + assert diagnostics == [] + + +def test_find_contested_markers_missing_delivery_is_silent(tmp_path, monkeypatch) -> None: + frame = _bare_frame_and_plan(monkeypatch, tmp_path) + markers, diagnostics = find_contested_markers(frame) + assert markers == {} + assert diagnostics == [] + + +def test_find_contested_markers_truncated_delivery_degrades_with_diagnostic( + tmp_path, monkeypatch +) -> None: + frame = _bare_frame_and_plan(monkeypatch, tmp_path) + delivery_store.save(Delivery(plan_slug="demo")) + p = delivery_store.path_for("demo") + p.write_text("{not valid json", encoding="utf-8") + + markers, diagnostics = find_contested_markers(frame) + assert markers == {} + assert len(diagnostics) == 1 + assert "demo" in diagnostics[0] + assert "unreadable" in diagnostics[0] + + +def test_find_contested_markers_newer_schema_delivery_degrades_with_diagnostic( + tmp_path, monkeypatch +) -> None: + frame = _bare_frame_and_plan(monkeypatch, tmp_path) + delivery_store.save(Delivery(plan_slug="demo")) + p = delivery_store.path_for("demo") + raw = json.loads(p.read_text(encoding="utf-8")) + raw["schema_version"] = DELIVERY_SCHEMA_VERSION + 99 + p.write_text(json.dumps(raw), encoding="utf-8") + + markers, diagnostics = find_contested_markers(frame) + assert markers == {} + assert len(diagnostics) == 1 + assert "demo" in diagnostics[0] + assert "schema" in diagnostics[0] + + +def test_find_contested_markers_unreadable_plan_is_skipped_with_diagnostic( + tmp_path, monkeypatch +) -> None: + frame = _bare_frame_and_plan(monkeypatch, tmp_path) + p = plan_store.path_for("demo") + p.write_text("{not valid json", encoding="utf-8") + + markers, diagnostics = find_contested_markers(frame) + assert markers == {} + assert len(diagnostics) == 1 + assert "unreadable" in diagnostics[0] + + +def test_find_contested_markers_newer_schema_plan_is_skipped_with_diagnostic( + tmp_path, monkeypatch +) -> None: + frame = _bare_frame_and_plan(monkeypatch, tmp_path) + p = plan_store.path_for("demo") + raw = json.loads(p.read_text(encoding="utf-8")) + raw["schema_version"] = raw["schema_version"] + 99 + p.write_text(json.dumps(raw), encoding="utf-8") + + markers, diagnostics = find_contested_markers(frame) + assert markers == {} + assert len(diagnostics) == 1 + assert "schema" in diagnostics[0] + + +def test_proposed_deviation_is_not_contested_until_approved(tmp_path, monkeypatch) -> None: + frame = _bare_frame_and_plan(monkeypatch, tmp_path) + d = Delivery(plan_slug="demo") + d.add_deviation("x", "t1", "why", affects=["c1"], origin="llm") # lands proposed + delivery_store.save(d) + + markers, diagnostics = find_contested_markers(frame) + assert markers == {} + assert diagnostics == [] + + +def test_affects_naming_a_rejected_claim_is_not_marked(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + frame = Frame(slug="demo", title="Demo") + c = frame.add_claim("boundary", "will be rejected", origin="user") + frame.set_status(c.id, "rejected") + store.save(frame) + plan = Plan(slug="demo", title="Demo Plan", frame_slug="demo") + plan.add_task("t1") + plan_store.save(plan) + d = Delivery(plan_slug="demo") + d.add_deviation("x", "t1", "why", affects=[c.id], origin="user") + delivery_store.save(d) + + markers, diagnostics = find_contested_markers(frame) + assert markers == {} + assert diagnostics == [] + + +def test_deviation_affecting_only_task_ids_yields_no_markers(tmp_path, monkeypatch) -> None: + # The shape of this repo's own committed d1 on issue-backlog-sweep: names + # only task ids, never a claim -- a good regression guard that a real, + # ordinary deviation produces zero markers. + frame = _bare_frame_and_plan(monkeypatch, tmp_path) + plan = plan_store.load("demo") + plan.add_task("second task") # t2 + plan_store.save(plan) + d = Delivery(plan_slug="demo") + d.add_deviation("reordered execution", "t1", "why", affects=["t1", "t2"], origin="user") + delivery_store.save(d) + + markers, diagnostics = find_contested_markers(frame) + assert markers == {} + assert diagnostics == [] + + +def test_multiple_plans_for_the_same_frame_are_all_joined(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + frame = Frame(slug="demo", title="Demo") + frame.add_claim("announcement", "ship it", origin="user") # c1 + store.save(frame) + plan_a = Plan(slug="demo-a", title="A", frame_slug="demo") + plan_a.add_task("t1") + plan_store.save(plan_a) + plan_b = Plan(slug="demo-b", title="B", frame_slug="demo") + plan_b.add_task("t1") + plan_store.save(plan_b) + d_a = Delivery(plan_slug="demo-a") + d_a.add_deviation("x", "t1", "why a", affects=["c1"], origin="user") + delivery_store.save(d_a) + d_b = Delivery(plan_slug="demo-b") + d_b.add_deviation("y", "t1", "why b", affects=["c1"], origin="user") + delivery_store.save(d_b) + + markers, diagnostics = find_contested_markers(frame) + assert diagnostics == [] + assert [m.plan_slug for m in markers["c1"]] == ["demo-a", "demo-b"] + + # An unrelated plan (different frame_slug) must never leak in. + plan_c = Plan(slug="unrelated", title="C", frame_slug="other-frame") + plan_c.add_task("t1") + plan_store.save(plan_c) + d_c = Delivery(plan_slug="unrelated") + d_c.add_deviation("z", "t1", "why c", affects=["c1"], origin="user") + delivery_store.save(d_c) + + markers2, _ = find_contested_markers(frame) + assert len(markers2["c1"]) == 2 + + +def test_find_contested_markers_never_writes_any_store(tmp_path, monkeypatch) -> None: + frame = _bare_frame_and_plan(monkeypatch, tmp_path) + d = Delivery(plan_slug="demo") + d.add_deviation("x", "t1", "why", affects=["c1"], origin="user") + delivery_store.save(d) + + def _boom(*_a, **_kw): + raise AssertionError("contested derivation must never write any store") + + monkeypatch.setattr(store, "save", _boom) + monkeypatch.setattr(plan_store, "save", _boom) + monkeypatch.setattr(delivery_store, "save", _boom) + + markers, diagnostics = find_contested_markers(frame) + assert diagnostics == [] + assert markers # sanity: the derivation still actually ran and found d1 + + +def test_sorted_markers_is_numeric_aware_and_deterministic() -> None: + # Fabricate ids out of numeric order to prove the sort is numeric, not + # lexicographic (which would put "d10" before "d2"). + m1 = ContestedMarker("c1", "d10", "x", "reason ten", None, "demo") + m2 = ContestedMarker("c1", "d2", "y", "reason two", None, "demo") + flat = sorted_markers({"c1": [m1, m2]}) + assert [m.deviation_id for m in flat] == ["d2", "d10"] + + +def test_marker_to_dict_shape() -> None: + m = ContestedMarker("c1", "d1", "what happened", "why", "risky", "demo") + assert marker_to_dict(m) == { + "claim": "c1", + "deviation": "d1", + "what": "what happened", + "reason": "why", + "classification": "risky", + "plan": "demo", + } + + +# ── module-level unit tests: render_spec(frame, contested=...) ────────────── + + +def test_render_spec_marks_contested_confirmed_requirement() -> None: + frame = Frame(slug="demo", title="Demo") + frame.add_claim("announcement", "ship it", origin="user") + req = frame.add_claim("requirement", "flat transcript layout", origin="user") + marker = ContestedMarker( + claim_id=req.id, + deviation_id="d8", + what="walk recursively", + reason="measured 408 of 695 files (59%) below the depth the walker searches", + classification="risky", + plan_slug="demo", + ) + out = render_spec(frame, contested={req.id: [marker]}) + assert "## Requirements" in out + assert ( + " - ⚠ contested by `d8` (risky): measured 408 of 695 files " + "(59%) below the depth the walker searches" in out + ) + + +def test_render_spec_marks_contested_announcement_claim() -> None: + frame = Frame(slug="demo", title="Demo") + ann = frame.add_claim("announcement", "ship it", origin="user") + marker = ContestedMarker( + claim_id=ann.id, + deviation_id="d1", + what="x", + reason="drifted", + classification=None, + plan_slug="demo", + ) + out = render_spec(frame, contested={ann.id: [marker]}) + assert "> ⚠ contested by `d1`: drifted" in out + # No classification -> no empty parens. + assert "()" not in out + + +def test_render_spec_marks_contested_claim_in_a_generic_section() -> None: + # Boundary claims go through the generic `_claim_section`/`_claim_bullets` + # path, distinct from both the announcement blockquote and Requirements. + frame = Frame(slug="demo", title="Demo") + frame.add_claim("announcement", "ship it", origin="user") + b = frame.add_claim("boundary", "flat layout only", origin="user") + marker = ContestedMarker( + claim_id=b.id, + deviation_id="d8", + what="x", + reason="wrong about the world", + classification="risky", + plan_slug="demo", + ) + out = render_spec(frame, contested={b.id: [marker]}) + assert "## Scope / boundaries" in out + assert "- ⚠ contested by `d8` (risky): wrong about the world" in out + + +def test_render_spec_without_contested_arg_is_unaffected() -> None: + frame = Frame(slug="demo", title="Demo") + frame.add_claim("announcement", "ship it", origin="user") + out = render_spec(frame) + assert "contested" not in out + + +def test_render_spec_never_marks_a_claim_not_named_in_contested() -> None: + frame = Frame(slug="demo", title="Demo") + c1 = frame.add_claim("announcement", "ship it", origin="user") + frame.add_claim("boundary", "stays clean", origin="user") + marker = ContestedMarker( + claim_id=c1.id, + deviation_id="d1", + what="x", + reason="y", + classification=None, + plan_slug="demo", + ) + out = render_spec(frame, contested={c1.id: [marker]}) + boundary_section = out.split("## Scope / boundaries")[1] + assert "contested" not in boundary_section + + +def test_render_spec_never_mutates_frame_state() -> None: + # #92's derivation must be presentational only -- pin this the same way + # test_render.py's own mutation-safety test does for parks/hard questions. + frame = Frame(slug="demo", title="Demo") + c1 = frame.add_claim("announcement", "ship it", origin="user") + before = [(c.id, c.kind, c.text, c.status) for c in frame.claims] + marker = ContestedMarker( + claim_id=c1.id, + deviation_id="d1", + what="x", + reason="y", + classification=None, + plan_slug="demo", + ) + render_spec(frame, contested={c1.id: [marker]}) + after = [(c.id, c.kind, c.text, c.status) for c in frame.claims] + assert before == after diff --git a/tests/test_export_markdownlint_integration.py b/tests/test_export_markdownlint_integration.py index bfa8fc6..9b5ee91 100644 --- a/tests/test_export_markdownlint_integration.py +++ b/tests/test_export_markdownlint_integration.py @@ -261,3 +261,61 @@ def test_repeated_export_is_byte_stable_and_frame_json_content_unchanged( result = _run_markdownlint(spec_path) assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + + +# ── t14 (#92): a re-exported spec with a contested-by-deviation marker ────── + + +def _build_and_export_contested_frame(monkeypatch, tmp_path) -> Path: + """A converged, exported frame carrying an approved deviation (#92) whose + ``--affects`` names a confirmed claim and whose ``reason`` is hostile + input (an underscore identifier, a bare URL, trailing punctuation) -- + proves the contested marker's own ``_safe()`` composition holds under the + same hostile-input contract every other verbatim field in this file is + pinned against. + """ + monkeypatch.chdir(tmp_path) + main(["new", "Ship the contested marker end to end."]) + for kind in ("audience", "after_state", "before_state", "boundary", "success_signal"): + main(["capture", "--kind", kind, f"{kind} text.", "--origin", "user"]) + frame = store.load(store.current_slug()) + for c in frame.claims: + main(["interrogate", c.id, "--honesty", "must hold.", "--origin", "user"]) + slug = store.current_slug() + + main(["plan", "new", "--frame", slug]) + plan = plan_store.load(slug) + args = ["plan", "task", "cover everything.", "--accept", "all good."] + for tg in plan.targets: + args += ["--covers", tg.id] + main(args) + + main( + [ + "deviate", + "walk transcripts recursively", + "--task", + "t1", + "--reason", + "measured 408 of 695 files (59%) below __the_depth__ the walker " + "searches, see https://example.com/report.", + "--affects", + "c1", + "--classification", + "risky", + ] + ) + + main(["converge"]) + main(["export"]) + frame = store.load(slug) + return Path("docs/specs") / f"{frame.created[:10]}-{frame.slug}.md" + + +def test_contested_marker_export_passes_markdownlint_cli2(tmp_path, monkeypatch) -> None: + spec_path = _build_and_export_contested_frame(monkeypatch, tmp_path) + assert spec_path.exists() + out = spec_path.read_text(encoding="utf-8") + assert "contested by `d1`" in out + result = _run_markdownlint(spec_path) + assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" From a4aabfd987ea112d26b0071cc0b5cb55ae8da9dd Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 11:25:33 +0300 Subject: [PATCH 16/25] feat(plan): transactional multi-id confirm/reject (#86, t11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plan confirm/reject now accept N ids (nargs="+") and apply them transactionally — every id is validated against the plan first, and if any is unknown nothing is changed, matching the frame-side contract (confirm.py's _run). Argument errors raised inside a devague plan parser now hint at `devague plan explain ` instead of the generic ` --help`, scoped so top-level and other flat-verb errors keep their existing hint unchanged. --- devague/cli/__init__.py | 30 +++++++++- devague/cli/_commands/plan.py | 33 ++++++++--- tests/test_cli_plan.py | 106 ++++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 9 deletions(-) diff --git a/devague/cli/__init__.py b/devague/cli/__init__.py index a50d465..33e3977 100644 --- a/devague/cli/__init__.py +++ b/devague/cli/__init__.py @@ -22,16 +22,44 @@ from devague.cli._output import emit_error +def _plan_explain_move(prog: str) -> str | None: + """Return the move name if ``prog`` names a nested ``devague plan `` + parser — used by :meth:`_DevagueArgumentParser.error` to point argument + errors raised inside the ``plan`` group at ``devague plan explain `` + instead of the generic `` --help`` (issue #86). Every subparser + ``devague plan`` registers is named after a ``PLAN_MOVES`` key, so any + ``prog`` matching this shape names a move ``plan explain`` accepts. + + Returns ``None`` for the top-level parser (``"devague"``), for every other + flat verb (``"devague capture"``, ...), and for the ``plan`` group parser + itself (``"devague plan"``, e.g. an unrecognized ``plan_command`` choice) + — none of those name a single move to explain, so they keep the original + `` --help`` hint. + """ + prefix = "devague plan " + if not prog.startswith(prefix): + return None + rest = prog[len(prefix) :] + if not rest or " " in rest: + return None + return rest + + class _DevagueArgumentParser(argparse.ArgumentParser): """ArgumentParser that routes errors through :func:`emit_error`.""" _json_hint: bool = False def error(self, message: str) -> None: # type: ignore[override] + move = _plan_explain_move(self.prog) + if move is not None: + remediation = f"run 'devague plan explain {move}' to see valid arguments" + else: + remediation = f"run '{self.prog} --help' to see valid arguments" err = DevagueError( code=EXIT_USER_ERROR, message=message, - remediation=f"run '{self.prog} --help' to see valid arguments", + remediation=remediation, ) emit_error(err, json_mode=type(self)._json_hint) raise SystemExit(err.code) diff --git a/devague/cli/_commands/plan.py b/devague/cli/_commands/plan.py index c979d38..c248a80 100644 --- a/devague/cli/_commands/plan.py +++ b/devague/cli/_commands/plan.py @@ -593,14 +593,31 @@ def _cmd_plan_defer_undo(args: argparse.Namespace, plan: Plan) -> int: def _transition(args: argparse.Namespace, status: str) -> int: + """Transactional multi-id confirm/reject (issue #86): every id is validated + against the plan FIRST; if any is unknown, nothing is changed and the + message names the offender(s) — matching the frame-side contract + (``confirm.py`` ``_run``, issue #17). Plan tasks have no honesty + conditions / hard questions to cascade over (that is a frame-only + concept), so unlike the frame side there is nothing to cascade — only the + output shape (one ``"{id} -> {status}"`` line per id) mirrors it. + Single-id usage (still the common case) behaves exactly as before. + """ plan = resolve_plan(args.plan) - if not plan.set_status(args.id, status): - raise DevagueError(EXIT_USER_ERROR, f"no such task: {args.id}", "run 'devague plan show'") + ids = list(args.ids) + unknown = [tid for tid in ids if plan.find_task(tid) is None] + if unknown: + raise DevagueError( + EXIT_USER_ERROR, + f"no such task: {', '.join(unknown)}", + "run 'devague plan show'; the batch is transactional — nothing was changed", + ) + for tid in ids: + plan.set_status(tid, status) plan_store.save(plan) if getattr(args, "json", False): - emit_result({"id": args.id, "status": status}, json_mode=True) + emit_result({"ids": ids, "status": status}, json_mode=True) else: - emit_result(f"{args.id} -> {status}", json_mode=False) + emit_result("\n".join(f"{tid} -> {status}" for tid in ids), json_mode=False) return 0 @@ -1018,13 +1035,13 @@ def register(sub: argparse._SubParsersAction) -> None: _plan_opt(pdf) pdf.set_defaults(func=cmd_plan_defer) - pcf = psub.add_parser("confirm", help="Confirm a task (user-only).") - pcf.add_argument("id", help=_TASK_ID_HELP) + pcf = psub.add_parser("confirm", help="Confirm one or more tasks (user-only).") + pcf.add_argument("ids", nargs="+", help="One or more task ids (e.g. t1 t2 t3).") _plan_opt(pcf) pcf.set_defaults(func=cmd_plan_confirm) - prj = psub.add_parser("reject", help="Reject a task.") - prj.add_argument("id", help=_TASK_ID_HELP) + prj = psub.add_parser("reject", help="Reject one or more tasks.") + prj.add_argument("ids", nargs="+", help="One or more task ids (e.g. t1 t2 t3).") _plan_opt(prj) prj.set_defaults(func=cmd_plan_reject) diff --git a/tests/test_cli_plan.py b/tests/test_cli_plan.py index efd22ae..318db7f 100644 --- a/tests/test_cli_plan.py +++ b/tests/test_cli_plan.py @@ -378,6 +378,112 @@ def test_confirm_and_reject(tmp_path, monkeypatch, capsys) -> None: assert plan_store.load(slug).find_task("t1").status == "rejected" +def test_confirm_single_id_unchanged(tmp_path, monkeypatch, capsys) -> None: + """The nargs='+' switch must not change single-id text-mode output.""" + slug = _converged_frame(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + main(["plan", "task", "a", "--origin", "llm"]) # t1, proposed + capsys.readouterr() + rc = main(["plan", "confirm", "t1"]) + assert rc == 0 + assert capsys.readouterr().out == "t1 -> confirmed\n" + assert plan_store.load(slug).find_task("t1").status == "confirmed" + + +def test_confirm_multi_id_transactional_all_valid(tmp_path, monkeypatch, capsys) -> None: + """Issue #86: `plan confirm` gets the same multi-id, transactional contract + frame-side `confirm` already has (`confirm.py` `_run`, issue #17).""" + slug = _converged_frame(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + main(["plan", "task", "a", "--origin", "llm"]) # t1, proposed + main(["plan", "task", "b", "--origin", "llm"]) # t2, proposed + capsys.readouterr() + + rc = main(["plan", "confirm", "t1", "t2", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["ids"] == ["t1", "t2"] + assert payload["status"] == "confirmed" + plan = plan_store.load(slug) + assert plan.find_task("t1").status == "confirmed" + assert plan.find_task("t2").status == "confirmed" + + +def test_reject_multi_id_all_valid_applies_all_in_one_call(tmp_path, monkeypatch, capsys) -> None: + """Issue #86: `plan reject t1 t2 t3` — matching frame-side `reject t1 t2 t3`.""" + slug = _converged_frame(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + main(["plan", "task", "a"]) # t1, confirmed (origin=user default) + main(["plan", "task", "b"]) # t2, confirmed + main(["plan", "task", "c"]) # t3, confirmed + capsys.readouterr() + + rc = main(["plan", "reject", "t1", "t2", "t3"]) + assert rc == 0 + out = capsys.readouterr().out + assert "t1 -> rejected" in out + assert "t2 -> rejected" in out + assert "t3 -> rejected" in out + plan = plan_store.load(slug) + assert plan.find_task("t1").status == "rejected" + assert plan.find_task("t2").status == "rejected" + assert plan.find_task("t3").status == "rejected" + + +def test_reject_multi_id_one_invalid_applies_none_and_says_why( + tmp_path, monkeypatch, capsys +) -> None: + """Issue #86: with three ids where one is invalid, the batch is + transactional — NOTHING is applied (not even the two valid ids) — and the + error names the offender rather than silently dropping it.""" + slug = _converged_frame(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + main(["plan", "task", "a"]) # t1, confirmed + main(["plan", "task", "b"]) # t2, confirmed + capsys.readouterr() + + rc = main(["plan", "reject", "t1", "t2", "tX"]) + assert rc == 1 + err = capsys.readouterr().err + assert "no such task: tX" in err + assert "transactional" in err + plan = plan_store.load(slug) + assert plan.find_task("t1").status == "confirmed" # untouched + assert plan.find_task("t2").status == "confirmed" # untouched + + +# ── argument-error hints inside the `plan` group (issue #86) ──────────────── +def test_plan_group_argument_error_hints_at_plan_explain(tmp_path, monkeypatch, capsys) -> None: + """An argparse-level error raised inside a `devague plan ` parser + (missing required args, invalid choice, ...) must point at + `devague plan explain ` instead of the generic ` --help` — the + old hint pointed at `--help` text that does not explain the actual + problem.""" + monkeypatch.chdir(tmp_path) + with pytest.raises(SystemExit) as exc: + main(["plan", "confirm"]) # missing required 'ids' + assert exc.value.code == 1 + err = capsys.readouterr().err + assert "hint: run 'devague plan explain confirm'" in err + + with pytest.raises(SystemExit) as exc: + main(["plan", "risk", "text", "--kind", "bogus"]) # invalid --kind choice + assert exc.value.code == 1 + err = capsys.readouterr().err + assert "hint: run 'devague plan explain risk'" in err + + +def test_top_level_argument_error_keeps_existing_hint(tmp_path, monkeypatch, capsys) -> None: + """Non-plan (flat verb) argument errors are unaffected by the plan-group + hint change — they keep their existing ` --help` remediation.""" + monkeypatch.chdir(tmp_path) + with pytest.raises(SystemExit) as exc: + main(["capture"]) # missing required 'text' and '--kind' + assert exc.value.code == 1 + err = capsys.readouterr().err + assert "hint: run 'devague capture --help'" in err + + # ── risk ──────────────────────────────────────────────────────────────────── def test_risk_recorded_and_unknown_task_errors(tmp_path, monkeypatch, capsys) -> None: slug = _converged_frame(monkeypatch, tmp_path) From a7b5aa11049eab0571c1e3204ec06a875e228920 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 11:29:58 +0300 Subject: [PATCH 17/25] feat(amend): correct a claim or scope finding without id churn (#84, t6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `devague amend --text/--kind [--reason]` and `devague scope --amend --finding` edit a claim or scope entry in place — same id, same honesty conditions/hard questions/instruction, same inbound scope-entry seeds. The only prior route (reject + recapture) churned the id and left scope-entry seeds dangling. Amending a confirmed claim flips it back to proposed and echoes the transition, mirroring the `interrogate --instruction` precedent; origin is never touched (no flag reaches it). A lightweight `Claim.revisions` trail (text/kind/reason) records what an amend superseded, added without a schema_version bump since `from_dict` loads it tolerantly. --- devague/cli/__init__.py | 2 + devague/cli/_commands/amend.py | 88 ++++++++++++++++++ devague/cli/_commands/scope.py | 42 ++++++++- devague/frame.py | 117 +++++++++++++++++++++++ tests/test_cli_moves.py | 137 +++++++++++++++++++++++++++ tests/test_cli_scope.py | 92 +++++++++++++++++++ tests/test_frame.py | 163 +++++++++++++++++++++++++++++++++ 7 files changed, 640 insertions(+), 1 deletion(-) create mode 100644 devague/cli/_commands/amend.py diff --git a/devague/cli/__init__.py b/devague/cli/__init__.py index a50d465..e621a55 100644 --- a/devague/cli/__init__.py +++ b/devague/cli/__init__.py @@ -54,6 +54,7 @@ def _build_parser() -> argparse.ArgumentParser: ) sub = parser.add_subparsers(dest="command", parser_class=_DevagueArgumentParser) + from devague.cli._commands import amend as _amend_cmd from devague.cli._commands import capture as _capture_cmd from devague.cli._commands import confirm as _confirm_cmd from devague.cli._commands import converge as _converge_cmd @@ -78,6 +79,7 @@ def _build_parser() -> argparse.ArgumentParser: _explain_cmd.register(sub) _new_cmd.register(sub) _capture_cmd.register(sub) + _amend_cmd.register(sub) _interrogate_cmd.register(sub) _confirm_cmd.register(sub) _reject_cmd.register(sub) diff --git a/devague/cli/_commands/amend.py b/devague/cli/_commands/amend.py new file mode 100644 index 0000000..ed31e86 --- /dev/null +++ b/devague/cli/_commands/amend.py @@ -0,0 +1,88 @@ +"""``devague amend`` — correct a claim's text and/or kind without churning its id. + +The `amend` move (issue #84): the only prior way to fix a typo or a wrong +number in a claim was `reject` + `capture` (a new id) + re-authoring every +honesty condition / instruction the old id carried, and any inbound +`scope --seeds` reference to the rejected claim was left dangling. `amend` +edits the claim in place — same id, same honesty conditions, same +instruction, same inbound seed references — so correcting one number is one +move, not five. + +Amending a CONFIRMED claim flips it back to `proposed` and echoes that, the +same re-confirm rule `interrogate --instruction` already applies (see +`interrogate.py`'s `_apply_instruction` — the precedent this mirrors). +`origin` is never touched: there is no flag that can reach it, on purpose. +""" + +from __future__ import annotations + +import argparse + +from devague import store +from devague.cli._errors import EXIT_USER_ERROR, DevagueError +from devague.cli._frames import resolve +from devague.cli._output import emit_diagnostic, emit_result +from devague.frame import CLAIM_KINDS + + +def cmd_amend(args: argparse.Namespace) -> int: + frame = resolve(args.frame) + try: + claim, flipped = frame.amend_claim( + args.claim_id, + text=args.text, + kind=args.kind, + reason=args.reason or "", + ) + except ValueError as err: + message = str(err) + if message.startswith("unknown claim id"): + raise DevagueError( + EXIT_USER_ERROR, + message, + "run 'devague show' to see current claim ids", + ) from err + raise DevagueError( + EXIT_USER_ERROR, + message, + "pass --text and/or --kind with the corrected value", + ) from err + store.save(frame) + flip_note = None + if flipped: + flip_note = ( + f"{claim.id} was confirmed; the amend flips it back to proposed — " + f"re-confirm with 'devague confirm {claim.id}'" + ) + emit_diagnostic(flip_note) + if getattr(args, "json", False): + emit_result( + { + "id": claim.id, + "kind": claim.kind, + "text": claim.text, + "origin": claim.origin, + "status": claim.status, + "flipped": flipped, + }, + json_mode=True, + ) + else: + emit_result(f"amended {claim.id} ({claim.kind}, {claim.status})", json_mode=False) + return 0 + + +def register(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser( + "amend", help="Correct a claim's text and/or kind in place (keeps id and attachments)." + ) + p.add_argument("claim_id", help="Claim id to amend (e.g. c1).") + p.add_argument("--text", help="Corrected claim text.") + p.add_argument("--kind", choices=CLAIM_KINDS, help="Corrected claim kind.") + p.add_argument( + "--reason", + help="Optional note recorded alongside the superseded text/kind (why it was amended).", + ) + p.add_argument("--frame", help="Frame slug (default: current).") + p.add_argument("--json", action="store_true", help="Emit structured JSON.") + p.set_defaults(func=cmd_amend) diff --git a/devague/cli/_commands/scope.py b/devague/cli/_commands/scope.py index 8c5f09b..1784c7c 100644 --- a/devague/cli/_commands/scope.py +++ b/devague/cli/_commands/scope.py @@ -6,6 +6,10 @@ links the entry to claim ids it went on to seed; an unknown seed id is refused with a hint rather than silently accepted (see ``Frame.add_scope_entry``). + +``--amend --finding `` (issue #84) replaces an existing entry's +finding in place — same id, same ``surface``, same ``seeds`` — instead of +recording a second entry that says "supersedes sN" (the old, only recourse). """ from __future__ import annotations @@ -50,6 +54,29 @@ def _record(args: argparse.Namespace, frame) -> int: return 0 +def _amend(args: argparse.Namespace, frame) -> int: + if not args.finding: + raise DevagueError( + EXIT_USER_ERROR, + "missing --finding", + 'pass --finding "" to replace the entry\'s finding', + ) + try: + entry = frame.amend_scope_entry(args.amend, args.finding) + except ValueError as exc: + raise DevagueError( + EXIT_USER_ERROR, + str(exc), + "run 'devague scope' to see current scope entry ids", + ) from exc + store.save(frame) + if getattr(args, "json", False): + emit_result(_entry_dict(entry), json_mode=True) + else: + emit_result(f"amended {entry.id} ({entry.surface})", json_mode=False) + return 0 + + def _list(args: argparse.Namespace, frame) -> int: entries = frame.scope_entries if getattr(args, "json", False): @@ -72,6 +99,14 @@ def _list(args: argparse.Namespace, frame) -> int: def cmd_scope(args: argparse.Namespace) -> int: frame = resolve(args.frame) + if args.amend: + if args.surface: + raise DevagueError( + EXIT_USER_ERROR, + "pass a surface or --amend, not both", + "drop the positional surface when amending an existing entry", + ) + return _amend(args, frame) if args.surface: return _record(args, frame) return _list(args, frame) @@ -80,7 +115,7 @@ def cmd_scope(args: argparse.Namespace) -> int: def register(sub: argparse._SubParsersAction) -> None: p = sub.add_parser("scope", help="Record an explored surface + finding (pre-frame scoping).") p.add_argument("surface", nargs="?", help="The surface explored (omit to list).") - p.add_argument("--finding", help="What was learned about the surface.") + p.add_argument("--finding", help="What was learned about the surface (or the amend text).") p.add_argument( "--seeds", nargs="*", @@ -88,6 +123,11 @@ def register(sub: argparse._SubParsersAction) -> None: metavar="CLAIM_ID", help="Claim ids this finding seeded (must already exist).", ) + p.add_argument( + "--amend", + metavar="SID", + help="Replace scope entry SID's finding in place (pairs with --finding).", + ) p.add_argument("--list", action="store_true", help="List recorded scope entries (default).") p.add_argument("--frame", help="Frame slug (default: current).") p.add_argument("--json", action="store_true", help="Emit structured JSON.") diff --git a/devague/frame.py b/devague/frame.py index 6601ddb..51b7747 100644 --- a/devague/frame.py +++ b/devague/frame.py @@ -16,6 +16,10 @@ # v4 (issue-backlog-sweep t2) is reserved for t4's HardQuestion resolution field; # t2 itself only bumps the number, hardens store.load's check-before-parse order, # and makes HardQuestion/Vagueness loading tolerant of unknown keys like Claim. +# Claim.revisions (t6, issue #84 — the `amend` move) is added WITHOUT a bump: +# it is purely additive with a `default_factory=list`, and `from_dict` below +# loads it tolerantly (`c.get("revisions", [])`), so a v4 frame written before +# t6 still loads cleanly with an empty revision trail. SCHEMA_VERSION = 4 CLAIM_KINDS = ( @@ -86,6 +90,26 @@ class HardQuestion: resolution: str = "" +@dataclass +class ClaimRevision: + """A superseded ``(text, kind)`` pair, recorded when a claim is amended. + + ``amend`` corrects a claim WITHOUT churning its id (issue #84) — but the + frame is meant to be an evidence trail, so the value it had before the + amend is kept here rather than silently overwritten. This is + deliberately a *lightweight* marker, not a full audit log: it captures + only the two fields ``Frame.amend_claim`` can change, plus an optional + operator-authored ``reason``, and carries no timestamp or actor (no + other Frame entity does either). A full revision history keyed by time + would be a schema change; this is not one — it is a plain list field + with a ``default_factory``, so older frames simply load with ``[]``. + """ + + text: str + kind: str + reason: str = "" + + @dataclass class Claim: id: str @@ -100,6 +124,11 @@ class Claim: # implement this claim. Empty string means "no instruction" — never # fabricated or defaulted to prose (#53 t1, c10/h3). instruction: str = "" + # Prior (text, kind) pairs superseded by `Frame.amend_claim` (t6, #84), in + # chronological order (most recent supersession last). Empty for a claim + # that has never been amended — the common case, and every claim + # predating t6. + revisions: list[ClaimRevision] = field(default_factory=list) def __post_init__(self) -> None: if self.kind not in CLAIM_KINDS: @@ -190,6 +219,56 @@ def add_claim(self, kind: str, text: str, origin: str = "user") -> Claim: def find_claim(self, cid: str) -> Optional[Claim]: return next((c for c in self.claims if c.id == cid), None) + def amend_claim( + self, + claim_id: str, + *, + text: Optional[str] = None, + kind: Optional[str] = None, + reason: str = "", + ) -> tuple[Claim, bool]: + """Correct a claim's ``text`` and/or ``kind`` in place (issue #84). + + Unlike reject-and-recapture, amending never changes the claim's id, + so its honesty conditions, hard questions, ``instruction``, and any + scope-entry ``seeds`` that cite this id all keep pointing at + something real — the whole point of the move. ``origin`` is never + touched here: correcting what a claim says is not the same fact as + who originally proposed it, and there is no flag that can reach it. + + Amending a claim that is currently ``confirmed`` flips it back to + ``proposed`` — the same re-confirm rule ``interrogate --instruction`` + already applies to a change of that weight (the issue calls this + "good behaviour and the right precedent"). Returns ``(claim, + flipped)`` so the caller can echo the transition the same way + ``interrogate.py``'s ``_apply_instruction`` does; ``flipped`` is + ``False`` for a claim that was already ``proposed``/``rejected``. + + The superseded ``(text, kind)`` pair is appended to + ``claim.revisions`` (with ``reason``, if given) rather than + discarded — the frame is an evidence trail, not just current state. + + Raises ``ValueError`` if the claim id is unknown, if neither + ``text`` nor ``kind`` is given (nothing to amend), or if ``kind`` + names an unknown claim kind. + """ + claim = self.find_claim(claim_id) + if claim is None: + raise ValueError(f"unknown claim id: {claim_id!r}") + if text is None and kind is None: + raise ValueError("amend requires a new text and/or a new kind") + if kind is not None and kind not in CLAIM_KINDS: + raise ValueError(f"unknown claim kind: {kind!r}") + claim.revisions.append(ClaimRevision(text=claim.text, kind=claim.kind, reason=reason)) + if text is not None: + claim.text = text + if kind is not None: + claim.kind = kind + flipped = claim.status == "confirmed" + if flipped: + claim.status = "proposed" + return claim, flipped + def find_honesty(self, hid: str) -> Optional[HonestyCondition]: return next((h for h in self._all_honesty() if h.id == hid), None) @@ -295,6 +374,33 @@ def add_scope_entry( self.scope_entries.append(entry) return entry + def find_scope_entry(self, sid: str) -> Optional[ScopeEntry]: + return next((e for e in self.scope_entries if e.id == sid), None) + + def amend_scope_entry(self, entry_id: str, finding: str) -> ScopeEntry: + """Replace a scope entry's ``finding`` in place (issue #84). + + Before this move, correcting a scope finding meant recording a + *second* entry that says "supersedes s18" — the exported spec then + carries both the wrong entry and its correction, and the reader has + to notice the word "supersedes". Amending replaces the finding in + place instead: same id, same ``surface``, same ``seeds`` — nothing + else about the entry changes. (Unlike ``amend_claim``, a scope entry + carries no ``status``/``origin`` to protect and nothing else in the + method's contract calls for a revision trail here — see the CLI + module for that decision.) + + Raises ``ValueError`` if the entry id is unknown or ``finding`` is + empty. + """ + entry = self.find_scope_entry(entry_id) + if entry is None: + raise ValueError(f"unknown scope entry id: {entry_id!r}") + if not finding: + raise ValueError("amend requires a new finding") + entry.finding = finding + return entry + def set_status(self, item_id: str, status: str) -> bool: claim = self.find_claim(item_id) if claim is not None: @@ -417,6 +523,17 @@ def from_dict(d: dict) -> Frame: links=list(c.get("links", [])), # v1 frames predate this field (#53 t1); default to "no instruction". instruction=c.get("instruction", ""), + revisions=[ + ClaimRevision( + text=r["text"], + kind=r["kind"], + reason=r.get("reason", ""), + ) + # Pre-t6 frames (#84) predate this field entirely; default to + # an empty trail, the same tolerant pattern as hard_questions + # above. + for r in c.get("revisions", []) + ], ) for c in d.get("claims", []) ] diff --git a/tests/test_cli_moves.py b/tests/test_cli_moves.py index 67df9cb..6b255b4 100644 --- a/tests/test_cli_moves.py +++ b/tests/test_cli_moves.py @@ -2,6 +2,8 @@ import json +import pytest + from devague import store from devague.cli import main @@ -500,3 +502,138 @@ def test_interrogate_resolve_combined_with_add_flag_refused(tmp_path, monkeypatc f = store.load(store.current_slug()) assert f.claims[0].hard_questions[0].resolved is False assert f.claims[0].honesty_conditions == [] # nothing was smuggled in either + + +# --- amend (issue #84): correct a claim without id churn ---------------------- + + +def test_amend_one_move_fixes_a_number_and_keeps_id_and_attachments( + tmp_path, monkeypatch, capsys +) -> None: + """The #84 acceptance criterion: correcting one number costs exactly one + move, and the id/attachments/inbound seed all survive it.""" + _seed(monkeypatch, tmp_path) # announcement c1 + main(["capture", "--kind", "before_state", "count is 16", "--origin", "user"]) # c2, confirmed + main(["interrogate", "c2", "--honesty", "count is independently verified"]) # h1 + main(["interrogate", "c2", "--instruction", "verify via grep -c"]) + main(["scope", "colleague/tools.py", "--finding", "16 spawn literals", "--seeds", "c2"]) # s1 + capsys.readouterr() + + rc = main(["amend", "c2", "--text", "count is 21"]) # the single corrective move + + assert rc == 0 + f = store.load(store.current_slug()) + claim = f.find_claim("c2") + assert claim.id == "c2" # no id churn + assert claim.text == "count is 21" + assert claim.origin == "user" # never changes silently + assert [h.id for h in claim.honesty_conditions] == ["h1"] + assert claim.honesty_conditions[0].text == "count is independently verified" + assert claim.instruction == "verify via grep -c" + assert f.scope_entries[0].seeds == ["c2"] # inbound seed still resolves + + +def test_amend_confirmed_claim_flips_to_proposed_and_echoes(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + main( + ["capture", "--kind", "boundary", "policy gate order", "--origin", "user"] + ) # c2, confirmed + capsys.readouterr() + rc = main(["amend", "c2", "--text", "policy gate order, corrected"]) + assert rc == 0 + err = capsys.readouterr().err + assert "c2 was confirmed" in err + assert "flips it back to proposed" in err + assert "devague confirm c2" in err + f = store.load(store.current_slug()) + assert f.find_claim("c2").status == "proposed" + + +def test_amend_proposed_claim_does_not_flip_and_no_echo(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + main(["capture", "--kind", "boundary", "x", "--origin", "llm"]) # c2, proposed + capsys.readouterr() + rc = main(["amend", "c2", "--text", "x, corrected"]) + assert rc == 0 + assert capsys.readouterr().err == "" + f = store.load(store.current_slug()) + assert f.find_claim("c2").status == "proposed" + + +def test_amend_kind_only(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + main(["capture", "--kind", "boundary", "x", "--origin", "user"]) + capsys.readouterr() + rc = main(["amend", "c2", "--kind", "requirement"]) + assert rc == 0 + f = store.load(store.current_slug()) + assert f.find_claim("c2").kind == "requirement" + assert f.find_claim("c2").text == "x" + + +def test_amend_json_shape(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + main(["capture", "--kind", "boundary", "x", "--origin", "user"]) + capsys.readouterr() + rc = main(["amend", "c2", "--text", "x, corrected", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload == { + "id": "c2", + "kind": "boundary", + "text": "x, corrected", + "origin": "user", + "status": "proposed", + "flipped": True, + } + + +def test_amend_reason_recorded_on_revision(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + main(["capture", "--kind", "before_state", "count is 16", "--origin", "user"]) + main(["amend", "c2", "--text", "count is 21", "--reason", "reviewer caught a miscount"]) + f = store.load(store.current_slug()) + claim = f.find_claim("c2") + assert claim.revisions[0].text == "count is 16" + assert claim.revisions[0].kind == "before_state" + assert claim.revisions[0].reason == "reviewer caught a miscount" + assert claim.text == "count is 21" + + +def test_amend_missing_text_and_kind_errors(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + capsys.readouterr() + rc = main(["amend", "c1"]) + assert rc == 1 + err = capsys.readouterr().err + assert "requires a new text" in err + assert "--text" in err and "--kind" in err + + +def test_amend_unknown_id_errors_with_hint(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + rc = main(["amend", "c99", "--text", "x"]) + assert rc == 1 + err = capsys.readouterr().err + assert "unknown claim id" in err + assert "hint:" in err and "devague show" in err + + +def test_amend_invalid_kind_choice_errors(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + with pytest.raises(SystemExit) as exc: + main(["amend", "c1", "--kind", "bogus"]) + assert exc.value.code == 1 + err = capsys.readouterr().err + assert "bogus" in err + + +def test_amend_does_not_touch_hard_questions(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + main(["capture", "--kind", "boundary", "x", "--origin", "user"]) + main(["interrogate", "c2", "--hard-question", "what if empty?", "--blocking"]) # q1 + main(["amend", "c2", "--text", "x, corrected"]) + f = store.load(store.current_slug()) + claim = f.find_claim("c2") + assert [q.id for q in claim.hard_questions] == ["q1"] + assert claim.hard_questions[0].text == "what if empty?" diff --git a/tests/test_cli_scope.py b/tests/test_cli_scope.py index eaf842a..ebd4e4f 100644 --- a/tests/test_cli_scope.py +++ b/tests/test_cli_scope.py @@ -136,6 +136,98 @@ def test_scope_frame_flag_targets_named_frame(tmp_path, monkeypatch, capsys) -> assert store.load("second-idea").scope_entries == [] +# --- scope --amend (issue #84): replace a finding in place -------------------- + + +def test_scope_amend_replaces_finding_in_place(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + slug = store.current_slug() + main(["capture", "--kind", "before_state", "count is 16", "--origin", "user"]) # c2 + main( + [ + "scope", + "colleague subprocess inventory", + "--finding", + "16 spawn literals", + "--seeds", + "c2", + ] + ) # s1 + rc = main( + [ + "scope", + "--amend", + "s1", + "--finding", + "21 spawn literals across 15 modules", + ] + ) + assert rc == 0 + frame = store.load(slug) + entry = frame.scope_entries[0] + assert entry.id == "s1" # no id churn + assert entry.surface == "colleague subprocess inventory" # untouched + assert entry.finding == "21 spawn literals across 15 modules" + assert entry.seeds == ["c2"] # untouched + + +def test_scope_amend_json_shape(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + main(["scope", "a.py", "--finding", "first"]) + capsys.readouterr() + rc = main(["scope", "--amend", "s1", "--finding", "corrected", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload == { + "id": "s1", + "surface": "a.py", + "finding": "corrected", + "seeds": [], + } + + +def test_scope_amend_echoes_text_mode(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + main(["scope", "a.py", "--finding", "first"]) + capsys.readouterr() + rc = main(["scope", "--amend", "s1", "--finding", "corrected"]) + assert rc == 0 + assert capsys.readouterr().out.strip() == "amended s1 (a.py)" + + +def test_scope_amend_unknown_id_errors_with_hint(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + rc = main(["scope", "--amend", "s99", "--finding", "corrected"]) + assert rc == 1 + err = capsys.readouterr().err + assert "unknown scope entry id" in err + assert "hint:" in err + + +def test_scope_amend_missing_finding_errors(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + main(["scope", "a.py", "--finding", "first"]) + capsys.readouterr() + rc = main(["scope", "--amend", "s1"]) + assert rc == 1 + err = capsys.readouterr().err + assert "--finding" in err + frame = store.load(store.current_slug()) + assert frame.scope_entries[0].finding == "first" # untouched + + +def test_scope_amend_with_surface_positional_refused(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + main(["scope", "a.py", "--finding", "first"]) + capsys.readouterr() + rc = main(["scope", "stray-surface", "--amend", "s1", "--finding", "corrected"]) + assert rc == 1 + err = capsys.readouterr().err + assert "not both" in err + frame = store.load(store.current_slug()) + assert frame.scope_entries[0].finding == "first" # untouched + + def test_scope_deterministic_no_subprocess_or_llm(tmp_path, monkeypatch) -> None: # Guard against accidental scope creep: recording must never shell out or # touch the filesystem beyond the frame store itself. diff --git a/tests/test_frame.py b/tests/test_frame.py index 50be3df..d2c7f95 100644 --- a/tests/test_frame.py +++ b/tests/test_frame.py @@ -7,6 +7,7 @@ SCHEMA_VERSION, SPEC_AFFECTING_KINDS, Claim, + ClaimRevision, Frame, HonestyCondition, Vagueness, @@ -418,3 +419,165 @@ def test_reject_unknown_id_raises() -> None: f = Frame(slug="s", title="t") with pytest.raises(ValueError, match="unknown"): f.reject("zzz") + + +# --- amend (issue #84): claim + scope-entry correction without id churn ------- + + +def test_amend_claim_confirmed_flips_to_proposed_and_reports_flip() -> None: + f = Frame(slug="s", title="t") + c = f.add_claim("before_state", "count is 16", origin="user") # user -> confirmed + h = f.add_honesty(c, "count is independently verified", origin="user") + c.instruction = "verify via `grep -c literal file.py`" + assert c.status == "confirmed" + + claim, flipped = f.amend_claim("c1", text="count is 21") + + assert flipped is True + assert claim is c + assert claim.id == "c1" # no id churn + assert claim.text == "count is 21" + assert claim.status == "proposed" # flipped, mirroring interrogate --instruction + assert claim.origin == "user" # never changes silently + # Every attachment survives, untouched: + assert claim.honesty_conditions == [h] + assert claim.instruction == "verify via `grep -c literal file.py`" + + +def test_amend_claim_not_confirmed_does_not_flip() -> None: + f = Frame(slug="s", title="t") + f.add_claim("boundary", "x", origin="llm") # llm -> proposed + claim, flipped = f.amend_claim("c1", text="x, corrected") + assert flipped is False + assert claim.status == "proposed" + assert claim.origin == "llm" + + +def test_amend_claim_rejected_stays_rejected_no_flip() -> None: + f = Frame(slug="s", title="t") + f.add_claim("boundary", "x", origin="llm") + f.reject("c1") + claim, flipped = f.amend_claim("c1", text="x, corrected") + assert flipped is False + assert claim.status == "rejected" + + +def test_amend_claim_kind_only() -> None: + f = Frame(slug="s", title="t") + f.add_claim("boundary", "x", origin="user") + claim, _ = f.amend_claim("c1", kind="requirement") + assert claim.kind == "requirement" + assert claim.text == "x" # untouched + + +def test_amend_claim_records_revision_with_reason() -> None: + f = Frame(slug="s", title="t") + f.add_claim("before_state", "count is 16", origin="user") + f.amend_claim("c1", text="count is 21", reason="reviewer caught a miscount") + claim = f.find_claim("c1") + assert claim.revisions == [ + ClaimRevision(text="count is 16", kind="before_state", reason="reviewer caught a miscount") + ] + assert claim.text == "count is 21" # current value is the corrected one + + +def test_amend_claim_revision_defaults_reason_empty() -> None: + f = Frame(slug="s", title="t") + f.add_claim("boundary", "x", origin="user") + f.amend_claim("c1", text="y") + assert f.find_claim("c1").revisions[0].reason == "" + + +def test_amend_claim_multiple_amends_append_to_revisions_in_order() -> None: + f = Frame(slug="s", title="t") + f.add_claim("boundary", "v1", origin="user") + f.amend_claim("c1", text="v2") + f.amend_claim("c1", text="v3") + claim = f.find_claim("c1") + assert [r.text for r in claim.revisions] == ["v1", "v2"] + assert claim.text == "v3" + + +def test_amend_claim_preserves_inbound_scope_seed() -> None: + f = Frame(slug="s", title="t") + f.add_claim("before_state", "count is 16", origin="user") + f.add_scope_entry("colleague/tools.py:669-722", "spawn literal count", seeds=["c1"]) + f.amend_claim("c1", text="count is 21") + # The seed reference still resolves to a live (non-rejected) claim — no + # id churn means no dangling provenance (the damage issue #84 documents). + assert f.scope_entries[0].seeds == ["c1"] + assert f.find_claim("c1").status != "rejected" + + +def test_amend_claim_unknown_id_raises() -> None: + f = Frame(slug="s", title="t") + with pytest.raises(ValueError, match="unknown claim id"): + f.amend_claim("c99", text="x") + + +def test_amend_claim_requires_text_or_kind() -> None: + f = Frame(slug="s", title="t") + f.add_claim("boundary", "x", origin="user") + with pytest.raises(ValueError, match="requires a new text"): + f.amend_claim("c1") + + +def test_amend_claim_unknown_kind_raises() -> None: + f = Frame(slug="s", title="t") + f.add_claim("boundary", "x", origin="user") + with pytest.raises(ValueError, match="unknown claim kind"): + f.amend_claim("c1", kind="bogus") + + +def test_amend_claim_roundtrips_revisions_via_dict() -> None: + f = Frame(slug="s", title="t") + f.add_claim("before_state", "count is 16", origin="user") + f.amend_claim("c1", text="count is 21", reason="miscount") + f2 = from_dict(to_dict(f)) + assert to_dict(f2) == to_dict(f) + assert f2.claims[0].revisions[0].text == "count is 16" + assert f2.claims[0].revisions[0].reason == "miscount" + + +def test_legacy_claim_dict_without_revisions_loads_with_empty_list() -> None: + legacy = { + "slug": "s", + "title": "t", + "claims": [ + { + "id": "c1", + "kind": "boundary", + "text": "x", + "origin": "user", + "status": "confirmed", + } + ], + "open_vagueness": [], + } + f = from_dict(legacy) + assert f.claims[0].revisions == [] + + +def test_amend_scope_entry_replaces_finding_in_place() -> None: + f = Frame(slug="s", title="t") + f.add_claim("before_state", "count is 16", origin="user") + entry = f.add_scope_entry("colleague subprocess inventory", "16 spawn literals", seeds=["c1"]) + amended = f.amend_scope_entry("s1", "21 spawn literals across 15 modules") + assert amended is entry + assert entry.id == "s1" # no id churn + assert entry.surface == "colleague subprocess inventory" # untouched + assert entry.finding == "21 spawn literals across 15 modules" + assert entry.seeds == ["c1"] # untouched + + +def test_amend_scope_entry_unknown_id_raises() -> None: + f = Frame(slug="s", title="t") + with pytest.raises(ValueError, match="unknown scope entry id"): + f.amend_scope_entry("s99", "x") + + +def test_amend_scope_entry_empty_finding_raises() -> None: + f = Frame(slug="s", title="t") + f.add_scope_entry("a.py", "first") + with pytest.raises(ValueError, match="requires a new finding"): + f.amend_scope_entry("s1", "") From 6146c4d7f39a5b2021484baa91e4e2f6321197b7 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 11:38:46 +0300 Subject: [PATCH 18/25] feat(scope): --seeds accepts hard-question ids (#84, t7) Frame.add_scope_entry validates a seed against find_claim first and the new find_hard_question second, so a claim-attached hard question id (q*) records alongside claim ids (c*) -- unknown ids of either kind still refuse with the existing "unknown seed claim id" error and "run 'devague show'" hint. render/spec_md._seed_label renders a question seed as `(question)`, or `(question, resolved)` once answered, distinct from the existing claim/rejected-claim markers. This closes the "smaller, related gap" in issue #84: the /scope skill's own routing table sends a "genuinely unknown, needs a user decision" finding to the `question` move rather than `capture`, so a scope entry recording that finding previously had no way to link its provenance. --- devague/cli/_commands/scope.py | 12 +++++---- devague/frame.py | 37 +++++++++++++++++++++++++++- devague/render/spec_md.py | 21 +++++++++++++--- tests/test_cli_scope.py | 38 ++++++++++++++++++++++++++++ tests/test_frame.py | 45 ++++++++++++++++++++++++++++++++++ tests/test_render.py | 27 ++++++++++++++++++++ 6 files changed, 170 insertions(+), 10 deletions(-) diff --git a/devague/cli/_commands/scope.py b/devague/cli/_commands/scope.py index 1784c7c..24dac8a 100644 --- a/devague/cli/_commands/scope.py +++ b/devague/cli/_commands/scope.py @@ -3,9 +3,11 @@ The pre-frame exploration leg (`/scope`, #53 t3). Recording is deterministic: no LLM calls, no subprocess, no filesystem exploration — the move only RECORDS what the operator already explored read-only. Optional ``--seeds`` -links the entry to claim ids it went on to seed; an unknown seed id is -refused with a hint rather than silently accepted (see -``Frame.add_scope_entry``). +links the entry to the claim ids (``c*``) or claim-attached hard-question +ids (``q*``, issue #84's "smaller, related gap" — the branch a +"needs a user decision" finding routes to via ``question`` rather than +``capture``) it went on to seed; an unknown seed id is refused with a hint +rather than silently accepted (see ``Frame.add_scope_entry``). ``--amend --finding `` (issue #84) replaces an existing entry's finding in place — same id, same ``surface``, same ``seeds`` — instead of @@ -120,8 +122,8 @@ def register(sub: argparse._SubParsersAction) -> None: "--seeds", nargs="*", default=None, - metavar="CLAIM_ID", - help="Claim ids this finding seeded (must already exist).", + metavar="ID", + help="Claim ids (c*) or hard-question ids (q*) this finding seeded (must already exist).", ) p.add_argument( "--amend", diff --git a/devague/frame.py b/devague/frame.py index 51b7747..3149153 100644 --- a/devague/frame.py +++ b/devague/frame.py @@ -272,6 +272,26 @@ def amend_claim( def find_honesty(self, hid: str) -> Optional[HonestyCondition]: return next((h for h in self._all_honesty() if h.id == hid), None) + def find_hard_question(self, qid: str) -> Optional[HardQuestion]: + """Look up a claim-attached hard question by id, across every claim. + + Mirrors ``find_claim``/``find_honesty`` — added so ``add_scope_entry`` + can validate a ``q*`` seed and ``render.spec_md`` can render one + (issue #84's "smaller, related gap": ``scope --seeds`` refused + question ids even though the ``/scope`` routing table sends a + "needs a user decision" finding to ``question`` rather than + ``capture``, leaving that branch's provenance unlinkable). Two + independent things mint ``qN`` ids in this tool — claim-attached + hard questions (this method) and the separate durable + ``.devague/questions/`` artifact driven by ``devague question`` — + and they can collide (both start counting at ``q1``) because they + are independent counters. This method only ever searches + ``Frame.claims[*].hard_questions``, the same restriction + ``resolve_hard_question`` documents; it cannot disambiguate the two + namespaces, only search the one it is documented to search. + """ + return next((q for q in self._all_hard_questions() if q.id == qid), None) + def add_honesty(self, claim: Claim, text: str, origin: str = "llm") -> HonestyCondition: status = "confirmed" if origin == "user" else "proposed" h = HonestyCondition( @@ -361,9 +381,24 @@ def resolve_hard_question(self, claim_id: str, qid: str, resolution: str = "") - def add_scope_entry( self, surface: str, finding: str, seeds: Optional[list[str]] = None ) -> ScopeEntry: + """Record a scope-exploration finding, optionally citing what it seeded. + + ``seeds`` may name a claim id (``c*``) or a claim-attached hard + question id (``q*``, issue #84's "smaller, related gap") — + validated against :meth:`find_claim` first and + :meth:`find_hard_question` second, so an id resolving to either is + accepted. This is the branch the ``/scope`` skill's routing table + sends a "genuinely unknown, needs a user decision" finding down (the + ``question`` move rather than ``capture``); before this, that + finding's provenance link was unrecordable. Any id resolving to + neither is refused (the error text says "claim" for both cases — + this is the one seam that already validated seed ids before ``q*`` + was accepted, and the CLI's accompanying hint, "run 'devague show' + to see valid claim ids", is unchanged). + """ seed_ids = list(seeds) if seeds else [] for sid in seed_ids: - if self.find_claim(sid) is None: + if self.find_claim(sid) is None and self.find_hard_question(sid) is None: raise ValueError(f"unknown seed claim id: {sid!r}") entry = ScopeEntry( id=self._next(self.scope_entries, "s"), diff --git a/devague/render/spec_md.py b/devague/render/spec_md.py index 0d45402..e6ca6d7 100644 --- a/devague/render/spec_md.py +++ b/devague/render/spec_md.py @@ -240,12 +240,25 @@ def _resolved_vagueness_section(frame: Frame) -> list[str]: def _seed_label(frame: Frame, seed_id: str) -> str: """A scope-entry seed id, flagged when it cites a rejected claim (the fourth #84 acceptance criterion, c33/h26) instead of rendering a bare - dead reference. An id that resolves to no claim at all (or to a - confirmed/proposed one) renders as the plain backticked id, unchanged. + dead reference, or when it cites a claim-attached hard question rather + than a claim (#84's "smaller, related gap" — a finding the ``/scope`` + routing table sent to the ``question`` move, not ``capture``). A + question seed reads as ``(question)``, or ``(question, resolved)`` once + it carries an answer — distinct from a claim seed, since a question is + not a claim and a resolved one is worth telling apart from a still-open + one. An id that resolves to neither a claim nor a hard question renders + as the plain backticked id, unchanged. """ claim = frame.find_claim(seed_id) - if claim is not None and claim.status == "rejected": - return f"`{seed_id}` (rejected)" + if claim is not None: + if claim.status == "rejected": + return f"`{seed_id}` (rejected)" + return f"`{seed_id}`" + question = frame.find_hard_question(seed_id) + if question is not None: + if question.resolved: + return f"`{seed_id}` (question, resolved)" + return f"`{seed_id}` (question)" return f"`{seed_id}`" diff --git a/tests/test_cli_scope.py b/tests/test_cli_scope.py index ebd4e4f..f6ebf81 100644 --- a/tests/test_cli_scope.py +++ b/tests/test_cli_scope.py @@ -103,6 +103,44 @@ def test_scope_unknown_seed_id_refused_with_hint(tmp_path, monkeypatch, capsys) assert frame.scope_entries == [] +# --- scope --seeds accepts question ids (issue #84's "smaller, related gap") - + + +def test_scope_records_entry_with_hard_question_seed(tmp_path, monkeypatch) -> None: + # The /scope routing table sends a "needs a user decision" finding to the + # `question` move, not `capture` — so the scope entry recording that + # finding must be able to cite the hard question id it seeded (#84). + _seed(monkeypatch, tmp_path) + slug = store.current_slug() + main(["capture", "--kind", "requirement", "add a scope move"]) # c1 + main(["interrogate", "c1", "--hard-question", "does this need a user decision?"]) # q1 + rc = main( + [ + "scope", + "devague/cli/_commands/scope.py", + "--finding", + "a genuinely unknown case, routed to question", + "--seeds", + "q1", + ] + ) + assert rc == 0 + frame = store.load(slug) + assert frame.scope_entries[0].seeds == ["q1"] + + +def test_scope_unknown_question_seed_id_refused_with_hint(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + rc = main(["scope", "devague/frame.py", "--finding", "bogus link", "--seeds", "q1"]) + assert rc == 1 + err = capsys.readouterr().err + assert "unknown seed claim id" in err + assert "hint:" in err + # Transactional: nothing was recorded. + frame = store.load(store.current_slug()) + assert frame.scope_entries == [] + + def test_scope_missing_finding_errors(tmp_path, monkeypatch, capsys) -> None: _seed(monkeypatch, tmp_path) rc = main(["scope", "devague/frame.py"]) diff --git a/tests/test_frame.py b/tests/test_frame.py index d2c7f95..58e263a 100644 --- a/tests/test_frame.py +++ b/tests/test_frame.py @@ -581,3 +581,48 @@ def test_amend_scope_entry_empty_finding_raises() -> None: f.add_scope_entry("a.py", "first") with pytest.raises(ValueError, match="requires a new finding"): f.amend_scope_entry("s1", "") + + +# --- scope --seeds accepts question ids (issue #84's "smaller, related gap") - + + +def test_find_hard_question_looks_up_across_all_claims() -> None: + f = Frame(slug="s", title="t") + c1 = f.add_claim("announcement", "x", origin="user") # c1 + f.add_claim("audience", "devs", origin="user") # c2 + q = f.add_hard_question(c1, "is this real?", blocking=True) # q1 + assert f.find_hard_question("q1") is q + + +def test_find_hard_question_unknown_id_returns_none() -> None: + f = Frame(slug="s", title="t") + f.add_claim("announcement", "x", origin="user") # c1 + assert f.find_hard_question("q99") is None + + +def test_add_scope_entry_accepts_hard_question_seed_id() -> None: + # The /scope routing table sends a "needs a user decision" finding to the + # `question` move rather than `capture` — a scope entry recording that + # finding must be able to cite the hard question it seeded, not just a + # claim (#84). + f = Frame(slug="s", title="t") + c = f.add_claim("announcement", "x", origin="user") # c1 + f.add_hard_question(c, "is this real?", blocking=True) # q1 + entry = f.add_scope_entry("some/surface.py", "a finding", seeds=["q1"]) + assert entry.seeds == ["q1"] + + +def test_add_scope_entry_accepts_mixed_claim_and_question_seeds() -> None: + f = Frame(slug="s", title="t") + c = f.add_claim("announcement", "x", origin="user") # c1 + f.add_hard_question(c, "is this real?", blocking=True) # q1 + entry = f.add_scope_entry("some/surface.py", "a finding", seeds=["c1", "q1"]) + assert entry.seeds == ["c1", "q1"] + + +def test_add_scope_entry_unknown_question_seed_id_raises() -> None: + f = Frame(slug="s", title="t") + f.add_claim("announcement", "x", origin="user") # c1 — no hard question exists + with pytest.raises(ValueError, match="unknown seed claim id"): + f.add_scope_entry("some/surface.py", "a finding", seeds=["q1"]) + assert f.scope_entries == [] # transactional: nothing recorded on the refusal diff --git a/tests/test_render.py b/tests/test_render.py index df45b57..63008be 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -486,6 +486,33 @@ def test_spec_md_scope_seed_citing_live_claim_stays_a_bare_id() -> None: assert f"`{c.id}` (rejected)" not in out +# ── issue-backlog-sweep t7: scope --seeds accepts question ids (#84's ──────── +# "smaller, related gap") — the seeded question must render, distinguishably +# from a claim seed, in the exported scope-exploration section ─────────────- + + +def test_spec_md_scope_seed_citing_hard_question_renders_question_marker() -> None: + f = Frame(slug="qseed", title="Question Seed") + ann = f.add_claim("announcement", "Shipped", origin="user") + q = f.add_hard_question(ann, "will this scale?", blocking=True) + f.add_scope_entry("some/surface.py", "a finding that seeded a question", seeds=[q.id]) + out = render.render(f, "spec-md") + assert f"`{q.id}` (question)" in out + + +def test_spec_md_scope_seed_citing_resolved_hard_question_renders_resolved_marker() -> None: + # A resolved question seed is distinguished from a still-open one — the + # answer is part of what the scope entry seeded. + f = Frame(slug="qseedresolved", title="Question Seed Resolved") + ann = f.add_claim("announcement", "Shipped", origin="user") + q = f.add_hard_question(ann, "will this scale?", blocking=True) + f.add_scope_entry("some/surface.py", "a finding that seeded a question", seeds=[q.id]) + f.resolve_hard_question(ann.id, q.id, "yes, load-tested at 10x") + out = render.render(f, "spec-md") + assert f"`{q.id}` (question, resolved)" in out + assert f"`{q.id}` (question)\n" not in out # not the still-open form + + def test_spec_md_rendering_never_mutates_park_or_hard_question_state() -> None: # Acceptance criterion 4 (#87 h18/c22): escaping and the new park/hard- # question rendering are presentational only — the frame's own fields From caf6b291112301876307a2c3f76670098c9fa52c Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 11:40:02 +0300 Subject: [PATCH 19/25] feat(plan): risk --amend corrects a stale risk in place (#84, t12) Add `Plan.amend_risk` and `devague plan risk --amend RID --text ""` so a risk whose prose names a task id that rotated (rejected + recreated during a scope change) can be corrected in place instead of resolving it just to record a duplicate. Preserves id, kind, task link, and resolution state verbatim -- a resolved risk stays resolved after its text is fixed. --- devague/cli/_commands/plan.py | 72 +++++++++++++++++++++++---- devague/plan.py | 27 +++++++++++ tests/test_cli_plan.py | 76 +++++++++++++++++++++++++++++ tests/test_e2e_resolve.py | 91 +++++++++++++++++++++++++++++++++++ tests/test_plan.py | 30 ++++++++++++ 5 files changed, 287 insertions(+), 9 deletions(-) diff --git a/devague/cli/_commands/plan.py b/devague/cli/_commands/plan.py index c248a80..b231962 100644 --- a/devague/cli/_commands/plan.py +++ b/devague/cli/_commands/plan.py @@ -630,23 +630,27 @@ def cmd_plan_reject(args: argparse.Namespace) -> int: def cmd_plan_risk(args: argparse.Namespace) -> int: - """Record a new plan risk, or (``--resolve RID --decision TEXT``) close one - out — mirrors ``park --resolve`` (t5) on the risk subcommand. - - ``--kind`` is optional at the parser level (so a bare ``--resolve`` call - does not need it), but the create path still requires it — refused here, - in the handler, rather than by argparse. The create path (positional text - + ``--kind`` + optional ``--task``) is otherwise unchanged. + """Record a new plan risk, (``--resolve RID --decision TEXT``) close one + out, or (``--amend RID --text TEXT``) correct one's text in place — + mirrors ``park --resolve`` (t5) on the risk subcommand. + + ``--kind`` is optional at the parser level (so a bare ``--resolve``/ + ``--amend`` call does not need it), but the create path still requires + it — refused here, in the handler, rather than by argparse. The create + path (positional text + ``--kind`` + optional ``--task``) is otherwise + unchanged. """ plan = resolve_plan(args.plan) if args.resolve: return _cmd_plan_risk_resolve(args, plan) + if args.amend: + return _cmd_plan_risk_amend(args, plan) if not args.text or not args.kind: raise DevagueError( EXIT_USER_ERROR, "text and --kind are required to record a new risk", - 'pass "" --kind , or --resolve RID --decision "" to ' - "resolve an existing risk", + 'pass "" --kind , --amend RID --text "" to correct ' + 'an existing risk in place, or --resolve RID --decision "" to resolve one', ) if args.task is not None: _require_task(plan, args.task) @@ -690,6 +694,45 @@ def _cmd_plan_risk_resolve(args: argparse.Namespace, plan: Plan) -> int: return 0 +def _cmd_plan_risk_amend(args: argparse.Namespace, plan: Plan) -> int: + """``plan risk --amend RID --text ""`` — correct a risk's text + in place without touching its id, kind, task link, or resolution state + (issue #84 comment: a referenced task id rotated after tasks were + rejected and recreated during a scope change). + + Fail-closed, same contract as :meth:`Plan.amend_risk`: a bare ``--amend`` + without ``--text`` persists nothing, and an unknown id is refused. + """ + if not args.amend_text: + raise DevagueError( + EXIT_USER_ERROR, + "--text is required to amend a risk", + f'pass --text "" for {args.amend}', + ) + try: + risk = plan.amend_risk(args.amend, args.amend_text) + except ValueError as exc: + raise DevagueError( + EXIT_USER_ERROR, str(exc), "run 'devague plan show' to see current risks" + ) from exc + plan_store.save(plan) + if getattr(args, "json", False): + emit_result( + { + "id": risk.id, + "kind": risk.kind, + "text": risk.text, + "task": risk.task_id, + "resolved": risk.resolved, + "resolution": risk.resolution, + }, + json_mode=True, + ) + else: + emit_result(f"{risk.id}: amended", json_mode=False) + return 0 + + def cmd_plan_converge(args: argparse.Namespace) -> int: plan = resolve_plan(args.plan) _frame, targets = _live(plan) @@ -1053,6 +1096,17 @@ def register(sub: argparse._SubParsersAction) -> None: "--resolve", metavar="RID", help="Resolve an existing risk id instead of creating one." ) prk.add_argument("--decision", help="The resolution text recorded with --resolve.") + prk.add_argument( + "--amend", + metavar="RID", + help="Correct an existing risk's text in place instead of creating one.", + ) + prk.add_argument( + "--text", + dest="amend_text", + metavar="TEXT", + help="Corrected risk text, used with --amend.", + ) _plan_opt(prk) prk.set_defaults(func=cmd_plan_risk) diff --git a/devague/plan.py b/devague/plan.py index e56fa08..1cc5edd 100644 --- a/devague/plan.py +++ b/devague/plan.py @@ -199,6 +199,33 @@ def resolve_risk(self, rid: str, resolution: str) -> PlanRisk: risk.resolution = resolution return risk + def amend_risk(self, rid: str, text: str) -> PlanRisk: + """Correct a risk's ``text`` in place (issue #84 comment): the common + case is a risk whose prose names a task id that later rotated (the + referenced task was rejected and recreated with a new id during a + scope change) — the risk is still substantively correct, only the id + it mentions went stale. + + Preserves ``id``, ``kind``, ``task_id``, AND resolution state + (``resolved``/``resolution``) verbatim — a resolved risk that gets + its text corrected stays resolved; only ``text`` changes. Unlike + ``Frame.amend_claim`` (the frame-side sibling move, #84 t6), this is + a plain in-place replace with no revision trail: this engine's own + precedent for editing an already-recorded entity, ``amend_task``, + does not keep one either, and a ``PlanRisk`` has no honesty + conditions / hard questions / scope-entry seeds pointing at it the + way a ``Claim`` does — ``task_id`` is its only structural link, and + that is left untouched by design. + + Raises ``ValueError`` on an unknown risk id — mirroring + ``resolve_risk``'s fail-closed contract. + """ + risk = self.find_risk(rid) + if risk is None: + raise ValueError(f"unknown risk id: {rid!r}") + risk.text = text + return risk + def find_target(self, target_id: str) -> Optional[CoverageTarget]: return next((tg for tg in self.targets if tg.id == target_id), None) diff --git a/tests/test_cli_plan.py b/tests/test_cli_plan.py index 318db7f..6b475a7 100644 --- a/tests/test_cli_plan.py +++ b/tests/test_cli_plan.py @@ -565,6 +565,82 @@ def test_risk_resolve_already_resolved_refused(tmp_path, monkeypatch, capsys) -> assert plan_store.load(slug).find_risk("r1").resolution == "first decision" +# ── risk --amend (issue #84 comment, t12) ───────────────────────────────────── +def test_risk_amend_replaces_text_keeps_id_kind_and_task(tmp_path, monkeypatch, capsys) -> None: + slug = _converged_frame(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + main(["plan", "task", "install the scanner"]) # t1 + main( + [ + "plan", + "risk", + "t1 installs and reports the counter only", + "--kind", + "out_of_scope", + "--task", + "t1", + ] + ) + capsys.readouterr() + + rc = main( + ["plan", "risk", "--amend", "r1", "--text", "t73 installs and reports the counter only"] + ) + assert rc == 0 + assert "r1: amended" in capsys.readouterr().out + risk = plan_store.load(slug).find_risk("r1") + assert risk.text == "t73 installs and reports the counter only" + assert (risk.kind, risk.task_id) == ("out_of_scope", "t1") + assert risk.resolved is False + + +def test_risk_amend_keeps_resolved_state_and_resolution_and_json_parity( + tmp_path, monkeypatch, capsys +) -> None: + slug = _converged_frame(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + main(["plan", "risk", "t53 installs the scanner", "--kind", "out_of_scope"]) + main(["plan", "risk", "--resolve", "r1", "--decision", "confirmed out of scope"]) + capsys.readouterr() + + rc = main(["plan", "risk", "--amend", "r1", "--text", "t73 installs the scanner", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload == { + "id": "r1", + "kind": "out_of_scope", + "text": "t73 installs the scanner", + "task": None, + "resolved": True, + "resolution": "confirmed out of scope", + } + risk = plan_store.load(slug).find_risk("r1") + assert risk.resolved is True + assert risk.resolution == "confirmed out of scope" + + +def test_risk_amend_without_text_refused(tmp_path, monkeypatch, capsys) -> None: + slug = _converged_frame(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + main(["plan", "risk", "scaling unknown", "--kind", "unknown_blocking"]) + capsys.readouterr() + + rc = main(["plan", "risk", "--amend", "r1"]) + assert rc == 1 + assert "--text" in capsys.readouterr().err + assert plan_store.load(slug).find_risk("r1").text == "scaling unknown" + + +def test_risk_amend_unknown_id_refused(tmp_path, monkeypatch, capsys) -> None: + slug = _converged_frame(monkeypatch, tmp_path) + main(["plan", "new", "--frame", slug]) + capsys.readouterr() + + rc = main(["plan", "risk", "--amend", "rX", "--text", "whatever"]) + assert rc == 1 + assert "unknown" in capsys.readouterr().err + + # ── defer (issue #85, t9) ───────────────────────────────────────────────────── def test_defer_happy_path_and_json(tmp_path, monkeypatch, capsys) -> None: slug = _converged_frame(monkeypatch, tmp_path) diff --git a/tests/test_e2e_resolve.py b/tests/test_e2e_resolve.py index bf58e09..e8ecdb9 100644 --- a/tests/test_e2e_resolve.py +++ b/tests/test_e2e_resolve.py @@ -225,6 +225,97 @@ def test_e2e_issue57_plan_risk_resolve_lifecycle(tmp_path, monkeypatch, capsys) assert plan_path.exists() +def test_e2e_issue84_plan_risk_amend_after_task_recreation_still_converges( + tmp_path, monkeypatch, capsys +) -> None: + """The issue #84 comment repro: a plan risk's TEXT names a task id that + later rotates (the referenced task was rejected and recreated during a + scope change). ``plan risk --amend RID --text TEXT`` corrects the stale + reference in place — same id, kind, and (once resolved) resolution state + — instead of leaving misleading text in the ledger or resolving the risk + just to record a corrected duplicate. The amend must not disturb an + already-resolved risk's resolution, and the plan must still converge + and export afterward. + """ + _converging_frame(monkeypatch, tmp_path) + slug = store.current_slug() + + assert main(["plan", "new", "--frame", slug]) == 0 + assert main(["plan", "task", "install the scanner"]) == 0 # t1 + args = ["plan", "task", "cover everything", "--accept", "all targets satisfied"] + for target in _PLAN_TARGETS: + args += ["--covers", target] + assert main(args) == 0 # t2 + + assert ( + main( + [ + "plan", + "risk", + "t1 installs and reports the counter only", + "--kind", + "out_of_scope", + "--task", + "t1", + ] + ) + == 0 + ) # r1 + + # simulate the scope-driven rebuild: t1 is rejected and recreated as t3, + # which stops covering the risk's stale text (but not the risk record + # itself) — the plan still converges (the risk is non-blocking). + assert main(["plan", "reject", "t1"]) == 0 + assert ( + main( + [ + "plan", + "task", + "install the scanner (rebuilt)", + "--accept", + "scanner installed and reporting", + ] + ) + == 0 + ) # t3 + + capsys.readouterr() + assert main(["plan", "converge", "--json"]) == 0 + assert _json_out(capsys)["ready_for_plan"] is True + + capsys.readouterr() + rc = main( + ["plan", "risk", "--amend", "r1", "--text", "t3 installs and reports the counter only"] + ) + assert rc == 0 + assert "r1: amended" in capsys.readouterr().out + risk = plan_store.load(slug).find_risk("r1") + assert risk.text == "t3 installs and reports the counter only" + assert (risk.id, risk.kind, risk.task_id) == ("r1", "out_of_scope", "t1") + assert risk.resolved is False + + # now resolve it, and prove a subsequent amend leaves the resolution alone. + capsys.readouterr() + assert ( + main(["plan", "risk", "--resolve", "r1", "--decision", "confirmed still out of scope"]) == 0 + ) + capsys.readouterr() + rc = main( + ["plan", "risk", "--amend", "r1", "--text", "t3 installs and reports the counter only (v2)"] + ) + assert rc == 0 + risk = plan_store.load(slug).find_risk("r1") + assert risk.text == "t3 installs and reports the counter only (v2)" + assert risk.resolved is True + assert risk.resolution == "confirmed still out of scope" + + assert main(["plan", "converge", "--json"]) == 0 + assert main(["plan", "export"]) == 0 + plan = plan_store.load(slug) + plan_path = Path("docs/plans") / f"{plan.created[:10]}-{slug}.md" + assert plan_path.exists() + + def test_e2e_issue48_52_hard_question_block_resolve_converge_lifecycle( tmp_path, monkeypatch, capsys ) -> None: diff --git a/tests/test_plan.py b/tests/test_plan.py index 91de74d..de056eb 100644 --- a/tests/test_plan.py +++ b/tests/test_plan.py @@ -101,6 +101,36 @@ def test_resolve_risk_rejects_already_resolved() -> None: p.resolve_risk(r.id, "second decision") +# ── amend_risk (issue #84 comment, t12) ─────────────────────────────────────── +def test_amend_risk_replaces_text_preserves_id_kind_and_task() -> None: + p = _plan() + p.add_task("install the scanner") # t1 + r = p.add_risk("t1 installs and reports the counter only", "out_of_scope", task_id="t1") + amended = p.amend_risk(r.id, "t73 installs and reports the counter only") + assert amended is r + assert r.text == "t73 installs and reports the counter only" + assert (r.id, r.kind, r.task_id) == ("r1", "out_of_scope", "t1") + # unresolved before the amend stays unresolved after it. + assert r.resolved is False + assert r.resolution == "" + + +def test_amend_risk_on_a_resolved_risk_keeps_it_resolved() -> None: + p = _plan() + r = p.add_risk("t53 installs the scanner", "out_of_scope") + p.resolve_risk(r.id, "SUPERSEDED by r14 — text referenced rejected task t53") + amended = p.amend_risk(r.id, "t73 installs the scanner") + assert amended.text == "t73 installs the scanner" + assert amended.resolved is True + assert amended.resolution == "SUPERSEDED by r14 — text referenced rejected task t53" + + +def test_amend_risk_rejects_unknown_id() -> None: + p = _plan() + with pytest.raises(ValueError): + p.amend_risk("rX", "new text") + + # ── per-target deferral (issue #85, t9) ─────────────────────────────────────── From 028f9809dfd218d293b8e6dbffa6cf5d213ec106 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 11:51:04 +0300 Subject: [PATCH 20/25] docs(learn): teach every surface shipped in the sweep (#52, t17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit interrogate --resolve, amend, scope --amend (+ --seeds accepting q* hard-question ids), plan defer, plan risk --amend, transactional multi-id plan confirm/reject, live plan cover/--covers validation, the flat reject cascade, and the scope subagent fan-out threshold were all shipped ahead of learn/explain catching up — devague explain amend even 404'd with "unknown move: amend" before this. Also fixes plan.py's cmd_plan_learn, which still named "six operator skills" and omitted challenge from the list. Closes the #52 acceptance criterion: learn/explain document the resolve path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QtUixLUjgEt51PXS9TPVt3 --- devague/cli/_commands/learn.py | 52 +++++- devague/cli/_commands/plan.py | 19 ++- tests/test_teaching_surface_sweep.py | 247 +++++++++++++++++++++++++++ 3 files changed, 303 insertions(+), 15 deletions(-) create mode 100644 tests/test_teaching_surface_sweep.py diff --git a/devague/cli/_commands/learn.py b/devague/cli/_commands/learn.py index e17b778..6bd51f4 100644 --- a/devague/cli/_commands/learn.py +++ b/devague/cli/_commands/learn.py @@ -11,13 +11,33 @@ MOVES = { "scope": ( "Record an explored surface + finding as first-class state (optional " - "pre-frame leg; --seeds links a finding to the claims it seeded)." + "pre-frame leg; --seeds links a finding to the claim ids (c*) or " + "claim-attached hard-question ids (q*) it seeded; --amend SID --finding " + "TEXT replaces an entry's finding in place — no revision trail, unlike " + "claim amend)." ), "new": "Start a frame from the announcement (pretend it shipped).", "capture": "Record and classify a claim (audience, after_state, boundary, ...).", - "interrogate": "Pressure-test a claim: honesty conditions, hard questions, contradictions.", - "confirm": "Confirm a claim or honesty condition (user-only — no fabricated rigor).", - "reject": "Reject a claim or honesty condition.", + "amend": ( + "Correct a claim's text and/or kind without id churn — keeps its honesty " + "conditions, instruction, and inbound scope --seeds references; a " + "confirmed claim flips back to proposed on change." + ), + "interrogate": ( + "Pressure-test a claim: honesty conditions, hard questions, contradictions; " + "'interrogate --resolve --decision TEXT' closes out a blocking " + "hard question (a USER decision, like confirm/park --resolve)." + ), + "confirm": ( + "Confirm one or more claims/honesty conditions in one transactional call " + "(user-only — no fabricated rigor), or apply decisions from a " + "'--from-review' file." + ), + "reject": ( + "Reject one or more claims/honesty conditions, transactionally; rejecting " + "a claim cascades onto its still-live honesty conditions/hard questions " + "(echoed as '(also rejected: ...)', and as a 'cascaded' key in --json)." + ), "review": ( "List every proposed (unconfirmed) claim + honesty condition for " "human review (read-only)." @@ -124,6 +144,10 @@ "decided, close it out with 'park --resolve VID --decision TEXT' instead of " "leaving it parked or hand-editing state — the item stays on record with " "its resolution and drops out of the gate.", + "Blocking hard questions route through the user too — a hard question " + "raised 'blocking' via interrogate stays open until decided; close it out " + "with 'interrogate CID --resolve QID --decision TEXT' (the claim-level twin " + "of 'park --resolve') instead of deleting the question or hand-editing state.", "Converge, don't vibe — 'export' is gated on 'converge'; resolve every " "listed gap instead of declaring readiness on a hunch.", "Order is adaptive — the ten stages are an artifact shape, not a mandatory " @@ -153,12 +177,23 @@ SCOPE_STAGE = { "name": "Scope (optional, before Announcement)", "prompt": "what does this idea touch, and what should it not touch?", - "move": 'scope "" --finding "" [--seeds ...]', + "move": 'scope "" --finding "" [--seeds ...]', "loop": ( "Explore read-only first (no CLI moves while surveying), then record " "each surveyed surface + finding with 'devague scope'; pass --seeds " - "with the claim ids a finding goes on to seed so the frame's boundary / " - "non_goal / assumption claims cite what was actually explored." + "with the claim ids (c*) or claim-attached hard-question ids (q*) a " + "finding goes on to seed so the frame's boundary / non_goal / assumption " + "claims cite what was actually explored. 'scope --amend SID --finding " + "TEXT' corrects an entry's finding in place afterward." + ), + "fan_out": ( + "How to explore: 4 or fewer candidate surfaces — explore them yourself, " + "inline, serially. 5 or more — fan out one read-only exploration " + "subagent per surface (or tight cluster), defaulting every subagent to " + "the smaller tier, sonnet. Subagents explore and report; they never run " + "a devague move — the main agent alone runs every capture/scope/" + "question/park call, from the subagents' reported evidence, so " + "provenance stays in one place." ), "recommended_when": ( "Recommended when the idea touches an existing codebase or ecosystem — " @@ -196,7 +231,8 @@ "Optional lead-in — scope exploration (recommended when the idea touches an\n" "existing codebase; skip freely for small ideas — not a mandatory first stage):\n" f" {SCOPE_STAGE['prompt']} [devague {SCOPE_STAGE['move']}]\n" - f" {SCOPE_STAGE['loop']}\n\n" + f" {SCOPE_STAGE['loop']}\n" + f" {SCOPE_STAGE['fan_out']}\n\n" "Guided stages (the recommended sequence — drive them with the moves):\n" + "\n".join( f" {i:>2}. {name:<13} {prompt} [{move}]" diff --git a/devague/cli/_commands/plan.py b/devague/cli/_commands/plan.py index b231962..b96ce2a 100644 --- a/devague/cli/_commands/plan.py +++ b/devague/cli/_commands/plan.py @@ -56,16 +56,21 @@ "Record that a task depends on another (--on); --remove cuts one edge " "(may flip confirmed -> proposed)." ), - "cover": "Mark a task as covering a coverage target (c*/h*).", + "cover": ( + "Mark a task as covering a coverage target (c*/h*); validated against " + "the live source frame, so a target the frame grew after seeding can " + "be covered right away." + ), "defer": ( "Deliberately exclude a coverage target from this plan's gate " "(--reason TEXT), or --undo to reverse it." ), - "confirm": "Confirm a task (user-only — no fabricated rigor).", - "reject": "Reject a task.", + "confirm": "Confirm one or more tasks, transactionally (user-only — no fabricated rigor).", + "reject": "Reject one or more tasks, transactionally.", "risk": ( "Record a first-class plan risk instead of papering over it, " - "or --resolve RID --decision TEXT to close one out." + "--resolve RID --decision TEXT to close one out, or --amend RID --text " + "TEXT to correct an existing risk's text in place." ), "converge": "Check whether the plan can export, against the live frame.", "export": "Write the buildable plan — only once the plan converges.", @@ -955,9 +960,9 @@ def cmd_plan_learn(args: argparse.Namespace) -> int: "criteria, the dependency graph is acyclic, and no blocking risk remains.\n\n" "Moves:\n" + "\n".join(f" {name:<9} {desc}" for name, desc in PLAN_MOVES.items()) - + "\n\nTo author the six operator skills (scope / think / spec-to-plan / " - "assign-to-workforce /\ndeviate / summarize-delivery) in your own runtime, " - "run 'devague learn skills'\n(with user consent)." + + "\n\nTo author the seven operator skills (scope / think / challenge / " + "spec-to-plan /\nassign-to-workforce / deviate / summarize-delivery) in " + "your own runtime, run 'devague learn skills'\n(with user consent)." ) if getattr(args, "json", False): emit_result( diff --git a/tests/test_teaching_surface_sweep.py b/tests/test_teaching_surface_sweep.py new file mode 100644 index 0000000..5ef1a40 --- /dev/null +++ b/tests/test_teaching_surface_sweep.py @@ -0,0 +1,247 @@ +"""Tests for the learn/explain documentation sweep (issues #48/#52/#84/#85/#86/#90). + +A wave of code tasks shipped real surfaces (`interrogate --resolve`, `amend`, +`scope --amend` + `--seeds` accepting hard-question ids, `plan defer`, +`plan risk --amend`, transactional multi-id `plan confirm`/`plan reject`, +"live" `plan cover`, the reject cascade, and the scope subagent fan-out) ahead +of the docs catching up. This file is the docs-catch-up: it greps +`devague learn` / `devague explain` / `devague plan learn` / +`devague plan explain` output for each new verb and flag. Documentation-only +— the moves themselves are already covered by their own functional tests +elsewhere (test_cli_moves.py, test_plan_escape_hatches.py, test_cli_plan.py, +...); this file only pins that they are *taught*, closing the #52 acceptance +criterion ("learn/explain document the resolve path"). +""" + +from __future__ import annotations + +import json + +import pytest + +from devague.cli import main + + +# ── 1. interrogate --resolve (issues #48/#52) — the criterion this task names directly +def test_learn_documents_interrogate_resolve(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["learn"]) + assert rc == 0 + out = capsys.readouterr().out + assert "interrogate" in out + assert "--resolve" in out + assert "--decision" in out + assert "USER decision" in out + + +def test_explain_interrogate_documents_resolve(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["explain", "interrogate"]) + assert rc == 0 + out = capsys.readouterr().out + assert "--resolve" in out + assert "--decision" in out + + +def test_explain_interrogate_json_documents_resolve(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["explain", "interrogate", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert "--resolve" in payload["description"] + + +def test_learn_operating_rules_name_interrogate_resolve_close_out( + capsys: pytest.CaptureFixture[str], +) -> None: + """Mirrors the existing `park --resolve` operating rule (issues #45/#55/#57/#60) + — a blocking hard question must not read as a permanent dead end either. + """ + rc = main(["learn"]) + assert rc == 0 + out = capsys.readouterr().out + assert "interrogate CID --resolve QID --decision TEXT" in out + + +# ── 2. amend (issue #84) — was entirely absent from MOVES/explain before this ── +def test_amend_is_registered_in_learn_moves(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["learn", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert "amend" in payload["moves"] + + +def test_amend_appears_in_bare_learn_moves_listing(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["learn"]) + assert rc == 0 + out = capsys.readouterr().out + assert "\n amend " in out + + +def test_explain_amend_works(capsys: pytest.CaptureFixture[str]) -> None: + """Before this sweep, `devague explain amend` failed with 'unknown move: amend'.""" + rc = main(["explain", "amend"]) + assert rc == 0 + out = capsys.readouterr().out.lower() + assert "amend" in out + assert "id churn" in out + + +def test_explain_amend_json(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["explain", "amend", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["move"] == "amend" + assert payload["description"] + + +# ── 3. scope --amend + --seeds accepting hard-question (q*) ids (issue #84) ──── +def test_learn_scope_seeds_mentions_claim_and_hard_question_ids( + capsys: pytest.CaptureFixture[str], +) -> None: + rc = main(["learn"]) + assert rc == 0 + out = capsys.readouterr().out.lower() + assert "hard-question" in out + assert "(q*)" in out + + +def test_explain_scope_mentions_amend(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["explain", "scope"]) + assert rc == 0 + out = capsys.readouterr().out + assert "--amend" in out + assert "SID" in out + + +def test_learn_json_scope_stage_mentions_hard_question_seeds( + capsys: pytest.CaptureFixture[str], +) -> None: + rc = main(["learn", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + section = json.dumps(payload["scope_stage"]).lower() + assert "hard-question" in section + assert "q*" in section + + +# ── 4. plan defer (issue #85) ─────────────────────────────────────────────────── +def test_plan_learn_documents_defer(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["plan", "learn", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert "defer" in payload["moves"] + + +def test_plan_explain_defer_documents_reason_and_undo( + capsys: pytest.CaptureFixture[str], +) -> None: + rc = main(["plan", "explain", "defer"]) + assert rc == 0 + out = capsys.readouterr().out + assert "--reason" in out + assert "--undo" in out + + +# ── 5. plan risk --amend (issue #84 comment) ──────────────────────────────────── +def test_plan_explain_risk_documents_amend(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["plan", "explain", "risk"]) + assert rc == 0 + out = capsys.readouterr().out + assert "--amend" in out + assert "--text" in out + assert "RID" in out + + +def test_plan_explain_risk_json_documents_amend(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["plan", "explain", "risk", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert "--amend" in payload["description"] + assert "--text" in payload["description"] + + +# ── 6. multi-id transactional plan confirm/reject (issue #86) ────────────────── +def test_plan_explain_confirm_documents_transactional_multi_id( + capsys: pytest.CaptureFixture[str], +) -> None: + rc = main(["plan", "explain", "confirm"]) + assert rc == 0 + out = capsys.readouterr().out.lower() + assert "one or more" in out + assert "transactionally" in out + + +def test_plan_explain_reject_documents_transactional_multi_id( + capsys: pytest.CaptureFixture[str], +) -> None: + rc = main(["plan", "explain", "reject"]) + assert rc == 0 + out = capsys.readouterr().out.lower() + assert "one or more" in out + assert "transactionally" in out + + +# ── 7. "live" plan cover / plan task --covers (issue #90) ────────────────────── +def test_plan_explain_cover_documents_live_frame_validation( + capsys: pytest.CaptureFixture[str], +) -> None: + rc = main(["plan", "explain", "cover"]) + assert rc == 0 + out = capsys.readouterr().out.lower() + assert "live" in out + + +def test_plan_explain_cover_json_documents_live_frame_validation( + capsys: pytest.CaptureFixture[str], +) -> None: + rc = main(["plan", "explain", "cover", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert "live" in payload["description"].lower() + + +# ── 8. flat reject cascade + --json "cascaded" key ────────────────────────────── +def test_learn_documents_reject_cascade(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["learn"]) + assert rc == 0 + out = capsys.readouterr().out.lower() + assert "cascad" in out + assert "also rejected" in out + + +def test_explain_reject_json_documents_cascaded_key(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["explain", "reject", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert "cascaded" in payload["description"] + + +# ── 9. the scope subagent fan-out (SCOPE_STAGE must not contradict SKILL.md) ─── +def test_learn_documents_scope_fan_out_threshold(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["learn"]) + assert rc == 0 + out = capsys.readouterr().out.lower() + assert "sonnet" in out + assert "4 or fewer" in out + assert "5 or more" in out + assert "never run a devague move" in out + + +def test_learn_json_scope_stage_includes_fan_out_key( + capsys: pytest.CaptureFixture[str], +) -> None: + rc = main(["learn", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert "fan_out" in payload["scope_stage"] + assert "sonnet" in payload["scope_stage"]["fan_out"].lower() + + +# ── 10. plan learn names all seven skills (stale "six"/missing "challenge") ──── +def test_plan_learn_names_seven_skills_including_challenge( + capsys: pytest.CaptureFixture[str], +) -> None: + rc = main(["plan", "learn"]) + assert rc == 0 + out = capsys.readouterr().out.lower() + assert "seven operator skills" in out + assert "challenge" in out + assert "six operator skills" not in out From f72fcf40e68ebd4688dd03a57e3fa248e9f45f66 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 12:24:35 +0300 Subject: [PATCH 21/25] docs: sweep README/CLAUDE/skills docs + changelog for the fifteen-issue sweep (t18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump 0.20.1 -> 0.21.0 (minor: feature release) and describe the shipped surface across every teaching doc. - CHANGELOG.md: one 0.21.0 entry mapping each of the fifteen issues to what changed (#48 #49 #52 #79 #82 #83 #84 #85 #86 #87 #88 #90 #91 #92 #93). - CLAUDE.md: new Status entry; working-backwards and spec->plan method lists gain amend / interrogate --resolve / scope --seeds q* / plan defer / risk --amend / multi-id plan confirm / live-frame cover; gate 2 now names its durable split artifact; module inventory picks up amend.py, contested.py, _md_safety.py and plan defer. - README.md: verb lists, the "nothing is deleted to make a gate go green" close-out rule, and the reject cascade. - docs/skills.md: scope subagent fan-out and the split-plan --write artifact; fixes the pre-existing drift that said briefs quote plan show --json / the plan-md (SKILL.md is right: plan waves --json is the single source). - docs/skill-sources.md: version stamps for scope and assign-to-workforce. - docs/spec-contract.md: schema_version 4 on both stores (check-before-parse), ClaimRevision, HardQuestion.resolution, CoverageTarget.deferred, the new moves and their validation errors, plus render-time contracts (md-safety, contested-by-deviation). - docs/llm-guidance.md + think/scope SKILL.md: the stale --seeds claim-ids-only wording, and the three new close-out/correct-in-place rules. Docs and version only — no behavior change. --- .claude/skills/scope/SKILL.md | 20 ++- .claude/skills/think/SKILL.md | 20 ++- CHANGELOG.md | 136 +++++++++++++++++++ CLAUDE.md | 152 +++++++++++++++++---- README.md | 16 ++- docs/llm-guidance.md | 52 +++++-- docs/skill-sources.md | 4 +- docs/skills.md | 101 +++++++++++--- docs/spec-contract.md | 248 +++++++++++++++++++++++++++++----- pyproject.toml | 2 +- uv.lock | 2 +- 11 files changed, 648 insertions(+), 105 deletions(-) diff --git a/.claude/skills/scope/SKILL.md b/.claude/skills/scope/SKILL.md index 3386d06..0b8c388 100644 --- a/.claude/skills/scope/SKILL.md +++ b/.claude/skills/scope/SKILL.md @@ -83,11 +83,14 @@ of the method: no wizard.) `devague new "" --title ""` (this is also `/think`'s first move) — scope entries live on the frame, so it must exist first. Record each explored surface as a first-class finding: - `` `devague scope "" --finding "" [--seeds ...]` `` + `` `devague scope "" --finding "" [--seeds ...]` `` — text that **cites the surface explored** ("the CLI stays deterministic per issue 20; scope exploration is agent-side" beats "we won't overreach"). Capture the claim it seeded first (`capture --kind ...`), then pass its id - to `--seeds` — an unknown seed id is refused with a hint. Provenance, not + to `--seeds`; a finding you routed to a hard question instead (the + "genuinely unknown" branch above) links the same way — `--seeds` accepts a + claim id (`c*`) **or** a claim-attached hard-question id (`q*`, #84) — and + an unknown seed id is refused with a hint. Provenance, not generic disclaimers: a reviewer should be able to trace every boundary claim back to something you read. **This move is always the main agent's, never a subagent's** — when step 2 fanned out, the main agent writes the finding @@ -112,7 +115,8 @@ already exist, so run `devague new` first: | Move | What it does | |------|--------------| | `devague scope "" --finding ""` | Record a finding on the current frame. | -| `devague scope "" --finding "" --seeds [ ...]` | Record a finding, linking it to the claim id(s) it went on to seed. An unknown seed id is refused with a hint (`run 'devague show' to see valid claim ids`). | +| `devague scope "" --finding "" --seeds [ ...]` | Record a finding, linking it to what it went on to seed — a claim id (`c*`) or a claim-attached hard-question id (`q*`, #84), so a finding routed to `interrogate --hard-question` keeps its provenance link too. An unknown seed id is refused with a hint (`run 'devague show' to see valid claim ids`). | +| `devague scope --amend --finding ""` | Replace an entry's finding in place — same `id`, `surface`, and `seeds` (#84). Use this instead of recording a second entry that says "supersedes s18"; there is no revision trail here, unlike claim `amend`. | | `devague scope --list [--json]` | Read every recorded entry back. | Boundary / non-goal / in-scope claims still land the same way they always @@ -127,6 +131,16 @@ followed*: | genuinely unknown, needs a user decision | `question ""` (later `question --resolve --decision ""`) | | genuinely unknown, not decidable now | `park "" --kind unknown_blocking\|unknown_nonblocking` | +**Which `q*` ids `--seeds` accepts.** Two independent things mint `qN` ids and +both start counting at `q1`: **claim-attached** hard questions +(`interrogate --hard-question ""`) and the separate durable +`.devague/questions/.md` artifact (`devague question`). `scope --seeds` +resolves against the **claim-attached** ones only. So when you want a +"needs a user decision" finding to carry a provenance link, raise it with +`interrogate --hard-question` on the claim it hangs off and seed that id — +seeding a `devague question` id would either be refused or, worse, match an +unrelated claim-attached question with the same number. + ## Hard rules (do not violate) - **Exploration is read-only.** Surveying scope never edits files, never diff --git a/.claude/skills/think/SKILL.md b/.claude/skills/think/SKILL.md index 2d76b12..e964a39 100644 --- a/.claude/skills/think/SKILL.md +++ b/.claude/skills/think/SKILL.md @@ -45,11 +45,15 @@ generic disclaimers). Small ideas skip it and start here — no wizard. Findings now land on the frame itself through the shipped `devague scope` move: start the frame with `new` first (scope entries live on it, like any other claim), then record each explored surface with -`` `devague scope "" --finding "" [--seeds ...]` `` -— `--seeds` links the finding to claim ids it went on to seed, and an unknown -seed id is refused with a hint. Read every recorded entry back with -`` `scope --list [--json]` ``. (From the sharper end-to-end method spec, -devague#53, task t3.) +`` `devague scope "" --finding "" [--seeds ...]` `` +— `--seeds` links the finding to what it went on to seed: a claim id (`c*`) +**or** a claim-attached hard-question id (`q*`, for a finding that routed to +`interrogate --hard-question` because it needed a user decision rather than a +claim). An unknown seed id is refused with a hint. Read every recorded entry +back with `` `scope --list [--json]` ``, and correct a wrong finding in place +with `` `scope --amend --finding ""` `` instead of recording a +second entry that says "supersedes". (From the sharper end-to-end method spec, +devague#53, task t3; `q*` seeds and `--amend` from #84.) ## How to run @@ -74,10 +78,12 @@ for portable resolution. |------|--------------| | `new "" [--title ""]` | Start a frame from the announcement (the first move). Seeds an auto-confirmed `announcement` claim. Always pass `--title` (see *Export hygiene*). | | `capture --kind "" [--instruction ""]` | Record + classify a claim. `--origin llm` lands it as `proposed`. `--instruction` attaches verbatim working guidance (how to verify/implement the claim) at creation time. | +| `amend [--text ""] [--kind ] [--reason ""]` | Correct a claim **without** churning its id — its honesty conditions, hard questions, `instruction`, and inbound `scope --seeds` refs all survive. The superseded `(text, kind)` pair is kept on `Claim.revisions`; a **confirmed** claim flips back to `proposed` (re-confirm it). Prefer this over reject-and-recapture. | | `interrogate --honesty "…"` | Attach an honesty condition (what must be true). Also `--hard-question`, `--risk`, `--contradicts`, `--blocking`. `--instruction ""` adds/updates a claim's or honesty condition's instruction — `` may be a claim (`c*`) or, with `--instruction` alone, an honesty condition (`h*`). | -| `confirm […]` / `reject […]` | Resolve one or more claims (`c*`) / honesty conditions (`h*`) in one **transactional** call. **User-only decision.** Also `confirm --from-review ` to apply an edited review artifact. | +| `interrogate --resolve [--decision ""]` | Close out that claim's blocking hard question — it stays on record (rendered `(resolved)` in the export) and stops blocking `converge`. **User-only decision**, the claim-level twin of `park --resolve`; never invoke it on your own. Mutually exclusive with the add-flags above. | +| `confirm […]` / `reject […]` | Resolve one or more claims (`c*`) / honesty conditions (`h*`) in one **transactional** call. **User-only decision.** Rejecting a claim cascades onto its still-live honesty conditions and unresolved hard questions (echoed as `(also rejected: h3, q1)`). Also `confirm --from-review ` to apply an edited review artifact. | | `review` | List every **proposed** (unconfirmed) claim + honesty condition with ids and their instructions (`--json` too); writes a non-authoritative artifact to `.devague/reviews/.md`. Un-gated; never mutates. | -| `scope "" --finding "" [--seeds ...]` / `scope --list` | Record (or list) a pre-frame exploration finding as first-class state (see the scope pointer above). | +| `scope "" --finding "" [--seeds ...]` / `scope --list` / `scope --amend --finding ""` | Record (or list, or correct in place) a pre-frame exploration finding as first-class state (see the scope pointer above). | | `question ""` | Record / list / `--resolve` a pending user decision as durable working state in `.devague/questions/.md`. | | `park "" --kind ` | Move uncertainty into first-class open vagueness instead of forcing an answer. | | `park --resolve --decision "" [--claim ]` | Close out a decided parked item — stays on record with its resolution, drops out of the convergence gate; `--claim` links the deciding claim. **User-only decision**, same as `confirm`. | diff --git a/CHANGELOG.md b/CHANGELOG.md index 99a47ec..3a0fdca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,142 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.21.0] - 2026-07-28 + +The fifteen-issue backlog sweep (the `issue-backlog-sweep` plan, tasks t1–t19) +— one devague-orchestrated workforce fan-out closing issues #48, #49, #52, #79, #82, #83, #84, #85, #86, #87, #88, #90, #91, #92, and #93. +Three of those were hard blockers downstream repos were already working around +by hand: hand-editing frame JSON (#48/#52), and writing a second plan renderer +(#85). + +### Added + +- **`devague interrogate --resolve [--decision ""]`** — close + out a claim's blocking hard question (#48, #52). Nothing in the codebase + ever set `HardQuestion.resolved`, so a single blocking question deadlocked + `converge` permanently and two downstream repos had to hand-edit + `.devague/frames/*.json` to get past it. This is a USER decision, like + `confirm` and `park --resolve`: the answer is recorded verbatim on the new + `HardQuestion.resolution`, the question stays on the record with a + `(resolved)` marker in the export, and the convergence hint now names the + executable move instead of prose advice. A blocking question on a + **rejected** claim also stops blocking — the claim was decided against, so + the question is moot (#52's third fix). +- **`devague amend [--text ""] [--kind K] [--reason ""]`** — + correct a claim without id churn (#84). Reject-and-recapture cost the claim + its id, and with it every honesty condition, hard question, `instruction`, + and inbound `scope --seeds` reference. `amend` keeps all of them, appends + the superseded `(text, kind)` pair to a new `Claim.revisions` trail, and + flips a confirmed claim back to `proposed` — the same re-confirm rule + `interrogate --instruction` already applies. `origin` is never touched. +- **`devague scope --amend --finding ""`** — replace a scope + entry's finding in place, instead of recording a second entry that says + "supersedes s18" and leaving the reader to notice the word (#84). + Deliberately asymmetric with claim `amend`: a scope entry carries no + status/origin to protect, so there is no revision trail. +- **`devague plan defer --reason ""` / `--undo`** — a + deliberate, documented per-target exclusion from the coverage gate (#85). A + milestone-scoped plan previously could not converge at all: every target + derived from the frame had to be covered, so the gate rewarded tasks that + merely *named* a target. A deferred target drops out of the gate, surfaces + in `parked_items` labeled `deferred:`, and renders under a + `## Deferred targets` section in the exported plan-md. +- **`devague plan risk --amend --text ""`** — the plan half of + #84: correct a stale risk's text in place. +- **Contested-by-deviation markers (`devague/contested.py`)** — a read-only + derivation joining a frame's confirmed claims to approved deviations' + `--affects` refs across the plan-slug boundary (#92). A re-exported spec + renders a `contested by` marker naming the `dN` id under the claim, and + `show` / `status` gain `contested:` lines (`--json` gains a `contested` + key). The spec is **not** rewritten — it points forward to the ledger, per + the issue's ruling that "deviate is the marking of the change". Fails open: + a missing, corrupt, or newer-schema plan/delivery file degrades to "no + markers from that source" plus a stderr diagnostic, never a crash. +- **`assign-to-workforce split-plan --write`** — persist the gate-2 + implementation split plan to `docs/plans/--split.md` + (#82), beside the plan-md it describes. Unlike the exported spec and plan, + gate 2 previously survived only in conversation. The file carries the full + per-wave/per-task content quoted verbatim from `plan waves --json`, a + `Task | Owner | Model` assignment table the script **reads back** so + hand-edited cells survive regeneration, and the same End state section as + plain `split-plan`. Artifact-only — no plan-schema change and no new + `devague` verb. + +### Changed + +- **`devague reject ` now cascades** onto the claim's still-live honesty + conditions and unresolved hard questions, echoing + `c21 -> rejected (also rejected: h3, q1)`; `--json` gains a `cascaded` key + (#83). Rejected content was reaching the exported spec and staying in the + `devague review` pool as if it still awaited a decision. `converge` also + stops warning about a **rejected** assumption — that decision is already + made, and the warning offered no useful next move. +- **`devague plan confirm` / `plan reject` take multiple ids in one + transactional call**, matching the frame side (#86); argument errors raised + inside the `plan` group now point at `devague plan explain ` instead + of the generic `--help`. +- **`plan task --dep` and `plan depend --on` validate at creation** — a + self-dependency and an unknown task id are both refused up front (#86), + instead of surfacing much later as a `waves` cycle or dangling-dep error. +- **`plan cover` and `plan task --covers` validate against targets re-derived + from the LIVE frame** (#90). The stored target snapshot was frozen at + seeding while `converge` / `status` / `export` re-derived from the live + frame, so `status` could recommend covering a target that `cover` then + refused as unknown — and a frame that legitimately grew a claim mid-run + could never converge again. The stored snapshot is still checked first (no + I/O in the common case) and is refreshed and persisted on a live hit. +- **Verbatim text is markdown-escaped at render time** (#87): + `devague/render/_md_safety.py` gains `md_safe_text()`, composed at every + verbatim site in `spec_md.py`, `plan_md.py`, and `summary_md.py`. + Underscore- and dunder-bearing identifiers wrap in code spans rather than + backslash-escaping — one move that fixes both MD037 and MD050 — other + markdown control characters escape, text already inside a code span passes + through byte-for-byte, and the transform is idempotent. Presentational + only: the store JSON and every `--json` payload are unchanged. +- **`devague summary` scopes Planned Work and Actual Delivery to CONFIRMED + tasks** (#88), plus one line recording how many tasks were rejected during + planning. A plan with 19 confirmed and 68 rejected tasks emitted 87 rows, + which made the delivery artifact unusable. A `proposed` task is excluded + too — it is still under adjudication, and folding it into either list would + report an open decision as a closed one. +- **Frame and plan `schema_version` bump 3 → 4** — `HardQuestion.resolution`, + `Claim.revisions`, and `CoverageTarget.deferred` / `.deferred_reason`. Both + stores now check the declared version against the **raw** dict *before* + constructing the domain object, so a genuinely newer file fails closed with + the upgrade hint instead of an opaque `TypeError` from a nested dataclass, + and nested `HardQuestion` / `Vagueness` loading tolerates unknown keys. A v3 + file loads with every new field defaulted. +- **The `scope` skill fans read-only exploration out to subagents** (#79, + #91): **4 or fewer** candidate surfaces are explored inline and serially; + **5 or more** fan out one read-only subagent per surface (or tight cluster), + defaulting every subagent to the smaller tier, **sonnet** — a default, not a + ceiling. Subagents explore and report; they never run a `devague` move. The + main agent runs every `capture` / `scope` / `question` / `park` call itself, + from the subagents' reported evidence, so provenance and the + anti-fabrication contract stay in one place. +- **`devague scope --seeds` accepts claim-attached hard-question ids (`q*`)** + as well as claim ids (#84's "smaller, related gap") — the branch the + `/scope` routing table sends a "genuinely unknown, needs a user decision" + finding down, whose provenance link was previously unrecordable. A question + seed renders as `(question)`, or `(question, resolved)` once answered. +- Teaching surfaces swept in lockstep with the moves: `devague learn` / + `learn skills`, `devague explain` / `plan explain`, `README.md`, + `CLAUDE.md`, `docs/spec-contract.md`, `docs/llm-guidance.md`, + `docs/skills.md`, `docs/skill-sources.md`, and the `think` / `scope` / + `assign-to-workforce` skills. + +### Fixed + +- **Export fidelity** (#93, #49, #83). All four park kinds now render under + `## Open parks`; the old filter surfaced only `follow_up` / `out_of_scope` + and silently dropped every open `unknown_nonblocking` item — exactly the + kind that legitimately coexists with a converged frame, so the artifact + claimed more certainty than the frame held. A resolved hard question now + carries a `(resolved)` marker instead of rendering as an open `(blocking)` + one. Hard questions attached to a rejected claim are dropped entirely. And + a scope-entry seed citing a rejected claim renders a `(rejected)` marker + instead of a bare dead reference. + ## [0.20.1] - 2026-07-20 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 8bd3a2c..a83b970 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,51 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Status +**The fifteen-issue backlog sweep (0.21.0, issues #48 #49 #52 #79 #82 #83 #84 #85 #86 #87 #88 #90 #91 #92 #93).** +One workforce fan-out (the +`issue-backlog-sweep` plan, t1–t19) closing fifteen issues, three of which +downstream repos were already working around by hand. New moves: +**`devague interrogate --resolve [--decision]`** — nothing ever set +`HardQuestion.resolved`, so one blocking question deadlocked `converge` +*permanently* and two repos hand-edited frame JSON to escape (#48/#52); +**`devague amend [--text] [--kind] [--reason]`** — correct a claim +keeping its id, honesty conditions, hard questions, `instruction`, and +inbound `scope --seeds` refs, recording the superseded pair on a new +`Claim.revisions` trail and flipping a confirmed claim back to `proposed` +(#84); **`devague scope --amend --finding`** (in-place finding +correction — no revision trail, a deliberate asymmetry); **`devague plan +defer --reason` / `--undo`** — a documented per-target exclusion +from the coverage gate so a milestone-scoped plan can converge at all, with a +`## Deferred targets` section in the export (#85); and **`devague plan risk +--amend --text`** (#84). Sharpened: `reject ` cascades onto its +honesty conditions and unresolved hard questions (`--json` gains `cascaded`), +and `converge` stops warning about *rejected* assumptions (#83); `plan +confirm` / `plan reject` are multi-id and transactional, and plan-group +argument errors point at `devague plan explain ` (#86); `plan task +--dep` / `plan depend --on` refuse self-deps and unknown ids at creation +(#86); `plan cover` / `plan task --covers` validate against targets re-derived +from the **live** frame, so a target the frame grew after seeding is coverable +immediately (#90); `devague summary` scopes Planned Work / Actual Delivery to +**confirmed** tasks plus one rejected-count line (#88). Export fidelity: all +four park kinds render under `## Open parks`, resolved hard questions carry a +`(resolved)` marker, hard questions on rejected claims are excluded, and a +scope seed citing a rejected claim renders `(rejected)` (#93, #49). New +`render/_md_safety.md_safe_text()` escapes every verbatim render site +(`spec_md` / `plan_md` / `summary_md`) — underscore/dunder identifiers wrap in +code spans, existing code spans pass through, idempotent, presentational only +(#87). New read-only `devague/contested.py` joins confirmed claims to approved +deviations' `--affects` across the plan-slug boundary: re-exported specs and +`show` / `status` mark a claim contested by `dN` — the spec is *not* rewritten, +it points forward to the ledger — and it fails open on a missing/corrupt/newer +store (#92). Both `SCHEMA_VERSION` and `PLAN_SCHEMA_VERSION` are now **4**, and +both stores check the declared version against the **raw** dict before parsing. +Skills: `/scope` fans read-only exploration out to subagents — 4 or fewer +surfaces inline, 5 or more fan out, defaulting to **sonnet**; subagents explore +and report, the main agent runs every move (#79/#91) — and +`assign-to-workforce` gains **`split-plan --write`**, a durable gate-2 artifact +at `docs/plans/--split.md` whose owner/model annotations +survive regeneration (#82). + **The challenge skill lands — the seventh leg (0.19.0, #73).** New seventh origin skill **`/challenge`** (`.claude/skills/challenge/SKILL.md`) — a risk-scaled blind-spot discovery pass that runs after `/think` exports and @@ -148,13 +193,32 @@ itself. The workflow: contract lives in `docs/llm-guidance.md` (#19). 2. `devague capture --kind ""` — add claims; LLM-proposed ones (`--origin llm`) land as `proposed` and require explicit user `confirm`. + Correct a claim in place with `devague amend [--text] [--kind] + [--reason]` (#84) — it keeps the id, so honesty conditions, hard questions, + `instruction`, and inbound `scope --seeds` refs all stay pointed at + something real; the superseded `(text, kind)` pair lands on + `Claim.revisions` and a confirmed claim flips back to `proposed`. 3. `devague interrogate ` — attach honesty conditions and hard - questions; honesty conditions from the LLM are also `proposed`. + questions; honesty conditions from the LLM are also `proposed`. A blocking + hard question is closed out with `devague interrogate --resolve + --decision ""` (#48/#52) — a USER decision, the claim-level twin of + `park --resolve`; without it a blocking question deadlocks `converge` + forever. 4. `devague confirm ` / `reject` / `park` — **all honesty conditions routed through the user**; the agent must not auto-confirm LLM proposals. -5. `devague converge` — evaluates the convergence gate; lists remaining gaps. -6. `devague export` — only succeeds after `converge` passes; writes a - buildable spec-md to `docs/specs/`. + Rejecting a claim cascades onto its still-live honesty conditions and + unresolved hard questions, echoing `(also rejected: h3, q1)` (#83). +5. `devague scope "" --finding "" [--seeds …]` — record + pre-frame exploration as first-class provenance; `--seeds` takes claim ids + *or* claim-attached hard-question ids (#84), and `scope --amend + --finding` corrects a finding in place. +6. `devague converge` — evaluates the convergence gate; lists remaining gaps. +7. `devague export` — only succeeds after `converge` passes; writes a + buildable spec-md to `docs/specs/`. Verbatim claim text is markdown-escaped + at render time (`render/_md_safety.md_safe_text`, #87) — presentational + only, the stored JSON is untouched. A confirmed claim named by an approved + deviation's `--affects` renders a `contested by ` marker (#92): the + spec is never rewritten, it points forward to the deviation ledger. Full design: `docs/superpowers/specs/2026-05-23-devague-working-backwards-design.md`. @@ -170,27 +234,40 @@ verbs). The workflow: Derives **coverage targets** (the frame's confirmed claims + honesty conditions). Refuses an unconverged frame; refuses to clobber an existing plan. 2. `devague plan task "" [--accept … --dep … --covers … --origin]` — - add tasks; `--origin llm` lands `proposed` (user must `confirm`). Refine with - `accept` / `depend` (or `depend --remove` to cut an edge, #68) / `cover` / - `instruct` / `amend` (edit a task's summary and/or replace/remove acceptance - criteria by index, #68). Amending or demoting a CONFIRMED task flips it back - to `proposed` and echoes that flip to stdout (#67). + add tasks; `--origin llm` lands `proposed` (user must `confirm` — and + `plan confirm` / `plan reject` take many ids in one transactional call, #86). + Refine with `accept` / `depend` (or `depend --remove` to cut an edge, #68) / + `cover` / `instruct` / `amend` (edit a task's summary and/or replace/remove + acceptance criteria by index, #68). Amending or demoting a CONFIRMED task + flips it back to `proposed` and echoes that flip to stdout (#67). `--dep` / + `depend --on` refuse a self-dependency or an unknown task id at creation + (#86); `cover` / `--covers` validate against targets re-derived from the + **live** frame, so a target the frame grew after seeding is coverable + straight away (#90). 3. `devague plan risk "" --kind ` — park a genuine unknown as a - first-class plan risk instead of guessing. -4. `devague plan converge` — re-evaluates the gate **against the live frame** + first-class plan risk instead of guessing (`--resolve` closes one out; + `--amend --text` corrects a stale one in place, #84). +4. `devague plan defer --reason ""` — deliberately exclude a + coverage target from *this* plan's gate when it genuinely belongs to a later + one (`--undo` reverses it, #85). A deferred target drops out of the gate, + surfaces in `parked_items` labeled `deferred:`, and renders under + `## Deferred targets` in the export. This is the honest alternative to + faking coverage — never write a task that merely names a target. +5. `devague plan converge` — re-evaluates the gate **against the live frame** (catches frame drift); lists gaps. A plan converges when every target is - covered by a confirmed task, every confirmed task has acceptance criteria, the - dependency graph is acyclic, and no blocking risk remains. -5. `devague plan export` — only after `converge` passes; writes a buildable + covered by a confirmed task **or deliberately deferred**, every confirmed + task has acceptance criteria, the dependency graph is acyclic, and no + blocking risk remains. +6. `devague plan export` — only after `converge` passes; writes a buildable plan-md (topologically ordered) to `docs/plans/-.md`. -6. `devague plan waves [--json]` — emit the plan's dependency graph as +7. `devague plan waves [--json]` — emit the plan's dependency graph as deterministic **scheduling metadata** (`{plan, waves}`): ordered batches of task ids that an external operator *could* fan out. Read-only, convergence-agnostic (works on an in-progress plan), and explicitly **not orchestration** — Devague describes the graph; it does not spawn subagents, manage worktrees, mark tasks done, or pick a backend (#20). A cyclic or dangling graph is refused via the plan-convergence dependency blockers. -7. `devague plan deliverables [--json]` — a read-only "end state" preview: +8. `devague plan deliverables [--json]` — a read-only "end state" preview: the plan's confirmed announcement/after-state/success-signal claims verbatim from its live source frame, every terminal task (an active task no other active task depends on) with its acceptance criteria, and the @@ -215,9 +292,13 @@ the devague CLI deterministic and non-orchestrating (#20). 1. **Spec gate**: the exported frame/spec. 2. **Implementation split plan gate**: the plan tasks map, per-task subagent + model assignment, and the go/no-go decision on assigning the plan to the - workforce. A mid-run deviation (recorded via `devague deviate` and the - cited `/deviate` skill) is **not** a fourth standing gate — it is the human - owner of this gate approving an amendment to it in-flight. + workforce. `split-plan --write` persists it as a durable artifact at + `docs/plans/--split.md` (#82) — the peer of the + exported spec and plan-md, which gate 2 previously lacked; hand-edited + `Owner` / `Model` cells are read back and survive regeneration. A mid-run + deviation (recorded via `devague deviate` and the cited `/deviate` skill) + is **not** a fourth standing gate — it is the human owner of this gate + approving an amendment to it in-flight. 3. **Final PR gate**: human code review of the merged result. ### Worktree contention safety @@ -274,7 +355,10 @@ skill and this convention, not in new CLI and not in a CI/CD runner. - **Operator/main agent**: drives execution of waves and merges each subagent's worktree (gated by TDD); owns the implementation split plan. - **Per-task subagents**: may be simpler or cheaper models; each builds a single - task test-first within its worktree. + task test-first within its worktree. The `/scope` leg uses the same idea + read-only: 5 or more candidate surfaces fan out one exploration subagent per + surface, defaulting to **sonnet** — and those subagents *never* run a + `devague` move, so provenance stays with the main agent (#79/#91). - **Human**: owns the three gates (spec, implementation split plan, final PR), including approving mid-run deviations against gate 2 via `/deviate`. @@ -284,8 +368,12 @@ skill and this convention, not in new CLI and not in a CI/CD runner. tasks}`); the `assign-to-workforce` skill's `split-plan` subcommand is the consumer — it renders the implementation split plan (task map, per-task agent/model proposal, go/no-go) and a trailing End state section quoting -`devague plan deliverables` verbatim (#70), then performs the fan-out itself. -`devague deviate` and the cited `/deviate` skill are the consumer for +`devague plan deliverables` verbatim (#70), optionally persists all of it plus +an owner/model annotation table to +`docs/plans/--split.md` with `--write` (#82), then +performs the fan-out itself. The same `waves --json` payload is the **single +source** for every per-task brief — no `plan show --json` or exported plan-md +needed alongside it. `devague deviate` and the cited `/deviate` skill are the consumer for mid-run departures from that plan; `devague summary` and `/summarize-delivery` are the consumer for what actually shipped once the run ends. Devague itself never orchestrates any of this (#20) — its use across all four is shared via @@ -358,18 +446,24 @@ that unless the user asks otherwise. The established sibling shape is: `DevagueError` + exit-code policy) and `_output.py` (strict stdout/stderr split, `--json` support). - `devague/cli/_commands/` — one module per verb, each exposing `register()`. - Frame verbs: `new`, `capture`, `interrogate`, `confirm`, `reject`, `park`, - `converge`, `export`, `status`, `show`, `list`, `learn`, `explain` (`status` - shares `cli/_status.py` with the plan engine), plus two more flat verbs, - `deviate` (`--list`, `--confirm`, `--reject`) and `summary` (`--pr`), backed - by `devague/delivery.py` + `devague/delivery_store.py`. The plan engine adds + Frame verbs: `new`, `capture`, `amend`, `interrogate`, `confirm`, `reject`, + `review`, `question`, `park`, `scope`, `converge`, `export`, `status`, + `show`, `list`, `learn`, `explain` (`status` shares `cli/_status.py` with + the plan engine), plus two more flat verbs, `deviate` (`--list`, + `--confirm`, `--reject`) and `summary` (`--pr`), backed by + `devague/delivery.py` + `devague/delivery_store.py`. The plan engine adds one module, `_commands/plan.py`, registering the nested `plan` subcommand group — `new` / `task` / `instruct` / `accept` / `amend` / `depend` (plus - `--remove`) / `cover` / `confirm` / `reject` / `risk` / `converge` / `export` - / `waves` / `deliverables` / `status` / `show` / `list` / `learn` / `explain`. + `--remove`) / `cover` / `defer` / `confirm` / `reject` / `risk` / `converge` + / `export` / `waves` / `deliverables` / `status` / `show` / `list` / `learn` + / `explain`. - Frame engine: `devague/frame.py`, `convergence.py`, `store.py`, `render/{spec_md,frame_md}.py`. Plan engine (its peer): `devague/plan.py`, `plan_convergence.py`, `plan_store.py`, `render/plan_md.py`, `cli/_plans.py`. + Delivery peer: `devague/delivery.py`, `delivery_store.py`, + `render/summary_md.py`. Cross-cutting: `devague/contested.py` (the read-only + claim↔deviation join, #92) and `render/_md_safety.py` (render-time markdown + escaping, #87) — both pure and read-only; neither ever mutates a store. - `pyproject.toml`, `CHANGELOG.md`, `tests/`, `docs/`, `culture.yaml`, `sonar-project.properties`, `uv.lock`. diff --git a/README.md b/README.md index 9f4a1eb..6897834 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,18 @@ devague --version - **Frame engine** (idea→spec) — start from the announcement ("pretend it shipped"), capture and pressure-test claims, park open vagueness, and `export` a spec only once the frame *converges*. Flat verbs: `devague new` / - `capture` / `interrogate` / `confirm` / `converge` / `export` / … + `capture` / `amend` / `interrogate` / `confirm` / `park` / `scope` / + `converge` / `export` / … - **Plan engine** (spec→plan) — seed a plan from a converged frame, cover every target with tasks that carry acceptance criteria and an acyclic dependency order, and `export` a plan only once it *converges*. Nested group: - `devague plan new` / `task` / `cover` / `converge` / `export` / … + `devague plan new` / `task` / `cover` / `defer` / `converge` / `export` / … + +Nothing gets deleted to make a gate go green. A parked unknown, a blocking +hard question, and a coverage target that belongs to a later milestone each +have an explicit close-out move — `park --resolve`, `interrogate +--resolve `, and `plan defer` — that keeps the item on the record with +the decision that closed it, and drops it out of the gate. Run `devague learn` (or `devague plan learn`) to learn the method, and `devague explain ` for any single move. @@ -51,6 +58,11 @@ durable, explicitly non-authoritative artifact you can review out of band, then apply: each item is emitted with a `pending` marker — change it to `confirm` or `reject` and feed the file back with `confirm --from-review`. `pending` lines are never auto-confirmed; a batch is transactional (one bad id ⇒ nothing changes). +Rejecting a claim sweeps its still-live honesty conditions and unresolved hard +questions with it (`c4 -> rejected (also rejected: h3, q1)`), so rejected +content leaves the review pool and the exported spec together. The plan side +mirrors all of this: `devague plan confirm t1 t2 t3` / `plan reject …` are +multi-id and transactional too. Open questions / pending decisions live as durable working state too: diff --git a/docs/llm-guidance.md b/docs/llm-guidance.md index 6682989..6834323 100644 --- a/docs/llm-guidance.md +++ b/docs/llm-guidance.md @@ -34,12 +34,13 @@ what remains before a spec (or plan) can be exported. Three legs share one chassis (the third optional): - **Scope (idea → explored scope, optional).** The `/scope` skill's pre-frame - survey: `ScopeEntry` records (`surface` explored, `finding`, and the claim - `id`s it `seeds`) that ground the frame in what was actually looked at, - instead of generic disclaimers. Small ideas skip straight to the frame — this - leg is optional by size, never a mandatory first stage. Its state - (`Frame.scope_entries`) already exists; the `devague scope` move that writes - it is landing in a follow-up (#53 task t3). + survey: `ScopeEntry` records (`surface` explored, `finding`, and the ids it + `seeds` — claim ids `c*` or claim-attached hard-question ids `q*`) that + ground the frame in what was actually looked at, instead of generic + disclaimers. Small ideas skip straight to the frame — this leg is optional by + size, never a mandatory first stage. Both the state (`Frame.scope_entries`) + and the `devague scope` move that writes it have shipped; `scope --amend + --finding ""` corrects a finding in place. - **Frame (idea → spec).** Claims (each with a *kind* — `announcement`, `audience`, `after_state`, `before_state`, `why_it_matters`, `boundary`, `success_signal`, `open_question`, `non_goal`, `requirement`, `assumption`, @@ -48,10 +49,14 @@ Three legs share one chassis (the third optional): `unknown_blocking` / `out_of_scope` / `follow_up`). A parked item is not stuck once decided: `park --resolve --decision "" [--claim ]` closes it out — it stays on record with its resolution, drops out of the - convergence gate, and never requires hand-editing `.devague` state. -- **Plan (spec → plan).** Coverage targets (derived from a converged frame), - tasks (with acceptance criteria, dependencies, and the targets they cover), - and first-class plan risks. + convergence gate, and never requires hand-editing `.devague` state. A + blocking **hard question** has the same exit: `interrogate --resolve + --decision ""`. A claim whose text or kind is wrong is corrected + with `amend `, keeping its id and everything attached to it. +- **Plan (spec → plan).** Coverage targets (derived from a converged frame, + each of which may be deliberately `defer`red out of this plan's gate with a + reason), tasks (with acceptance criteria, dependencies, and the targets they + cover), and first-class plan risks. Every element carries two orthogonal axes: @@ -115,12 +120,31 @@ These are not style preferences. Convergence is only meaningful if these hold. --decision ""` (plan side: `plan risk --resolve --decision ""`) rather than leaving it parked forever or hand-editing state; the resolved item stays on record with its resolution and stops blocking. +- **Blocking hard questions route through the user too.** A question raised + `--blocking` holds convergence back until it is *decided*, not until you talk + yourself out of it. Close it out with `interrogate --resolve + --decision ""` — the claim-level twin of `park --resolve`, and a + **user-only** decision like `confirm`. The question stays on record with its + answer and stops blocking; deleting it or hand-editing state does not. +- **Correct in place; never churn ids to fix wrong content.** A claim whose + text or kind is wrong is `amend`ed (`amend --text … --kind … --reason + …`), not rejected and recaptured: the id survives, so its honesty + conditions, hard questions, `instruction`, and inbound `scope --seeds` + references keep pointing at something real, the superseded value is kept as + an evidence trail, and a confirmed claim flips back to `proposed` for the + user to re-confirm. Same shape on the other two: `scope --amend + --finding ""` and `plan risk --amend --text ""`. +- **Defer honestly; don't fake coverage.** If a coverage target genuinely + belongs to a later milestone, `plan defer --reason ""` — a + documented exclusion that stays visible in `parked_items` and in the + exported plan. Never write a task that merely *names* a target so the + coverage gate goes green. - **Converge, don't vibe.** `export` is gated on `converge` passing. Never declare a frame or plan "ready" on a hunch — run `converge` and resolve every listed gap first. - **Instructions are optional and verbatim — never fabricated.** Leave `instruction` empty rather than invent one just to satisfy a gate warning - (once the structural sharpness warnings in #53 t7/t8 land). Changing an + (the structural sharpness warnings, #53 t7/t8). Changing an instruction on an already-confirmed item deliberately demotes it back to `proposed` — that is the same anti-fabrication contract catching a late-arriving field, not a bug. @@ -133,6 +157,9 @@ These are not style preferences. Convergence is only meaningful if these hold. | You proposed an honesty condition | `confirm h3` yourself so the gate passes | leave `h3` proposed; surface it for the user to confirm | | A key detail is genuinely unknown | invent a plausible answer to keep momentum | `park "" --kind unknown_blocking` | | A blocking park just got decided | leave it parked forever, or hand-edit `.devague` state to unblock convergence | `park --resolve --decision ""` | +| A blocking hard question just got decided | argue it is "not really blocking", or hand-edit the frame JSON | `interrogate --resolve --decision ""` (user-only) | +| A confirmed claim's text turns out to be wrong | `reject` it and `capture` a replacement, orphaning its honesty conditions and scope seeds | `amend --text "" --reason ""`; the user re-confirms | +| A coverage target belongs to a later milestone | add a task that name-drops the target so `plan converge` passes | `plan defer --reason ""` | | User asks "is this ready?" | "Yes, looks solid." | run `converge`; report the actual blockers/warnings | | The user skipped a stage | march through the stages in order anyway | capture what they gave you; let the arc fill in adaptively | | Plan: a task has no clear acceptance test | mark it confirmed and move on | leave it without criteria (the gate blocks it) or `park` the risk | @@ -145,7 +172,8 @@ spirit: - **Seed from a converged spec only** — `plan new` refuses an unconverged frame. - **LLM-proposed tasks stay proposed**; the user confirms them. -- **Cover every target, criteria on every task** — the gate requires it. +- **Cover every target, criteria on every task** — the gate requires it, unless + a target is deliberately `plan defer`red with a reason. - **Keep the dependency graph honest** — real task ids, acyclic. - **Park genuine unknowns as risks** (`unknown_blocking` holds convergence back); close a decided one out with `plan risk --resolve --decision diff --git a/docs/skill-sources.md b/docs/skill-sources.md index 031e342..106b0f8 100644 --- a/docs/skill-sources.md +++ b/docs/skill-sources.md @@ -57,11 +57,11 @@ shipped with it from day one. | Skill | Origin | Downstream | Notes | |-------|--------|------------|-------| -| `scope` | **devague** (here: `.claude/skills/scope/`) | `guildmaster`, then the AgentCulture mesh | Operator for the **idea→scope** leg: the optional pre-frame exploration stage from the sharper end-to-end method spec (devague#53). Method-only today — no entry-point script; findings land through existing `devague` moves via the `/think` wrapper. The deterministic `devague scope` CLI move (first-class findings with provenance) is planned in the #53 build plan (task t3); its `devague learn skills` authoring recipe lands with that move (tasks t10/t11). New in 0.15.0. Re-vendor from `../devague/.claude/skills/scope/`. | +| `scope` | **devague** (here: `.claude/skills/scope/`) | `guildmaster`, then the AgentCulture mesh | Operator for the **idea→scope** leg: the optional pre-frame exploration stage from the sharper end-to-end method spec (devague#53). Method-only — no entry-point script; findings land through the deterministic `devague scope` move (first-class findings with provenance; `--seeds` accepts claim ids **and** claim-attached hard-question ids, `--amend --finding` corrects one in place, #84), and `devague learn skills` teaches its authoring recipe. Since 0.21.0 the exploration step is subagent-aware: 4 or fewer candidate surfaces are explored inline, 5 or more fan out one read-only subagent per surface defaulting to the smaller tier (**sonnet**), and those subagents never run a `devague` move — the main agent runs every move, so provenance stays in one place (#79, #91). New in 0.15.0; updated in 0.21.0. Re-vendor from `../devague/.claude/skills/scope/`. | | `think` | **devague** (here: `.claude/skills/think/`) | `guildmaster`, then the AgentCulture mesh | Operator for the **idea→spec** leg of the deterministic devague CLI: portable resolution, driving the flat `devague ` verbs (`status` is now a first-class CLI verb, not embedded Python). Renamed from `devague` in 0.4.0. `guildmaster` re-vendors it from `../devague/.claude/skills/think/` and broadcasts it to the mesh. | | `challenge` | **devague** (here: `.claude/skills/challenge/`) | `guildmaster`, then the AgentCulture mesh | Operator for the **spec** leg, third in flow order — quoted verbatim from the shipped `SKILL.md` frontmatter `description`: "a risk-scaled blind-spot discovery pass over a converged, exported frame BETWEEN /think and /spec-to-plan (the seventh origin skill, third leg in flow order): pressure-test the spec through structured lenses, route every finding back through the existing deterministic moves as proposed-only content the human adjudicates, and on a clean pass record the examined lenses/surfaces and residual uncertainty — never a claim that there are no unknown unknowns." Method-only — no entry-point script; findings route through existing deterministic moves only (`capture` / `interrogate` / `question` / `park` / `devague scope` / `devague plan risk`), never a new CLI verb, engine, or state model (#20, devague#73). Not a fourth standing gate — findings are adjudicated inside the existing spec gate. New in 0.19.0. Re-vendor from `../devague/.claude/skills/challenge/`. | | `spec-to-plan` | **devague** (here: `.claude/skills/spec-to-plan/`) | `guildmaster`, then the AgentCulture mesh | Operator for the **spec→plan** leg (`devague plan ...`): portable resolution over the plan convergence gate. New in 0.4.0. Re-vendor from `../devague/.claude/skills/spec-to-plan/`. | -| `assign-to-workforce` | **devague** (here: `.claude/skills/assign-to-workforce/`) | `guildmaster`, then the AgentCulture mesh | Operator for the **implementation** leg (fan out `devague plan waves` to parallel agents in isolated git worktrees, with TDD-gated merges by the main agent). Three human gates only: spec / implementation split plan / final PR. The devague CLI remains non-orchestrating (#20) — this skill is the convention + helper, not new CLI behavior. New in 0.7.0. Re-vendor from `../devague/.claude/skills/assign-to-workforce/`. | +| `assign-to-workforce` | **devague** (here: `.claude/skills/assign-to-workforce/`) | `guildmaster`, then the AgentCulture mesh | Operator for the **implementation** leg (fan out `devague plan waves` to parallel agents in isolated git worktrees, with TDD-gated merges by the main agent). Three human gates only: spec / implementation split plan / final PR. Every per-task brief is built from the `plan waves --json` payload alone — the single source, no `plan show --json` or exported plan-md alongside it. Since 0.21.0 `split-plan --write` persists gate 2 as a durable artifact at `docs/plans/--split.md`, with a `Task` / `Owner` / `Model` table the script reads back so hand-edited assignments survive regeneration (#82) — artifact-only, no plan-schema change and no new CLI verb. The devague CLI remains non-orchestrating (#20) — this skill is the convention + helper, not new CLI behavior. New in 0.7.0; updated in 0.21.0. Re-vendor from `../devague/.claude/skills/assign-to-workforce/`. | | `deviate` | **devague** (here: `.claude/skills/deviate/`) | `guildmaster`, then the AgentCulture mesh | Operator for the **execution-time** leg: runs *during* an `/assign-to-workforce` fan-out, at the moment execution must diverge from the confirmed plan — stop the run, present what/why/what-it-affects, get explicit human approval, record via the deterministic `devague deviate` move (`--origin llm` lands proposed; only the user `--confirm`s), adjust the affected task briefs, resume. Not a fourth standing gate — the human owner of gate 2 (the implementation split plan) amending it mid-flight. Deviation records persist under `.devague/deliveries/.json` (devague#53 esd t3) and are the connective tissue `/summarize-delivery` quotes by `dN` id instead of reconstructing drift from memory. New in 0.18.0 (devague#53 esd t7). Re-vendor from `../devague/.claude/skills/deviate/`. | | `summarize-delivery` | **devague** (here: `.claude/skills/summarize-delivery/`) | `guildmaster`, then the AgentCulture mesh | Operator for the **delivery-side closure** leg (runs after `/assign-to-workforce`). Method-only in v1 — `SKILL.md` + template; no entry-point script, no new CLI verb. New in 0.17.0. Re-vendor from `../devague/.claude/skills/summarize-delivery/`. | diff --git a/docs/skills.md b/docs/skills.md index 1ed2aa0..6d2c79f 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -4,9 +4,10 @@ This is the canonical guide for **authoring the devague operator skills** in an agent runtime — what files they need, where they live, the entry-point shape, and the contract between a skill and devague's state. It is the long-form companion to `devague learn skills`, which surfaces a condensed, always-available -version of the same recipe. (`devague learn skills` currently teaches the three -CLI-driving skills; the recipe for the method-only `scope` skill joins it when -its `devague scope` CLI move lands — see the #53 build plan, tasks t3/t10/t11.) +version of the same recipe. (`devague learn skills` teaches all **seven** skills +in flow order since 0.19.0 — three CLI-driving ones that ship a +`scripts/.sh` resolver, and four method-only ones that are a `SKILL.md` +alone.) These skills are devague's **outbound** skills — devague is their origin/upstream (it dogfoods them to drive its own CLI), and guildmaster re-vendors @@ -105,7 +106,8 @@ exec "${DEVAGUE[@]}" "$@" `assign-to-workforce` adds one orchestration layer on top of this resolver (a `split-plan` subcommand that renders `devague plan waves --json` as a human-facing -table); the underlying `waves` call is still forwarded verbatim. +table, and with `--write` also persists it as a durable gate-2 artifact); the +underlying `waves` call is still forwarded verbatim. ## The skill ↔ devague contract @@ -131,9 +133,17 @@ A skill drives the deterministic CLI and adds no business logic of its own: --instruction`, #53 t4; plan side: `plan task --instruction` / `plan instruct `, #53 t5). Setting or changing an instruction on an already-confirmed item flips it back to `proposed` — the user re-confirms, same as any other - proposed content. `assign-to-workforce` is meant to quote a task's - instruction and acceptance criteria verbatim into the per-subagent brief - (#53 t9/t13) — no operator paraphrasing. + proposed content. `assign-to-workforce` quotes a task's instruction and + acceptance criteria verbatim into the per-subagent brief, straight from the + `plan waves --json` payload (#53 t9/t13) — no operator paraphrasing. +- **Correct in place; never delete to make a gate go green.** `devague amend` + (claim), `scope --amend` (finding), and `plan risk --amend` (risk text) fix + wrong content without churning ids — so honesty conditions, hard questions, + instructions, and inbound `--seeds` references stay pointed at something + real (#84). Closing something out is a separate, evidence-bearing move: + `park --resolve`, `interrogate --resolve `, `plan risk --resolve`, + and `plan defer` each keep the item on the record with the decision that + closed it. ## The operator skills @@ -145,11 +155,11 @@ final PR review): | Skill | Leg | What it drives | |-------|-----|----------------| -| `scope` | idea → explored scope (the optional opening leg) | read-only exploration; findings land via existing `devague` moves | +| `scope` | idea → explored scope (the optional opening leg) | read-only exploration (inline, or fanned out to sonnet subagents at 5+ surfaces); findings land via existing `devague` moves, always run by the main agent | | `think` | idea → spec (working backwards) | the flat `devague ` verbs | | `challenge` | "a risk-scaled blind-spot discovery pass over a converged, exported frame BETWEEN /think and /spec-to-plan" | "pressure-test the spec through structured lenses, route every finding back through the existing deterministic moves as proposed-only content the human adjudicates" | | `spec-to-plan` | spec → plan (working forwards) | the `devague plan ` group | -| `assign-to-workforce` | plan → parallel implementation | reads `devague plan waves` and `devague plan deliverables` (read-only) | +| `assign-to-workforce` | plan → parallel implementation | reads `devague plan waves` and `devague plan deliverables` (read-only); `split-plan --write` persists the gate-2 artifact | | `deviate` | execution-time — an in-flight fan-out diverges from the confirmed plan | the `devague deviate` move (`--list [--json]`, `--confirm`/`--reject`), backed by the delivery store | | `summarize-delivery` | execution → accountability artifact (the delivery-side closure leg) | starts from `devague summary` / `devague deviate --list`; reads plan / git / PR / test evidence (read-only) | @@ -162,13 +172,32 @@ unknown), then seed the `/think` frame with `boundary` / `non_goal` / ideas skip straight to `/think` (no wizard). From the sharper end-to-end method spec ([devague#53](https://github.com/agentculture/devague/pull/53)). +**How the exploration step runs (new in 0.21.0, #79/#91).** The candidate +count decides the shape: **4 or fewer** surfaces are explored inline and +serially by the main agent, exactly as before — spinning up subagents to read +three files costs more than it saves. **5 or more** fan out one **read-only +exploration subagent per surface** (or per tight cluster of related surfaces), +defaulting every subagent to the smaller tier, **sonnet** — a default, not a +ceiling: escalate one subagent when that specific surface genuinely needs +synthesis, never the whole survey. The load-bearing rule of the fan-out is +that **subagents explore and report; they never run a `devague` move**. Each +returns a touched / not-touched / unknown verdict with the file, line, or +command output that grounds it, and the main agent alone runs every `capture` +/ `scope` / `question` / `park` call from that reported evidence — so +provenance and the anti-fabrication contract stay in one place instead of +being scattered across subagent transcripts the user never sees. The recorded +finding still cites the actual surface, never "a subagent explored this". + The skill ships no entry-point script of its own, but the CLI surface it records into is live: `Frame.scope_entries` / `ScopeEntry` (`id`, `surface`, -`finding`, `seeds`) shipped in #53 task t1, and the deterministic -`devague scope` move that writes it (`devague scope "" ---finding "" [--seeds ...]`, plus `scope --list [--json]`) -shipped in #53 task t3; this skill's `devague learn skills` authoring recipe -lands with tasks t10/t11. +`finding`, `seeds`) and the deterministic `devague scope` move that writes it +— `devague scope "" --finding "" [--seeds ...]`, plus `scope --list [--json]` and, since 0.21.0, +`scope --amend --finding ""` to correct a finding in place (#84). +`--seeds` accepts claim ids (`c*`) **and** claim-attached hard-question ids +(`q*`, #84) — the latter is the branch this skill's routing table sends a +"genuinely unknown, needs a user decision" finding down, whose provenance link +was previously unrecordable. An unknown seed id is refused with a hint. - Source: [`.claude/skills/scope/`](https://github.com/agentculture/devague/blob/main/.claude/skills/scope/SKILL.md) @@ -180,6 +209,12 @@ Start from the announcement ("pretend it shipped — what would you announce?"), build an Announcement Frame by capturing and classifying claims, pressure-test them with honesty conditions and hard questions, park genuine unknowns as first-class open vagueness, and `export` a spec only once the frame **converges**. +Since 0.21.0 the two moves that used to have no exit exist: `devague amend ` +corrects a claim without losing its id (and its honesty conditions, hard +questions, `instruction`, and inbound scope seeds), and `devague interrogate + --resolve --decision ""` closes out a blocking hard question — +before that, one blocking question deadlocked `converge` permanently +(#84, #48/#52). - Source: [`.claude/skills/think/`](https://github.com/agentculture/devague/blob/main/.claude/skills/think/SKILL.md) @@ -237,6 +272,17 @@ downstream fan-out can run wide waves. The per-task `instruction` field and the acceptance criteria remain the testable contract, instructions the working guidance carried verbatim to the workforce. +Since 0.21.0: `plan confirm` / `plan reject` take many ids in one transactional +call like the frame side, and argument errors inside the group point at +`devague plan explain ` (#86); `--dep` / `depend --on` refuse a +self-dependency or unknown id at creation rather than surfacing it later as a +`waves` cycle (#86); `cover` / `--covers` validate against targets re-derived +from the **live** frame, so a target the frame grew after seeding is coverable +straight away (#90); and `plan defer --reason ""` deliberately +excludes a target that belongs to a later milestone, which is what makes a +milestone-scoped plan converge honestly instead of being covered by a task that +merely names it (#85). + - Source: [`.claude/skills/spec-to-plan/`](https://github.com/agentculture/devague/blob/main/.claude/skills/spec-to-plan/SKILL.md) (`SKILL.md` + `scripts/spec-to-plan.sh`). @@ -253,8 +299,11 @@ task ids restart at `t1`, and reads as deletable scratch space to anyone else) and never inside the repo (where `git add -A` sweeps checkouts into the PR and `git clean -fdx` destroys live agent work). Exactly three human gates; the final PR uses the `cicd` skill (`agex pr open`). Each subagent's brief quotes its task's fields verbatim -from `plan show --json` / the exported plan-md — including the per-task -`instruction` — never an operator paraphrase. The `split-plan` subcommand +from the **`devague plan waves --json` payload** — the single source, whose +top-level `tasks` object carries each task's `summary`, `instruction`, +`acceptance_criteria`, and `covers`, so a brief needs no external context and +never an operator paraphrase (neither `plan show --json` nor the exported +plan-md is read alongside it). The `split-plan` subcommand renders the implementation split plan as a four-column Wave / Task / Model / Task summary table (real, editable model tokens; 72-character summary truncation, #69) with has-instruction and acceptance-criteria-count markers, @@ -263,6 +312,26 @@ verbatim (#70) — so gate 2's go/no-go sees what the plan actually produces, no just its task map. Degrades gracefully to a one-line version hint on a `devague` too old to have the `deliverables` verb. +**The durable gate-2 artifact (`split-plan --write`, new in 0.21.0, #82).** +The exported spec (`docs/specs/*.md`) and the exported plan +(`docs/plans/*.md`) both persist; gate 2 previously survived only in +conversation. `--write` closes that gap by persisting the same content to +`docs/plans/--split.md` — beside the plan-md, using the +same date-prefix convention derived from the plan's own `created` timestamp, +so re-running updates that one file in place instead of spawning a dated +duplicate. The file carries the full per-wave/per-task content quoted verbatim +from `plan waves --json` (one `## Wave N` heading per wave, one +`### ` per task), a `Task | Owner | Model` assignment +table that the script **reads back** before regenerating — so a human's edited +Owner/Model cell survives the next `--write`, matched by task id, rather than +resetting to the `sonnet` default — and the same End state section. It is an +**artifact-only** change: no plan-schema change, no new `devague` verb, and +`plan waves` / `show` / `deliverables` stay read-only exactly as before. Only +the assignment table is meant to be hand-edited; the wave/task sections above +it are fully regenerated every run. Present the file (or plain `split-plan`'s +stdout twin) at the go/no-go either way — `--write` keeps a committed record +of what was approved, it does not replace the live review. + - Source: [`.claude/skills/assign-to-workforce/`](https://github.com/agentculture/devague/blob/main/.claude/skills/assign-to-workforce/SKILL.md) (`SKILL.md` + `scripts/assign-to-workforce.sh`). diff --git a/docs/spec-contract.md b/docs/spec-contract.md index 34e54c9..59c4d7a 100644 --- a/docs/spec-contract.md +++ b/docs/spec-contract.md @@ -20,12 +20,26 @@ see [`llm-guidance.md`](llm-guidance.md) (also surfaced in `devague learn`). ## Versioning -Every frame carries an integer `schema_version` (currently `3`). It is written +Every frame carries an integer `schema_version` (currently `4`). It is written on save and checked on load: a frame whose `schema_version` is newer than this devague supports is rejected, fail-closed, with an actionable error. A 0.4.0 frame predates the field and loads as the current schema, so existing frames keep working. +The check runs against the **raw** loaded dict, *before* the domain object is +constructed (issue-backlog-sweep t2). Constructing first would surface a +genuinely newer file as an opaque `TypeError` from a nested dataclass that +does not recognise a key yet, instead of the intended fail-closed +`IncompatibleSchemaError` with its upgrade hint. For the same reason, nested +`HardQuestion` / `Vagueness` loading is tolerant of unknown keys rather than +splatting the raw dict. The plan store carries the identical guard. + +> **v4 (issue-backlog-sweep t4/t6).** Bumped to add `HardQuestion.resolution` +> (#48/#52) and `Claim.revisions` (#84) — see *HardQuestion* and *Claim* under +> Entities. A v3 frame predates both: it loads with `resolution` defaulted to +> `""` and `revisions` to `[]`, so no question loads as answered and no claim +> loads with a fabricated revision trail. +> > **v3 (resolve-parked-vagueness t1).** Bumped to add `Vagueness.resolved`, > `Vagueness.resolution`, and `Vagueness.resolution_claim_id` — see *Vagueness* > under Entities. A v2 frame predates all three: it loads with `resolved` @@ -73,6 +87,21 @@ A discrete statement that may become part of the spec. - `links` — related claim ids. - `instruction` — optional verbatim text: how to verify or implement this claim; `""` means none (v2, #53 t1). See *Instructions* below. +- `revisions` — list of ClaimRevision: the `(text, kind)` pairs this claim has + superseded, oldest first (v4, #84). Empty for a claim that has never been + amended — the common case, and every claim predating the field. Written only + by `Frame.amend_claim`. + +### ClaimRevision + +A superseded `(text, kind)` pair, recorded when a claim is amended (v4, #84). +Deliberately a *lightweight* evidence marker, not an audit log: it captures +only the two fields `amend` can change plus an optional operator-authored +reason, and carries no timestamp or actor (no other Frame entity does either). + +- `text` — the claim text that was replaced. +- `kind` — the claim kind that was replaced. +- `reason` — optional operator note on why it was amended; `""` means none. ### HonestyCondition @@ -90,8 +119,23 @@ An unresolved question against a claim. - `id` — `q1`, `q2`, … - `text` — the question. -- `resolved` — boolean. -- `blocking` — boolean; a blocking, unresolved question holds back convergence. +- `resolved` — boolean; `False` until closed via `Frame.resolve_hard_question` + (v4, issue-backlog-sweep t4). That is the **only** mutator — before it + shipped, nothing in the codebase ever set this field, so a blocking question + deadlocked `converge` permanently (#48/#52). +- `resolution` — the optional free-text answer recorded with `--decision`; + `""` means none (v4). Unlike `park --resolve`'s required `--decision`, the + note is optional here; the resolution *state* is what clears the gate. +- `blocking` — boolean; a blocking, unresolved question holds back convergence + unless its parent claim has been `rejected` (a claim decided against makes + its open questions moot — a pure status check, not a resolution, so a + rejected claim's question can still be resolved later without erroring). + +Two independent counters mint `qN` ids — claim-attached hard questions (here) +and the separate durable `.devague/questions/.md` artifact driven by +`devague question` — and both start at `q1`. `Frame.find_hard_question` and +`Frame.resolve_hard_question` only ever search claim-attached questions; the +claim id is what disambiguates which namespace is meant. ### Vagueness @@ -121,15 +165,30 @@ pre-frame leg (the `/scope` skill, #53). Lives on the frame as - `surface` — what was explored (a file, a subsystem, a doc — the operating agent's read-only survey, never devague's own code). - `finding` — what was learned about that surface. -- `seeds` — claim ids this finding seeded (typically `boundary` / `non_goal` / - `assumption` claims that cite what was actually explored); an unknown claim - id is refused at construction, the same fail-closed rule as everywhere else. +- `seeds` — the ids this finding seeded (typically `boundary` / `non_goal` / + `assumption` claims that cite what was actually explored). A seed may be a + **claim id** (`c*`) or a **claim-attached hard-question id** (`q*`, #84 — + the branch the `/scope` routing table sends a "genuinely unknown, needs a + user decision" finding down, whose provenance link was previously + unrecordable). An id resolving to neither is refused at construction, the + same fail-closed rule as everywhere else. The domain model (`Frame.add_scope_entry`) and its round-trip through -`schema_version` 2 ship in #53 task t1, and the CLI move that records one — -`devague scope "" --finding "" [--seeds ...]`, plus -`scope --list [--json]` to read them back — ships in #53 task t3. An unknown -seed claim id is refused with a hint and nothing is persisted. +`schema_version` 2 shipped in #53 task t1; the CLI move that records one — +`devague scope "" --finding "" [--seeds ...]`, plus +`scope --list [--json]` to read them back — shipped in #53 task t3. An unknown +seed id is refused with a hint and nothing is persisted. + +`scope --amend --finding ""` (#84) replaces an entry's `finding` in +place: same `id`, same `surface`, same `seeds`, nothing else changes. It is +deliberately asymmetric with `amend` on a claim — a scope entry carries no +`status`/`origin` to protect, so there is **no revision trail** here. The +alternative it replaces was recording a second entry saying "supersedes s18", +which left both the wrong finding and its correction in the exported spec. + +A seed that cites a **rejected** claim renders with a `(rejected)` marker in +the exported spec rather than as a bare dead reference, and a `q*` seed +renders as `(question)` — or `(question, resolved)` once answered (#84). ## Vocabulary @@ -205,15 +264,22 @@ serializes it under `ready_for_spec`; the plan CLI under `ready_for_plan`. - `ready_for_spec` (bool) — the gate: true when there are no blockers. - `blockers` (list) — what holds convergence back. -- `warnings` (list) — surfaced but non-blocking (e.g. an unconfirmed assumption). -- `parked_items` (list) — tracked, non-blocking open vagueness. -- `required_next_moves` (list) — the recommended move per blocker. +- `warnings` (list) — surfaced but non-blocking (e.g. a still-`proposed` + assumption; a **rejected** assumption no longer warns — that decision is + already made and neither confirming nor re-rejecting it is a useful next + move, #83). +- `parked_items` (list) — tracked, non-blocking open vagueness (plus, on the + plan side, deliberately deferred coverage targets, labeled `deferred:`). +- `required_next_moves` (list) — the recommended move per blocker. For a + blocking hard question this now names the executable move — + `devague interrogate --resolve --decision ""`, + flagged as a USER decision — rather than prose advice (#48/#52). A frame converges when there are confirmed `announcement` / `audience` / `after_state` claims, a `before_state` or `why_it_matters`, a `boundary`, a `success_signal`, a confirmed honesty condition on every spec-affecting claim, -and no unresolved blocking vagueness or blocking hard question. `export` is gated -on `ready_for_spec`. +and no unresolved blocking vagueness or unresolved blocking hard question on a +non-rejected claim. `export` is gated on `ready_for_spec`. ### Structural sharpness warnings (soft rollout) @@ -245,8 +311,11 @@ the exit code is non-zero and `stderr` carries a `hint:` line. |---|---|---|---| | `new ""` | announcement text | frame slug | creates a frame; seeds a confirmed `announcement` claim | | `capture --kind K "" [--origin]` | kind, text, origin | `{id, kind, origin, status}` | adds a claim (`llm` → `proposed`, else `confirmed`) | +| `amend [--text ""] [--kind K] [--reason ""]` | claim id, new text and/or kind, optional reason | `{id, kind, text, origin, status, flipped}` | corrects a claim **in place** — id, honesty conditions, hard questions, `instruction`, and inbound scope `seeds` all survive; appends the superseded pair to `revisions`; flips a **confirmed** claim to `proposed` (v4, #84). `origin` is never touched | | `interrogate [--honesty/--hard-question/--risk/--contradicts]` | claim id + attachment | `{added: [...]}` | attaches a honesty condition / question (`llm` honesty → `proposed`) | -| `confirm ` / `reject ` | claim or honesty id | `{id, status}` | the **only** path to `confirmed` / `rejected` — user-only | +| `interrogate --resolve [--decision ""]` | claim id, hard-question id, optional resolution note | `{claim, id, resolved, resolution}` | closes out that claim's hard question (v4, #48/#52) — the **only** path to `HardQuestion.resolved`; user-only, the claim-level twin of `park --resolve`; mutually exclusive with every add-flag above | +| `scope "" --finding "" [--seeds …]` | surface, finding, optional seed ids | `{id, surface, finding, seeds}` | records a scope-exploration finding (v2, #53 t3); `--amend --finding ""` replaces a finding in place (#84) | +| `confirm […]` / `reject […]` | claim or honesty ids | `{confirmed, rejected, cascaded}` | the **only** path to `confirmed` / `rejected` — user-only, transactional; rejecting a claim cascades onto its still-live honesty conditions and unresolved hard questions (`cascaded`, echoed as `(also rejected: h3, q1)`, #83) | | `park "" --kind K` | text, vagueness kind | `{id, kind}` | adds first-class open vagueness | | `park --resolve VID --decision "" [--claim CN]` | vagueness id, decision text, optional deciding claim id | `{id, resolved, resolution, resolution_claim_id}` | closes out a parked item (v3, resolve-parked-vagueness t5) — the **only** path to `Vagueness.resolved`; user-only, mirrors `question --resolve` | | `converge` | — | the convergence result | promotes/demotes frame `status` | @@ -259,7 +328,13 @@ claim kind / origin / status or vagueness kind (rejected at construction); unknown claim or honesty id on `confirm`/`reject`; `park --resolve` without `--decision`; positional park text passed together with `--resolve`; an unknown or already-resolved vagueness id on `park --resolve`; an unknown -`--claim` id on `park --resolve`; an invalid `--frame` slug; a missing frame; +`--claim` id on `park --resolve`; an unknown claim id on `amend`, or `amend` +with neither `--text` nor `--kind`, or an unknown `--kind`; an unknown claim +id, an unknown or wrong-claim hard-question id, or an already-resolved +question on `interrogate --resolve`, and `--resolve` combined with any +add-flag; an unknown `--seeds` id on `scope` (resolving to neither a claim nor +a claim-attached hard question); an unknown entry id or a missing `--finding` +on `scope --amend`; an invalid `--frame` slug; a missing frame; a malformed or hand-edited frame file (including one whose embedded slug doesn't match the requested slug, or whose `schema_version` is not an integer); a frame whose `schema_version` is too new. @@ -271,10 +346,12 @@ explicit user `confirm` before they affect convergence. Nothing auto-confirms, `converge` never mutates a claim's status, and no fixed prompt sequence is imposed — the CLI stays a move-driven state tracker. -The same guarantee extends to instructions once t4/t5 land: setting or -changing one on an already-confirmed item demotes it back to `proposed` (see -*Instructions* above) — content changes route through the user exactly like -new proposals. +The same guarantee extends to instructions (#53 t4/t5): setting or changing one +on an already-confirmed item demotes it back to `proposed` (see *Instructions* +above) — content changes route through the user exactly like new proposals. It +extends to `amend` the same way (#84): correcting a confirmed claim's text or +kind flips it back to `proposed` rather than editing an approved statement +under the user. It also extends to deviation records (see *The delivery peer* below): an `llm`-origin deviation lands `proposed` and requires an explicit user @@ -303,6 +380,38 @@ models, v3, resolve-parked-vagueness t2). It reuses the same structured convergence result, serialized under `ready_for_plan`. See `docs/superpowers/specs/2026-05-23-devague-spec-to-plan-design.md`. +### CoverageTarget + +One thing the plan must deliver, derived from the source frame at +`plan new` (every confirmed claim and confirmed honesty condition). + +- `id` — the frame id it mirrors: a claim id (`c*`) or honesty id (`h*`). +- `kind` — a claim kind, or `"honesty"` for an honesty condition. +- `text` — the target text, copied from the frame. +- `deferred` — boolean; `False` unless `plan defer` deliberately excluded this + target from *this* plan's gate (v4, #85). Written only by + `Plan.defer_target` / `Plan.undefer_target`. +- `deferred_reason` — why it is out of scope for this plan; `""` when not + deferred (v4). `plan defer` requires a reason — a deferral without one is + refused, the same evidence-bearing rule as `park --resolve --decision`. + +A deferred target drops out of the coverage blocker, surfaces in +`parked_items` labeled `deferred:` (so it is visible, never silently +dropped), and renders under a `## Deferred targets` section in the exported +plan-md. This is the honest alternative to a task that merely *names* a +target: before it existed, a milestone-scoped plan could not converge at all +unless it faked coverage of work belonging to a later plan. + +The stored `targets` list is a **snapshot** taken at `plan new`, while +`converge` / `status` / `export` re-derive targets from the live frame. `cover` +and `task --covers` therefore check the snapshot first (the common, no-I/O +case) and fall back to the live frame, refreshing and persisting the snapshot +on a hit (#90) — otherwise `status` could recommend covering a target that +`cover` then refused as unknown, and a frame that legitimately grew a claim +mid-run could never converge again. If the source frame has itself regressed +below its own gate, the frame-drift error surfaces as-is rather than being +reworded into "unknown coverage target". + ### Moves All plan moves take `--json` and `--plan ` (default: the current plan). @@ -318,12 +427,15 @@ seeded surfaces as a clean error (frame drift) rather than a stale pass. | `instruct ""` | task id, instruction text | `{id, instruction, status, flipped}` | sets/replaces the task's instruction; flips a **confirmed** task back to `proposed` (see *The re-confirm rule* below) | | `accept ""` | task id, criterion text | `{id, acceptance}` | appends an acceptance criterion; does not change `status` | | `amend [--summary ""] [--accept-replace "" …] [--accept-remove …]` | task id, optional summary replacement, index-addressed acceptance-criterion edits | `{id, summary, acceptance_criteria, status, flipped}` | edits the summary and/or acceptance criteria in place; flips a **confirmed** task back to `proposed`; refuses outright on a **rejected** task | -| `depend --on ` | dependent task id, dependency task id | `{id, deps}` | appends a dependency edge; does not change `status` | +| `depend --on ` | dependent task id, dependency task id | `{id, deps}` | appends a dependency edge; does not change `status`; refuses a self-dependency or an unknown `` at creation (#86) | | `depend --on --remove` | dependent task id, dependency task id | `{id, deps, status, flipped}` | cuts exactly that one edge; flips a **confirmed** task back to `proposed`; refuses if the task did not depend on `` | -| `cover --target ` | task id, coverage target id | `{id, covers}` | marks a task as covering a coverage target; does not change `status` | -| `confirm ` / `reject ` | task id | `{id, status}` | the **only** path to `confirmed` / `rejected` — user-only | +| `cover --target ` | task id, coverage target id | `{id, covers}` | marks a task as covering a coverage target; does not change `status`; the target is validated against the stored snapshot first, then the **live** frame (#90) | +| `defer --reason ""` | coverage target id, reason | `{id, deferred, reason}` | deliberately excludes a target from *this* plan's gate (v4, #85); `--reason` is required | +| `defer --undo` | coverage target id | `{id, deferred}` | reverses a prior deferral, returning the target to the active gate; refuses a target that was never deferred | +| `confirm […]` / `reject […]` | one or more task ids | `{ids, status}` | the **only** path to `confirmed` / `rejected` — user-only and **transactional**: every id is validated first, so one bad id changes nothing (#86). Plan tasks have no attachments to cascade over, unlike the frame side | | `risk "" --kind K [--task ]` | risk text, vagueness kind, optional task ref | `{id, kind, task}` | records a first-class PlanRisk | | `risk --resolve RID --decision ""` | risk id, decision text | `{id, resolved, resolution}` | closes out a plan risk (v3, resolve-parked-vagueness t6) — the **only** path to `PlanRisk.resolved`; user-only, mirrors `park --resolve` (no `--claim` analog — risks link tasks via `--task`, not a deciding claim) | +| `risk --amend RID --text ""` | risk id, corrected text | `{id, kind, text, task, resolved, resolution}` | corrects a risk's text in place (#84) — the plan-side twin of the frame's `amend`; `kind`, `task_id`, and resolution state are untouched | | `converge` | — | the convergence result (`ready_for_plan`) | promotes/demotes plan `status`; re-evaluates against the live source frame | | `export [--format plan-md]` | — | `{path, format}` | writes the buildable plan; requires `ready_for_plan` | | `waves [--json]` | — | `{plan, waves, tasks}` | none — read-only, convergence-agnostic (see below) | @@ -336,15 +448,21 @@ unknown task id on `instruct` / `accept` / `amend` / `depend` / `cover` / `confirm` / `reject`; an unknown coverage target on `cover` or `task --covers`; `amend` called against a **rejected** task, or with neither `--summary` nor an acceptance edit, or with an out-of-range acceptance index; -`depend --remove` naming an edge the task does not have; an unsound +`depend --remove` naming an edge the task does not have; a self-dependency or +an unknown dependency id on `task --dep` / `depend --on`; an unsound dependency graph (a cycle, or a dependency on a missing/rejected task) on `waves`; `new` against an unconverged frame or over an existing plan; a source frame that has regressed below its own convergence on `converge` / -`export` / `status` (frame drift); `risk --resolve` without `--decision`; -positional risk text passed together with `--resolve`; an unknown or -already-resolved risk id on `risk --resolve`; an invalid `--plan` / `--frame` -slug; a missing plan; a malformed or hand-edited plan file; a plan whose -`schema_version` is too new. +`export` / `status` (frame drift, also surfaced as-is by `cover` / `defer` +when the id is absent from the stored snapshot); `risk --resolve` without +`--decision`; positional risk text passed together with `--resolve`; an +unknown or already-resolved risk id on `risk --resolve`; an unknown risk id or +a missing `--text` on `risk --amend`; `defer` without `--reason`, an unknown +target id, an already-deferred target on `defer`, or a never-deferred target +on `defer --undo`; an invalid `--plan` / `--frame` slug; a missing plan; a +malformed or hand-edited plan file; a plan whose `schema_version` is too new. +An argument error raised inside the `plan` group points at +`devague plan explain ` rather than the generic `--help` (#86). ### The re-confirm rule @@ -385,8 +503,10 @@ what is useful *before* convergence, at the assign-to-workforce go/no-go (issue #20: Devague describes state, it does not gate the human's decision). Plans carry the same persistence contract as frames. Every plan has an integer -`schema_version` (currently `3`, `PLAN_SCHEMA_VERSION`), written on save and -checked on load: `plan_store.load` **fails closed** with a clean `DevagueError` +`schema_version` (currently `4`, `PLAN_SCHEMA_VERSION`), written on save and +checked on load — against the **raw** dict before the domain object is built, +the same hardening the frame store carries: `plan_store.load` **fails closed** +with a clean `DevagueError` (exit code 1, upgrade hint) when a plan declares a `schema_version` newer than this devague supports. A pre-0.7.0 plan with no `schema_version` key loads silently as the current schema. Loaded `Task.origin` / `Task.status` and @@ -401,6 +521,12 @@ slug (so a tampered file can't silently redirect a later `save`), and parse non-integer value is rejected rather than coerced. These guards are symmetric across the frame and plan persistence twins. +> **v4 (issue-backlog-sweep t9).** `PLAN_SCHEMA_VERSION` bumped to add +> `CoverageTarget.deferred` and `CoverageTarget.deferred_reason` (#85) — see +> *CoverageTarget* above. A v3 plan predates both: every target loads with +> `deferred` defaulted to `False` and `deferred_reason` to `""`, so nothing +> loads as silently out of scope. +> > **v3 (resolve-parked-vagueness t2).** `PLAN_SCHEMA_VERSION` bumped to add > `PlanRisk.resolved` and `PlanRisk.resolution` — the plan-side twin of the > frame's v3 Vagueness bump. A v2 plan predates both: it loads with `resolved` @@ -511,6 +637,64 @@ are pure functions of `(Plan, Optional[Frame], Delivery)`: read-only, no I/O beyond the initial loads, and deterministic — rendering twice yields byte-identical output. +Planned Work and Actual Delivery are scoped to **confirmed** tasks (#88), plus +one line recording how many tasks were rejected during planning. A `rejected` +task is planning history, and a `proposed` task is still under adjudication — +folding either into an accountability artifact about what shipped would report +an open or reversed decision as a closed one. A plan with 19 confirmed and 68 +rejected tasks emitted 87 rows before this. + +## Render-time contracts + +Two things happen only at render time. Neither touches the stored JSON, and +neither appears in any `--json` payload — a `--json` view mirrors the +underlying data verbatim, exactly as the stores hold it. + +### Markdown safety (#87) + +`devague/render/_md_safety.py` composes two passes over every field of +verbatim claim / task / instruction / deviation text, at every verbatim site +in `spec_md.py`, `plan_md.py`, and `summary_md.py`: + +- `md_safe_text()` — underscore- and dunder-bearing identifiers are wrapped in + code spans rather than backslash-escaped (fixing MD037 and MD050 in one + move, since Markdown never parses inside a code span); the remaining + markdown control characters (`*`, `[`, `]`, a stray backtick, a leading + `#`) are backslash-escaped; text already inside a matched backtick pair is + left byte-for-byte untouched. Pure and **idempotent**: + `md_safe_text(md_safe_text(x)) == md_safe_text(x)`. +- `autolink_urls()` / `heading_safe()` — bare URLs are wrapped in `<…>` + (MD034), and heading text additionally has MD026 trailing punctuation + stripped. + +The exported artifacts therefore pass `markdownlint-cli2` without the author +having to hand-escape claim text, and the frame/plan JSON round-trips +unchanged. + +### Contested claims (#92) + +`devague/contested.py` derives, read-only and at render time, the claims an +approved deviation has contested. A frame outlives the plans seeded from it +and carries no reverse pointer to them, so the join enumerates plan slugs, +keeps the plans whose `frame_slug` matches, loads each one's delivery ledger, +and matches every **approved** deviation's `--affects` refs back onto the +frame's **confirmed** claims. + +- The exported spec renders a nested "⚠ contested by `dN`" bullet under the + claim (with the deviation's `classification` and `reason`); `show` and + `status` render `contested: by ` lines, and both gain a + `contested` key under `--json`. +- **The spec is never rewritten.** Per the maintainer ruling on #92, the + artifact points *forward* to the ledger rather than being edited to match + execution — "deviate is the marking of the change". +- **Fails open.** This is the first time frame-side read paths reach across to + the plan and delivery stores at all, so a plan file or delivery ledger that + is missing, truncated, or declares a newer schema degrades to "no markers + derived from that source" plus a human-readable diagnostic on stderr — + never a crash and never a blocked `export` / `show` / `status`. A plan with + no delivery ledger yet (the common case) is not a diagnostic at all. +- Nothing here mutates a claim, a plan, or a ledger, and no id is invented. + ### Schema versioning Deliveries carry the same persistence contract as frames and plans. Every diff --git a/pyproject.toml b/pyproject.toml index e622e1b..313620d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "devague" -version = "0.20.1" +version = "0.21.0" description = "devague — turns a vague feature idea into a buildable spec, then a buildable plan." readme = "README.md" diff --git a/uv.lock b/uv.lock index e1fe3ce..25d5465 100644 --- a/uv.lock +++ b/uv.lock @@ -183,7 +183,7 @@ wheels = [ [[package]] name = "devague" -version = "0.20.1" +version = "0.21.0" source = { editable = "." } [package.dev-dependencies] From 95d8fed2e3b733e5bd8fe77b8a10e26bd38cfe91 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 13:08:35 +0300 Subject: [PATCH 22/25] test: end-to-end verification + issue closure map (t19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial verification of all fifteen issues in the sweep, each checked against its own stated acceptance criteria and — where the issue carried repro steps — by running the reporter's sequence against the built CLI in a scratch dir rather than by reading the tests. Closure map: docs/deliveries/2026-07-28-issue-backlog-sweep-closure-map.md (967 tests green, 98.37% coverage, 0 markdownlint errors including the force-linted .claude/skills/** files). Three defects the per-task agents did not catch, fixed here with tests: - #94 (regression this release introduced): md_safe_text and autolink_urls were composed in OPPOSITE orders by spec_md vs plan_md/summary_md, and both corrupted a URL containing an underscore — the plan order backticked inside the link, the spec order truncated it at the first underscore, silently pointing a committed artifact's link at the wrong address. md_safe_text now carves out URLs exactly as it already carved out code spans, so both orders produce identical intact links. - #49 was only half closed: a resolved hard question rendered "(resolved)" but dropped the recorded decision text, while the parks renderer already rendered its own. The issue asked for "a pointer to the claim/decision that answered them", so the export now renders "(resolved: )" when interrogate --resolve --decision recorded one. - spec-to-plan/SKILL.md was never swept and still taught `plan reject` as single-id with a shell loop — the exact workaround #86 removed — in the skill guildmaster re-broadcasts to the mesh. Moves table corrected and pinned against `devague plan --help` by a new test. The markdownlint integration test now exports the repo's own real issue-backlog-sweep frame and plan as lint corpus, per the task instruction. Two CHANGELOG factual errors corrected: "Three of those" listed only two workarounds (#87's lint ignores were missing), and the swept-surfaces list omitted spec-to-plan. --- .claude/skills/spec-to-plan/SKILL.md | 23 ++- CHANGELOG.md | 36 ++++- devague/render/_md_safety.py | 29 +++- devague/render/spec_md.py | 8 +- ...6-07-28-issue-backlog-sweep-closure-map.md | 122 ++++++++++++++ tests/test_export_markdownlint_integration.py | 149 ++++++++++++++++++ tests/test_md_safety.py | 79 ++++++++++ tests/test_render.py | 27 ++++ tests/test_spec_to_plan_skill.py | 105 ++++++++++++ 9 files changed, 563 insertions(+), 15 deletions(-) create mode 100644 docs/deliveries/2026-07-28-issue-backlog-sweep-closure-map.md diff --git a/.claude/skills/spec-to-plan/SKILL.md b/.claude/skills/spec-to-plan/SKILL.md index 3e749c0..e503343 100644 --- a/.claude/skills/spec-to-plan/SKILL.md +++ b/.claude/skills/spec-to-plan/SKILL.md @@ -54,12 +54,15 @@ install hint. Every move — including `status` — is forwarded verbatim as | `task ""` | Add a task. `--accept ""`, `--dep `, `--covers ` (each repeatable), `--instruction ""` (verbatim working guidance, at creation); `--origin llm` lands it `proposed`. | | `instruct ""` | Add/update a task's working instruction. Changing it on an already-`confirmed` task flips it back to `proposed` — the user re-confirms (the plan side's mirror of the frame side's `interrogate --instruction` re-confirm rule). | | `accept ""` | Add an acceptance criterion to a task. | -| `depend --on ` | Record that task `tN` depends on `tM`. | -| `cover --target ` | Mark a task as covering a coverage target. | -| `confirm ` / `reject ` | Resolve a task. **User-only decision.** Takes **one task id per call** — loop for batches (unlike the frame engine's transactional multi-id `confirm`; parity is a recorded follow-up in the 2026-07-01 plan, devague#53). | -| `risk "" --kind ` | Record a first-class plan risk (`--task ` to attach). | -| `converge` | Evaluate the gate against the **live** source frame; list remaining gaps, plus non-blocking warnings (e.g. a confirmed task with no instruction). | +| `amend ` | Edit a task's summary (`--summary`) and/or replace/remove an acceptance criterion by index. May flip a `confirmed` task back to `proposed`; refuses on a rejected task. | +| `depend --on ` | Record that task `tN` depends on `tM`; `--remove` cuts one edge. Both a self-dependency and an unknown task id are refused **at creation** with an actionable hint (devague#86) — the same checks `task --dep` applies. | +| `cover --target ` | Mark a task as covering a coverage target. Validated against the **live** source frame, exactly as `converge` derives it — so a target the frame grew after seeding can be covered straight away (devague#90). | +| `defer --reason ""` | Deliberately exclude a coverage target from this plan's gate (`--undo` reverses it). The honest way to scope a plan to a milestone: deferred targets stop blocking `converge` and render in the exported plan's **Deferred targets** section with their reason (devague#85). An `out_of_scope` *risk* does **not** excuse a target — only `defer` does. | +| `confirm […]` / `reject […]` | Resolve one or more tasks in one **transactional** call — all ids valid or nothing changes. **User-only decision.** Matches the frame engine's multi-id `confirm`/`reject` (parity landed for devague#86). | +| `risk "" --kind ` | Record a first-class plan risk (`--task ` to attach). `--resolve --decision ""` closes one out; `--amend --text ""` corrects a risk's text in place, preserving its id, kind, task link, and resolution state (devague#84). | +| `converge` | Evaluate the gate against the **live** source frame; list remaining gaps, plus non-blocking warnings (e.g. a confirmed task with no instruction). Deferred targets are excluded from the gate. | | `export` | Write the buildable plan to `docs/plans/` — only after `converge` passes. | +| `deliverables` | Read-only "end state" preview: the source frame's confirmed announcement/after-state/success-signal claims, every terminal task with its acceptance criteria, and the surviving open items. Never refuses — useful before convergence too. | | `waves` | Emit deterministic dependency waves — `{plan, waves}` plus a top-level `tasks` object keyed by task id (per-task summary/instruction/acceptance criteria/covers — see *The `waves --json` payload* below) — scheduling + subagent-brief metadata only, *not* orchestration. Read-only, works on an in-progress plan; refuses a cyclic/dangling graph. Devague describes the graph; an operator decides how to run it (#20). | | `status` | Read-only: where the plan stands + the recommended next move, re-checked against the live frame (`--json` too). | | `show` / `list` | Render a plan / list plans (`--json` for raw state). | @@ -106,6 +109,16 @@ These are the point of the method — convergence must mean something. - **Cover every target; criteria on every task.** The gate requires every coverage target to be covered by a confirmed task, and every confirmed task to carry at least one acceptance criterion. Don't hand-wave a task as "done-ish." +- **Never fake coverage to satisfy the gate — `defer` instead.** If a target is + deliberately out of scope (a later milestone, a separately reviewed change), + do **not** write a task that merely *mentions* it so coverage goes green. That + is the exact dishonesty devague#85 was filed about: a task claiming a target it + does not deliver looks perfectly healthy to the gate. Run + `devague plan defer --reason ""` — the target stops blocking + `converge` and is named, with its reason, in the exported plan's **Deferred + targets** section, so the exclusion is visible to a reviewer instead of implied + by absence. Deferring is a scoping decision: surface it to the user, don't take + it unilaterally. - **Keep the graph honest.** Dependencies must reference real tasks and form an acyclic graph; the gate rejects dangling deps and cycles. - **Park real unknowns as risks; don't paper over them.** A genuinely unknown diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a0fdca..e7e8956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). The fifteen-issue backlog sweep (the `issue-backlog-sweep` plan, tasks t1–t19) — one devague-orchestrated workforce fan-out closing issues #48, #49, #52, #79, #82, #83, #84, #85, #86, #87, #88, #90, #91, #92, and #93. Three of those were hard blockers downstream repos were already working around -by hand: hand-editing frame JSON (#48/#52), and writing a second plan renderer -(#85). +by hand: hand-editing frame JSON (#48/#52), excluding generated artifacts from +markdownlint entirely (#87), and writing a second plan renderer (#85). All +three workarounds are verified deletable in +`docs/deliveries/2026-07-28-issue-backlog-sweep-closure-map.md`. ### Added @@ -127,7 +129,13 @@ by hand: hand-editing frame JSON (#48/#52), and writing a second plan renderer `learn skills`, `devague explain` / `plan explain`, `README.md`, `CLAUDE.md`, `docs/spec-contract.md`, `docs/llm-guidance.md`, `docs/skills.md`, `docs/skill-sources.md`, and the `think` / `scope` / - `assign-to-workforce` skills. + `spec-to-plan` / `assign-to-workforce` skills. The `spec-to-plan` moves table + had gone stale across several releases — it still taught `plan reject` as + single-id ("loop for batches") and never gained `amend`, `defer`, + `deliverables`, `depend --remove`, or `risk --amend`. Since guildmaster + re-broadcasts that skill to the mesh, the stale row was actively teaching the + workaround #86 removed. `tests/test_spec_to_plan_skill.py` now pins the moves + table against `devague plan --help`. ### Fixed @@ -136,10 +144,24 @@ by hand: hand-editing frame JSON (#48/#52), and writing a second plan renderer and silently dropped every open `unknown_nonblocking` item — exactly the kind that legitimately coexists with a converged frame, so the artifact claimed more certainty than the frame held. A resolved hard question now - carries a `(resolved)` marker instead of rendering as an open `(blocking)` - one. Hard questions attached to a rejected claim are dropped entirely. And - a scope-entry seed citing a rejected claim renders a `(rejected)` marker - instead of a bare dead reference. + carries a `(resolved)` marker — or `(resolved: )` when + `interrogate --resolve --decision` recorded one, the "pointer to the + claim/decision that answered them" #49 actually asked for — instead of + rendering as an open `(blocking)` one. Hard questions attached to a rejected + claim are dropped entirely. And a scope-entry seed citing a rejected claim + renders a `(rejected)` marker instead of a bare dead reference. +- **URLs containing underscores survive the escaper** (#94). `md_safe_text()` + and `autolink_urls()` were introduced without knowing about each other, and + the renderers compose them in **opposite** orders — `spec_md.py` runs + `autolink_urls(md_safe_text(text))` while `plan_md.py` / `summary_md.py` run + `md_safe_text(autolink_urls(text))`. Both were wrong for a URL carrying an + underscore: the plan order backticked inside the link + (``) and the spec order truncated it at the first + underscore (``some_path``), silently pointing a committed + artifact's link at the wrong address. `md_safe_text` now carves out URLs — + bare or already `<…>`-wrapped — exactly as it already carved out code spans, + so both orders produce identical, intact links. Caught by end-to-end + verification (t19), not by any task's own acceptance criteria. ## [0.20.1] - 2026-07-20 diff --git a/devague/render/_md_safety.py b/devague/render/_md_safety.py index 910914e..354ad15 100644 --- a/devague/render/_md_safety.py +++ b/devague/render/_md_safety.py @@ -49,6 +49,26 @@ # rendered content markdownlint never asked us to change. _CODE_SPAN_RE = re.compile(r"`[^`]*`") +# Regions ``md_safe_text`` must never reach inside (#94). ``autolink_urls`` and +# ``md_safe_text`` are composed by the renderers in *both* orders — spec_md +# runs ``autolink_urls(md_safe_text(text))`` while plan_md/summary_md run +# ``md_safe_text(autolink_urls(text))`` — so ``md_safe_text`` has to be safe +# whether a URL still looks bare or has already been wrapped in ``<...>``. +# Without this carve-out an underscore inside a URL is treated as an +# identifier and wrapped in a code span, which corrupts the link in one order +# ("") and truncates it at the first underscore in the +# other ("`a_b`"). Alternation order matters: code spans are +# matched first so a URL inside backticks stays governed by the code-span +# rule, then already-wrapped autolinks, then bare URLs. Only the URL itself is +# protected — surrounding markdown control characters keep being escaped +# exactly as before, so a claim mentioning "[text](url)" still renders as +# literal prose rather than becoming a live link. +_PROTECTED_RE = re.compile( + r"`[^`]*`" # code span + r"|]*>" # already-autolinked URL + r"|https?://[^\s<>()]+" # bare URL +) + def _strip_url_trailing_punct(url: str) -> tuple[str, str]: """Split a matched URL into ``(url, trailing)`` the way GFM's @@ -171,15 +191,20 @@ def md_safe_text(text: str) -> str: completely untouched, byte-for-byte — devague claim text routinely mixes prose with backticked tokens (c32/h25, issue-backlog-sweep), so this must never double-wrap or re-escape what is already a code span. + - URLs are left untouched too, whether still bare or already wrapped in + ``<...>`` by ``autolink_urls`` (#94). An underscore inside a URL path is + part of the address, not an identifier to wrap — reaching inside would + corrupt or truncate the link, and it does so in both of the orders the + renderers compose these two passes in. - Pure and idempotent: ``md_safe_text(md_safe_text(x)) == md_safe_text(x)`` for any ``x`` (h25) — the underlying Frame/Plan JSON is never touched, only the rendered copy. """ parts: list[str] = [] last = 0 - for m in _CODE_SPAN_RE.finditer(text): + for m in _PROTECTED_RE.finditer(text): parts.append(_escape_segment(text[last : m.start()])) - parts.append(m.group(0)) # existing code span: verbatim, never touched + parts.append(m.group(0)) # code span or URL: verbatim, never touched last = m.end() parts.append(_escape_segment(text[last:])) result = "".join(parts) diff --git a/devague/render/spec_md.py b/devague/render/spec_md.py index e6ca6d7..3e3336a 100644 --- a/devague/render/spec_md.py +++ b/devague/render/spec_md.py @@ -167,9 +167,15 @@ def _hard_question_marker(q: HardQuestion) -> str: else ``(blocking)`` while it still blocks convergence, else nothing. Resolved takes priority over blocking — an answered question is no longer an open blocker regardless of the flag it was created with (#49). + + A resolved question carrying the optional ``--decision`` text renders it + inline, mirroring how ``_resolved_vagueness_section`` renders a resolved + park's decision. #49 asked for resolved questions to carry "a pointer to + the claim/decision that answered them", not merely a flag — the bare + marker alone leaves a reader knowing a question was settled but not how. """ if q.resolved: - return " (resolved)" + return f" (resolved: {_safe(q.resolution)})" if q.resolution else " (resolved)" if q.blocking: return " (blocking)" return "" diff --git a/docs/deliveries/2026-07-28-issue-backlog-sweep-closure-map.md b/docs/deliveries/2026-07-28-issue-backlog-sweep-closure-map.md new file mode 100644 index 0000000..da5a3bf --- /dev/null +++ b/docs/deliveries/2026-07-28-issue-backlog-sweep-closure-map.md @@ -0,0 +1,122 @@ +# Issue closure map — issue-backlog-sweep (0.21.0) + +Verification artifact for the fifteen-issue sweep. Each issue below was checked +against **its own stated acceptance criteria**, not the plan's paraphrase of +them, and — where the issue carried concrete repro steps — by running the +reporter's own sequence against the built CLI in a scratch directory rather +than by reading the tests. + +Path rationale: `docs/deliveries/` is where this repo's delivery-side artifacts +live, under the same `-` convention as the plan and spec +exports. The `-closure-map` suffix keeps it distinct from the +`devague summary`-derived delivery summary the `/summarize-delivery` leg writes +to `docs/deliveries/2026-07-28-issue-backlog-sweep.md`, mirroring how the gate-2 +split artifact sits beside the plan export as `…-split.md`. + +## Suite and lint evidence + +| Check | Result | +| --- | --- | +| `bash .claude/skills/run-tests/scripts/test.sh --ci` | **967 passed**, 0 failed | +| Coverage (gate: `fail_under = 95`) | **98.37 %** | +| `markdownlint-cli2 "**/*.md"` | 39 files, **0 errors** | +| `.claude/skills/**/*.md` (force-linted; the bulk glob ignores them) | 19 files, **0 errors** | +| Real-corpus export lint (`issue-backlog-sweep` frame + plan) | **0 errors** | + +## Closure map + +| # | Issue | Verdict | Regression test(s) | +| --- | --- | --- | --- | +| 48 | Hard questions can never be resolved; `converge` wedged | closed | `tests/test_e2e_resolve.py::test_e2e_issue48_52_hard_question_block_resolve_converge_lifecycle` | +| 49 | `export` lossy: resolved questions read as open; `unknown_nonblocking` parks dropped | closed | `tests/test_render.py::test_spec_md_resolved_hard_question_renders_with_resolved_marker`, `::test_spec_md_resolved_hard_question_renders_its_decision_text`, `::test_spec_md_open_nonblocking_park_renders_labeled_by_kind` | +| 52 | Same deadlock as #48, with a five-point AC list | closed | `tests/test_e2e_resolve.py::test_e2e_issue48_52_hard_question_block_resolve_converge_lifecycle`, `::test_e2e_issue52_rejected_claim_unresolved_blocking_question_no_longer_blocks` | +| 79 | `/scope` should use subagents for exploration | closed (method-only) | `tests/test_teaching_surface_sweep.py::test_learn_documents_scope_fan_out_threshold` | +| 82 | `split-plan`: real task map + durable gate-2 artifact | closed | `tests/test_assign_to_workforce_script.py::test_split_plan_write_creates_durable_artifact_with_real_content`, `::test_split_plan_write_rerun_overwrites_in_place_and_preserves_edit`, `::test_split_plan_write_artifact_passes_markdownlint` | +| 83 | Rejecting a claim orphans attachments; rejected content reaches the spec | closed | `tests/test_cli_converge_export.py::test_export_never_leaks_a_rejected_claims_risk_text_issue_83`, `tests/test_convergence.py::test_rejected_assumption_does_not_warn` | +| 84 | No `amend` move; correcting a claim costs its id and breaks provenance | closed | `tests/test_cli_moves.py::test_amend_one_move_fixes_a_number_and_keeps_id_and_attachments`, `tests/test_e2e_resolve.py::test_e2e_issue84_plan_risk_amend_after_task_recreation_still_converges` | +| 85 | Milestone-scoped plans cannot converge | closed (secondary ask deferred, see below) | `tests/test_plan_convergence.py::test_shell_cli_shape_90_covered_12_deferred_converges` | +| 86 | Self-dependency accepted at creation; `plan reject` single-id | closed | `tests/test_cli_plan.py::test_task_dep_self_cycle_errors`, `::test_task_dep_unknown_id_errors`, `::test_depend_self_cycle_errors` | +| 87 | `export` renders claim text unescaped → MD037 / MD050 | closed | `tests/test_md_safety.py` (48 cases), `tests/test_export_markdownlint_integration.py::test_mixed_identifier_export_passes_markdownlint_cli2`, `::test_real_issue_backlog_sweep_frame_exports_lint_clean` | +| 88 | `devague summary` renders rejected tasks as planned work | closed | `tests/test_summary.py::test_mixed_status_plan_scopes_planned_work_and_actual_delivery_to_confirmed`, `::test_actual_delivery_has_one_row_per_confirmed_task_with_fill_placeholders` | +| 90 | `plan cover` refuses targets `plan converge` demands | closed | `tests/test_cli_plan.py::test_status_recommends_cover_and_cover_succeeds_without_reconverge` | +| 91 | Use a smaller tier (sonnet) for scope subagents | closed (method-only) | `tests/test_teaching_surface_sweep.py::test_learn_documents_scope_fan_out_threshold`, `::test_learn_json_scope_stage_includes_fan_out_key` | +| 92 | Exported spec never shows a deviation contested a confirmed claim | closed | `tests/test_contested.py::test_export_show_status_render_contested_marker_end_to_end`, `::test_export_show_status_fail_open_on_corrupt_delivery_store` | +| 93 | `export` drops `unknown_nonblocking` parks | closed | `tests/test_render.py::test_spec_md_open_nonblocking_park_renders_labeled_by_kind`, `tests/test_export_markdownlint_integration.py::test_mixed_identifier_export_lists_both_open_park_kinds` | + +## Scope notes on partly-in-scope issues + +Three issues bundled asks that were **not** all in this release's scope. Recording +them here so the closures are not read as broader than they are. + +- **#82 ask 1** (inline real task content in `split-plan`) shipped in **0.16.0**; + the downstream symptom in the report was a stale vendored copy of the skill. + Only asks 2 (owner/model annotations) and 3 (durable artifact) were in scope, + and both landed. Ask 2 was implemented as the artifact-side annotation table + the issue offered as its second option — no `plan assign` verb, no plan schema + change (recorded as decision `c25`). +- **#85 secondary ask** — "warn when a task covers many targets with few + acceptance criteria" — was **deliberately deferred**, recorded as park `v3` on + the frame. #85's five formal acceptance criteria are all met; this was the + paragraph under *Secondary note on coverage semantics*, not one of them. +- **#84's** four ACs plus the `plan risk --amend` follow-up in the comment + thread all landed. `plan amend` for tasks pre-existed (0.18.0). + +## Downstream workarounds — deletability verdict + +| Workaround | Location | Verdict | +| --- | --- | --- | +| Plan projection script | `shell-cli` `scripts/render_plan.py` | **Deletable** | +| markdownlint ignores for generated artifacts | `shell-cli` `.markdownlint-cli2.yaml` | **Deletable for its stated cause**; one unrelated pre-existing error remains | +| Hand-edited `.devague/frames/.json` (`resolved: true`) | `arm101-cli`, `agentfront` (a practice, not a file) | **Deletable** | + +### 1. `shell-cli/scripts/render_plan.py` — deletable + +The script's own docstring says it should be deleted once devague#85 lands. It +existed because `guarded-local-operations-plane` (19 confirmed / 68 rejected +tasks, 102 targets) could not export: 12 targets were deliberately uncovered. + +Verified against that repo's **real** committed frame and plan, copied into a +scratch directory and driven with this branch's CLI: + +- `plan converge` reproduced exactly the 12 gaps #85 named, plus blocking risk + `r13` — which is *itself* the placeholder recording the #85 limitation. +- Applying `plan defer` to the 12 targets, then resolving `r13`, converged the + plan and **exported it**, with a `## Deferred targets` section naming all 12 + and their reasons. + +That is the artifact `render_plan.py` was written to fake. Note the script is in +**`shell-cli`**, not `reachy-mini-cli` — both #85's body and its follow-up +comment are signed `shell-cli (Claude)`. + +### 2. `shell-cli` markdownlint ignores — deletable for the cause #87 named + +The config ignores `docs/specs/**` and `docs/plans/**` (not `docs/handoff/**`, +which does not exist in that repo), citing devague#87 by number. + +Before/after on that repo's real spec, same frame, same rule set: + +| Renderer | MD037 | MD033 | Total | +| --- | --- | --- | --- | +| devague 0.20.x (the committed artifact) | 7 | 1 | **8** | +| devague 0.21.0 (this branch) | 0 | 1 | **1** | + +Every MD037 error the ignore was created for is gone. The surviving error is +**MD033/no-inline-html**, from a claim instruction containing the literal +placeholder token `` — angle brackets are *not* in #87's acceptance +criteria (which name `*`, backtick, `[`, `]`, and a leading `#`), and the error +predates this release. So the ignore is removable once that one token is +backticked in the source claim, or once the follow-up below lands. Recorded as a +follow-up, not fixed here, because it is outside #87's stated bar and touches +the shared escaper every renderer uses. + +### 3. Hand-edited frame JSON — deletable + +Both #48 and #52 report editing `.devague/frames/.json` by hand to set +`resolved: true` on a blocking hard question. `devague interrogate +--resolve [--decision ""]` now does it as a first-class move, +verified by running the reporters' own sequences; `suggest_move` names that +exact move, the decision text survives a save/load round trip, and a rejected +claim's unresolved blocking question no longer blocks the gate. Separately, the +store now tolerates unknown keys on hard questions, honesty conditions, and +parked vagueness instead of raising `TypeError` from `HardQuestion(**q)` — the +specific hazard #48 flagged about hand-editing. diff --git a/tests/test_export_markdownlint_integration.py b/tests/test_export_markdownlint_integration.py index 9b5ee91..c783d7f 100644 --- a/tests/test_export_markdownlint_integration.py +++ b/tests/test_export_markdownlint_integration.py @@ -319,3 +319,152 @@ def test_contested_marker_export_passes_markdownlint_cli2(tmp_path, monkeypatch) assert "contested by `d1`" in out result = _run_markdownlint(spec_path) assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + + +# ── t19: the repo's OWN issue-backlog-sweep frame + plan as lint corpus ─────── +# +# Every test above builds a synthetic frame. This one exports the real, +# committed `.devague/frames/issue-backlog-sweep.json` and its plan — the +# largest real corpus this repo has (36 claims, 23 scope entries, 5 parks, 19 +# tasks) and the one whose text was written by hand rather than shaped to suit +# the renderer. It is the release's own dogfood: if the escaping/park/scope +# rendering shipped here cannot lint the state that specified it, the fix is +# not done. It also guards against the artifact drifting out of lint-clean as +# the frame grows on future runs. + +_REPO_ROOT = Path(__file__).resolve().parent.parent +_SWEEP_SLUG = "issue-backlog-sweep" + + +def _copy_real_sweep_state(tmp_path) -> bool: + """Copy the committed sweep frame + plan into ``tmp_path``. Returns False + when they are absent (a consumer checkout without the state) so the test + skips rather than failing on someone else's tree. + """ + frame_src = _REPO_ROOT / ".devague" / "frames" / f"{_SWEEP_SLUG}.json" + plan_src = _REPO_ROOT / ".devague" / "plans" / f"{_SWEEP_SLUG}.json" + if not (frame_src.exists() and plan_src.exists()): + return False + for sub, src in (("frames", frame_src), ("plans", plan_src)): + dest_dir = tmp_path / ".devague" / sub + dest_dir.mkdir(parents=True, exist_ok=True) + (dest_dir / f"{_SWEEP_SLUG}.json").write_text(src.read_text(encoding="utf-8")) + return True + + +def test_real_issue_backlog_sweep_frame_exports_lint_clean(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + if not _copy_real_sweep_state(tmp_path): + pytest.skip("committed issue-backlog-sweep state not present in this checkout") + + assert main(["converge", "--frame", _SWEEP_SLUG]) == 0 + assert main(["export", "--frame", _SWEEP_SLUG]) == 0 + frame = store.load(_SWEEP_SLUG) + spec_path = Path("docs/specs") / f"{frame.created[:10]}-{frame.slug}.md" + assert spec_path.exists() + + result = _run_markdownlint(spec_path) + assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + + +def test_real_issue_backlog_sweep_plan_exports_lint_clean(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + if not _copy_real_sweep_state(tmp_path): + pytest.skip("committed issue-backlog-sweep state not present in this checkout") + + assert main(["plan", "converge", "--plan", _SWEEP_SLUG]) == 0 + assert main(["plan", "export", "--plan", _SWEEP_SLUG]) == 0 + plan = plan_store.load(_SWEEP_SLUG) + plan_path = Path("docs/plans") / f"{plan.created[:10]}-{plan.slug}.md" + assert plan_path.exists() + + result = _run_markdownlint(plan_path) + assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + + +def test_real_sweep_export_is_byte_stable_and_never_mutates_frame_json( + tmp_path, monkeypatch +) -> None: + # The release's escaping is presentational only (#87 c22/h18) — proven here + # against real state rather than a synthetic fixture. + monkeypatch.chdir(tmp_path) + if not _copy_real_sweep_state(tmp_path): + pytest.skip("committed issue-backlog-sweep state not present in this checkout") + + main(["converge", "--frame", _SWEEP_SLUG]) + main(["export", "--frame", _SWEEP_SLUG]) + frame = store.load(_SWEEP_SLUG) + spec_path = Path("docs/specs") / f"{frame.created[:10]}-{frame.slug}.md" + frame_json_path = store.path_for(_SWEEP_SLUG) + first_spec = spec_path.read_text(encoding="utf-8") + first_json = frame_json_path.read_text(encoding="utf-8") + + main(["export", "--frame", _SWEEP_SLUG]) + + assert spec_path.read_text(encoding="utf-8") == first_spec + assert _content_only(frame_json_path.read_text(encoding="utf-8")) == _content_only(first_json) + + +# ── t19 / #94: an underscore-bearing URL survives the real renderers ────────── + + +def test_underscore_url_in_claim_text_survives_export_and_lints(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + url = "https://example.com/some_path/file_name" + main( + [ + "new", + f"Ship the walker documented at {url}", + "--title", + "underscore-url", + ] + ) + for kind in ("audience", "after_state", "before_state", "boundary", "success_signal"): + main(["capture", "--kind", kind, f"{kind}: see {url} for the corpus", "--origin", "user"]) + frame = store.load(store.current_slug()) + for c in frame.claims: + main(["interrogate", c.id, "--honesty", f"verified against {url}", "--origin", "user"]) + main(["converge"]) + main(["export"]) + frame = store.load(store.current_slug()) + spec_path = Path("docs/specs") / f"{frame.created[:10]}-{frame.slug}.md" + out = spec_path.read_text(encoding="utf-8") + + # The link is intact and autolinked — not truncated at the first + # underscore, and not backticked mid-URL (#94, both composition orders). + assert f"<{url}>" in out + assert "some_path`" not in out + assert "" not in out + + result = _run_markdownlint(spec_path) + assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + + +def test_underscore_url_in_plan_task_survives_export_and_lints(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + url = "https://example.com/org/repo/main/__init__.py" + main(["new", "Ship the plan renderer", "--title", "underscore-url-plan"]) + for kind in ("audience", "after_state", "before_state", "boundary", "success_signal"): + main(["capture", "--kind", kind, f"{kind} text.", "--origin", "user"]) + frame = store.load(store.current_slug()) + for c in frame.claims: + main(["interrogate", c.id, "--honesty", "must hold.", "--origin", "user"]) + main(["converge"]) + slug = store.current_slug() + main(["plan", "new", "--frame", slug]) + plan = plan_store.load(slug) + args = ["plan", "task", f"Walk the corpus at {url}", "--accept", f"verified at {url}"] + for tg in plan.targets: + args += ["--covers", tg.id] + main(args) + main(["plan", "converge"]) + main(["plan", "export"]) + plan = plan_store.load(slug) + plan_path = Path("docs/plans") / f"{plan.created[:10]}-{plan.slug}.md" + out = plan_path.read_text(encoding="utf-8") + + assert f"<{url}>" in out + assert "`__init__.py`>" not in out + + result = _run_markdownlint(plan_path) + assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" diff --git a/tests/test_md_safety.py b/tests/test_md_safety.py index e853716..f010384 100644 --- a/tests/test_md_safety.py +++ b/tests/test_md_safety.py @@ -268,3 +268,82 @@ def test_md_safe_text_is_idempotent_on_mixed_content() -> None: def test_md_safe_text_noop_on_clean_text() -> None: text = "Ship the feature with no special characters" assert md_safe_text(text) == text + + +# ── #94: md_safe_text composed with autolink_urls (URLs carrying underscores) ─ +# +# The renderers compose these two passes in *opposite* orders — spec_md.py runs +# ``autolink_urls(md_safe_text(text))`` while plan_md.py and summary_md.py run +# ``md_safe_text(autolink_urls(text))``. Before #94 neither pass knew about the +# other, so an underscore inside a URL path was treated as an identifier to wrap +# in a code span: the plan order corrupted the link +# ("") and the spec order truncated it at the first +# underscore ("`some_path`"), silently pointing a committed +# artifact's link at the wrong address. Both orders must now leave the URL +# byte-identical and agree with each other. + + +def _both_orders(text: str) -> tuple[str, str]: + """(plan_md/summary_md order, spec_md order) for the same input.""" + return md_safe_text(autolink_urls(text)), autolink_urls(md_safe_text(text)) + + +def test_underscore_url_survives_plan_order_composition() -> None: + plan_order, _ = _both_orders("see https://example.com/some_path/file_name for details") + assert plan_order == "see for details" + + +def test_underscore_url_survives_spec_order_composition() -> None: + _, spec_order = _both_orders("see https://example.com/some_path/file_name for details") + assert spec_order == "see for details" + + +def test_dunder_url_survives_both_orders() -> None: + text = "https://raw.githubusercontent.com/org/repo/main/__init__.py" + plan_order, spec_order = _both_orders(text) + assert plan_order == f"<{text}>" + assert spec_order == f"<{text}>" + + +def test_both_composition_orders_agree_on_underscore_urls() -> None: + for text in ( + "docs at https://example.com/a_b", + "see https://example.com/some_path/file_name for details", + "https://raw.githubusercontent.com/org/repo/main/__init__.py", + "trailing https://example.com/a_b. next sentence", + "two https://e.com/a_b and https://f.com/c_d urls", + ): + plan_order, spec_order = _both_orders(text) + assert plan_order == spec_order, text + + +def test_url_protection_does_not_disable_identifier_wrapping_elsewhere() -> None: + # The carve-out is scoped to the URL itself — prose identifiers outside it + # must still be wrapped, or the #87 fix would regress. + plan_order, spec_order = _both_orders("https://example.com/a_b then _read_file in prose") + assert plan_order == spec_order + assert "" in plan_order + assert "`_read_file`" in plan_order + + +def test_url_inside_code_span_stays_literal_in_both_orders() -> None: + text = "code `https://example.com/a_b` stays literal, _read_file wraps" + plan_order, spec_order = _both_orders(text) + assert plan_order == spec_order + assert "`https://example.com/a_b`" in plan_order + assert "" not in plan_order + + +def test_markdown_link_destination_still_escapes_to_literal_prose() -> None: + # Protecting the URL must not turn a claim that merely *mentions* link + # syntax into a live link — the brackets keep their pre-#94 escaping. + plan_order, spec_order = _both_orders("a [link](https://example.com/a_b) and _read_file") + assert plan_order == spec_order + assert plan_order.startswith("a \\[link\\](https://example.com/a_b)") + + +def test_composition_is_idempotent_in_both_orders() -> None: + text = "see https://example.com/some_path/file_name and _read_file" + plan_order, spec_order = _both_orders(text) + assert md_safe_text(plan_order) == plan_order + assert autolink_urls(spec_order) == spec_order diff --git a/tests/test_render.py b/tests/test_render.py index 63008be..493cf91 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -435,6 +435,33 @@ def test_spec_md_resolved_hard_question_renders_with_resolved_marker() -> None: assert "(blocking)" not in out +def test_spec_md_resolved_hard_question_renders_its_decision_text() -> None: + # Acceptance criterion 2 (part 1 continued, #49): the issue asked for + # resolved questions to render "with a pointer to the claim/decision that + # answered them", not just a flag. When `interrogate --resolve --decision` + # recorded one, the export quotes it — the same shape a resolved park + # already renders ("— resolved: "). + f = Frame(slug="resolvedhq2", title="Resolved HQ With Decision") + ann = f.add_claim("announcement", "Shipped", origin="user") + q = f.add_hard_question(ann, "MCP or HTTP transport?", blocking=True) + f.resolve_hard_question(ann.id, q.id, "MCP — decided with the user on 2026-07-28") + out = render.render(f, "spec-md") + assert "- MCP or HTTP transport? (resolved: MCP — decided with the user on 2026-07-28)" in out + assert "(blocking)" not in out + + +def test_spec_md_resolved_hard_question_without_decision_keeps_bare_marker() -> None: + # A question resolved with no `--decision` text (the flag is optional) + # still renders the bare marker — never an empty "(resolved: )". + f = Frame(slug="resolvedhq3", title="Resolved HQ No Decision") + ann = f.add_claim("announcement", "Shipped", origin="user") + q = f.add_hard_question(ann, "will this scale?", blocking=True) + f.resolve_hard_question(ann.id, q.id) + out = render.render(f, "spec-md") + assert "- will this scale? (resolved)" in out + assert "(resolved: )" not in out + + def test_spec_md_hard_question_on_rejected_claim_is_absent_issue_83_repro() -> None: # Acceptance criterion 2 (part 2): the #83 repro shape — capture, # interrogate --risk, reject, converge, export — must never leak the diff --git a/tests/test_spec_to_plan_skill.py b/tests/test_spec_to_plan_skill.py index 5d5095a..23e70d0 100644 --- a/tests/test_spec_to_plan_skill.py +++ b/tests/test_spec_to_plan_skill.py @@ -122,3 +122,108 @@ def test_missing_cli_emits_install_hint(tmp_path: Path) -> None: proc = run("show", cwd=tmp_path, env=env) assert proc.returncode != 0 assert "devague CLI not found" in proc.stderr + + +# ── t19: SKILL.md must describe the surface the CLI actually ships ──────────── +# +# The moves table in this skill went stale across several releases: it still +# taught `plan reject` as single-id ("loop for batches") after devague#86 made +# it transactional and multi-id, and never gained `defer` (#85), `amend`, +# `deliverables`, `depend --remove`, or `risk --amend` (#84). That matters more +# here than in most docs: guildmaster re-broadcasts this skill to every repo in +# the mesh, so a stale instruction propagates the very workaround the release +# removed. These tests pin the skill text against the live CLI surface. + +SKILL_MD = REPO_ROOT / ".claude" / "skills" / "spec-to-plan" / "SKILL.md" + + +def _skill_text() -> str: + return SKILL_MD.read_text(encoding="utf-8") + + +def _moves_table_first_cells() -> str: + """The first column of every row in the ``### Moves`` table, joined. + + Rows legitimately pack two moves into one cell (``show`` / ``list``, + ``learn`` / ``explain``), so membership is checked against the whole + first-column text rather than against a row prefix. + """ + lines = _skill_text().splitlines() + start = next(i for i, ln in enumerate(lines) if ln.strip() == "### Moves") + cells: list[str] = [] + for ln in lines[start:]: + if not ln.startswith("|"): + if cells: # the table ended + break + continue + cells.append(ln.split("|")[1]) + return "\n".join(cells) + + +def test_skill_md_exists() -> None: + assert SKILL_MD.is_file() + + +@pytest.mark.parametrize( + "move", + [ + "new", + "task", + "instruct", + "accept", + "amend", + "depend", + "cover", + "defer", + "confirm", + "reject", + "risk", + "converge", + "export", + "waves", + "deliverables", + "status", + "show", + "list", + "learn", + "explain", + ], +) +def test_skill_md_moves_table_names_every_shipped_plan_move(move: str) -> None: + # Every subcommand `devague plan --help` registers must appear in the + # skill's Moves table, or an operator driving from the skill cannot reach it. + cells = _moves_table_first_cells() + assert f"`{move}" in cells, f"'{move}' missing from the SKILL.md moves table" + + +def test_skill_md_does_not_teach_the_single_id_reject_workaround() -> None: + # devague#86: `plan confirm`/`plan reject` are transactional and multi-id. + text = _skill_text() + assert "one task id per call" not in text + assert "loop for batches" not in text + + +def test_skill_md_documents_transactional_multi_id_confirm_reject() -> None: + text = _skill_text() + assert "transactional" in text + assert "`confirm […]`" in text + + +def test_skill_md_documents_defer_as_the_honest_scoping_move() -> None: + # devague#85: the gate rewards a task that *mentions* a target it does not + # deliver. The skill must point at `defer`, not at faking coverage. + text = _skill_text() + assert "defer" in text + assert "Deferred targets" in text + assert "out_of_scope` *risk* does **not** excuse a target" in text + + +def test_skill_md_documents_live_frame_cover_validation() -> None: + # devague#90: `cover` validates against the live frame, not the seed snapshot. + assert "live" in _skill_text().lower() + assert "grew after seeding" in _skill_text() + + +def test_skill_md_documents_risk_amend() -> None: + # devague#84 comment: a risk whose text names a rotated task id is amendable. + assert "--amend " in _skill_text() From 3404bd7c000f6c20d30b5699210e005531aecbf3 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 13:10:03 +0300 Subject: [PATCH 23/25] chore(deviate): commit the delivery ledger so d1/d2 reproduce for a fresh clone The contested-marker derivation (#92) reads .devague/deliveries/.json; without it committed, a fresh clone cannot reproduce the markers. Flagged by t19. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QtUixLUjgEt51PXS9TPVt3 --- .devague/deliveries/issue-backlog-sweep.json | 38 ++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .devague/deliveries/issue-backlog-sweep.json diff --git a/.devague/deliveries/issue-backlog-sweep.json b/.devague/deliveries/issue-backlog-sweep.json new file mode 100644 index 0000000..71e99c1 --- /dev/null +++ b/.devague/deliveries/issue-backlog-sweep.json @@ -0,0 +1,38 @@ +{ + "plan_slug": "issue-backlog-sweep", + "schema_version": 1, + "created": "2026-07-28T07:50:50Z", + "updated": "2026-07-28T08:16:09Z", + "deviations": [ + { + "id": "d1", + "what": "run t17 (learn/explain recipes) after wave 5 instead of in wave 3 \u2014 its acceptance criteria require documenting amend (t6, wave 4), multi-id plan confirm/reject (t11, wave 4), and plan risk --amend (t12, wave 5), so the confirmed dependency graph under-specifies it: t17 depends on t4 and t9 only", + "task_ref": "t17", + "reason": "the confirmed plan's dep graph places t17 in wave 3, but three of the four surfaces its acceptance criterion names do not exist until waves 4-5; documenting them there would mean writing recipes for unshipped verbs \u2014 the exact fabrication the method forbids. Execution order changes; no plan state is mutated (deviate is the marking of the change). Taken under the operator's standing mid-run authorization: 'Approved to run all waves by order. /deviate where needed and open issues for followups.'", + "affects": [ + "t17", + "t6", + "t11", + "t12" + ], + "origin": "user", + "status": "approved", + "classification": "acceptable" + }, + { + "id": "d2", + "what": "run t18 (docs sweep, changelog, version bump) after t7, t12, and t17 instead of in wave 4 \u2014 same root cause as d1: it must describe the shipped surface, and amend/scope --amend land in t6, plan risk --amend in t12, scope --seeds q-ids in t7, and the learn/explain recipes in the re-sequenced t17", + "task_ref": "t18", + "reason": "t18's confirmed deps are t15/t16/t17; with t17 moved by d1 and three more verbs landing in waves 4-5, running the docs sweep in wave 4 would document unshipped behavior and require a second pass. Execution order changes only; no plan state is mutated. Taken under the operator's standing authorization to deviate where needed.", + "affects": [ + "t18", + "t17", + "t7", + "t12" + ], + "origin": "user", + "status": "approved", + "classification": "acceptable" + } + ] +} From df54159ec095c17d5e0bd1aa7b4f8725fec005ff Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 13:13:10 +0300 Subject: [PATCH 24/25] docs(delivery): delivery summary for the issue-backlog-sweep run Eight-section accountability artifact built from the devague summary skeleton: 19/19 tasks delivered, 2 approved deviations (d1/d2, execution-order only), 3 defects found and fixed by t19's cross-task verification, and the honest remaining-work list including the two follow-up issues opened during the run. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QtUixLUjgEt51PXS9TPVt3 --- .../2026-07-28-issue-backlog-sweep.md | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 docs/deliveries/2026-07-28-issue-backlog-sweep.md diff --git a/docs/deliveries/2026-07-28-issue-backlog-sweep.md b/docs/deliveries/2026-07-28-issue-backlog-sweep.md new file mode 100644 index 0000000..f04991f --- /dev/null +++ b/docs/deliveries/2026-07-28-issue-backlog-sweep.md @@ -0,0 +1,175 @@ +# Delivery Summary — issue-backlog-sweep + +plan: `issue-backlog-sweep` · run: `complete` · date: `2026-07-28` +baseline: `devague summary skeleton` + +## Intent + +Close devague's entire open issue backlog — fifteen issues — in one combined +release, executed as a single `/assign-to-workforce` fan-out of the +`issue-backlog-sweep` plan (19 tasks, 6 dependency waves, one agent per task +per wave in an isolated git worktree, TDD-gated merges). Three of the fifteen +were being worked around by hand in downstream repos: a permanent convergence +deadlock escaped only by editing frame JSON, a coverage gate that made a +milestone-scoped plan unexportable (worked around with a second renderer), and +unescaped export text failing consuming repos' markdownlint CI (worked around +by excluding the generated directory from lint). The release's own success +signal was that those three workarounds become deletable. + +## Planned Work + +Quoted verbatim from the `devague summary` skeleton: + +- `t1` — Escaping engine in render/`_md_safety.py` +- `t2` — Schema and load-order hardening in both stores +- `t3` — `spec_md` renderer sweep: parks, hard questions, dead seeds, escaping +- `t4` — Hard-question resolve move: interrogate --resolve +- `t5` — Reject cascade over attachments +- `t6` — Claim and scope-entry amend moves +- `t7` — scope --seeds accepts question ids +- `t8` — Live-target validation for cover and --covers +- `t9` — Per-target deferral: plan defer +- `t10` — Dependency validation at task creation +- `t11` — Multi-id transactional plan confirm/reject +- `t12` — Plan-risk amend +- `t13` — Summary scoped to confirmed tasks +- `t14` — Contested-by-deviation derivation: export, show, status +- `t15` — Durable gate-2 split artifact in assign-to-workforce +- `t16` — Scope skill fans out to smaller-tier subagents +- `t17` — learn/explain recipes cover every new surface +- `t18` — Docs sweep, changelog, version bump +- `t19` — End-to-end verification and issue closure map + +## Actual Delivery + +All 19 tasks delivered. Every merge passed the TDD gate (suite green before and +after); no merge was reverted. + +| Plan task | Status | What actually landed | +|-----------|--------|----------------------| +| `t1` | delivered | `md_safe_text()` in `devague/render/_md_safety.py` — pure, idempotent, code-span-aware. Commit `d655c2d` | +| `t2` | delivered | Both stores check `schema_version` before parsing; tolerant nested loads; `SCHEMA_VERSION`/`PLAN_SCHEMA_VERSION` → 4. Commit `4b97c57` | +| `t3` | delivered | `## Open parks` (all four kinds), resolved-question markers, rejected-parent exclusion, rejected-seed markers, escaping at every verbatim site. Commit `35932e4` | +| `t4` | delivered | `devague interrogate --resolve [--decision]`; gate skips rejected claims; `suggest_move` names the real move. Commit `615667c` | +| `t5` | delivered | `reject` cascades over honesty conditions and unresolved hard questions; `converge` stops warning about rejected assumptions. Commit `24b1837` | +| `t6` | delivered | `devague amend [--text] [--kind] [--reason]` + `scope --amend `; `Claim.revisions` trail. Commit `a7b5aa1` | +| `t7` | delivered | `scope --seeds` accepts hard-question (`q*`) ids; question seeds render in the scope section. Commit `6146c4d` | +| `t8` | delivered | `cover` / `--covers` validate against live-frame-derived targets. Commit `4b88608` | +| `t9` | delivered | `devague plan defer --reason` / `--undo`; `## Deferred targets` in the export. Commit `22df59a` | +| `t10` | delivered | `--dep` / `depend --on` refuse self-deps and unknown ids at creation. Commit `95a5c2a` | +| `t11` | delivered | `plan confirm` / `plan reject` multi-id transactional; plan-group errors point at `plan explain`. Commit `a4aabfd` | +| `t12` | delivered | `plan risk --amend --text`. Commit `caf6b29` | +| `t13` | delivered | Summary scopes to confirmed tasks plus one rejected-count line. Commit `f439537` | +| `t14` | delivered | `devague/contested.py` — read-only derivation; markers in export, `show`, `status`; fails open. Commit `a7eda7a` | +| `t15` | delivered | `split-plan --write` → `docs/plans/--split.md` with a round-tripping owner/model block. Commit `7b654c6` | +| `t16` | delivered | `/scope` fans out to read-only subagents (sonnet default, 4-or-fewer inline / 5-or-more fan out). Commit `7059452` | +| `t17` | delivered | `learn` / `explain` / `PLAN_MOVES` teach all ten new surfaces; 24 grep tests. Commit `028f980` | +| `t18` | delivered | README, CLAUDE.md status entry, `docs/skills.md`, `docs/skill-sources.md`, `docs/spec-contract.md`, `docs/llm-guidance.md`, CHANGELOG, 0.20.1 → 0.21.0. Commit `f72fcf4` | +| `t19` | delivered | Adversarial end-to-end verification, three defect fixes, the closure map. Commit `95d8fed` | + +## Mid-work Decisions + +- `d1` — run t17 (learn/explain recipes) after wave 5 instead of in wave 3 — + the confirmed dependency graph under-specified it: three of the four surfaces + its acceptance criterion names (`amend`, multi-id `plan confirm`/`reject`, + `plan risk --amend`) do not exist until waves 4–5, so documenting them in + wave 3 would have meant writing recipes for unshipped verbs. +- `d2` — run t18 (docs sweep, changelog, version bump) after t7, t12, and t17 + — same root cause: it must describe the shipped surface. Its confirmed deps + were t15/t16/t17, and with t17 moved by `d1` plus three more verbs landing in + waves 4–5, running it in wave 4 would have documented unshipped behavior. +- Both deviations changed execution order only; no plan state was mutated + (deviate is the marking of the change). Both were taken under the operator's + standing mid-run authorization and recorded before resuming. +- Not covered by any deviation record: a stale committed artifact + (`docs/plans/2026-07-17-resolve-parked-vagueness.md`, exported by PR #81 + before the escaper existed) carried three MD037 errors. Rather than + hand-editing it, it was re-exported through the new escaper — which both + cleared the errors and demonstrated the #87 fix end to end on real + pre-existing content. Commit `7ccf4ca`. +- Not covered by any deviation record: `.devague/deliveries/issue-backlog-sweep.json` + was untracked, so the contested-marker derivation could not reproduce `d1`/`d2` + for a fresh clone. Committed at `3404bd7` after t19 flagged it. + +## Drift From Plan + +| Plan item | Reason for divergence | Classification | +|-----------|-----------------------|----------------| +| `t17` (`d1`) | its acceptance criteria require documenting verbs that land in waves 4–5; the confirmed dep graph listed only t4 and t9 | acceptable | +| `t18` (`d2`) | must describe the shipped surface; deps under-specified for the same reason as `d1` | acceptable | + +No task's *content* drifted from its confirmed contract — every task's +acceptance criteria were met as written. The drift is purely in execution +sequencing, and both entries are covered by approved records. + +Three defects were found by t19's cross-task verification and fixed inside the +run rather than deferred, because each was small and in scope: + +1. A **regression this release introduced**: `spec_md.py` composed + `autolink_urls(md_safe_text(t))` while `plan_md.py`/`summary_md.py` composed + the opposite order. Both corrupted an underscore-bearing URL — the spec order + truncated it at the first underscore, silently pointing a committed + artifact's link at the wrong address. Fixed by carving URLs out of + `md_safe_text` the way code spans already were. This is the hazard filed + speculatively as #94; it was real. +2. **#49 was only half closed** — a resolved hard question rendered + `(resolved)` but dropped the recorded decision text, while the issue asked + for "a pointer to the claim/decision that answered them". Now renders + `(resolved: )`. +3. **`spec-to-plan/SKILL.md` was never swept** — it fell between t17 (CLI + recipes) and t18 (whose criteria named four other docs). It still taught + `plan reject` as single-id with a shell loop — *the exact workaround #86 + removed* — in a skill guildmaster re-broadcasts to the mesh. + +## Evidence + +- tests: `bash .claude/skills/run-tests/scripts/test.sh --ci` — **967 passed**, 0 failed +- coverage: **98.37 %** (gate: `fail_under = 95`) +- lint: `markdownlint-cli2 "**/*.md"` — 39 files, **0 errors** +- lint: `.claude/skills/**/*.md` force-linted — 19 files, **0 errors** +- lint: real-corpus export of the `issue-backlog-sweep` frame and plan — **0 errors** +- commits: `main..HEAD` — 42 commits, 71 files changed (+9405 / −417) +- version: `devague 0.21.0` (from 0.20.1) +- closure map: `docs/deliveries/2026-07-28-issue-backlog-sweep-closure-map.md` +- issues closed: #48, #49, #52, #79, #82, #83, #84, #85, #86, #87, #88, #90, #91, #92, #93, #94 +- issues opened during the run: #94 (escaper composition — since fixed and closable), #95 (MD033 angle-bracket tokens — deferred) + +## Delivery Claims + +| Claim | Confidence | Evidence | +|-------|------------|----------| +| All fifteen backlog issues close, each with a named regression test | high | `docs/deliveries/2026-07-28-issue-backlog-sweep-closure-map.md` — verified against each issue's own acceptance criteria, and by running each reporter's repro against the built CLI | +| The convergence deadlock is gone; no frame JSON hand-editing is needed | high | test `tests/test_e2e_resolve.py::test_e2e_issue48_52_hard_question_block_resolve_converge_lifecycle`; both reporters' sequences re-run in scratch dirs | +| A milestone-scoped plan converges and exports with deferred targets | high | shell-cli's real 87-task plan converged and exported after `plan defer` on the 12 gaps + resolving risk `r13` — the artifact their `render_plan.py` was faking | +| Exported artifacts pass markdownlint on underscore-bearing identifiers | high | shell-cli's real spec: **8 errors → 1**, all seven MD037 cleared; survivor is unrelated MD033 (#95) | +| Rejected content no longer reaches the exported spec | high | test asserting the #83 repro's risk text is absent from exported markdown | +| An approved deviation marks the claims it contests, without rewriting the spec | high | `devague/contested.py` + `tests/test_contested.py`; verified against this repo's own `d1`/`d2` (task-only affects → correctly zero markers) | +| The escaper is presentational only — stored JSON and `--json` unchanged | high | byte-stability test over repeated export; frame JSON compared before/after | +| The gate-2 split decision now survives as a committed artifact | high | `docs/plans/2026-07-28-issue-backlog-sweep-split.md`; owner/model edit verified to survive regeneration | +| `/scope` exploration costs less by fanning out to smaller-tier subagents | medium | `.claude/skills/scope/SKILL.md` + `learn.py` `SCOPE_STAGE` pinned by `tests/test_teaching_surface_sweep.py`; the method change itself is not mechanically testable | +| The three downstream workarounds are deletable | medium | verified against the sibling repos' real state, not the issue text — see closure map; deletion happens in those repos, not here | + +## Remaining Work / Follow-up + +- **#95 (opened this run, deferred)** — angle-bracket tokens still render as + inline HTML (MD033). Out of #87's stated criteria and not a regression; + fixing it touches the shared escaper every renderer composes, which was too + risky during final verification. It is the sole remaining lint error on + shell-cli's real spec. +- **#94 (opened this run, fixed during it)** — the escaper-composition hazard + turned out to be real and is fixed here; close it with this PR. +- **Downstream re-vendoring** — reachy-mini-cli, shell-cli, headspace-cli, and + arm101-cli carry stale vendored skills and the three workarounds. They can + re-vendor via guildmaster's re-broadcast after this merges and then delete + the workarounds. Recorded as plan risk `r4` (`follow_up`). +- **The `qN` namespace collision** — claim-attached hard questions and the + durable questions file both mint `qN` ids from independent counters, so + `scope --seeds q1` could in principle link the wrong one. Documented as a + hazard in `scope/SKILL.md` and `docs/spec-contract.md` this run; no data + loss, but worth a real fix. Not yet filed. +- **#85's secondary ask, deliberately deferred** — warn at converge time when a + task covers many targets with few acceptance criteria. Recorded as park `v3` + on the frame; the issue's five formal acceptance criteria all pass without it. +- **`.devague/current_plan` tracking inconsistency** — it is tracked in git, but + README lists it as uncommitted and `.gitignore` only ignores `.devague/current`. + Pre-existing; flagged by t18, not fixed (it is a state change, not a docs one). From 445ad3a1678e916f4746906e79e0ac81f6fd88c1 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 13:28:30 +0300 Subject: [PATCH 25/25] refactor: address SonarCloud findings on the sweep's new modules - contested.py: extract _delivery_for_frame and _markers_from_delivery so find_contested_markers drops from cognitive complexity 24 to under the 15 allowed (python:S3776). - _md_safety.py: name the file-extension alternation instead of relying on implicit string concatenation in the regex (python:S5799). Behavior unchanged; 967 tests still pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QtUixLUjgEt51PXS9TPVt3 --- devague/contested.py | 70 +++++++++++++++++++++++------------- devague/render/_md_safety.py | 5 ++- 2 files changed, 47 insertions(+), 28 deletions(-) diff --git a/devague/contested.py b/devague/contested.py index d9bdc8f..1f35dbb 100644 --- a/devague/contested.py +++ b/devague/contested.py @@ -123,6 +123,47 @@ def _load_delivery_safely(slug: str) -> tuple[Optional[Delivery], Optional[str]] ) +def _delivery_for_frame(slug: str, frame_slug: str): + """Load ``slug``'s delivery ledger, but only if its plan targets ``frame_slug``. + + Returns ``(delivery_or_None, diagnostics)``. A plan seeded from a different + frame, an unreadable plan, and a plan with no (or an unreadable) ledger all + return ``None`` — the caller skips the slug either way; the diagnostics say + which of those it was, when there is anything to say. + """ + diagnostics: list[str] = [] + plan, plan_diag = _load_plan_safely(slug) + if plan_diag: + diagnostics.append(plan_diag) + if plan is None or plan.frame_slug != frame_slug: + return None, diagnostics + + delivery, delivery_diag = _load_delivery_safely(slug) + if delivery_diag: + diagnostics.append(delivery_diag) + return delivery, diagnostics + + +def _markers_from_delivery(delivery, slug: str, confirmed_ids: set) -> list[ContestedMarker]: + """Every marker one delivery ledger contributes: each approved deviation's + ``--affects`` refs that name a confirmed claim id. + """ + return [ + ContestedMarker( + claim_id=ref, + deviation_id=dev.id, + what=dev.what, + reason=dev.reason, + classification=dev.classification, + plan_slug=slug, + ) + for dev in delivery.deviations + if dev.status == "approved" + for ref in dev.affects + if ref in confirmed_ids + ] + + def find_contested_markers( frame: Frame, ) -> tuple[dict[str, list[ContestedMarker]], list[str]]: @@ -150,33 +191,12 @@ def find_contested_markers( return markers, diagnostics for slug in slugs: - plan, plan_diag = _load_plan_safely(slug) - if plan_diag: - diagnostics.append(plan_diag) - if plan is None or plan.frame_slug != frame.slug: - continue - - delivery, delivery_diag = _load_delivery_safely(slug) - if delivery_diag: - diagnostics.append(delivery_diag) + delivery, slug_diags = _delivery_for_frame(slug, frame.slug) + diagnostics.extend(slug_diags) if delivery is None: continue - - for dev in delivery.deviations: - if dev.status != "approved": - continue - for ref in dev.affects: - if ref in confirmed_ids: - markers.setdefault(ref, []).append( - ContestedMarker( - claim_id=ref, - deviation_id=dev.id, - what=dev.what, - reason=dev.reason, - classification=dev.classification, - plan_slug=slug, - ) - ) + for marker in _markers_from_delivery(delivery, slug, confirmed_ids): + markers.setdefault(marker.claim_id, []).append(marker) for entries in markers.values(): entries.sort(key=lambda m: (m.plan_slug, _numeric_suffix(m.deviation_id))) diff --git a/devague/render/_md_safety.py b/devague/render/_md_safety.py index 354ad15..595a11b 100644 --- a/devague/render/_md_safety.py +++ b/devague/render/_md_safety.py @@ -140,9 +140,8 @@ def heading_safe(text: str) -> str: # conservative allowlist of common file extensions) lets a dunder *file name* # such as ``__init__.py`` wrap as a single token instead of splitting at the # dot — the exact shape named in the #87 MD050 follow-up comment. -_IDENTIFIER_RE = re.compile( - r"[A-Za-z0-9_]*_[A-Za-z0-9_]*" r"(?:\.(?:py|md|rst|json|ya?ml|toml|cfg|ini|sh|js|ts|rb|go))?" -) +_IDENTIFIER_EXTENSIONS = "py|md|rst|json|ya?ml|toml|cfg|ini|sh|js|ts|rb|go" +_IDENTIFIER_RE = re.compile(rf"[A-Za-z0-9_]*_[A-Za-z0-9_]*(?:\.(?:{_IDENTIFIER_EXTENSIONS}))?") # Markdown control characters that corrupt rendered document structure when a # verbatim field contains them (the #87 issue's fallback list: '*' opens