diff --git a/.claude/skills/assign-to-workforce/SKILL.md b/.claude/skills/assign-to-workforce/SKILL.md index a1c9ef0..bd79f2a 100644 --- a/.claude/skills/assign-to-workforce/SKILL.md +++ b/.claude/skills/assign-to-workforce/SKILL.md @@ -227,6 +227,14 @@ Once the human approves, the main agent fans out each wave in order: - Instruction to work **test-first** (TDD): write the failing test(s) that match the acceptance criteria before implementing. - Instruction to commit its work to the worktree branch. + - Instruction to **report, never file**, any reasoning-degradation lapse it + notices in its own work — a skipped check, an assumption standing in for + a real measurement, an unverified grader, missing provenance, or another + of the six `LAPSE_CODES` in `devague/frame.py`. The task agent names it + in its transcript or final report; it never runs `devague lapse` itself, + because it never runs any devague command inside its worktree (see the + hard rule below). The **main agent** files the record (`devague lapse + "" --code --origin llm`) once the worktree is reconciled. 3. **Same-wave tasks run in parallel** (within-wave tasks have no inter-task dependency; the dependency graph guarantees this). Same-file @@ -317,9 +325,21 @@ These protect the human-gate contract and the TDD guarantee. baseline was already broken — fix the baseline first. - **Human does not gate per-task merges.** The TDD contract replaces the human here. Do not pause for human approval between wave tasks. -- **devague CLI is not orchestrated.** `devague plan waves` is read-only - scheduling metadata (#20). Never run `devague plan` commands inside a task - worktree to "mark a task done" or modify plan state from a subagent. +- **No devague move runs inside a task worktree — not just `devague plan`.** + `devague plan waves` is read-only scheduling metadata (#20); more broadly, a + task agent never runs any devague command in its worktree, including + `devague lapse`. If a task agent notices its own reasoning degraded — a + skipped check, an assumption standing in for a real measurement, an + unverified grader, missing provenance, or another `LAPSE_CODES` case + (`devague/frame.py`) — it reports the degradation in its transcript or + final report; it does not file it. The **main agent** files that record + after reconciling the worktree (`devague lapse "" --code + --origin llm`), the same way it alone runs every plan-mutating move — + mirroring the `/scope` subagent boundary, where exploration subagents + report and only the main agent runs a `devague` move (#79/#91). Adjudicating + a filed lapse (`devague lapse --confirm`/`--reject`) is the same human who + already owns gate 2/3 — no new role — typically exercised once the run + reaches `/summarize-delivery`. - **Three gates only.** The human's gates are: (1) the exported spec, (2) the implementation split plan, (3) the final PR. No silent fourth gate. - **No LLM calls in the devague CLI.** The CLI is deterministic. This skill 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 baec71f..8a81e5e 100755 --- a/.claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh +++ b/.claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh @@ -367,6 +367,16 @@ _URL_TRAILING_PUNCT = ".,;:!?'\"" _CODE_SPAN_RE = re.compile(r"`[^`]*`") _ANGLE_TOKEN_RE = re.compile(r"<[^<>\s][^<>]*>") +# The `md_safe_text` half of the upstream helper. Task instructions routinely +# name underscore-bearing identifiers (`__init__.py`, `_build_parser`), which +# markdown reads as emphasis/strong (MD037, MD050) unless wrapped in a code +# span. Ported rather than imported for the same portability reason as above. +_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}))?") +_STRAY_CONTROL_CHAR_RE = re.compile(r"(?]*>|https?://[^\s<>()]+") + def _strip_url_trailing_punct(url): trail = "" @@ -425,12 +435,40 @@ def heading_safe(text): return _HEADING_TRAILING_PUNCT_RE.sub("", autolink_urls(text)) +def _escape_segment(segment): + """Escape + wrap one non-code-span slice. Order matters for idempotence: + stray backticks and control chars first, identifier wrapping last.""" + segment = _STRAY_BACKTICK_RE.sub(r"\\`", segment) + segment = _STRAY_CONTROL_CHAR_RE.sub(r"\\\1", segment) + return _IDENTIFIER_RE.sub(lambda m: f"`{m.group(0)}`", segment) + + +def md_safe_text(text): + """Wrap underscore/dunder identifiers in code spans and backslash-escape + the remaining stray control characters. Code spans and URLs pass through + byte-for-byte; idempotent, so composing it with the helpers above in + either order is safe.""" + if not text: + return text + parts = [] + last = 0 + for m in _PROTECTED_RE.finditer(text): + parts.append(_escape_segment(text[last : m.start()])) + parts.append(m.group(0)) # code span or URL: verbatim, never touched + last = m.end() + parts.append(_escape_segment(text[last:])) + result = "".join(parts) + if result.startswith("#"): + result = "\\" + result + return result + + def safe_body(text): - return autolink_urls(backtick_bare_angle_brackets(text)) + return md_safe_text(autolink_urls(backtick_bare_angle_brackets(text))) def safe_heading(text): - return heading_safe(backtick_bare_angle_brackets(text)) + return heading_safe(md_safe_text(backtick_bare_angle_brackets(text))) def parse_existing_assignments(path): diff --git a/.claude/skills/challenge/SKILL.md b/.claude/skills/challenge/SKILL.md index fd738a2..4569f08 100644 --- a/.claude/skills/challenge/SKILL.md +++ b/.claude/skills/challenge/SKILL.md @@ -132,7 +132,11 @@ through these structured lenses (from issue 73): 4. **Route every finding through an existing move.** Use the routing table below. Everything the agent proposes carries `--origin llm` and lands `proposed` — the pass cannot silently convert speculation into confirmed - requirements. + requirements. If the pass itself notices its own reasoning degraded while + sweeping — a skipped check, an assumption standing in for a real + measurement — that is not a finding about the spec; self-report it the same + moment via `devague lapse --origin llm` (the routing table's last row, + issue #97). 5. **Let the human adjudicate.** `devague review` lists every proposal with ids; `devague confirm` / `devague reject` / `devague question --resolve` are user-only decisions. This is the existing spec gate doing its job. @@ -160,6 +164,21 @@ move to land in: | unexamined surfaces | what this pass did not (or could not) look at | `devague scope "" --finding ""` | | residual surprise risk | uncertainty that survives the pass | `park` on the frame while speccing; `devague plan risk --kind ` once the plan exists | | resilience measures | containment, rollback, recovery the surprise cost demands | spec-side `capture --kind requirement` / `--kind boundary`; plan-side `devague plan risk` (see below) | +| reasoning degradation *(not one of issue 73's six — the pass's own self-report, not a spec finding)* | a shortcut, skipped check, or degraded reasoning the pass itself made while sweeping | `devague lapse "" --code [--skipped ""] [--ref ] --origin llm` | + +Every row above except the last lands **proposed content about the spec** — +a claim, a question, a park, a scope finding — that the human adjudicates +through the spec gate. The `lapse` row is different in kind: it is not a +finding about the spec at all but the agent's own self-report that its +reasoning degraded while running the pass — an assumption stood in for a real +measurement, a check the pass meant to run was skipped, an unverified +grader, missing provenance, and the other codes in `devague/frame.py`'s +`LAPSE_CODES` (issue #97). It still carries `--origin llm` and lands +`proposed` — the same anti-fabrication discipline as every other row — but it +is filed the instant the degradation is noticed rather than batched with the +sweep's other findings, and it is adjudicated later via `devague lapse +--confirm`/`--reject`, exercised by the same human who already owns this +leg's gate — never a new gate or role. Every finding names the **lens and surface** it came from (the `challenge pass / : ` convention in scope entries; provenance @@ -201,11 +220,12 @@ blocking risks visible until resolved. finding the agent proposes carries `--origin llm` and lands `proposed`; only the user's `confirm` makes it real. The pass must not be able to silently convert speculation into confirmed requirements. -- **Findings route through existing deterministic moves only.** `capture`, - `interrogate`, `question`, `park`, `devague scope`, `devague plan risk` — - nothing else. No parallel prose artifact, no new CLI verb, engine, or - state model (issue 20; issue 73's stated preference). If it didn't land in - a move, it didn't land. +- **Findings — and the pass's own reasoning self-reports — route through + existing deterministic moves only.** `capture`, `interrogate`, `question`, + `park`, `devague scope`, `devague plan risk`, `devague lapse` — nothing + else. No parallel prose artifact, no new CLI verb, engine, or state model + (issue 20; issue 73's stated preference; issue #97 for the lapse ledger). + If it didn't land in a move, it didn't land. - **Provenance on every finding.** Name the lens and the surface it came from. If you didn't read it, don't claim it — same bar as `/scope`. - **Proportional, never skipped.** Lightweight is the floor, not an diff --git a/.claude/skills/summarize-delivery/SKILL.md b/.claude/skills/summarize-delivery/SKILL.md index 3e94124..2931a61 100644 --- a/.claude/skills/summarize-delivery/SKILL.md +++ b/.claude/skills/summarize-delivery/SKILL.md @@ -119,10 +119,18 @@ directly (if `devague` isn't on your PATH: `uv tool install devague`). test suite, the linters, `git log` — to substantiate a claim *before* you write it. Verification never mutates code or state. A claim you cannot verify stays `unverified`. -6. **State delivery claims with confidence + evidence.** Each claim carries a - confidence level (`high` / `medium` / `low` / `unverified`) and at least one - **resolvable** evidence pointer, or an explicit `unverified` marker. A claim - without evidence is `unverified` — never asserted as done. +6. **State delivery claims with confidence + evidence — grounded in the lapse + ledger.** Each claim carries a confidence level (`high` / `medium` / `low` / + `unverified`) and at least one **resolvable** evidence pointer, or an + explicit `unverified` marker. Read `devague lapse --list` first (or the + `Lapse ledger evidence:` block `devague summary`'s skeleton already renders + under this section — see Method step 1): an **approved** lapse touching a + claim — an unverified grader, a below-target sample size, an assumption + standing in for a real measurement, or another of `LAPSE_CODES` + (`devague/frame.py`) — caps that claim's confidence honestly instead of + letting it default to `high`; a still-**proposed** lapse is pending, not + yet evidence, and must not be cited as if it already were. A claim without + evidence is `unverified` — never asserted as done. 7. **Name the remaining work.** What is incomplete, deferred, or newly discovered — including any failure and its cause. 8. **Write the artifact and commit it.** Fill the eight-section template into a @@ -240,6 +248,15 @@ Every Delivery Claims row carries three fields: number** that is real, or a **test node id** that ran. "It works" is not evidence. +`devague summary`'s skeleton already appends a `Lapse ledger evidence:` block +beneath the placeholder row whenever the frame has any filed lapses — +**approved** entries rendered as a small `Lapse | Code | What` table, +still-**proposed** ones listed as `pending approval (not yet evidence)`, and +**rejected** ones omitted entirely (`devague/render/summary_md.py`'s +`_lapse_evidence_lines`). Treat that block as the ledger's contribution to +this section — read it (or run `devague lapse --list` directly) rather than +re-deriving by memory which lapses bear on which claim. + ### Drift From Plan — the entry contract Every Drift From Plan entry names three things: @@ -288,6 +305,7 @@ it documents is read-only: |------|---------------| | `devague summary [--pr] [--json]` | The eight-section delivery-summary skeleton (or condensed `--pr` skeleton), pre-filled verbatim from the plan's tasks, its live source frame, and the delivery (deviation) store — the primary planned-work baseline (Method step 1). | | `devague deviate --list [--json]` | Every recorded deviation, read back by `dN` id — the source Drift From Plan and Mid-work Decisions quote. Recording or confirming a deviation is `/deviate`'s job, never this skill's. | +| `devague lapse --list [--json]` | Every filed reasoning-degradation lapse, read back by `lN` id — **approved** entries are the evidence that grounds a Delivery Claims confidence level (Method step 6); **proposed** ones are pending, not yet evidence; **rejected** ones are omitted. Filing a lapse is the agent's job at the moment the degradation is noticed (`/challenge`, `/assign-to-workforce`); confirming or rejecting one is `devague lapse --confirm`/`--reject`, exercised by the gate-owning human — never this skill's. | | `devague plan show [--json]` | The plan's tasks, acceptance criteria, dependencies — the hand-assembly fallback when `devague summary` (or the state it needs) is unavailable. | | `devague plan waves --json` | The wave batches + per-task `summary` / `instruction` / `acceptance_criteria` / `covers`, keyed by id — the hand-assembly fallback's verbatim planned-work baseline. | | `devague scope --list [--json]` | Recorded scope-exploration findings, if the frame carried any. | @@ -314,12 +332,14 @@ These are the point of the method — a delivery summary must be trustworthy. `git log` to substantiate a claim before writing it. Verification **never** mutates code or state. A claim you cannot verify stays `unverified`. - **No devague state mutation.** The only devague moves this skill uses are the - read-only `summary`, `deviate --list`, `plan show`, `plan waves`, - `scope --list`, `show`, and `status` (see the table above). `deviate --list` - is read-only — recording or confirming a deviation belongs to `/deviate`, - never this skill. Never run a mutating devague command, and never run - `devague plan` inside a task worktree to "mark a task done" — that is - `/assign-to-workforce`'s boundary too (#20). + read-only `summary`, `deviate --list`, `lapse --list`, `plan show`, + `plan waves`, `scope --list`, `show`, and `status` (see the table above). + `deviate --list` and `lapse --list` are both read-only — recording or + confirming a deviation belongs to `/deviate`, and filing or adjudicating a + lapse (`lapse --confirm`/`--reject`) belongs to whoever filed it and the + gate-owning human, never this skill. Never run a mutating devague command, + and never run `devague plan` inside a task worktree to "mark a task done" — + that is `/assign-to-workforce`'s boundary too (#20). - **Account for 100 % of plan tasks.** Every plan task appears in Actual Delivery as delivered / partial / dropped / blocked — no silent omissions. Both the task count and the claim-evidence coverage are checkable by diff --git a/.devague/current_plan b/.devague/current_plan index a3a11d1..9ab2a86 100644 --- a/.devague/current_plan +++ b/.devague/current_plan @@ -1 +1 @@ -issue-backlog-sweep +reasoning-degradation-ledger diff --git a/.devague/deliveries/reasoning-degradation-ledger.json b/.devague/deliveries/reasoning-degradation-ledger.json new file mode 100644 index 0000000..b38da5c --- /dev/null +++ b/.devague/deliveries/reasoning-degradation-ledger.json @@ -0,0 +1,21 @@ +{ + "plan_slug": "reasoning-degradation-ledger", + "schema_version": 1, + "created": "2026-07-29T18:22:32Z", + "updated": "2026-07-29T18:22:32Z", + "deviations": [ + { + "id": "d1", + "what": "the split-plan --write path escapes verbatim task text before writing markdown", + "task_ref": "t5", + "reason": "the committed gate-2 artifact failed the repo's own markdownlint: cli/__init__.py in t2's instruction rendered as strong-emphasis (MD050 x2, MD037 x1). No plan task covers the split-plan script, and CI does not lint markdown, so nothing would have caught it before review. Approved by the user mid-run.", + "affects": [ + "t5", + "c19" + ], + "origin": "user", + "status": "approved", + "classification": "acceptable" + } + ] +} diff --git a/.devague/frames/reasoning-degradation-ledger.json b/.devague/frames/reasoning-degradation-ledger.json new file mode 100644 index 0000000..a0b586c --- /dev/null +++ b/.devague/frames/reasoning-degradation-ledger.json @@ -0,0 +1,603 @@ +{ + "slug": "reasoning-degradation-ledger", + "title": "reasoning-degradation ledger", + "schema_version": 5, + "status": "exported", + "created": "2026-07-29T16:31:48Z", + "updated": "2026-07-29T19:15:41Z", + "claims": [ + { + "id": "c1", + "kind": "announcement", + "text": "devague gains a deterministic move that records degradations of the reasoning process \u2014 moments where an assumption was silently substituted for a check \u2014 as first-class append-only ledger entries filed when they happen: the reasoning-side twin of deviate, never gating convergence", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h1", + "text": "filing an entry is one deterministic CLI call \u2014 no LLM, no subprocess \u2014 and converge output is byte-identical before and after filing, pinned by a test", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [ + { + "id": "q1", + "text": "where does the ledger attach: the existing delivery store (zero new engine, but keyed 1:1 by plan slug \u2014 unreachable before a plan exists, resolve_plan fails closed with `no plan selected`), frame state, plan state, or a frame-keyed fourth store? the issue evidence arose at execution time, but its codes can arise during think and challenge too", + "resolved": true, + "blocking": true, + "resolution": "frame-level list, like scope_entries: reachable from devague new through execution; entries may free-text-ref plan tasks the way the deviate affects field does; no new store" + }, + { + "id": "q2", + "text": "what is the verb named? `ledger` collides semantically with the established delivery-ledger vocabulary across contested.py, delivery_store.py, and deviate.py; `degrade` collides with the fail-open error-handling idiom in 8 files; `lapse` and `erode` are unused; the record id prefix must avoid c, h, q, v, s, t, r, and d", + "resolved": true, + "blocking": true, + "resolution": "the verb is lapse; record ids are lN" + } + ], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c2", + "kind": "requirement", + "text": "the move reuses the deviate chassis: prefix-generic id minting (`Delivery._next`, devague/delivery.py:63-72), origin-driven initial status (llm-origin lands proposed, delivery.py:85), fail-closed enum validation in `__post_init__`, and append-only records with no delete path", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h2", + "text": "the new record type reuses `_next`, origin-driven initial status, and fail-closed `__post_init__` validation rather than re-implementing them \u2014 checked in review of the shipping diff", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [ + { + "id": "q3", + "text": "does filing require human approval before recording, as deviate mandates (a user-origin record IS the approval, deviate/SKILL.md:97-100), or is the agent self-report recorded without approval as issue 97 proposes (the agent decides when it degraded; the CLI records it deterministically)? the load-bearing feature of the deviate template is exactly what the proposal drops", + "resolved": true, + "blocking": true, + "resolution": "file free, adjudicate later: llm-origin entries land proposed and never block; the human adjudicates in bulk at summarize-delivery time; only approved entries are citable as confidence evidence" + } + ], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c3", + "kind": "non_goal", + "text": "the ledger never gates: no participation in blockers, warnings, or parked_items \u2014 both convergence gates iterate hand-written allowlists (frame.claims and frame.open_vagueness in convergence.py; plan.tasks and plan.risks in plan_convergence.py), so a new list field is invisible to them by default, the scope_entries precedent", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c4", + "kind": "requirement", + "text": "devague summary consumes approved ledger entries as evidence for the Delivery Claims confidence column, following the Mid-work Decisions and Drift From Plan render pattern (render/summary_md.py:205-244); the high/medium/low/unverified vocabulary lives only in the summarize-delivery skill today, not in code", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h3", + "text": "devague summary renders approved entries only; proposed entries render as visibly pending (mirroring the deviation pattern); a missing ledger degrades to the existing empty-state line, never an error", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c5", + "kind": "requirement", + "text": "the closed move enumerations in the producer and consumer skills are amended to name the new move: the challenge routing rule (findings route through existing deterministic moves only \u2014 nothing else, challenge/SKILL.md:204-208) has no row shaped like a degradation that already happened, and the summarize-delivery read-only move table (SKILL.md:282-296) is likewise closed", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h4", + "text": "the challenge and summarize-delivery closed move enumerations name the new move in the same PR that ships the verb \u2014 no doc-drift window", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c6", + "kind": "boundary", + "text": "fan-out subagents never file ledger entries \u2014 only the main agent runs devague moves; a degradation noticed inside a task worktree is reported in the task-agent transcript and recorded by the main agent (assign-to-workforce SKILL.md:320-322 currently forbids only `devague plan` commands by name)", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h5", + "text": "assign-to-workforce SKILL.md generalizes its worktree prohibition to name the new verb, not just `devague plan` commands", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c7", + "kind": "requirement", + "text": "the move lands in the MOVES dict in learn.py (today only 15 entries \u2014 deviate, summary, and plan are already absent, so `devague explain deviate` fails), the per-move contract tables in docs/spec-contract.md, README.md, and docs/skills.md, plus a CHANGELOG entry and the CI-enforced version bump", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h6", + "text": "`devague explain ` works for the new verb in the shipping PR \u2014 the MOVES dict entry is test-pinned \u2014 and the pre-existing deviate/summary/plan explain gap is filed as its own issue", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c8", + "kind": "assumption", + "text": "degradation codes ship as a closed enum validated fail-closed at construction, like every existing kind vocabulary (CLAIM_KINDS, VAGUENESS_KINDS, and CLASSIFICATIONS \u2014 the nearest precedent: an optional single code per record, delivery.py:32); the six codes in issue 97 are the starting set, not the contract", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [ + { + "id": "q4", + "text": "how does the spec distinguish covered from reachable for each code (embodiment#18: a code nobody ever files reads as a category nobody ever hit) \u2014 does each shipped code need a named producer moment in a skill, or a dogfood report before the vocabulary freezes?", + "resolved": false, + "blocking": false, + "resolution": "" + } + ], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c9", + "kind": "audience", + "text": "operators \u2014 the main agent driving the CLI mid-run \u2014 plus the humans who own gate 2 and the final PR, and downstream method consumers (embodiment) whose delivery summaries cite ledger entries as confidence evidence", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h7", + "text": "no new human workflow is introduced: the same humans who own gate 2 and the final PR adjudicate ledger entries, inside gates that already exist", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c10", + "kind": "after_state", + "text": "a degradation is filed in seconds at the moment an assumption substitutes for a check; the delivery summary confidence column cites entry ids instead of end-of-run memory, and recovering a transition no longer costs hours of reading raw data afterwards", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h8", + "text": "in the dogfood cycle no single filing costs the operator more than a minute \u2014 otherwise the cheap-enough-to-use-mid-flight premise is false", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c11", + "kind": "before_state", + "text": "corrections records are reconstructed at the end, from memory, shaped by how the story turned out \u2014 in the embodiment cycle four graders failed, every one found by reading data afterwards, none by a test failing, and one nearly shipped a false safety claim", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h9", + "text": "the embodiment corrections record and its four grader failures are real committed artifacts quoted in issue 97, not a reconstruction made for this spec", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c12", + "kind": "why_it_matters", + "text": "written late is written flattering: a ledger entry at the moment of the transition costs seconds, recovering it afterwards costs hours \u2014 so the recording move must be cheap enough that filing mid-flight actually happens", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h10", + "text": "at least one transition in the embodiment cycle was recoverable only because raw data happened to be committed \u2014 the near-miss is documented, not anecdotal", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c13", + "kind": "success_signal", + "text": "in >= 1 dogfooded embodiment cycle, every degradation entry is filed mid-flight (0 reconstructed in the retrospective) and 0 codes ship without a named producer moment \u2014 codes with zero filings are dropped before the vocabulary freezes", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h11", + "text": "a code with zero filings after the dogfood cycle is actually removed from the enum, not kept as documentation \u2014 covered is distinguished from reachable", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c14", + "kind": "decision", + "text": "the ledger attaches to the Frame as a new list of lapse records \u2014 the scope_entries pattern: reachable from `devague new` through execution, rendered but never gating; entries may free-text-ref plan tasks (tN) the way the deviate affects field does; no new store, no new engine", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c15", + "kind": "decision", + "text": "filing is friction-free: the agent files a lapse immediately at the moment of the transition; llm-origin entries land proposed and never block anything; the human adjudicates in bulk at summarize-delivery time, and only approved entries are citable as confidence evidence in the Delivery Claims table", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c16", + "kind": "decision", + "text": "the verb is `lapse` (noun and verb, like park and deviate); record ids are `lN`; the degrade and ledger names were rejected for collisions with the fail-open error-handling idiom and the delivery-ledger vocabulary", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c17", + "kind": "requirement", + "text": "adding the lapses list bumps SCHEMA_VERSION 4 to 5: without the bump an older installed binary loads a lapse-bearing frame tolerantly and its next save silently drops every filed lapse (save re-stamps the current version and to_dict writes only known fields) \u2014 the scope_entries v2 precedent; the fail-closed version check is what turns silent data loss into a version hint", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h12", + "text": "a v4-reading binary pointed at a v5 frame fails closed with the version hint instead of silently dropping lapse records on save \u2014 pinned by a reject-newer store test", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c18", + "kind": "requirement", + "text": "adjudication is on the verb, mirroring deviate: `devague lapse --confirm ` / `--reject ` (confirm and reject keep taking only c* and h* ids); statuses are proposed/approved/rejected, and a user-origin filing lands approved immediately \u2014 the deviate origin contract", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h13", + "text": "an llm-origin lapse never becomes citable without a human --confirm; devague summary renders it as visibly pending until then \u2014 no path upgrades it automatically", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c19", + "kind": "requirement", + "text": "lapses render in devague show (frame_md) and the delivery summary; the exported spec-md never grows a lapse section \u2014 export overwrites the same dated file, so execution-time lapses would rewrite the what-to-build artifact on re-export; the contested-marker philosophy applies: process history points forward, the spec is not rewritten", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h14", + "text": "re-exporting the spec after filing lapses produces a byte-identical spec-md \u2014 pinned by a test that files a lapse and diffs the export", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c20", + "kind": "boundary", + "text": "lapse records are append-only in the strong sense: no amend and no delete \u2014 unlike scope entries, which amend in place with no trail; a wrong lapse is rejected and refiled, because an editable lapse re-enables written-late-is-written-flattering", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h15", + "text": "no CLI path mutates a lapse record after filing except the status transition \u2014 pinned by the argument surface (no amend flag) and a test", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c21", + "kind": "requirement", + "text": "code validation is fail-closed at filing time but tolerant at load time: retiring a dead code after the dogfood cycle must never brick an existing frame \u2014 probe-confirmed: an unknown kind raises ValueError at construction, and from_dict constructs at load, so a closed load-time enum would refuse to load any frame that ever filed the retired code", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h16", + "text": "a frame holding a lapse with a retired code still loads and renders after the code leaves the filing enum \u2014 pinned by a test that files, retires, reloads", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + }, + { + "id": "c22", + "kind": "assumption", + "text": "lapse refs are free text and never validated \u2014 the deviate comparison in decision c14 is imprecise: deviate validates id-shaped affects refs against the plan and its live frame (deviate.py:78-113), which a frame-side lapse cannot do for tN refs before a plan exists; the record is testimony, not a join", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "", + "revisions": [] + } + ], + "open_vagueness": [ + { + "id": "v1", + "text": "embodiment offers to dogfood a prototype and report back real entry counts and which degradation codes turned out dead \u2014 the closing offer in issue 97", + "kind": "follow_up", + "claim_id": "c8", + "resolved": false, + "resolution": "", + "resolution_claim_id": null + }, + { + "id": "v2", + "text": "whether id-shaped lapse refs later deserve a contested-style join into plan and summary renders \u2014 deferred until dogfood shows refs are actually filed", + "kind": "follow_up", + "claim_id": "c22", + "resolved": false, + "resolution": "", + "resolution_claim_id": null + } + ], + "scope_entries": [ + { + "id": "s1", + "surface": "agentculture/devague#97 (issue body)", + "finding": "the evidence base: a 21-task, 7-wave embodiment run whose corrections record was reconstructed from memory at the end; four graders failed, all caught by reading data afterwards, none by a test failing; three explicit non-asks \u2014 not a gate, no new engine if a move on existing state suffices, no automation", + "seeds": [ + "c1" + ] + }, + { + "id": "s2", + "surface": "devague/delivery.py + delivery_store.py + cli/_commands/deviate.py", + "finding": "DeviationRecord is the direct template (append-only, origin-driven status, fail-closed enums, prefix-generic `_next`); but the store is keyed 1:1 by plan slug and every entry point resolves a plan first, failing closed with `no plan selected` \u2014 pre-plan degradations have nowhere to land without generalizing the keying", + "seeds": [ + "c2", + "q1" + ] + }, + { + "id": "s3", + "surface": "devague/frame.py + plan.py + store.py + plan_store.py + docs/spec-contract.md", + "finding": "id prefixes c, h, q, v, s, t, r, d are taken (per-list prefix-generic `_next`); a new optional list field loads tolerantly without a schema bump (the `Claim.revisions` precedent) though new top-level lists have bumped by convention (`scope_entries`, v2); every kind vocabulary is a closed enum validated in `__post_init__` \u2014 no free-string kinds exist", + "seeds": [ + "c8", + "q2" + ] + }, + { + "id": "s4", + "surface": "devague/convergence.py + plan_convergence.py", + "finding": "both gates are hand-written allowlists over named fields \u2014 frame.claims and frame.open_vagueness, plan.tasks and plan.risks, nothing else; scope_entries appears nowhere in convergence.py (grep-confirmed) \u2014 the shipped precedent that a new list field is recorded, visible in renders, and never gates by default", + "seeds": [ + "c3" + ] + }, + { + "id": "s5", + "surface": "devague/render/summary_md.py + _md_safety.py + cli/_commands/summary.py", + "finding": "the Delivery Claims section renders a bare `` placeholder \u2014 the high/medium/low/unverified vocabulary exists only in summarize-delivery SKILL.md, not in code; approved deviations render in exactly two sections (Mid-work Decisions, Drift From Plan); a new verbatim render site needs `md_safe_text` plus table-cell escaping", + "seeds": [ + "c4" + ] + }, + { + "id": "s6", + "surface": ".claude/skills/{challenge,deviate,summarize-delivery,assign-to-workforce}/SKILL.md", + "finding": "the deviate method gates the recording itself behind explicit human approval (the one non-negotiable step); the challenge routing table has no row for a degradation that already happened and its hard rule is a closed nothing-else enumeration; the summarize-delivery read-only move table is likewise closed; assign-to-workforce forbids subagents only `devague plan` commands by name", + "seeds": [ + "c5", + "c6", + "q3" + ] + }, + { + "id": "s7", + "surface": "devague/cli/__init__.py + cli/_commands/learn.py + README.md + docs/skills.md", + "finding": "a new verb is one `_commands/` module exposing register() plus two lines in `_build_parser` (cli/__init__.py:73-127); the MOVES dict in learn.py holds only 15 of 20 verbs \u2014 deviate, summary, and plan are absent, so `devague explain deviate` fails today, a live gap the new move must not repeat; version-check CI blocks merge without a pyproject bump", + "seeds": [ + "c7", + "q2" + ] + }, + { + "id": "s8", + "surface": "challenge pass / adjacent-systems lens: devague/store.py + frame.py tolerant load", + "finding": "an older binary loads a frame carrying an unknown list tolerantly and re-saves without it \u2014 silent loss of filed lapses unless SCHEMA_VERSION bumps; scope_entries shipped with the v2 bump for exactly this reason", + "seeds": [ + "c17" + ] + }, + { + "id": "s9", + "surface": "challenge pass / failure-mode lens: frame.py `__post_init__` validation at load (probe)", + "finding": "probe: a Claim with kind bogus-kind raises ValueError at construction; from_dict constructs at load \u2014 so retiring a lapse code from a closed enum bricks loading of frames that filed it; write-closed load-tolerant validation is what makes the h11 dead-code removal safe", + "seeds": [ + "c21" + ] + }, + { + "id": "s10", + "surface": "challenge pass / lifecycle lens: export overwrite semantics (spec_md, frame_md, summary_md)", + "finding": "the spec left render placement undefined while export overwrites the same dated file \u2014 execution-time lapses would rewrite the spec artifact on re-export unless spec_md deliberately excludes the ledger", + "seeds": [ + "c19" + ] + }, + { + "id": "s11", + "surface": "challenge pass / unstated-assumptions lens: the adjudication surface", + "finding": "decision c15 says adjudicate in bulk but names no move \u2014 confirm and reject take c* and h* ids only today; the deviate precedent is confirm and reject flags on the verb itself", + "seeds": [ + "c18" + ] + }, + { + "id": "s12", + "surface": "challenge pass / data-flow lens: deviate.py `_validate_refs` vs frame-side refs", + "finding": "the deviate affects field validates id-shaped refs against plan tasks, coverage targets, and the live source frame \u2014 a frame-side lapse cannot validate tN refs before a plan exists, so the free-text-ref language in decision c14 is imprecise as written", + "seeds": [ + "c22" + ] + }, + { + "id": "s13", + "surface": "challenge pass / reversibility lens: deviate append-only vs scope --amend", + "finding": "two correction idioms coexist: deviate corrects by append (no amend move), scope amends in place (no trail) \u2014 the lapse ledger must pick append-only or the written-late-is-written-flattering rationale collapses", + "seeds": [ + "c20" + ] + }, + { + "id": "s14", + "surface": "challenge pass / concurrency lens: single-writer CLI + worktree fan-out", + "finding": "clean \u2014 only the main agent at the repo root mutates .devague state (boundary c6), no locking exists today; residual risk confined to two operators sharing one checkout, which no current flow does", + "seeds": [] + } + ], + "lapses": [ + { + "id": "l1", + "code": "provenance-missing", + "what": "the challenge pass concluded markdown-safety was handled after reading only the three CLI renderers (spec_md, frame_md, summary_md); it never read assign-to-workforce.sh, which also writes committed markdown and had never ported md_safe_text", + "skipped_check": "grep the repo for every writer of committed .md, not just the render/ package", + "refs": [ + "s10", + "d1" + ], + "origin": "llm", + "status": "proposed" + }, + { + "id": "l2", + "code": "grader-unverified", + "what": "the per-task TDD merge gate ran pytest and flake8 and I read green as 'the artifacts are clean' \u2014 the gate never ran markdownlint at any point, so it could not have caught the broken split artifact it was implicitly trusted to catch", + "skipped_check": "run markdownlint-cli2 as part of the merge gate, or check what the gate actually asserts before trusting it", + "refs": [ + "d1" + ], + "origin": "llm", + "status": "proposed" + }, + { + "id": "l3", + "code": "grader-unverified", + "what": "I reported 'the project isn't registered on SonarCloud, so no quality gate ran' on the strength of one instrument \u2014 agex pr read printed 'Project not on SonarCloud (skipped)'. The cicd status extension, querying SonarCloud directly, shows the gate ran and passed with 0 open issues and 0 hotspots; the sonarqubecloud bot had also posted a passing gate on the same PR", + "skipped_check": "cross-check the second Sonar surface the same skill ships (workflow.sh status) before relaying the first one's verdict", + "refs": [ + "d1", + "PR 101" + ], + "origin": "llm", + "status": "proposed" + }, + { + "id": "l4", + "code": "grader-unverified", + "what": "I reported 'Qodo required no fixes' after reading one comment; the actual Code Review \u2014 carrying a real correctness bug \u2014 landed three minutes later. My poll's exit condition matched any qodo comment that was not the 'busy working' placeholder, so it fired on the PR Summary and I treated arrival as completion", + "skipped_check": "validate the poll's exit condition against what a completed Qodo review actually looks like, rather than against 'a comment appeared'", + "refs": [ + "PR 101", + "l3" + ], + "origin": "llm", + "status": "proposed" + } + ] +} diff --git a/.devague/plans/reasoning-degradation-ledger.json b/.devague/plans/reasoning-degradation-ledger.json new file mode 100644 index 0000000..ad9ae98 --- /dev/null +++ b/.devague/plans/reasoning-degradation-ledger.json @@ -0,0 +1,389 @@ +{ + "slug": "reasoning-degradation-ledger", + "title": "reasoning-degradation ledger", + "frame_slug": "reasoning-degradation-ledger", + "schema_version": 4, + "status": "exported", + "created": "2026-07-29T17:10:25Z", + "updated": "2026-07-29T17:22:27Z", + "targets": [ + { + "id": "c1", + "kind": "announcement", + "text": "devague gains a deterministic move that records degradations of the reasoning process \u2014 moments where an assumption was silently substituted for a check \u2014 as first-class append-only ledger entries filed when they happen: the reasoning-side twin of deviate, never gating convergence", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h1", + "kind": "honesty", + "text": "filing an entry is one deterministic CLI call \u2014 no LLM, no subprocess \u2014 and converge output is byte-identical before and after filing, pinned by a test", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c2", + "kind": "requirement", + "text": "the move reuses the deviate chassis: prefix-generic id minting (`Delivery._next`, devague/delivery.py:63-72), origin-driven initial status (llm-origin lands proposed, delivery.py:85), fail-closed enum validation in `__post_init__`, and append-only records with no delete path", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h2", + "kind": "honesty", + "text": "the new record type reuses `_next`, origin-driven initial status, and fail-closed `__post_init__` validation rather than re-implementing them \u2014 checked in review of the shipping diff", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c4", + "kind": "requirement", + "text": "devague summary consumes approved ledger entries as evidence for the Delivery Claims confidence column, following the Mid-work Decisions and Drift From Plan render pattern (render/summary_md.py:205-244); the high/medium/low/unverified vocabulary lives only in the summarize-delivery skill today, not in code", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h3", + "kind": "honesty", + "text": "devague summary renders approved entries only; proposed entries render as visibly pending (mirroring the deviation pattern); a missing ledger degrades to the existing empty-state line, never an error", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c5", + "kind": "requirement", + "text": "the closed move enumerations in the producer and consumer skills are amended to name the new move: the challenge routing rule (findings route through existing deterministic moves only \u2014 nothing else, challenge/SKILL.md:204-208) has no row shaped like a degradation that already happened, and the summarize-delivery read-only move table (SKILL.md:282-296) is likewise closed", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h4", + "kind": "honesty", + "text": "the challenge and summarize-delivery closed move enumerations name the new move in the same PR that ships the verb \u2014 no doc-drift window", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c6", + "kind": "boundary", + "text": "fan-out subagents never file ledger entries \u2014 only the main agent runs devague moves; a degradation noticed inside a task worktree is reported in the task-agent transcript and recorded by the main agent (assign-to-workforce SKILL.md:320-322 currently forbids only `devague plan` commands by name)", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h5", + "kind": "honesty", + "text": "assign-to-workforce SKILL.md generalizes its worktree prohibition to name the new verb, not just `devague plan` commands", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c7", + "kind": "requirement", + "text": "the move lands in the MOVES dict in learn.py (today only 15 entries \u2014 deviate, summary, and plan are already absent, so `devague explain deviate` fails), the per-move contract tables in docs/spec-contract.md, README.md, and docs/skills.md, plus a CHANGELOG entry and the CI-enforced version bump", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h6", + "kind": "honesty", + "text": "`devague explain ` works for the new verb in the shipping PR \u2014 the MOVES dict entry is test-pinned \u2014 and the pre-existing deviate/summary/plan explain gap is filed as its own issue", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c9", + "kind": "audience", + "text": "operators \u2014 the main agent driving the CLI mid-run \u2014 plus the humans who own gate 2 and the final PR, and downstream method consumers (embodiment) whose delivery summaries cite ledger entries as confidence evidence", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h7", + "kind": "honesty", + "text": "no new human workflow is introduced: the same humans who own gate 2 and the final PR adjudicate ledger entries, inside gates that already exist", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c10", + "kind": "after_state", + "text": "a degradation is filed in seconds at the moment an assumption substitutes for a check; the delivery summary confidence column cites entry ids instead of end-of-run memory, and recovering a transition no longer costs hours of reading raw data afterwards", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h8", + "kind": "honesty", + "text": "in the dogfood cycle no single filing costs the operator more than a minute \u2014 otherwise the cheap-enough-to-use-mid-flight premise is false", + "deferred": true, + "deferred_reason": "post-ship embodiment dogfood milestone (park v1): measurable only after a real cycle runs with the shipped verb" + }, + { + "id": "c11", + "kind": "before_state", + "text": "corrections records are reconstructed at the end, from memory, shaped by how the story turned out \u2014 in the embodiment cycle four graders failed, every one found by reading data afterwards, none by a test failing, and one nearly shipped a false safety claim", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h9", + "kind": "honesty", + "text": "the embodiment corrections record and its four grader failures are real committed artifacts quoted in issue 97, not a reconstruction made for this spec", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c12", + "kind": "why_it_matters", + "text": "written late is written flattering: a ledger entry at the moment of the transition costs seconds, recovering it afterwards costs hours \u2014 so the recording move must be cheap enough that filing mid-flight actually happens", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h10", + "kind": "honesty", + "text": "at least one transition in the embodiment cycle was recoverable only because raw data happened to be committed \u2014 the near-miss is documented, not anecdotal", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c13", + "kind": "success_signal", + "text": "in >= 1 dogfooded embodiment cycle, every degradation entry is filed mid-flight (0 reconstructed in the retrospective) and 0 codes ship without a named producer moment \u2014 codes with zero filings are dropped before the vocabulary freezes", + "deferred": true, + "deferred_reason": "post-ship embodiment dogfood milestone (park v1): measurable only after a real cycle runs with the shipped verb" + }, + { + "id": "h11", + "kind": "honesty", + "text": "a code with zero filings after the dogfood cycle is actually removed from the enum, not kept as documentation \u2014 covered is distinguished from reachable", + "deferred": true, + "deferred_reason": "post-ship embodiment dogfood milestone (park v1): the dead-code removal decision needs the dogfood filing counts" + }, + { + "id": "c17", + "kind": "requirement", + "text": "adding the lapses list bumps SCHEMA_VERSION 4 to 5: without the bump an older installed binary loads a lapse-bearing frame tolerantly and its next save silently drops every filed lapse (save re-stamps the current version and to_dict writes only known fields) \u2014 the scope_entries v2 precedent; the fail-closed version check is what turns silent data loss into a version hint", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h12", + "kind": "honesty", + "text": "a v4-reading binary pointed at a v5 frame fails closed with the version hint instead of silently dropping lapse records on save \u2014 pinned by a reject-newer store test", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c18", + "kind": "requirement", + "text": "adjudication is on the verb, mirroring deviate: `devague lapse --confirm ` / `--reject ` (confirm and reject keep taking only c* and h* ids); statuses are proposed/approved/rejected, and a user-origin filing lands approved immediately \u2014 the deviate origin contract", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h13", + "kind": "honesty", + "text": "an llm-origin lapse never becomes citable without a human --confirm; devague summary renders it as visibly pending until then \u2014 no path upgrades it automatically", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c19", + "kind": "requirement", + "text": "lapses render in devague show (frame_md) and the delivery summary; the exported spec-md never grows a lapse section \u2014 export overwrites the same dated file, so execution-time lapses would rewrite the what-to-build artifact on re-export; the contested-marker philosophy applies: process history points forward, the spec is not rewritten", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h14", + "kind": "honesty", + "text": "re-exporting the spec after filing lapses produces a byte-identical spec-md \u2014 pinned by a test that files a lapse and diffs the export", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c20", + "kind": "boundary", + "text": "lapse records are append-only in the strong sense: no amend and no delete \u2014 unlike scope entries, which amend in place with no trail; a wrong lapse is rejected and refiled, because an editable lapse re-enables written-late-is-written-flattering", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h15", + "kind": "honesty", + "text": "no CLI path mutates a lapse record after filing except the status transition \u2014 pinned by the argument surface (no amend flag) and a test", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "c21", + "kind": "requirement", + "text": "code validation is fail-closed at filing time but tolerant at load time: retiring a dead code after the dogfood cycle must never brick an existing frame \u2014 probe-confirmed: an unknown kind raises ValueError at construction, and from_dict constructs at load, so a closed load-time enum would refuse to load any frame that ever filed the retired code", + "deferred": false, + "deferred_reason": "" + }, + { + "id": "h16", + "kind": "honesty", + "text": "a frame holding a lapse with a retired code still loads and renders after the code leaves the filing enum \u2014 pinned by a test that files, retires, reloads", + "deferred": false, + "deferred_reason": "" + } + ], + "tasks": [ + { + "id": "t1", + "summary": "Lapse domain model on Frame: LapseRecord, lapse codes, schema v5", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "Frame.lapses exists; add_lapse mints l1, l2, ... via the prefix-generic `_next`; llm origin lands proposed, user origin lands approved; to_dict/from_dict round-trip lapses verbatim", + "filing an unknown code raises ValueError, while from_dict loads a stored record carrying a retired code without error \u2014 pinned by a test that files, retires the code, and reloads", + "SCHEMA_VERSION == 5; a frame declaring 6 is refused fail-closed before parsing; a v4 frame without lapses loads clean and re-saves as v5", + "no amend or delete API exists for lapse records \u2014 the only post-filing mutation is set_lapse_status; refs are stored verbatim as free text, never validated" + ], + "deps": [], + "covers": [ + "c2", + "h2", + "c17", + "h12", + "c20", + "c21", + "h16" + ], + "instruction": "mirror DeviationRecord (devague/delivery.py) for the record shape \u2014 id, code, what, skipped_check, refs, origin, status (proposed/approved/rejected); validate code in add_lapse (the filing path), NOT in `__post_init__`, so retired codes stay loadable \u2014 c21 deliberately refines the c2 chassis pattern here; statuses and origin still validate fail-closed in `__post_init__` (they never retire); the six issue-97 codes are the starting LAPSE_CODES tuple; bump SCHEMA_VERSION to 5 in frame.py and move the pin in tests/test_frame_schema_v2.py; new tests in tests/test_frame_lapse.py" + }, + { + "id": "t2", + "summary": "CLI verb lapse: file, list, adjudicate", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "`devague lapse \"\" --code ` files against the current frame and echoes the minted id; `--origin llm` lands proposed; `--skipped \"\"` and repeatable `--ref` are stored verbatim", + "`--list [--json]` renders every record with id, code, and status; `--confirm ` / `--reject ` transition only proposed records, refuse otherwise, and are mutually exclusive with recording", + "the argument surface has no amend or delete flag \u2014 pinned by a test over the parser", + "`devague explain lapse` succeeds and bare `devague learn` lists the move \u2014 the MOVES entry is test-pinned", + "recording is deterministic: no subprocess and no LLM call, mirroring the deviate determinism test" + ], + "deps": [ + "t1" + ], + "covers": [ + "c1", + "c12", + "c18", + "h15", + "h6" + ], + "instruction": "clone the deviate.py argument surface minus --task and minus id-ref validation (refs stay free text); one new module devague/cli/_commands/lapse.py exposing register(), two lines in cli/__init__.py _build_parser; add the lapse row to the MOVES dict in learn.py; tests in tests/test_cli_lapse.py" + }, + { + "id": "t3", + "summary": "Render the ledger: show and summary consume, spec stays untouched", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "devague show renders a Lapse ledger section (id, code, status, what) omitted entirely when empty", + "devague summary cites approved lapses as evidence for the Delivery Claims confidence column; proposed entries render visibly pending; rejected are omitted; zero entries keeps the existing empty-state line \u2014 all through md_safe_text and table-cell escaping", + "re-exporting the spec after filing lapses produces a byte-identical spec-md \u2014 pinned by a test that files a lapse and diffs render_spec output", + "a frame that fails to load degrades in summary exactly as today \u2014 no new failure mode" + ], + "deps": [ + "t1" + ], + "covers": [ + "c4", + "h3", + "c10", + "c19", + "h14", + "h13" + ], + "instruction": "follow the _mid_work_lines/_drift_lines approved/pending/rejected discipline in summary_md.py; frame_md.py gets the new section; spec_md.py gets NO code change \u2014 only the byte-identity regression test; tests in tests/test_summary.py and tests/test_render_sharper.py" + }, + { + "id": "t4", + "summary": "Gate inertness pinned by tests", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "converge output is byte-identical before and after filing lapses on an otherwise converged frame \u2014 proposed, approved, and rejected records all tried", + "neither gate ever names lapse records: frame and plan convergence blockers, warnings, and parked_items stay lapse-free in every status combination" + ], + "deps": [ + "t1" + ], + "covers": [ + "h1" + ], + "instruction": "pure test task, no production code: if a gate references lapses the production change is wrong, not the test; add to tests/test_convergence.py and tests/test_plan_convergence.py" + }, + { + "id": "t5", + "summary": "Skills sweep: producer, consumer, and the subagent boundary", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "the challenge routing table gains a row routing an already-happened reasoning degradation to devague lapse, and the nothing-else hard rule names the move", + "the summarize-delivery read-only moves table and hard rule gain `lapse --list`, and the Delivery Claims method step reads the ledger to ground each confidence level", + "the assign-to-workforce worktree prohibition generalizes to every devague move, naming lapse explicitly: a task agent reports a degradation in its transcript, the main agent files it", + "no SKILL.md introduces a new gate or workflow owner: adjudication is named as `lapse --confirm` / `--reject` exercised by the existing gate owners; docs/skills.md enumerations match every table touched" + ], + "deps": [ + "t2" + ], + "covers": [ + "c5", + "h4", + "c6", + "h5", + "c9", + "h7" + ], + "instruction": "quote the shipped CLI surface exactly as t2 built it \u2014 no paraphrase; sweep .claude/skills/{challenge,summarize-delivery,assign-to-workforce}/SKILL.md plus docs/skills.md; the deviate skill needs no change beyond any enumeration that names all moves" + }, + { + "id": "t6", + "summary": "Docs, contract, changelog, version", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "docs/spec-contract.md gains the lapse entity (fields, statuses, id prefix l, the filing-time-closed load-time-tolerant code rule) plus a Moves contract row and a schema v5 line in Versioning", + "README.md names lapse in the flat-verb inventory and the agent-driving flow; CLAUDE.md status reflects the new surface", + "CHANGELOG entry and version bump land so the CI version-check passes", + "the CHANGELOG or README cites issue 97 and the embodiment corrections-record evidence \u2014 the before-state and its four grader failures are documented, not anecdotal" + ], + "deps": [ + "t2" + ], + "covers": [ + "c7", + "c11", + "h9", + "h10" + ], + "instruction": "minor version bump (new feature) per the version-bump convention; keep the spec-contract Moves row shape identical to the deviate row" + } + ], + "risks": [ + { + "id": "r1", + "text": "file the pre-existing learn.py explain gap (deviate, summary, plan absent from MOVES) as its own upstream issue before the PR merges \u2014 t2 adds only the lapse entry", + "kind": "follow_up", + "task_id": null, + "resolved": false, + "resolution": "" + }, + { + "id": "r2", + "text": "whether summarize-delivery should cap a delivery-claim confidence at low or unverified when an approved lapse names it \u2014 deferred to the embodiment dogfood report", + "kind": "follow_up", + "task_id": null, + "resolved": false, + "resolution": "" + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index e7e8956..014d049 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,64 @@ 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.22.0] - 2026-07-29 + +The Reasoning Degradation Ledger (issue #97) — the reasoning-side twin of +`deviate`: a first-class, append-only ledger entry filed the moment an +assumption is silently substituted for a check, instead of a corrections +record reconstructed at the end from memory. The motivating evidence is a +real, committed artifact: the embodiment repo's 21-task, 7-wave +`/scope`→`/summarize-delivery` fan-out produced its most useful artifact — +that corrections record — only at the end, reconstructed from memory. Four +graders failed in that cycle (three inside a single task); every one was +found by reading data afterwards, none by a test failing; and one nearly +shipped a false safety claim. At least one of those transitions was +recoverable only because raw data happened to be committed, not because +anything guaranteed it would be. The most repeated entry in that record was +"the mechanism was right and the verification was the defect" — a +degradation class, not a bug class, and the reason filing has to be cheap +enough to happen mid-flight rather than at the end. + +### Added + +- **`devague lapse "" --code [--skipped ""] [--ref REF + ...] [--origin user|llm] [--json]`** (plus `--list [--json]` and + `--confirm ` / `--reject `) — file a reasoning-degradation lapse + against the current frame. New `Frame.lapses` / `LapseRecord`, deliberately + mirroring `DeviationRecord`: prefix-generic `lN` ids, origin-driven initial + status (`llm` → `proposed`, needs a human `--confirm`/`--reject`; `user` → + auto-`approved`), and six starting codes + (`assumption-for-measurement`, `grader-unverified`, `control-absent`, + `n-below-claim`, `instrument-changed-mid-series`, `provenance-missing`). + `code` validates fail-closed at the **filing** path (`Frame.add_lapse`), + deliberately *not* in `__post_init__` like every other kind vocabulary in + this codebase — retiring a code after a dogfood cycle must not brick a + frame that already filed it under that code. The ledger is append-only in + the strong sense: no amend, no delete — the only post-filing mutation is + the status transition, a deliberate asymmetry with `scope --amend`, whose + in-place correction would re-enable the written-late-is-written-flattering + failure this ledger exists to prevent. +- The ledger **never gates**: no convergence blocker, warning, or parked item + on either engine ever names a lapse, in any status — pinned by + `tests/test_convergence.py` and `tests/test_plan_convergence.py`. It + renders in `devague show` (every filed lapse, any status, under a new + "Lapse ledger" section) and as confidence evidence in `devague summary`'s + Delivery Claims section (only `approved` entries render fully; a + `proposed` one surfaces as a visibly pending id; a `rejected` one is + omitted). The exported spec-md gains **no** lapse section at all — + `export` overwrites the same dated file on every re-export, so an + execution-time entry rendering there would rewrite the what-to-build + artifact rather than record process history; a byte-identical-export + regression test pins this. +- `SCHEMA_VERSION` bumped 4→5 for `Frame.lapses`. Unlike the `Claim.revisions` + addition in 0.21.0 (shipped without a bump, since a missing list defaults + tolerantly to `[]`), this one is a hard requirement: an older v4-labeled + binary that loads a v5 frame and re-saves it would silently drop every + filed lapse (`save()` re-stamps `schema_version`, and `to_dict` only + serializes known dataclass fields) — the same failure mode the + `scope_entries` v2 bump exists to prevent. A v4 frame predates the field + and loads with an empty ledger, never a fabricated one. + ## [0.21.0] - 2026-07-28 The fifteen-issue backlog sweep (the `issue-backlog-sweep` plan, tasks t1–t19) diff --git a/CLAUDE.md b/CLAUDE.md index a83b970..80f2aa6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,44 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Status +**The Reasoning Degradation Ledger lands (0.22.0, issue #97).** A new flat +verb, **`devague lapse "" --code [--skipped ""] [--ref +REF ...] [--origin user|llm] [--json]`** (plus `--list [--json]` and +`--confirm ` / `--reject `), files a reasoning-degradation lapse — a +moment an assumption was silently substituted for a check — as a +first-class, **append-only** ledger entry on the frame (`Frame.lapses` / +`LapseRecord`, `SCHEMA_VERSION` 4→5): six starting codes +(`assumption-for-measurement`, `grader-unverified`, `control-absent`, +`n-below-claim`, `instrument-changed-mid-series`, `provenance-missing`) +validated fail-closed at the *filing* path (`Frame.add_lapse`), deliberately +**not** in `__post_init__` — a code retired after a dogfood cycle must not +brick a frame that already filed it, unlike every other kind vocabulary in +this codebase, which validates (and therefore re-validates on load) in +`__post_init__`. Filing mirrors `deviate`: `llm`-origin lands `proposed` +(needs a human `--confirm`/`--reject`), `user`-origin auto-approves — but +adjudication is the *only* mutation a filed lapse ever gets; there is no +amend and no delete, a deliberate asymmetry with `scope --amend`, since an +editable lapse would re-enable the written-late-is-written-flattering +failure the ledger exists to prevent. The ledger **never gates**: no +convergence blocker, warning, or parked item on either engine ever names a +lapse, in any status (pinned by `tests/test_convergence.py` and +`tests/test_plan_convergence.py`). It renders in `devague show` (every +lapse, any status) and as confidence evidence in `devague summary`'s +Delivery Claims section (approved entries only; a proposed one renders as +visibly pending; a rejected one is omitted); the exported spec-md never +grows a lapse section at all, since `export` overwrites the same dated file +on every re-export and process history must not rewrite the +what-to-build artifact. The motivating evidence, cited verbatim from issue +`agentculture/devague#97`: the embodiment repo's 21-task, 7-wave +`/scope`→`/summarize-delivery` fan-out produced its most useful artifact — a +corrections record — reconstructed only at the end, from memory; four +graders failed in that cycle (three inside a single task), every one found +by reading data +afterwards and none by a test failing, and one nearly shipped a false +safety claim; at least one of those transitions was recoverable only +because raw data happened to be committed, not because anything guaranteed +it would be. + **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 @@ -447,10 +485,11 @@ that unless the user asks otherwise. The established sibling shape is: split, `--json` support). - `devague/cli/_commands/` — one module per verb, each exposing `register()`. 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 + `review`, `question`, `park`, `scope`, `lapse` (`--list`, `--confirm`, + `--reject`; the Reasoning Degradation Ledger, #97), `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 diff --git a/README.md b/README.md index 6897834..d35e7b1 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ devague --version shipped"), capture and pressure-test claims, park open vagueness, and `export` a spec only once the frame *converges*. Flat verbs: `devague new` / `capture` / `amend` / `interrogate` / `confirm` / `park` / `scope` / - `converge` / `export` / … + `lapse` / `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: @@ -105,6 +105,15 @@ route through the same moves `/think` already uses. The CLI-driving pair — next-move helper over the convergence gate; the CLI is the deterministic affordance and the agent decides the next move. +Cutting across all seven legs is `devague lapse` (issue #97) — file a +reasoning-degradation lapse (an assumption silently substituted for a check) +the moment it happens, instead of reconstructing a corrections record from +memory once the run ends. It is never a gate: filing is friction-free +(`llm`-origin lands `proposed`, a user-authored one auto-approves), it never +blocks convergence on either engine, and only an **approved** entry is ever +cited as confidence evidence in `/summarize-delivery`'s Delivery Claims +section. + These skills serve two audiences: **operators** — the main agent that drives the deterministic CLI move by move across all seven legs — and the **humans** who own the three standing gates: the exported spec, the go/no-go on the diff --git a/devague/cli/__init__.py b/devague/cli/__init__.py index ecf8b4d..1b01f95 100644 --- a/devague/cli/__init__.py +++ b/devague/cli/__init__.py @@ -90,6 +90,7 @@ def _build_parser() -> argparse.ArgumentParser: from devague.cli._commands import explain as _explain_cmd from devague.cli._commands import export as _export_cmd from devague.cli._commands import interrogate as _interrogate_cmd + from devague.cli._commands import lapse as _lapse_cmd from devague.cli._commands import learn as _learn_cmd from devague.cli._commands import list_frames as _list_cmd from devague.cli._commands import new as _new_cmd @@ -119,6 +120,7 @@ def _build_parser() -> argparse.ArgumentParser: _export_cmd.register(sub) _plan_cmd.register(sub) _deviate_cmd.register(sub) + _lapse_cmd.register(sub) _status_cmd.register(sub) _summary_cmd.register(sub) _show_cmd.register(sub) diff --git a/devague/cli/_commands/lapse.py b/devague/cli/_commands/lapse.py new file mode 100644 index 0000000..e3caf82 --- /dev/null +++ b/devague/cli/_commands/lapse.py @@ -0,0 +1,203 @@ +"""``devague lapse`` — file, list, or adjudicate a reasoning-degradation lapse. + +The Reasoning Degradation Ledger's CLI move (issue #97 t2). The domain model +(``Frame.lapses`` / ``LapseRecord`` / ``Frame.add_lapse`` / ``find_lapse`` / +``set_lapse_status``) landed in t1 (:mod:`devague.frame`); this module is +its CLI twin. + +This is a direct clone of :mod:`devague.cli._commands.deviate`'s argument +surface and confirm/reject/list shape, with two deliberate omissions: + +- **No ``--task``** — a lapse is filed against the current *frame*, not a + *plan*; there is no plan-item link to validate. +- **No id-ref validation** — a deviation's ``--affects`` must resolve to a + known plan/frame id (:func:`deviate._validate_refs`); a lapse's ``--ref`` + stays free text, never checked against known ids (see + :class:`devague.frame.LapseRecord`'s docstring for why: the ledger records + what was skipped, and demanding a real id for that would let a filer dodge + filing when they can't (or shouldn't) cite one precisely). + +Recording is deterministic: no LLM calls, no subprocess. Origin drives the +initial status exactly like a claim, a task, or a deviation: ``--origin llm`` +lands ``proposed`` and needs an explicit user ``--confirm``; a user-authored +record (the default) auto-approves (``Frame.add_lapse``). ``--confirm`` / +``--reject`` are therefore the only way a proposed lapse is resolved. +""" + +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_result +from devague.frame import LAPSE_CODES, ORIGINS + + +def _record_dict(rec) -> dict: + return { + "id": rec.id, + "code": rec.code, + "what": rec.what, + "skipped_check": rec.skipped_check, + "refs": rec.refs, + "origin": rec.origin, + "status": rec.status, + } + + +def _record(args: argparse.Namespace, frame) -> int: + if not args.code: + raise DevagueError( + EXIT_USER_ERROR, + "missing --code", + f"pass --code : {', '.join(LAPSE_CODES)}", + ) + try: + rec = frame.add_lapse( + args.code, + args.what, + skipped_check=args.skipped_check or "", + refs=args.refs, + origin=args.origin or "user", + ) + except ValueError as exc: + raise DevagueError( + EXIT_USER_ERROR, + str(exc), + f"valid lapse codes: {', '.join(LAPSE_CODES)}", + ) from exc + store.save(frame) + if getattr(args, "json", False): + emit_result(_record_dict(rec), json_mode=True) + else: + emit_result(f"filed {rec.id} ({rec.status})", json_mode=False) + return 0 + + +def _resolve_status(args: argparse.Namespace, frame, lid: str, status: str) -> int: + rec = frame.find_lapse(lid) + if rec is None: + raise DevagueError( + EXIT_USER_ERROR, + f"no such lapse: {lid}", + "run 'devague lapse --list' to see filed lapse ids", + ) + if rec.status != "proposed": + raise DevagueError( + EXIT_USER_ERROR, + f"lapse {lid} is already {rec.status}", + f"only a 'proposed' lapse can be confirmed/rejected — " + f"{lid} is already {rec.status}", + ) + frame.set_lapse_status(lid, status) + store.save(frame) + if getattr(args, "json", False): + emit_result({"id": lid, "status": status}, json_mode=True) + else: + emit_result(f"{lid} -> {status}", json_mode=False) + return 0 + + +def _list(args: argparse.Namespace, frame) -> int: + records = frame.lapses + if getattr(args, "json", False): + emit_result( + {"frame": frame.slug, "lapses": [_record_dict(r) for r in records]}, + json_mode=True, + ) + elif not records: + emit_result("no lapses filed yet", json_mode=False) + else: + lines = [f"{r.id}: {r.what} ({r.code}, {r.status})" for r in records] + emit_result("\n".join(lines), json_mode=False) + return 0 + + +def cmd_lapse(args: argparse.Namespace) -> int: + if (args.confirm or args.reject) and args.what: + raise DevagueError( + EXIT_USER_ERROR, + "cannot combine --confirm/--reject with a positional 'what' argument", + "resolve and record are separate moves: run " + "'devague lapse --confirm ' (or --reject), then " + "'devague lapse \"\" --code '", + ) + if args.list and args.what: + raise DevagueError( + EXIT_USER_ERROR, + "cannot combine --list with a positional 'what' argument", + "list and record are separate moves: run 'devague lapse --list' " + "or 'devague lapse \"\" --code '", + ) + # Record-only flags with no `what` used to fall through to listing, so + # `devague lapse --code --skipped ""` exited 0 having filed + # nothing (Qodo, PR #101). For a ledger whose premise is that filing is + # cheap enough to do mid-flight, a silent no-op is the worst failure + # available: the operator believes the degradation is recorded and it is + # not. Fail closed, matching the flag/positional-ambiguity precedent (#72). + if not args.what: + given = [ + flag + for flag, value in ( + ("--code", args.code), + ("--skipped", args.skipped_check), + ("--ref", args.refs), + ("--origin", args.origin), + ) + if value + ] + if given: + raise DevagueError( + EXIT_USER_ERROR, + f"{', '.join(given)} given without a positional 'what' to file", + 'record the lapse in one move: devague lapse "" ' + "--code , or drop the flags to list", + ) + frame = resolve(args.frame) + if args.confirm: + return _resolve_status(args, frame, args.confirm, "approved") + if args.reject: + return _resolve_status(args, frame, args.reject, "rejected") + if args.what: + return _record(args, frame) + return _list(args, frame) + + +def register(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser("lapse", help="File, list, or adjudicate a reasoning-degradation lapse.") + p.add_argument("what", nargs="?", help="What lapsed (omit with --confirm/--reject/--list).") + p.add_argument( + "--code", + choices=LAPSE_CODES, + help="Which lapse code this instance is.", + ) + p.add_argument( + "--skipped", + dest="skipped_check", + default="", + metavar="CHECK", + help="What check should have caught this but didn't.", + ) + p.add_argument( + "--ref", + dest="refs", + action="append", + default=None, + metavar="REF", + help="A free-text reference this lapse relates to (repeatable, never validated).", + ) + # default=None (not "user") so an explicitly-passed --origin is + # distinguishable from the default — cmd_lapse needs that to tell + # record intent from a bare list. _record resolves None to "user". + p.add_argument("--origin", choices=ORIGINS, default=None, help="Who proposed it.") + resolution = p.add_mutually_exclusive_group() + resolution.add_argument( + "--confirm", metavar="ID", help="Approve a proposed lapse id (user-only)." + ) + resolution.add_argument("--reject", metavar="ID", help="Reject a lapse id (user-only).") + resolution.add_argument("--list", action="store_true", help="List filed lapses (default).") + 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_lapse) diff --git a/devague/cli/_commands/learn.py b/devague/cli/_commands/learn.py index 6bd51f4..af33d45 100644 --- a/devague/cli/_commands/learn.py +++ b/devague/cli/_commands/learn.py @@ -52,6 +52,12 @@ "status": "Report where the frame stands + the recommended next move (read-only).", "show": "Render the Announcement Frame.", "list": "List frames.", + "lapse": ( + "File a reasoning-degradation lapse against the current frame " + "('lapse \"\" --code ', --origin llm lands proposed); " + "'--confirm/--reject ' resolves a proposed one (user-only); " + "'--list [--json]' shows every filed lapse." + ), } FIRST_QUESTION = "What's the announcement?" diff --git a/devague/frame.py b/devague/frame.py index 3149153..6273002 100644 --- a/devague/frame.py +++ b/devague/frame.py @@ -20,7 +20,12 @@ # 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 +# v5 (issue #97 t1) adds Frame.lapses / LapseRecord — the Reasoning Degradation +# Ledger. This DOES need a real bump (unlike Claim.revisions above): save() +# re-stamps schema_version and to_dict only serializes known dataclass fields, +# so an older v4-labeled binary reading a v5 frame and re-saving it would +# silently drop every filed lapse (the scope_entries v2 precedent, c17/h12). +SCHEMA_VERSION = 5 CLAIM_KINDS = ( "announcement", @@ -62,6 +67,21 @@ HONESTY_STATUSES = ("proposed", "confirmed", "rejected") ORIGINS = ("user", "llm") +# The Reasoning Degradation Ledger's starting vocabulary (issue #97 t1). Unlike +# every other vocabulary tuple above, this one is expected to grow/retire over +# time as dogfooding surfaces new degradation shapes — see LapseRecord's +# __post_init__ docstring for why that means `code` is validated at the filing +# path (Frame.add_lapse), never here at load/construction time. +LAPSE_CODES = ( + "assumption-for-measurement", + "grader-unverified", + "control-absent", + "n-below-claim", + "instrument-changed-mid-series", + "provenance-missing", +) +LAPSE_STATUSES = ("proposed", "approved", "rejected") + @dataclass class HonestyCondition: @@ -173,6 +193,45 @@ class ScopeEntry: seeds: list[str] = field(default_factory=list) +@dataclass +class LapseRecord: + """A filed reasoning-degradation lapse (issue #97) — the Reasoning + Degradation Ledger's record shape, mirroring ``DeviationRecord`` + (:mod:`devague.delivery`): prefix-generic id minting via ``Frame._next``, + origin-driven initial status, append-only with no delete path. + + ``code`` deliberately refines that chassis pattern (c21): it is validated + at the *filing* path (``Frame.add_lapse``), NOT here in + ``__post_init__``. Every other enum-like field in this module validates + in ``__post_init__``, which also means it validates at *load* time + (``from_dict`` constructs the dataclass directly) — correct for + ``kind``/``origin``/``status`` vocabularies that never retire, but wrong + for lapse codes: retiring a code after a dogfood cycle must not brick + every frame that ever filed it. ``status`` and ``origin`` still validate + here — they never retire. + + ``refs`` is stored verbatim as free text (task/claim ids, prose, or + nothing) and is never validated — unlike ``ScopeEntry.seeds``, which + checks its ids against the frame. + """ + + id: str + code: str + what: str + skipped_check: str = "" + refs: list[str] = field(default_factory=list) + origin: str = "user" # user | llm + status: str = "approved" # proposed | approved | rejected + + def __post_init__(self) -> None: + if self.origin not in ORIGINS: + raise ValueError(f"unknown lapse origin: {self.origin!r}") + if self.status not in LAPSE_STATUSES: + raise ValueError(f"unknown lapse status: {self.status!r}") + # `code` is NOT validated here on purpose — see the class docstring + # and Frame.add_lapse. + + @dataclass class Frame: slug: str @@ -184,6 +243,9 @@ class Frame: claims: list[Claim] = field(default_factory=list) open_vagueness: list[Vagueness] = field(default_factory=list) scope_entries: list[ScopeEntry] = field(default_factory=list) + # The Reasoning Degradation Ledger (issue #97 t1, schema v5). Append-only: + # no amend, no delete — the only post-filing mutation is set_lapse_status. + lapses: list[LapseRecord] = field(default_factory=list) @staticmethod def _next(items: list, prefix: str) -> str: @@ -436,6 +498,64 @@ def amend_scope_entry(self, entry_id: str, finding: str) -> ScopeEntry: entry.finding = finding return entry + def add_lapse( + self, + code: str, + what: str, + skipped_check: str = "", + refs: Optional[list[str]] = None, + origin: str = "user", + ) -> LapseRecord: + """File a reasoning-degradation lapse (issue #97). + + ``code`` is validated here — the filing path — against + :data:`LAPSE_CODES`, fail-closed with a clear error naming the + unknown code. This is deliberately NOT in ``LapseRecord.__post_init__`` + (see that class's docstring): a code retired after this call already + succeeded must still be loadable via ``from_dict``, which constructs + ``LapseRecord`` directly and never goes through this method. + + ``origin`` drives the initial ``status`` exactly like + ``Delivery.add_deviation``: ``llm`` lands ``proposed`` (needs a human + ``set_lapse_status`` to approve), ``user`` auto-approves. ``refs`` is + stored verbatim free text, never validated (unlike + :meth:`add_scope_entry`'s seed ids). + """ + if code not in LAPSE_CODES: + raise ValueError(f"unknown lapse code: {code!r}") + status = "proposed" if origin == "llm" else "approved" + rec = LapseRecord( + id=self._next(self.lapses, "l"), + code=code, + what=what, + skipped_check=skipped_check, + refs=list(refs) if refs else [], + origin=origin, + status=status, + ) + self.lapses.append(rec) + return rec + + def find_lapse(self, lid: str) -> Optional[LapseRecord]: + return next((r for r in self.lapses if r.id == lid), None) + + def set_lapse_status(self, lid: str, status: str) -> bool: + """Set a lapse record's status, failing closed on a typo'd/unknown value. + + The only mutator a filed lapse ever gets — there is no amend or + delete API (c20): a wrong lapse is rejected and refiled, never + edited in place. Mirrors :meth:`devague.delivery.Delivery.set_status`: + validates ``status`` against :data:`LAPSE_STATUSES` *before* touching + the record, so an invalid string never mutates anything. + """ + if status not in LAPSE_STATUSES: + raise ValueError(f"unknown lapse status: {status!r}") + rec = self.find_lapse(lid) + if rec is not None: + rec.status = status + return True + return False + def set_status(self, item_id: str, status: str) -> bool: claim = self.find_claim(item_id) if claim is not None: @@ -595,6 +715,21 @@ def from_dict(d: dict) -> Frame: ) for s in d.get("scope_entries", []) ] + lapses = [ + LapseRecord( + id=r["id"], + code=r["code"], + what=r["what"], + skipped_check=r.get("skipped_check", ""), + refs=list(r.get("refs", [])), + origin=r.get("origin", "user"), + status=r.get("status", "approved"), + ) + # Pre-v5 frames predate this field entirely (issue #97 t1); default to + # an empty ledger. `code` is deliberately NOT re-validated here — a + # retired code must still load (see LapseRecord's docstring). + for r in d.get("lapses", []) + ] return Frame( slug=d["slug"], title=d["title"], @@ -607,4 +742,6 @@ def from_dict(d: dict) -> Frame: open_vagueness=vag, # v1 frames predate this field (#53 t1); default to no scope entries. scope_entries=scope_entries, + # v5 frames only (issue #97 t1); default to no lapses. + lapses=lapses, ) diff --git a/devague/render/frame_md.py b/devague/render/frame_md.py index 2a7973a..88644fc 100644 --- a/devague/render/frame_md.py +++ b/devague/render/frame_md.py @@ -86,13 +86,38 @@ def _scope_lines(frame: Frame) -> list[str]: return [] lines = ["## Scope exploration", ""] for e in frame.scope_entries: - lines.append(f"- `{e.id}` — `{e.surface}`: {e.finding}") + # A surface carrying its own code span cannot be wrapped again (MD038). + span = e.surface if "`" in e.surface else f"`{e.surface}`" + lines.append(f"- `{e.id}` — {span}: {e.finding}") if e.seeds: lines.append(f" - seeds: {', '.join(f'`{s}`' for s in e.seeds)}") lines.append("") return lines +def _lapse_lines(frame: Frame) -> list[str]: + """The Reasoning Degradation Ledger (issue #97 t1's ``Frame.lapses``), + mirroring ``_scope_lines``'s omitted-when-empty shape: every filed lapse + renders with its id, code, status, and what. + + Unlike ``render.summary_md``'s approved/pending/rejected discipline (a + rejected deviation, and by the same pattern a rejected lapse, is dropped + there entirely), ``devague show`` is the working-state view — every + lapse renders here regardless of status, the same way ``_vagueness_lines`` + shows both resolved and still-open parked items in one flat list. Never + rendered in ``spec_md``: the exported spec overwrites the same dated file + on every re-export, so execution-time lapses rendering there would + rewrite the what-to-build artifact (#97 t3). + """ + if not frame.lapses: + return [] + lines = ["## Lapse ledger", ""] + for r in frame.lapses: + lines.append(f"- `{r.id}` — `{r.code}` ({r.status}): {r.what}") + lines.append("") + return lines + + def render_frame(frame: Frame) -> str: out = [ f"# Announcement Frame — {frame.title}", @@ -104,4 +129,5 @@ def render_frame(frame: Frame) -> str: out.extend(_section_lines(frame, kind, heading)) out.extend(_scope_lines(frame)) out.extend(_vagueness_lines(frame)) + out.extend(_lapse_lines(frame)) return "\n".join(out).rstrip() + "\n" diff --git a/devague/render/spec_md.py b/devague/render/spec_md.py index 3e3336a..d0d88fb 100644 --- a/devague/render/spec_md.py +++ b/devague/render/spec_md.py @@ -268,6 +268,18 @@ def _seed_label(frame: Frame, seed_id: str) -> str: return f"`{seed_id}`" +def _surface_span(surface: str) -> str: + """Wrap a scope surface in one code span — unless it already carries one. + + A surface containing a backtick cannot be blindly wrapped (the nested + spans render broken and trip MD038); it routes through ``_safe`` instead, + which passes its embedded code spans through untouched. + """ + if "`" in surface: + return _safe(surface) + return f"`{surface}`" + + 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 @@ -277,7 +289,7 @@ def _scope_section(frame: Frame) -> list[str]: return [] out = ["## Scope exploration", ""] for e in frame.scope_entries: - out.append(f"- `{e.id}` — `{e.surface}`: {_safe(e.finding)}") + out.append(f"- `{e.id}` — {_surface_span(e.surface)}: {_safe(e.finding)}") if e.seeds: out.append(f" - seeds: {', '.join(_seed_label(frame, s) for s in e.seeds)}") return out + [""] diff --git a/devague/render/summary_md.py b/devague/render/summary_md.py index 765eaa1..a70f058 100644 --- a/devague/render/summary_md.py +++ b/devague/render/summary_md.py @@ -256,8 +256,54 @@ def _evidence_lines() -> list[str]: ] -def _delivery_claims_lines() -> list[str]: - return [ +def _approved_lapses(frame: Optional[Frame]): + lapses = [] if frame is None else frame.lapses + return [r for r in lapses if r.status == "approved"] + + +def _pending_lapses(frame: Optional[Frame]): + lapses = [] if frame is None else frame.lapses + return [r for r in lapses if r.status == "proposed"] + + +def _lapse_evidence_lines(frame: Optional[Frame]) -> list[str]: + """Approved reasoning-degradation lapses (``Frame.lapses``, issue #97 t1) + rendered as evidence grounding the Delivery Claims confidence column, + following the exact approved/pending/rejected discipline + :func:`_mid_work_lines` / :func:`_drift_lines` already apply to deviation + records: approved entries render fully in a small table (escaped the same + way :func:`_drift_lines` escapes free-form ``reason`` text — a raw ``|`` + or newline in a lapse's ``what`` must not corrupt the table), a proposed + (not-yet-adjudicated) entry surfaces only as a visibly pending id, and a + rejected entry is omitted entirely. + + A frame with no lapses at all — or no frame, a degraded load (acceptance + criterion 4) — adds nothing here, leaving the Delivery Claims table's + existing hardcoded placeholder row as the section's only content, + unchanged: no new failure mode from a missing/lapse-free frame. + """ + approved = _approved_lapses(frame) + pending = _pending_lapses(frame) + if not approved and not pending: + return [] + lines = ["Lapse ledger evidence:", ""] + if approved: + lines.append("| Lapse | Code | What |") + lines.append("|-------|------|------|") + for r in approved: + code = _escape_table_cell(r.code) + what = _escape_table_cell(_verbatim(r.what)) + lines.append(f"| `{r.id}` | `{code}` | {what} |") + lines.append("") + if pending: + ids = ", ".join(f"`{r.id}`" for r in pending) + lines.append(f"pending approval (not yet evidence): {ids}") + lines.append("") + return lines + + +def _delivery_claims_lines(frame: Optional[Frame]) -> list[str]: + lines = [ "## Delivery Claims", "", "| Claim | Confidence | Evidence |", @@ -265,6 +311,8 @@ def _delivery_claims_lines() -> list[str]: "| `` | `` | `` |", "", ] + lines += _lapse_evidence_lines(frame) + return lines def _remaining_work_lines() -> list[str]: @@ -296,7 +344,7 @@ def render_summary(plan: Plan, frame: Optional[Frame], delivery: Delivery) -> st out += _mid_work_lines(delivery) out += _drift_lines(delivery) out += _evidence_lines() - out += _delivery_claims_lines() + out += _delivery_claims_lines(frame) out += _remaining_work_lines() return "\n".join(out).rstrip() + "\n" @@ -352,6 +400,16 @@ def summary_data(plan: Plan, frame: Optional[Frame], delivery: Delivery) -> dict "pending_deviations": [d.id for d in pending], "evidence": "", "delivery_claims": "", + # JSON parity for _lapse_evidence_lines: approved lapses carry + # their full evidence triple, a pending one is only its id (the + # "not yet evidence" marker, mirroring pending_deviations above); + # a rejected lapse is absent from both lists, same as the render. + "lapse_evidence": { + "approved": [ + {"id": r.id, "code": r.code, "what": r.what} for r in _approved_lapses(frame) + ], + "pending": [r.id for r in _pending_lapses(frame)], + }, "remaining_work": "", }, } diff --git a/docs/deliveries/2026-07-29-reasoning-degradation-ledger.md b/docs/deliveries/2026-07-29-reasoning-degradation-ledger.md new file mode 100644 index 0000000..68ab931 --- /dev/null +++ b/docs/deliveries/2026-07-29-reasoning-degradation-ledger.md @@ -0,0 +1,166 @@ +# Delivery Summary — reasoning-degradation ledger + +plan: `reasoning-degradation-ledger` · run: `complete` · date: `2026-07-29` +baseline: `devague summary skeleton` + +## Intent + +Ship issue [#97](https://github.com/agentculture/devague/issues/97) — a +deterministic move that records degradations of the *reasoning process* +(moments where an assumption was silently substituted for a check) as +first-class, append-only ledger entries filed when they happen. The +reasoning-side twin of `deviate`, filed friction-free and never gating +convergence. Six tasks across three dependency waves, fanned out by +`/assign-to-workforce` after the `/scope` → `/think` → `/challenge` → +`/spec-to-plan` legs converged and exported the spec and plan. + +## Planned Work + +Quoted verbatim from the `devague summary` skeleton: + +- `t1` — Lapse domain model on Frame: LapseRecord, lapse codes, schema v5 +- `t2` — CLI verb lapse: file, list, adjudicate +- `t3` — Render the ledger: show and summary consume, spec stays untouched +- `t4` — Gate inertness pinned by tests +- `t5` — Skills sweep: producer, consumer, and the subagent boundary +- `t6` — Docs, contract, changelog, version + +## Actual Delivery + +| Plan task | Status | What actually landed | +|-----------|--------|----------------------| +| `t1` | delivered | `LapseRecord` + `LAPSE_CODES` + `LAPSE_STATUSES` and `Frame.lapses` / `add_lapse` / `find_lapse` / `set_lapse_status` in `devague/frame.py`; `SCHEMA_VERSION` 4→5; 31 new tests in `tests/test_frame_lapse.py`. Merged `4a47f74` | +| `t2` | delivered | `devague/cli/_commands/lapse.py` (file / `--list` / `--confirm` / `--reject`), registered in `cli/__init__.py`, `MOVES` row added in `learn.py`; 34 new tests in `tests/test_cli_lapse.py`. Merged `2bc7620` | +| `t3` | delivered | `_lapse_lines` in `render/frame_md.py`, `_lapse_evidence_lines` + `lapse_evidence` JSON in `render/summary_md.py`; `spec_md.py` unchanged by design, pinned by a byte-identity regression test; 17 new tests. Merged `0f67775` | +| `t4` | delivered | 20 gate-inertness tests across `tests/test_convergence.py` and `tests/test_plan_convergence.py`; zero production code, as the task specified. Merged `5259c33` | +| `t5` | delivered | `challenge` routing row + hard rule, `summarize-delivery` moves table + Delivery Claims step, `assign-to-workforce` worktree prohibition generalized, `docs/skills.md` enumerations swept. Merged `e95b7af` | +| `t6` | delivered | `docs/spec-contract.md` `LapseRecord` entity + Moves rows + schema v5, `README.md`, `CLAUDE.md`, `CHANGELOG.md`, version 0.21.0→0.22.0. Merged `b92f982` | + +All six tasks delivered; none partial, dropped, or blocked. + +## Mid-work Decisions + +- **Post-review fix (Qodo, PR #101):** `cmd_lapse` fell through to listing + whenever the positional `what` was absent, so + `devague lapse --code --skipped ""` exited `0` having filed + nothing. Fixed to fail closed on the review branch (commit `d6b9326`, 7 new + tests). No deviation record covers this — it is post-fan-out review + feedback, not a mid-run departure from the plan. The same defect exists in + the released `deviate` verb it was cloned from, filed as + [#102](https://github.com/agentculture/devague/issues/102). +- `d1` — the `split-plan --write` path escapes verbatim task text before + writing markdown — the committed gate-2 artifact failed the repo's own + markdownlint: `cli/__init__.py` in `t2`'s instruction rendered as + strong-emphasis (MD050 ×2, MD037 ×1). No plan task covers the split-plan + script, and CI does not lint markdown, so nothing would have caught it + before review. Approved by the gate-2 owner mid-run, recorded before the fix + landed. +- The `/challenge` pass, run before `/spec-to-plan`, itself broke the spec + export: a scope surface carrying its own code span was blind-wrapped in a + second one (MD038). Fixed in `spec_md.py` / `frame_md.py` with three new + tests, and folded into the baseline commit rather than a plan task — the + breakage predated the plan, so no task could have covered it. No deviation + record covers this; captured here directly. +- Three coverage targets (`h8`, `c13`, `h11`) were deliberately deferred at + plan time, not dropped mid-run: all three measure the shipped verb in a real + embodiment dogfood cycle and cannot be tested inside this PR. They render in + the plan's `## Deferred targets` section with their reason. + +## Drift From Plan + +| Plan item | Reason for divergence | Classification | +|-----------|-----------------------|----------------| +| `t5` (`d1`) | the committed gate-2 artifact failed the repo's own markdownlint: `cli/__init__.py` in `t2`'s instruction rendered as strong-emphasis (MD050 ×2, MD037 ×1). No plan task covers the split-plan script, and CI does not lint markdown, so nothing would have caught it before review. Approved by the user mid-run. | acceptable | + +No other task diverged from its confirmed contract. Two additions beyond the +letter of the acceptance criteria are noted rather than classified as drift, +because each stays inside its task's file scope and criteria: `t3` added a +`lapse_evidence` key to `summary_data()`'s JSON for parity with every other +section, and `t1` moved a second `SCHEMA_VERSION` pin in `tests/test_frame.py` +that the plan's instruction did not know existed. + +## Evidence + +- tests: full suite `uv run pytest -n auto -q` — **1080 passed**, 0 failed + (970 before the run; +110, including 7 from the post-review fix) +- tests: `tests/test_frame_lapse.py`, `tests/test_cli_lapse.py` — 74 passed +- tests: `tests/test_convergence.py`, `tests/test_plan_convergence.py` — 67 passed +- lint: `uv run flake8 --config=.flake8 devague/ tests/` — clean +- lint: `uv run black --check devague/ tests/` — 101 files unchanged +- lint: `markdownlint-cli2 "README.md" "CHANGELOG.md" "CLAUDE.md" "docs/**/*.md"` + — 0 errors +- SonarCloud: Quality Gate **passed** — 0 new issues, 0 accepted issues, + 0 security hotspots, 0.0% duplication, **98.7% coverage on new code** +- version: `uv run devague --version` — `devague 0.22.0` +- commits: `e5047a4..d6b9326` (16 commits) +- issues: [#97](https://github.com/agentculture/devague/issues/97) (delivered), + [#98](https://github.com/agentculture/devague/issues/98), + [#99](https://github.com/agentculture/devague/issues/99), + [#100](https://github.com/agentculture/devague/issues/100), + [#102](https://github.com/agentculture/devague/issues/102), + `agentculture/devex#96` (filed during the run and its review) + +## Delivery Claims + +| Claim | Confidence | Evidence | +|-------|------------|----------| +| `devague lapse` files, lists, and adjudicates lapse records end to end | high | 41 tests in `tests/test_cli_lapse.py` · commits `2bc7620`, `d6b9326` · exercised for real on this run (`l1`–`l4` filed) | +| record flags without a positional `what` fail closed rather than silently listing | high | 7 tests in `tests/test_cli_lapse.py` · commit `d6b9326` · Qodo finding on PR `#101` | +| lapse codes validate fail-closed at filing but load tolerantly, so a retired code never bricks a frame | high | `tests/test_frame_lapse.py` file→retire→reload regression · commit `4a47f74` | +| the ledger never gates — no convergence blocker, warning, or parked item names a lapse in any status | high | 20 tests in `tests/test_convergence.py` + `tests/test_plan_convergence.py` · commit `5259c33` | +| the exported spec-md is byte-identical before and after filing lapses | high | byte-identity regression test in `tests/test_render_sharper.py` · commit `0f67775` | +| `SCHEMA_VERSION` 5 protects filed lapses from an older binary silently dropping them on save | high | reject-newer store test in `tests/test_frame_lapse.py` · commit `4a47f74` | +| the ledger surfaces as confidence evidence in `devague summary` | high | `_lapse_evidence_lines` · `tests/test_summary.py` · **this artifact's own skeleton rendered `l1`/`l2` as pending** | +| filing costs the operator under a minute (honesty condition `h8`) | unverified | deferred to the embodiment dogfood cycle — not claimed done | +| every shipped code has a reachable producer, not merely a definition (`c13`/`h11`) | unverified | deferred to the embodiment dogfood cycle — no code has been filed against four of the six | + +Lapse ledger evidence: four lapses are **filed but still proposed** — pending +the gate owner's `devague lapse --confirm`/`--reject`, so none is yet evidence +and none caps a claim above. All four are self-reports about *this run's own* +reasoning, and three share one root cause — a single instrument read once and +its output treated as settled: + +- `l1` (`provenance-missing`) — the `/challenge` pass concluded markdown + safety was handled after reading only the three CLI renderers, never + `assign-to-workforce.sh`, which also writes committed markdown. +- `l2` (`grader-unverified`) — the per-task TDD merge gate ran pytest and + flake8, and green was read as "the artifacts are clean"; it never ran + markdownlint at all. `d1` is the consequence `l1` and `l2` both describe. +- `l3` (`grader-unverified`) — `agex pr read`'s "Project not on SonarCloud + (skipped)" was relayed as fact before the second Sonar surface the same + skill ships was checked; the gate had in fact run and passed. +- `l4` (`grader-unverified`) — "Qodo required no fixes" was reported after + reading one comment, three minutes before the Code Review carrying a real + correctness bug arrived; the poll's exit condition matched *any* non- + placeholder Qodo comment, so it fired on the PR Summary. + +## Remaining Work / Follow-up + +- **Adjudicate `l1`–`l4`** — `devague lapse --confirm l1 l2 l3 l4` (or reject). + Until then they are pending, not evidence. This is the first real exercise of + the adjudication path. +- [#102](https://github.com/agentculture/devague/issues/102) — the released + `deviate` verb carries the same silent no-op Qodo found in `lapse`: an + approved deviation given without a positional `what` is discarded and the + run resumes believing it was recorded. +- [#98](https://github.com/agentculture/devague/issues/98) — `learn.py`'s + `MOVES` dict is missing five verbs, so `devague explain deviate|summary|plan` + all fail. Pre-existing; this run added only the `lapse` row deliberately, to + keep the fix reviewable on its own (plan risk `r1`). +- [#99](https://github.com/agentculture/devague/issues/99) — the `d1` deviation + filed as its own issue: `split-plan --write` wrote unescaped markdown because + `safe_body` never ported `md_safe_text` despite a comment claiming exact + parity. Fixed here; the issue proposes adding markdownlint to CI and + addressing the duplicated-helper drift risk. +- [#100](https://github.com/agentculture/devague/issues/100) — `deviate` and + `summarize-delivery` SKILL.md still draw the six-leg flow, omitting + `/challenge`. Pre-existing since 0.19.0 and outbound to the whole mesh. +- **Deferred coverage targets `h8`, `c13`, `h11`** — the embodiment dogfood + cycle. Issue #97 closes with embodiment offering to run it and report real + entry counts and which codes turned out dead. `h11` commits to *removing* a + dead code, not documenting it, so this is a real decision waiting on data + (plan risk `r2`; parked items `v1`, `v2`). +- **Hard question `q4`** — how the spec distinguishes "covered" from + "reachable" per code — remains open on the frame, non-blocking. Four of the + six shipped codes have no filing yet, which is precisely the condition + embodiment#18 warned about. diff --git a/docs/plans/2026-07-29-reasoning-degradation-ledger-split.md b/docs/plans/2026-07-29-reasoning-degradation-ledger-split.md new file mode 100644 index 0000000..d9aab12 --- /dev/null +++ b/docs/plans/2026-07-29-reasoning-degradation-ledger-split.md @@ -0,0 +1,129 @@ +# Implementation Split Plan — reasoning-degradation ledger + +slug: `reasoning-degradation-ledger` · 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` +- Wave 2: `t2`, `t3`, `t4` +- Wave 3: `t5`, `t6` + +## 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 | +| `t3` | | sonnet | +| `t4` | | sonnet | +| `t5` | | sonnet | +| `t6` | | sonnet | + +## Wave 1 + +### t1 — Lapse domain model on Frame: LapseRecord, lapse codes, schema v5 + +- instruction: mirror DeviationRecord (devague/delivery.py) for the record shape — id, code, what, `skipped_check`, refs, origin, status (proposed/approved/rejected); validate code in `add_lapse` (the filing path), NOT in `__post_init__`, so retired codes stay loadable — c21 deliberately refines the c2 chassis pattern here; statuses and origin still validate fail-closed in `__post_init__` (they never retire); the six issue-97 codes are the starting `LAPSE_CODES` tuple; bump `SCHEMA_VERSION` to 5 in frame.py and move the pin in tests/`test_frame_schema_v2.py`; new tests in tests/`test_frame_lapse.py` +- covers: c2, h2, c17, h12, c20, c21, h16 +- acceptance: + - Frame.lapses exists; `add_lapse` mints l1, l2, ... via the prefix-generic `_next`; llm origin lands proposed, user origin lands approved; `to_dict`/`from_dict` round-trip lapses verbatim + - filing an unknown code raises ValueError, while `from_dict` loads a stored record carrying a retired code without error — pinned by a test that files, retires the code, and reloads + - `SCHEMA_VERSION` == 5; a frame declaring 6 is refused fail-closed before parsing; a v4 frame without lapses loads clean and re-saves as v5 + - no amend or delete API exists for lapse records — the only post-filing mutation is `set_lapse_status`; refs are stored verbatim as free text, never validated + +## Wave 2 + +### t2 — CLI verb lapse: file, list, adjudicate + +- instruction: clone the deviate.py argument surface minus --task and minus id-ref validation (refs stay free text); one new module devague/cli/`_commands`/lapse.py exposing register(), two lines in cli/`__init__.py` `_build_parser`; add the lapse row to the MOVES dict in learn.py; tests in tests/`test_cli_lapse.py` +- covers: c1, c12, c18, h15, h6 +- acceptance: + - `devague lapse "" --code ` files against the current frame and echoes the minted id; `--origin llm` lands proposed; `--skipped ""` and repeatable `--ref` are stored verbatim + - `--list [--json]` renders every record with id, code, and status; `--confirm ` / `--reject ` transition only proposed records, refuse otherwise, and are mutually exclusive with recording + - the argument surface has no amend or delete flag — pinned by a test over the parser + - `devague explain lapse` succeeds and bare `devague learn` lists the move — the MOVES entry is test-pinned + - recording is deterministic: no subprocess and no LLM call, mirroring the deviate determinism test + +### t3 — Render the ledger: show and summary consume, spec stays untouched + +- instruction: follow the `_mid_work_lines`/`_drift_lines` approved/pending/rejected discipline in `summary_md.py`; `frame_md.py` gets the new section; `spec_md.py` gets NO code change — only the byte-identity regression test; tests in tests/`test_summary.py` and tests/`test_render_sharper.py` +- covers: c4, h3, c10, c19, h14, h13 +- acceptance: + - devague show renders a Lapse ledger section (id, code, status, what) omitted entirely when empty + - devague summary cites approved lapses as evidence for the Delivery Claims confidence column; proposed entries render visibly pending; rejected are omitted; zero entries keeps the existing empty-state line — all through `md_safe_text` and table-cell escaping + - re-exporting the spec after filing lapses produces a byte-identical spec-md — pinned by a test that files a lapse and diffs `render_spec` output + - a frame that fails to load degrades in summary exactly as today — no new failure mode + +### t4 — Gate inertness pinned by tests + +- instruction: pure test task, no production code: if a gate references lapses the production change is wrong, not the test; add to tests/`test_convergence.py` and tests/`test_plan_convergence.py` +- covers: h1 +- acceptance: + - converge output is byte-identical before and after filing lapses on an otherwise converged frame — proposed, approved, and rejected records all tried + - neither gate ever names lapse records: frame and plan convergence blockers, warnings, and `parked_items` stay lapse-free in every status combination + +## Wave 3 + +### t5 — Skills sweep: producer, consumer, and the subagent boundary + +- instruction: quote the shipped CLI surface exactly as t2 built it — no paraphrase; sweep .claude/skills/{challenge,summarize-delivery,assign-to-workforce}/SKILL.md plus docs/skills.md; the deviate skill needs no change beyond any enumeration that names all moves +- covers: c5, h4, c6, h5, c9, h7 +- acceptance: + - the challenge routing table gains a row routing an already-happened reasoning degradation to devague lapse, and the nothing-else hard rule names the move + - the summarize-delivery read-only moves table and hard rule gain `lapse --list`, and the Delivery Claims method step reads the ledger to ground each confidence level + - the assign-to-workforce worktree prohibition generalizes to every devague move, naming lapse explicitly: a task agent reports a degradation in its transcript, the main agent files it + - no SKILL.md introduces a new gate or workflow owner: adjudication is named as `lapse --confirm` / `--reject` exercised by the existing gate owners; docs/skills.md enumerations match every table touched + +### t6 — Docs, contract, changelog, version + +- instruction: minor version bump (new feature) per the version-bump convention; keep the spec-contract Moves row shape identical to the deviate row +- covers: c7, c11, h9, h10 +- acceptance: + - docs/spec-contract.md gains the lapse entity (fields, statuses, id prefix l, the filing-time-closed load-time-tolerant code rule) plus a Moves contract row and a schema v5 line in Versioning + - README.md names lapse in the flat-verb inventory and the agent-driving flow; CLAUDE.md status reflects the new surface + - CHANGELOG entry and version bump land so the CI version-check passes + - the CHANGELOG or README cites issue 97 and the embodiment corrections-record evidence — the before-state and its four grader failures are documented, not anecdotal + +## End state + +### Announcement + +- devague gains a deterministic move that records degradations of the reasoning process — moments where an assumption was silently substituted for a check — as first-class append-only ledger entries filed when they happen: the reasoning-side twin of deviate, never gating convergence + +### After state + +- a degradation is filed in seconds at the moment an assumption substitutes for a check; the delivery summary confidence column cites entry ids instead of end-of-run memory, and recovering a transition no longer costs hours of reading raw data afterwards + +### Success signals + +- in >= 1 dogfooded embodiment cycle, every degradation entry is filed mid-flight (0 reconstructed in the retrospective) and 0 codes ship without a named producer moment — codes with zero filings are dropped before the vocabulary freezes + +### Terminal tasks + +- `t3` — Render the ledger: show and summary consume, spec stays untouched + - devague show renders a Lapse ledger section (id, code, status, what) omitted entirely when empty + - devague summary cites approved lapses as evidence for the Delivery Claims confidence column; proposed entries render visibly pending; rejected are omitted; zero entries keeps the existing empty-state line — all through md_safe_text and table-cell escaping + - re-exporting the spec after filing lapses produces a byte-identical spec-md — pinned by a test that files a lapse and diffs render_spec output + - a frame that fails to load degrades in summary exactly as today — no new failure mode +- `t4` — Gate inertness pinned by tests + - converge output is byte-identical before and after filing lapses on an otherwise converged frame — proposed, approved, and rejected records all tried + - neither gate ever names lapse records: frame and plan convergence blockers, warnings, and parked_items stay lapse-free in every status combination +- `t5` — Skills sweep: producer, consumer, and the subagent boundary + - the challenge routing table gains a row routing an already-happened reasoning degradation to devague lapse, and the nothing-else hard rule names the move + - the summarize-delivery read-only moves table and hard rule gain `lapse --list`, and the Delivery Claims method step reads the ledger to ground each confidence level + - the assign-to-workforce worktree prohibition generalizes to every devague move, naming lapse explicitly: a task agent reports a degradation in its transcript, the main agent files it + - no SKILL.md introduces a new gate or workflow owner: adjudication is named as `lapse --confirm` / `--reject` exercised by the existing gate owners; docs/skills.md enumerations match every table touched +- `t6` — Docs, contract, changelog, version + - docs/spec-contract.md gains the lapse entity (fields, statuses, id prefix l, the filing-time-closed load-time-tolerant code rule) plus a Moves contract row and a schema v5 line in Versioning + - README.md names lapse in the flat-verb inventory and the agent-driving flow; CLAUDE.md status reflects the new surface + - CHANGELOG entry and version bump land so the CI version-check passes + - the CHANGELOG or README cites issue 97 and the embodiment corrections-record evidence — the before-state and its four grader failures are documented, not anecdotal + +### Open items + +- [follow_up] embodiment offers to dogfood a prototype and report back real entry counts and which degradation codes turned out dead — the closing offer in issue 97 +- [follow_up] whether id-shaped lapse refs later deserve a contested-style join into plan and summary renders — deferred until dogfood shows refs are actually filed +- [follow_up] file the pre-existing learn.py explain gap (deviate, summary, plan absent from MOVES) as its own upstream issue before the PR merges — t2 adds only the lapse entry +- [follow_up] whether summarize-delivery should cap a delivery-claim confidence at low or unverified when an approved lapse names it — deferred to the embodiment dogfood report diff --git a/docs/plans/2026-07-29-reasoning-degradation-ledger.md b/docs/plans/2026-07-29-reasoning-degradation-ledger.md new file mode 100644 index 0000000..c941680 --- /dev/null +++ b/docs/plans/2026-07-29-reasoning-degradation-ledger.md @@ -0,0 +1,82 @@ +# Build Plan — reasoning-degradation ledger + +slug: `reasoning-degradation-ledger` · status: `exported` · from frame: `reasoning-degradation-ledger` + +> devague gains a deterministic move that records degradations of the reasoning process — moments where an assumption was silently substituted for a check — as first-class append-only ledger entries filed when they happen: the reasoning-side twin of deviate, never gating convergence + +## Tasks + +### t1 — Lapse domain model on Frame: LapseRecord, lapse codes, schema v5 + +- instruction: mirror DeviationRecord (devague/delivery.py) for the record shape — id, code, what, `skipped_check`, refs, origin, status (proposed/approved/rejected); validate code in `add_lapse` (the filing path), NOT in `__post_init__`, so retired codes stay loadable — c21 deliberately refines the c2 chassis pattern here; statuses and origin still validate fail-closed in `__post_init__` (they never retire); the six issue-97 codes are the starting `LAPSE_CODES` tuple; bump `SCHEMA_VERSION` to 5 in frame.py and move the pin in tests/`test_frame_schema_v2.py`; new tests in tests/`test_frame_lapse.py` +- covers: c2, h2, c17, h12, c20, c21, h16 +- acceptance: + - Frame.lapses exists; `add_lapse` mints l1, l2, ... via the prefix-generic `_next`; llm origin lands proposed, user origin lands approved; `to_dict`/`from_dict` round-trip lapses verbatim + - filing an unknown code raises ValueError, while `from_dict` loads a stored record carrying a retired code without error — pinned by a test that files, retires the code, and reloads + - `SCHEMA_VERSION` == 5; a frame declaring 6 is refused fail-closed before parsing; a v4 frame without lapses loads clean and re-saves as v5 + - no amend or delete API exists for lapse records — the only post-filing mutation is `set_lapse_status`; refs are stored verbatim as free text, never validated + +### t2 — CLI verb lapse: file, list, adjudicate + +- instruction: clone the deviate.py argument surface minus --task and minus id-ref validation (refs stay free text); one new module devague/cli/`_commands`/lapse.py exposing register(), two lines in cli/`__init__.py` `_build_parser`; add the lapse row to the MOVES dict in learn.py; tests in tests/`test_cli_lapse.py` +- depends on: t1 +- covers: c1, c12, c18, h15, h6 +- acceptance: + - `devague lapse "" --code ` files against the current frame and echoes the minted id; `--origin llm` lands proposed; `--skipped ""` and repeatable `--ref` are stored verbatim + - `--list [--json]` renders every record with id, code, and status; `--confirm ` / `--reject ` transition only proposed records, refuse otherwise, and are mutually exclusive with recording + - the argument surface has no amend or delete flag — pinned by a test over the parser + - `devague explain lapse` succeeds and bare `devague learn` lists the move — the MOVES entry is test-pinned + - recording is deterministic: no subprocess and no LLM call, mirroring the deviate determinism test + +### t3 — Render the ledger: show and summary consume, spec stays untouched + +- instruction: follow the `_mid_work_lines`/`_drift_lines` approved/pending/rejected discipline in `summary_md.py`; `frame_md.py` gets the new section; `spec_md.py` gets NO code change — only the byte-identity regression test; tests in tests/`test_summary.py` and tests/`test_render_sharper.py` +- depends on: t1 +- covers: c4, h3, c10, c19, h14, h13 +- acceptance: + - devague show renders a Lapse ledger section (id, code, status, what) omitted entirely when empty + - devague summary cites approved lapses as evidence for the Delivery Claims confidence column; proposed entries render visibly pending; rejected are omitted; zero entries keeps the existing empty-state line — all through `md_safe_text` and table-cell escaping + - re-exporting the spec after filing lapses produces a byte-identical spec-md — pinned by a test that files a lapse and diffs `render_spec` output + - a frame that fails to load degrades in summary exactly as today — no new failure mode + +### t4 — Gate inertness pinned by tests + +- instruction: pure test task, no production code: if a gate references lapses the production change is wrong, not the test; add to tests/`test_convergence.py` and tests/`test_plan_convergence.py` +- depends on: t1 +- covers: h1 +- acceptance: + - converge output is byte-identical before and after filing lapses on an otherwise converged frame — proposed, approved, and rejected records all tried + - neither gate ever names lapse records: frame and plan convergence blockers, warnings, and `parked_items` stay lapse-free in every status combination + +### t5 — Skills sweep: producer, consumer, and the subagent boundary + +- instruction: quote the shipped CLI surface exactly as t2 built it — no paraphrase; sweep .claude/skills/{challenge,summarize-delivery,assign-to-workforce}/SKILL.md plus docs/skills.md; the deviate skill needs no change beyond any enumeration that names all moves +- depends on: t2 +- covers: c5, h4, c6, h5, c9, h7 +- acceptance: + - the challenge routing table gains a row routing an already-happened reasoning degradation to devague lapse, and the nothing-else hard rule names the move + - the summarize-delivery read-only moves table and hard rule gain `lapse --list`, and the Delivery Claims method step reads the ledger to ground each confidence level + - the assign-to-workforce worktree prohibition generalizes to every devague move, naming lapse explicitly: a task agent reports a degradation in its transcript, the main agent files it + - no SKILL.md introduces a new gate or workflow owner: adjudication is named as `lapse --confirm` / `--reject` exercised by the existing gate owners; docs/skills.md enumerations match every table touched + +### t6 — Docs, contract, changelog, version + +- instruction: minor version bump (new feature) per the version-bump convention; keep the spec-contract Moves row shape identical to the deviate row +- depends on: t2 +- covers: c7, c11, h9, h10 +- acceptance: + - docs/spec-contract.md gains the lapse entity (fields, statuses, id prefix l, the filing-time-closed load-time-tolerant code rule) plus a Moves contract row and a schema v5 line in Versioning + - README.md names lapse in the flat-verb inventory and the agent-driving flow; CLAUDE.md status reflects the new surface + - CHANGELOG entry and version bump land so the CI version-check passes + - the CHANGELOG or README cites issue 97 and the embodiment corrections-record evidence — the before-state and its four grader failures are documented, not anecdotal + +## Deferred targets + +- `h8` (honesty): in the dogfood cycle no single filing costs the operator more than a minute — otherwise the cheap-enough-to-use-mid-flight premise is false — deferred: post-ship embodiment dogfood milestone (park v1): measurable only after a real cycle runs with the shipped verb +- `c13` (success_signal): in >= 1 dogfooded embodiment cycle, every degradation entry is filed mid-flight (0 reconstructed in the retrospective) and 0 codes ship without a named producer moment — codes with zero filings are dropped before the vocabulary freezes — deferred: post-ship embodiment dogfood milestone (park v1): measurable only after a real cycle runs with the shipped verb +- `h11` (honesty): a code with zero filings after the dogfood cycle is actually removed from the enum, not kept as documentation — covered is distinguished from reachable — deferred: post-ship embodiment dogfood milestone (park v1): the dead-code removal decision needs the dogfood filing counts + +## Risks + +- [follow_up] file the pre-existing learn.py explain gap (deviate, summary, plan absent from MOVES) as its own upstream issue before the PR merges — t2 adds only the lapse entry +- [follow_up] whether summarize-delivery should cap a delivery-claim confidence at low or unverified when an approved lapse names it — deferred to the embodiment dogfood report diff --git a/docs/skills.md b/docs/skills.md index 6d2c79f..72c42fe 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -161,7 +161,7 @@ final PR review): | `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); `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) | +| `summarize-delivery` | execution → accountability artifact (the delivery-side closure leg) | starts from `devague summary` / `devague deviate --list` / `devague lapse --list`; reads plan / git / PR / test evidence (read-only) | ### `scope` — idea → explored scope (method-only) @@ -246,7 +246,11 @@ probes) against the exported spec, routes findings through the existing deterministic moves (`capture` / `interrogate` / `question` / `park` / `devague scope` / `devague plan risk`) as `--origin llm`-proposed content the human adjudicates, then reconverges and re-exports the same dated spec file. -Mandatory but proportional — lightweight for ordinary work, rigorous when an +If the pass notices its own reasoning degraded while sweeping — not a finding +about the spec, but the agent's own self-report — it files that the same +moment via `devague lapse --origin llm` instead (issue #97); adjudication is +`devague lapse --confirm`/`--reject`, exercised by the same human who already +owns the spec gate, not a new role. Mandatory but proportional — lightweight for ordinary work, rigorous when an escalation signal (migrations, security-sensitive work, distributed state, hardware, destructive or hard-to-reverse changes, concurrency hazards, any data-loss surface) applies. Not a fourth standing gate: findings are @@ -312,6 +316,16 @@ 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. +**No devague move runs inside a task worktree — not just `devague plan` +(issue #97).** If a task agent notices its own reasoning degraded (a skipped +check, an assumption standing in for a real measurement, …), it reports that +in its transcript rather than filing it; the **main agent** files the record +after reconciling the worktree (`devague lapse "" --code +--origin llm`) the same way exploration subagents in `/scope` report rather +than run a `devague` move themselves (#79/#91). Adjudicating a filed lapse is +`devague lapse --confirm`/`--reject`, exercised by the same human who owns +gate 2/3 — no new role. + **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 @@ -367,7 +381,11 @@ unchanged, #20). New in 0.17.0. As of 0.18.0 it starts from the `devague summary` (optionally `--pr`) skeleton and quotes every approved `/deviate` record by its `dN` id as recorded ground truth for Drift From Plan and Mid-work Decisions, instead of reconstructing execution-time drift from -memory. +memory. It also reads `devague lapse --list` (issue #97): an **approved** +reasoning-degradation lapse grounds a Delivery Claims confidence level +honestly instead of letting it default to `high`; a still-**proposed** one is +pending, not yet evidence; and adjudicating one (`devague lapse --confirm`/ +`--reject`) is the same gate-owning human's job, never this skill's. - Source: [`.claude/skills/summarize-delivery/`](https://github.com/agentculture/devague/blob/main/.claude/skills/summarize-delivery/SKILL.md) diff --git a/docs/spec-contract.md b/docs/spec-contract.md index 59c4d7a..330f2ee 100644 --- a/docs/spec-contract.md +++ b/docs/spec-contract.md @@ -20,7 +20,7 @@ see [`llm-guidance.md`](llm-guidance.md) (also surfaced in `devague learn`). ## Versioning -Every frame carries an integer `schema_version` (currently `4`). It is written +Every frame carries an integer `schema_version` (currently `5`). 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 @@ -34,6 +34,18 @@ does not recognise a key yet, instead of the intended fail-closed `HardQuestion` / `Vagueness` loading is tolerant of unknown keys rather than splatting the raw dict. The plan store carries the identical guard. +> **v5 (issue #97 t1).** Bumped to add `Frame.lapses` and the `LapseRecord` +> entity — the Reasoning Degradation Ledger (see *LapseRecord* under +> Entities). Unlike the `Claim.revisions` addition just below (deliberately +> shipped *without* a bump, since a missing list defaults tolerantly to +> `[]`), this one is a hard requirement: `save()` re-stamps `schema_version` +> and `to_dict` only serializes known dataclass fields, so an older +> v4-labeled binary that loads a v5 frame and re-saves it would silently +> drop every filed lapse — the same failure mode the `scope_entries` v2 bump +> exists to prevent (c17/h12). A v4 frame predates the field entirely; it +> loads with `lapses` defaulted to `[]` — an empty ledger, never a +> fabricated one. +> > **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 @@ -72,6 +84,8 @@ A feature-framing workspace. - `claims` — list of Claim. - `open_vagueness` — list of Vagueness. - `scope_entries` — list of ScopeEntry (v2, #53 t1; see below). +- `lapses` — list of LapseRecord — the Reasoning Degradation Ledger (v5, issue + #97 t1; see below). ### Claim @@ -190,6 +204,79 @@ 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). +### LapseRecord + +A filed reasoning-degradation lapse — the **Reasoning Degradation Ledger** +(issue #97), the reasoning-side twin of a deviation record. Lives on the +frame as `Frame.lapses` (v5, #97 t1), not a separate store: a lapse is about +how the frame itself was reasoned through — an assumption silently +substituted for a check — not about execution against a confirmed plan, so +it has to be reachable before a plan exists. It deliberately mirrors +`DeviationRecord` (see *The delivery peer* below) in shape: prefix-generic +id minting, origin-driven initial status, append-only with no delete path. + +- `id` — `l1`, `l2`, … +- `code` — which degradation shape this instance is; one of `LAPSE_CODES`, + the starting vocabulary: `assumption-for-measurement`, + `grader-unverified`, `control-absent`, `n-below-claim`, + `instrument-changed-mid-series`, `provenance-missing`. +- `what` — what lapsed, verbatim. +- `skipped_check` — what check should have caught this but didn't; `""` + means none. +- `refs` — free-text references this lapse relates to (task ids, claim ids, + prose, or nothing). Stored verbatim and **never validated** — unlike + `ScopeEntry.seeds`. A deviation's `--affects` can validate id-shaped refs + against a plan and its live frame; a frame-side lapse has no plan to + validate against (one may not exist yet), and demanding a resolvable id + would let a filer dodge filing when they can't, or shouldn't, cite one + precisely — the ledger records what was skipped, as testimony, not a join. +- `origin` — `user` | `llm` (who filed it). +- `status` — `proposed` | `approved` | `rejected`. + +**`code` validates fail-closed at the filing path, not in `__post_init__` — +a deliberate exception to how every other vocabulary in this document +behaves.** `kind`, `origin`, `status`, and vagueness `kind` all validate in +their dataclass's `__post_init__`, which also runs at *load* time +(`from_dict` constructs each dataclass directly); that is the right rule for +a vocabulary that never retires, but the wrong rule for lapse codes, whose +whole premise is that dogfooding surfaces new degradation shapes over time. +`Frame.add_lapse` — the filing path — is the only place `code` is checked +against `LAPSE_CODES`; `LapseRecord.__post_init__` and `from_dict` load it +unchecked, so retiring a code after a dogfood cycle never bricks a frame +that already filed it. `status` and `origin` still validate in +`__post_init__` — they never retire. + +A user-authored lapse (the default) **auto-approves**, landing directly as +`approved` — filing is meant to be friction-free enough that it actually +happens mid-flight, and a user-authored record already carries the human's +own attention. An `llm`-authored lapse (`--origin llm`) lands `proposed` and +needs an explicit user `--confirm`/`--reject` (`devague lapse --confirm ` +/ `--reject `) — the same anti-fabrication rule as claims, tasks, and +deviation records; only `Frame.set_lapse_status` can move it, and only from +`proposed`. Only an `approved` lapse is ever citable as confidence evidence +(`render/summary_md.py`); a `proposed` one renders only as a visibly pending +id, and a `rejected` one is omitted entirely. + +The ledger is **append-only in the strong sense**: there is no amend and no +delete API — `set_lapse_status` is the only mutator a filed lapse ever gets. +A wrong lapse is rejected and refiled, never edited in place. This is +deliberately unlike `scope --amend`, which corrects a scope finding's text +in place with no trail: an editable lapse would re-enable exactly the +written-late-is-written-flattering failure the ledger exists to prevent. + +The ledger never gates: no convergence blocker, warning, or parked item ever +names a lapse, on either the frame or the plan side (issue #97 t4; pinned by +`tests/test_convergence.py` and `tests/test_plan_convergence.py`) — the same +`scope_entries` precedent, since both convergence gates iterate hand-written +allowlists over named fields and a new list is invisible to them by +default. It renders in `devague show` (every lapse, any status) and in +`devague summary`'s Delivery Claims evidence; the exported spec-md never +grows a lapse section at all — `export` overwrites the same dated file on +every re-export, so an execution-time lapse rendering there would rewrite +the what-to-build artifact rather than record process history (the same +principle behind *Contested claims* below: process history points forward, +the spec is not rewritten). + ## Vocabulary ### Claim kinds @@ -281,6 +368,11 @@ A frame converges when there are confirmed `announcement` / `audience` / and no unresolved blocking vagueness or unresolved blocking hard question on a non-rejected claim. `export` is gated on `ready_for_spec`. +The Reasoning Degradation Ledger (`Frame.lapses`, see *LapseRecord* above) +never appears in any of the four lists above, in any status, on either +engine — no blocker, warning, or parked item ever names a lapse (issue #97 +t4; pinned by `tests/test_convergence.py` and `tests/test_plan_convergence.py`). + ### Structural sharpness warnings (soft rollout) Two more deterministic warnings tighten the frame gate without changing what @@ -318,6 +410,9 @@ the exit code is non-zero and `stderr` carries a `hint:` line. | `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` | +| `lapse "" --code K [--skipped ""] [--ref …] [--origin]` | what, code, skipped-check text, refs, origin | `{id, code, what, skipped_check, refs, origin, status}` | appends a LapseRecord to the Reasoning Degradation Ledger (v5, issue #97 t1/t2); `llm` → `proposed`, else auto-`approved` — never gates convergence | +| `lapse --confirm ` / `lapse --reject ` | lapse id | `{id, status}` | the only path to `approved` / `rejected` — user-only; mutually exclusive with each other; refused unless the record is currently `proposed` | +| `lapse --list [--json]` / bare `lapse` | — | `{frame, lapses: […]}` | none (default action) | | `converge` | — | the convergence result | promotes/demotes frame `status` | | `export [--format spec-md]` | — | `{path, format}` | writes the spec; requires `ready_for_spec` | | `show` / `list` | — | frame dict / slug list | none | @@ -334,7 +429,11 @@ 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; +on `scope --amend`; a missing `--code` on `lapse`, or an unknown `--code` +(rejected at the filing path, never at load — see *LapseRecord* above); +combining `lapse --confirm` / `--reject` / `--list` with a positional `what`; +an unknown lapse id, or a lapse that is not currently `proposed`, on +`lapse --confirm` / `--reject`; 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. @@ -359,6 +458,14 @@ It also extends to deviation records (see *The delivery peer* below): an `set_status` only ever accepts a transition **from** `proposed` — it never lets `--confirm` or `--reject` silently overwrite an already-resolved record. +It also extends to lapse records (see *LapseRecord* above): an `llm`-origin +lapse lands `proposed` and requires an explicit user `--confirm` / +`--reject`; only an `approved` lapse is ever rendered as confidence +evidence in `devague summary`, and the ledger's only mutator, +`set_lapse_status`, accepts a transition only **from** `proposed` — the same +guarantee, applied to a record about the reasoning process rather than the +plan's execution. + ## Worked example `docs/examples/contract-example.json` is a real, converged frame exercising the diff --git a/docs/specs/2026-07-29-reasoning-degradation-ledger.md b/docs/specs/2026-07-29-reasoning-degradation-ledger.md new file mode 100644 index 0000000..4c42356 --- /dev/null +++ b/docs/specs/2026-07-29-reasoning-degradation-ledger.md @@ -0,0 +1,112 @@ +# reasoning-degradation ledger + +> devague gains a deterministic move that records degradations of the reasoning process — moments where an assumption was silently substituted for a check — as first-class append-only ledger entries filed when they happen: the reasoning-side twin of deviate, never gating convergence + +## Audience + +- operators — the main agent driving the CLI mid-run — plus the humans who own gate 2 and the final PR, and downstream method consumers (embodiment) whose delivery summaries cite ledger entries as confidence evidence + +## Before → After + +- Before: corrections records are reconstructed at the end, from memory, shaped by how the story turned out — in the embodiment cycle four graders failed, every one found by reading data afterwards, none by a test failing, and one nearly shipped a false safety claim +- After: a degradation is filed in seconds at the moment an assumption substitutes for a check; the delivery summary confidence column cites entry ids instead of end-of-run memory, and recovering a transition no longer costs hours of reading raw data afterwards + +## Why it matters + +- written late is written flattering: a ledger entry at the moment of the transition costs seconds, recovering it afterwards costs hours — so the recording move must be cheap enough that filing mid-flight actually happens + +## Requirements + +- the move reuses the deviate chassis: prefix-generic id minting (`Delivery._next`, devague/delivery.py:63-72), origin-driven initial status (llm-origin lands proposed, delivery.py:85), fail-closed enum validation in `__post_init__`, and append-only records with no delete path + - honesty: the new record type reuses `_next`, origin-driven initial status, and fail-closed `__post_init__` validation rather than re-implementing them — checked in review of the shipping diff +- devague summary consumes approved ledger entries as evidence for the Delivery Claims confidence column, following the Mid-work Decisions and Drift From Plan render pattern (render/`summary_md.py`:205-244); the high/medium/low/unverified vocabulary lives only in the summarize-delivery skill today, not in code + - honesty: devague summary renders approved entries only; proposed entries render as visibly pending (mirroring the deviation pattern); a missing ledger degrades to the existing empty-state line, never an error +- the closed move enumerations in the producer and consumer skills are amended to name the new move: the challenge routing rule (findings route through existing deterministic moves only — nothing else, challenge/SKILL.md:204-208) has no row shaped like a degradation that already happened, and the summarize-delivery read-only move table (SKILL.md:282-296) is likewise closed + - honesty: the challenge and summarize-delivery closed move enumerations name the new move in the same PR that ships the verb — no doc-drift window +- the move lands in the MOVES dict in learn.py (today only 15 entries — deviate, summary, and plan are already absent, so `devague explain deviate` fails), the per-move contract tables in docs/spec-contract.md, README.md, and docs/skills.md, plus a CHANGELOG entry and the CI-enforced version bump + - honesty: `devague explain ` works for the new verb in the shipping PR — the MOVES dict entry is test-pinned — and the pre-existing deviate/summary/plan explain gap is filed as its own issue +- adding the lapses list bumps `SCHEMA_VERSION` 4 to 5: without the bump an older installed binary loads a lapse-bearing frame tolerantly and its next save silently drops every filed lapse (save re-stamps the current version and `to_dict` writes only known fields) — the `scope_entries` v2 precedent; the fail-closed version check is what turns silent data loss into a version hint + - honesty: a v4-reading binary pointed at a v5 frame fails closed with the version hint instead of silently dropping lapse records on save — pinned by a reject-newer store test +- adjudication is on the verb, mirroring deviate: `devague lapse --confirm ` / `--reject ` (confirm and reject keep taking only c\* and h\* ids); statuses are proposed/approved/rejected, and a user-origin filing lands approved immediately — the deviate origin contract + - honesty: an llm-origin lapse never becomes citable without a human --confirm; devague summary renders it as visibly pending until then — no path upgrades it automatically +- lapses render in devague show (`frame_md`) and the delivery summary; the exported spec-md never grows a lapse section — export overwrites the same dated file, so execution-time lapses would rewrite the what-to-build artifact on re-export; the contested-marker philosophy applies: process history points forward, the spec is not rewritten + - honesty: re-exporting the spec after filing lapses produces a byte-identical spec-md — pinned by a test that files a lapse and diffs the export +- code validation is fail-closed at filing time but tolerant at load time: retiring a dead code after the dogfood cycle must never brick an existing frame — probe-confirmed: an unknown kind raises ValueError at construction, and `from_dict` constructs at load, so a closed load-time enum would refuse to load any frame that ever filed the retired code + - honesty: a frame holding a lapse with a retired code still loads and renders after the code leaves the filing enum — pinned by a test that files, retires, reloads + +## Honesty conditions + +- filing an entry is one deterministic CLI call — no LLM, no subprocess — and converge output is byte-identical before and after filing, pinned by a test +- assign-to-workforce SKILL.md generalizes its worktree prohibition to name the new verb, not just `devague plan` commands +- no new human workflow is introduced: the same humans who own gate 2 and the final PR adjudicate ledger entries, inside gates that already exist +- in the dogfood cycle no single filing costs the operator more than a minute — otherwise the cheap-enough-to-use-mid-flight premise is false +- the embodiment corrections record and its four grader failures are real committed artifacts quoted in issue 97, not a reconstruction made for this spec +- at least one transition in the embodiment cycle was recoverable only because raw data happened to be committed — the near-miss is documented, not anecdotal +- a code with zero filings after the dogfood cycle is actually removed from the enum, not kept as documentation — covered is distinguished from reachable +- no CLI path mutates a lapse record after filing except the status transition — pinned by the argument surface (no amend flag) and a test + +## Success signals + +- in >= 1 dogfooded embodiment cycle, every degradation entry is filed mid-flight (0 reconstructed in the retrospective) and 0 codes ship without a named producer moment — codes with zero filings are dropped before the vocabulary freezes + +## Scope / boundaries + +- fan-out subagents never file ledger entries — only the main agent runs devague moves; a degradation noticed inside a task worktree is reported in the task-agent transcript and recorded by the main agent (assign-to-workforce SKILL.md:320-322 currently forbids only `devague plan` commands by name) +- lapse records are append-only in the strong sense: no amend and no delete — unlike scope entries, which amend in place with no trail; a wrong lapse is rejected and refiled, because an editable lapse re-enables written-late-is-written-flattering + +## Non-goals + +- the ledger never gates: no participation in blockers, warnings, or `parked_items` — both convergence gates iterate hand-written allowlists (frame.claims and frame.`open_vagueness` in convergence.py; plan.tasks and plan.risks in `plan_convergence.py`), so a new list field is invisible to them by default, the `scope_entries` precedent + +## Assumptions + +- degradation codes ship as a closed enum validated fail-closed at construction, like every existing kind vocabulary (`CLAIM_KINDS`, `VAGUENESS_KINDS`, and CLASSIFICATIONS — the nearest precedent: an optional single code per record, delivery.py:32); the six codes in issue 97 are the starting set, not the contract +- lapse refs are free text and never validated — the deviate comparison in decision c14 is imprecise: deviate validates id-shaped affects refs against the plan and its live frame (deviate.py:78-113), which a frame-side lapse cannot do for tN refs before a plan exists; the record is testimony, not a join + +## Scope exploration + +- `s1` — `agentculture/devague#97 (issue body)`: the evidence base: a 21-task, 7-wave embodiment run whose corrections record was reconstructed from memory at the end; four graders failed, all caught by reading data afterwards, none by a test failing; three explicit non-asks — not a gate, no new engine if a move on existing state suffices, no automation + - seeds: `c1` +- `s2` — `devague/delivery.py + delivery_store.py + cli/_commands/deviate.py`: DeviationRecord is the direct template (append-only, origin-driven status, fail-closed enums, prefix-generic `_next`); but the store is keyed 1:1 by plan slug and every entry point resolves a plan first, failing closed with `no plan selected` — pre-plan degradations have nowhere to land without generalizing the keying + - seeds: `c2`, `q1` (question, resolved) +- `s3` — `devague/frame.py + plan.py + store.py + plan_store.py + docs/spec-contract.md`: id prefixes c, h, q, v, s, t, r, d are taken (per-list prefix-generic `_next`); a new optional list field loads tolerantly without a schema bump (the `Claim.revisions` precedent) though new top-level lists have bumped by convention (`scope_entries`, v2); every kind vocabulary is a closed enum validated in `__post_init__` — no free-string kinds exist + - seeds: `c8`, `q2` (question, resolved) +- `s4` — `devague/convergence.py + plan_convergence.py`: both gates are hand-written allowlists over named fields — frame.claims and frame.`open_vagueness`, plan.tasks and plan.risks, nothing else; `scope_entries` appears nowhere in convergence.py (grep-confirmed) — the shipped precedent that a new list field is recorded, visible in renders, and never gates by default + - seeds: `c3` +- `s5` — `devague/render/summary_md.py + _md_safety.py + cli/_commands/summary.py`: the Delivery Claims section renders a bare `` placeholder — the high/medium/low/unverified vocabulary exists only in summarize-delivery SKILL.md, not in code; approved deviations render in exactly two sections (Mid-work Decisions, Drift From Plan); a new verbatim render site needs `md_safe_text` plus table-cell escaping + - seeds: `c4` +- `s6` — `.claude/skills/{challenge,deviate,summarize-delivery,assign-to-workforce}/SKILL.md`: the deviate method gates the recording itself behind explicit human approval (the one non-negotiable step); the challenge routing table has no row for a degradation that already happened and its hard rule is a closed nothing-else enumeration; the summarize-delivery read-only move table is likewise closed; assign-to-workforce forbids subagents only `devague plan` commands by name + - seeds: `c5`, `c6`, `q3` (question, resolved) +- `s7` — `devague/cli/__init__.py + cli/_commands/learn.py + README.md + docs/skills.md`: a new verb is one `_commands/` module exposing register() plus two lines in `_build_parser` (cli/`__init__.py`:73-127); the MOVES dict in learn.py holds only 15 of 20 verbs — deviate, summary, and plan are absent, so `devague explain deviate` fails today, a live gap the new move must not repeat; version-check CI blocks merge without a pyproject bump + - seeds: `c7`, `q2` (question, resolved) +- `s8` — `challenge pass / adjacent-systems lens: devague/store.py + frame.py tolerant load`: an older binary loads a frame carrying an unknown list tolerantly and re-saves without it — silent loss of filed lapses unless `SCHEMA_VERSION` bumps; `scope_entries` shipped with the v2 bump for exactly this reason + - seeds: `c17` +- `s9` — challenge pass / failure-mode lens: frame.py `__post_init__` validation at load (probe): probe: a Claim with kind bogus-kind raises ValueError at construction; `from_dict` constructs at load — so retiring a lapse code from a closed enum bricks loading of frames that filed it; write-closed load-tolerant validation is what makes the h11 dead-code removal safe + - seeds: `c21` +- `s10` — `challenge pass / lifecycle lens: export overwrite semantics (spec_md, frame_md, summary_md)`: the spec left render placement undefined while export overwrites the same dated file — execution-time lapses would rewrite the spec artifact on re-export unless `spec_md` deliberately excludes the ledger + - seeds: `c19` +- `s11` — `challenge pass / unstated-assumptions lens: the adjudication surface`: decision c15 says adjudicate in bulk but names no move — confirm and reject take c\* and h\* ids only today; the deviate precedent is confirm and reject flags on the verb itself + - seeds: `c18` +- `s12` — challenge pass / data-flow lens: deviate.py `_validate_refs` vs frame-side refs: the deviate affects field validates id-shaped refs against plan tasks, coverage targets, and the live source frame — a frame-side lapse cannot validate tN refs before a plan exists, so the free-text-ref language in decision c14 is imprecise as written + - seeds: `c22` +- `s13` — `challenge pass / reversibility lens: deviate append-only vs scope --amend`: two correction idioms coexist: deviate corrects by append (no amend move), scope amends in place (no trail) — the lapse ledger must pick append-only or the written-late-is-written-flattering rationale collapses + - seeds: `c20` +- `s14` — `challenge pass / concurrency lens: single-writer CLI + worktree fan-out`: clean — only the main agent at the repo root mutates .devague state (boundary c6), no locking exists today; residual risk confined to two operators sharing one checkout, which no current flow does + +## Decisions + +- the ledger attaches to the Frame as a new list of lapse records — the `scope_entries` pattern: reachable from `devague new` through execution, rendered but never gating; entries may free-text-ref plan tasks (tN) the way the deviate affects field does; no new store, no new engine +- filing is friction-free: the agent files a lapse immediately at the moment of the transition; llm-origin entries land proposed and never block anything; the human adjudicates in bulk at summarize-delivery time, and only approved entries are citable as confidence evidence in the Delivery Claims table +- the verb is `lapse` (noun and verb, like park and deviate); record ids are `lN`; the degrade and ledger names were rejected for collisions with the fail-open error-handling idiom and the delivery-ledger vocabulary + +## Hard questions + +- where does the ledger attach: the existing delivery store (zero new engine, but keyed 1:1 by plan slug — unreachable before a plan exists, `resolve_plan` fails closed with `no plan selected`), frame state, plan state, or a frame-keyed fourth store? the issue evidence arose at execution time, but its codes can arise during think and challenge too (resolved: frame-level list, like `scope_entries`: reachable from devague new through execution; entries may free-text-ref plan tasks the way the deviate affects field does; no new store) +- what is the verb named? `ledger` collides semantically with the established delivery-ledger vocabulary across contested.py, `delivery_store.py`, and deviate.py; `degrade` collides with the fail-open error-handling idiom in 8 files; `lapse` and `erode` are unused; the record id prefix must avoid c, h, q, v, s, t, r, and d (resolved: the verb is lapse; record ids are lN) +- does filing require human approval before recording, as deviate mandates (a user-origin record IS the approval, deviate/SKILL.md:97-100), or is the agent self-report recorded without approval as issue 97 proposes (the agent decides when it degraded; the CLI records it deterministically)? the load-bearing feature of the deviate template is exactly what the proposal drops (resolved: file free, adjudicate later: llm-origin entries land proposed and never block; the human adjudicates in bulk at summarize-delivery time; only approved entries are citable as confidence evidence) +- how does the spec distinguish covered from reachable for each code (embodiment#18: a code nobody ever files reads as a category nobody ever hit) — does each shipped code need a named producer moment in a skill, or a dogfood report before the vocabulary freezes? + +## Open parks + +- [follow_up] embodiment offers to dogfood a prototype and report back real entry counts and which degradation codes turned out dead — the closing offer in issue 97 +- [follow_up] whether id-shaped lapse refs later deserve a contested-style join into plan and summary renders — deferred until dogfood shows refs are actually filed diff --git a/pyproject.toml b/pyproject.toml index 313620d..7b91eaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "devague" -version = "0.21.0" +version = "0.22.0" description = "devague — turns a vague feature idea into a buildable spec, then a buildable plan." readme = "README.md" diff --git a/tests/test_cli_lapse.py b/tests/test_cli_lapse.py new file mode 100644 index 0000000..bc95c3c --- /dev/null +++ b/tests/test_cli_lapse.py @@ -0,0 +1,523 @@ +"""Tests for ``devague lapse`` — file/list/adjudicate a reasoning-degradation +lapse on the current frame (issue #97 t2). + +``devague lapse`` is the CLI twin of ``devague deviate`` (:mod:`devague.cli. +_commands.deviate`), minus the plan link (``--task``) and minus id-ref +validation — a lapse's ``refs`` stay free text, never checked against known +ids (see :class:`devague.frame.LapseRecord`'s docstring). Acceptance +criteria (verbatim from the confirmed plan): + +1. ``devague lapse "" --code `` files against the current frame + and echoes the minted id; ``--origin llm`` lands proposed; ``--skipped + ""`` and repeatable ``--ref`` are stored verbatim +2. ``--list [--json]`` renders every record with id, code, and status; + ``--confirm `` / ``--reject `` transition only proposed records, + refuse otherwise, and are mutually exclusive with recording +3. the argument surface has no amend or delete flag — pinned by a test over + the parser +4. ``devague explain lapse`` succeeds and bare ``devague learn`` lists the + move — the MOVES entry is test-pinned +5. recording is deterministic: no subprocess and no LLM call, mirroring the + deviate determinism test +""" + +from __future__ import annotations + +import argparse +import json + +import pytest + +from devague import store +from devague.cli import _build_parser, main +from devague.cli._commands.learn import MOVES +from devague.frame import LAPSE_CODES + + +def _seed(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + main(["new", "Ship sharper method", "--title", "sharper-method"]) + + +# ── CLI: recording (acceptance criterion 1) ────────────────────────────────── + + +def test_lapse_records_entry_round_trip(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + slug = store.current_slug() + rc = main( + [ + "lapse", + "assumed the mean was already normalized", + "--code", + "assumption-for-measurement", + ] + ) + assert rc == 0 + frame = store.load(slug) + assert len(frame.lapses) == 1 + rec = frame.lapses[0] + assert rec.id == "l1" + assert rec.code == "assumption-for-measurement" + assert rec.what == "assumed the mean was already normalized" + assert rec.skipped_check == "" + assert rec.refs == [] + + +def test_lapse_user_origin_auto_approves_end_to_end(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + rc = main(["lapse", "skipped grading", "--code", "grader-unverified"]) + assert rc == 0 + assert store.load(store.current_slug()).lapses[0].status == "approved" + + +def test_lapse_llm_origin_lands_proposed_end_to_end(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + rc = main( + [ + "lapse", + "skipped grading", + "--code", + "grader-unverified", + "--origin", + "llm", + ] + ) + assert rc == 0 + assert store.load(store.current_slug()).lapses[0].status == "proposed" + + +def test_lapse_skipped_and_refs_stored_verbatim(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + rc = main( + [ + "lapse", + "no control group used", + "--code", + "control-absent", + "--skipped", + "control-group review", + "--ref", + "c1", + "--ref", + "the auth benchmark", + ] + ) + assert rc == 0 + rec = store.load(store.current_slug()).lapses[0] + assert rec.skipped_check == "control-group review" + assert rec.refs == ["c1", "the auth benchmark"] + + +def test_lapse_refs_are_never_validated_against_known_ids(tmp_path, monkeypatch) -> None: + # Unlike scope --seeds, a lapse's --ref is free text: a nonexistent id + # (c99) must NOT be refused. + _seed(monkeypatch, tmp_path) + rc = main( + [ + "lapse", + "provenance unclear", + "--code", + "provenance-missing", + "--ref", + "c99", + ] + ) + assert rc == 0 + assert store.load(store.current_slug()).lapses[0].refs == ["c99"] + + +def test_lapse_json_shape_on_record(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + capsys.readouterr() + rc = main( + [ + "lapse", + "n below claimed sample size", + "--code", + "n-below-claim", + "--json", + ] + ) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload == { + "id": "l1", + "code": "n-below-claim", + "what": "n below claimed sample size", + "skipped_check": "", + "refs": [], + "origin": "user", + "status": "approved", + } + + +def test_lapse_missing_code_errors_with_hint(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + rc = main(["lapse", "something went wrong"]) + assert rc == 1 + err = capsys.readouterr().err + assert "--code" in err + assert "hint:" in err + assert store.load(store.current_slug()).lapses == [] + + +def test_lapse_unknown_code_rejected_by_parser(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + with pytest.raises(SystemExit): + main(["lapse", "something went wrong", "--code", "not-a-real-code"]) + assert store.load(store.current_slug()).lapses == [] + + +def test_lapse_all_documented_codes_are_accepted(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + for code in LAPSE_CODES: + rc = main(["lapse", f"instance of {code}", "--code", code]) + assert rc == 0 + frame = store.load(store.current_slug()) + assert [r.code for r in frame.lapses] == list(LAPSE_CODES) + + +def test_lapse_frame_flag_targets_named_frame(tmp_path, monkeypatch, capsys) -> None: + monkeypatch.chdir(tmp_path) + main(["new", "First idea", "--title", "first-idea"]) + capsys.readouterr() + main(["new", "Second idea", "--title", "second-idea"]) + capsys.readouterr() + rc = main( + [ + "lapse", + "filed against the first frame", + "--code", + "grader-unverified", + "--frame", + "first-idea", + ] + ) + assert rc == 0 + assert len(store.load("first-idea").lapses) == 1 + assert store.load("second-idea").lapses == [] + + +def test_lapse_no_frame_selected_errors(tmp_path, monkeypatch, capsys) -> None: + monkeypatch.chdir(tmp_path) + rc = main(["lapse", "something", "--code", "grader-unverified"]) + assert rc == 1 + err = capsys.readouterr().err + assert "no frame selected" in err + assert "hint:" in err + + +# ── CLI: list (acceptance criterion 2) ─────────────────────────────────────── + + +def test_lapse_list_text_output_empty(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + capsys.readouterr() + rc = main(["lapse", "--list"]) + assert rc == 0 + assert "no lapses filed yet" in capsys.readouterr().out + + +def test_lapse_bare_invocation_lists(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + main(["lapse", "grading was manual", "--code", "grader-unverified"]) + capsys.readouterr() + rc = main(["lapse"]) + assert rc == 0 + out = capsys.readouterr().out + assert "l1" in out + assert "grading was manual" in out + assert "grader-unverified" in out + assert "approved" in out + + +def test_lapse_list_json_shape(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + main(["lapse", "first", "--code", "grader-unverified"]) + main(["lapse", "second", "--code", "control-absent"]) + capsys.readouterr() + rc = main(["lapse", "--list", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["frame"] == store.current_slug() + assert [r["id"] for r in payload["lapses"]] == ["l1", "l2"] + assert [r["code"] for r in payload["lapses"]] == [ + "grader-unverified", + "control-absent", + ] + assert [r["status"] for r in payload["lapses"]] == ["approved", "approved"] + + +# ── CLI: confirm / reject (user-only) ──────────────────────────────────────── + + +def test_lapse_confirm_marks_approved(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + main(["lapse", "swap", "--code", "grader-unverified", "--origin", "llm"]) + assert store.load(store.current_slug()).lapses[0].status == "proposed" + rc = main(["lapse", "--confirm", "l1"]) + assert rc == 0 + assert store.load(store.current_slug()).lapses[0].status == "approved" + + +def test_lapse_reject_marks_rejected(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + main(["lapse", "swap", "--code", "grader-unverified", "--origin", "llm"]) + rc = main(["lapse", "--reject", "l1"]) + assert rc == 0 + assert store.load(store.current_slug()).lapses[0].status == "rejected" + + +def test_lapse_confirm_json_shape(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + main(["lapse", "swap", "--code", "grader-unverified", "--origin", "llm"]) + capsys.readouterr() + rc = main(["lapse", "--confirm", "l1", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload == {"id": "l1", "status": "approved"} + + +def test_lapse_confirm_unknown_id_errors(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + rc = main(["lapse", "--confirm", "l99"]) + assert rc == 1 + err = capsys.readouterr().err + assert "no such lapse" in err + assert "hint:" in err + + +def test_lapse_confirm_already_approved_is_refused(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + main(["lapse", "swap", "--code", "grader-unverified"]) # user origin -> approved + capsys.readouterr() + rc = main(["lapse", "--confirm", "l1"]) + assert rc == 1 + err = capsys.readouterr().err + assert "already approved" in err + assert "hint:" in err + assert store.load(store.current_slug()).lapses[0].status == "approved" + + +def test_lapse_reject_already_rejected_is_refused(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + main(["lapse", "swap", "--code", "grader-unverified", "--origin", "llm"]) + main(["lapse", "--reject", "l1"]) + capsys.readouterr() + rc = main(["lapse", "--reject", "l1"]) + assert rc == 1 + err = capsys.readouterr().err + assert "already rejected" in err + assert store.load(store.current_slug()).lapses[0].status == "rejected" + + +def test_lapse_reject_after_approve_is_refused(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + main(["lapse", "swap", "--code", "grader-unverified"]) # auto-approved + capsys.readouterr() + rc = main(["lapse", "--reject", "l1"]) + assert rc == 1 + assert "already approved" in capsys.readouterr().err + + +# ── CLI: conflicting flags refused, never silently resolved ───────────────── + + +def test_lapse_confirm_and_reject_are_mutually_exclusive(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + with pytest.raises(SystemExit) as exc: + main(["lapse", "--confirm", "l1", "--reject", "l1"]) + assert exc.value.code == 1 + + +def test_lapse_confirm_and_list_are_mutually_exclusive(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + with pytest.raises(SystemExit) as exc: + main(["lapse", "--confirm", "l1", "--list"]) + assert exc.value.code == 1 + + +def test_lapse_reject_and_list_are_mutually_exclusive(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + with pytest.raises(SystemExit) as exc: + main(["lapse", "--reject", "l1", "--list"]) + assert exc.value.code == 1 + + +def test_lapse_confirm_with_positional_what_is_refused(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + rc = main(["lapse", "swap", "--confirm", "l1"]) + assert rc == 1 + err = capsys.readouterr().err + assert "cannot combine --confirm/--reject" in err + assert "hint:" in err + + +def test_lapse_reject_with_positional_what_is_refused(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + rc = main(["lapse", "swap", "--reject", "l1"]) + assert rc == 1 + err = capsys.readouterr().err + assert "cannot combine --confirm/--reject" in err + + +def test_lapse_list_with_positional_what_is_refused(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + rc = main(["lapse", "swap", "--list"]) + assert rc == 1 + err = capsys.readouterr().err + assert "cannot combine --list" in err + assert "hint:" in err + + +# ── acceptance criterion 3: no amend or delete flag on the parser ─────────── + + +def _lapse_subparser() -> argparse.ArgumentParser: + parser = _build_parser() + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + return action.choices["lapse"] + raise AssertionError("lapse subparser not registered") + + +def test_lapse_parser_has_no_amend_or_delete_flag() -> None: + p = _lapse_subparser() + flags = {opt for a in p._actions for opt in (a.option_strings or [])} + assert "--amend" not in flags + assert "--delete" not in flags + + +def test_lapse_parser_has_no_task_flag() -> None: + # Cloned from deviate.py minus --task (no plan link) per the plan + # instruction — pin that it never crept back in. + p = _lapse_subparser() + flags = {opt for a in p._actions for opt in (a.option_strings or [])} + assert "--task" not in flags + assert "--affects" not in flags + + +# ── acceptance criterion 4: explain + learn ────────────────────────────────── + + +def test_explain_lapse_succeeds(capsys) -> None: + rc = main(["explain", "lapse"]) + assert rc == 0 + out = capsys.readouterr().out + assert out.startswith("lapse:") + + +def test_moves_dict_has_lapse_entry() -> None: + assert "lapse" in MOVES + assert isinstance(MOVES["lapse"], str) and MOVES["lapse"] + + +def test_bare_learn_lists_lapse_move(capsys) -> None: + rc = main(["learn"]) + assert rc == 0 + out = capsys.readouterr().out + assert "lapse" in out + + +def test_bare_learn_json_lists_lapse_move(capsys) -> None: + rc = main(["learn", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert "lapse" in payload["moves"] + + +# ── acceptance criterion 5: deterministic recording ────────────────────────── + + +def test_lapse_deterministic_no_subprocess_or_llm(tmp_path, monkeypatch) -> None: + # Guard against scope creep: recording must never shell out (mirrors + # test_deviate_deterministic_no_subprocess_or_llm). + import subprocess + + _seed(monkeypatch, tmp_path) + called = {"n": 0} + real_run = subprocess.run + + def _guard(*args, **kwargs): + called["n"] += 1 + return real_run(*args, **kwargs) + + monkeypatch.setattr(subprocess, "run", _guard) + main(["lapse", "no subprocess used", "--code", "grader-unverified"]) + assert called["n"] == 0 + + +# ── Qodo #101: record flags must never be silently ignored ─────────────────── +# `cmd_lapse` fell through to listing whenever the positional `what` was +# absent, so `devague lapse --code --skipped ""` exited 0 having +# filed nothing. For a ledger whose whole premise is "filing is cheap enough +# to do mid-flight", a silent no-op is the worst available failure: the +# operator believes the degradation is on record and it is not. Fail closed +# instead, matching the flag/positional-ambiguity precedent set in PR #72. + + +@pytest.mark.parametrize( + "flags", + [ + ["--code", "provenance-missing"], + ["--skipped", "the check I skipped"], + ["--ref", "s10"], + ["--origin", "llm"], + ["--code", "provenance-missing", "--skipped", "a check", "--ref", "d1"], + ], +) +def test_lapse_record_flags_without_what_are_refused(tmp_path, monkeypatch, capsys, flags) -> None: + _seed(monkeypatch, tmp_path) + rc = main(["lapse", *flags]) + assert rc != 0 + err = capsys.readouterr().err + assert "without a positional" in err + assert "hint:" in err + # Nothing was filed — the refusal is not a partial write. + assert store.load("sharper-method").lapses == [] + + +def test_lapse_record_flags_with_confirm_are_refused(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + main(["lapse", "grading was manual", "--code", "grader-unverified", "--origin", "llm"]) + capsys.readouterr() + rc = main(["lapse", "--confirm", "l1", "--code", "control-absent"]) + assert rc != 0 + assert "without a positional" in capsys.readouterr().err + # The record kept its own code and stayed proposed — no partial resolution. + rec = store.load("sharper-method").lapses[0] + assert rec.code == "grader-unverified" + assert rec.status == "proposed" + + +def test_lapse_record_flags_with_list_are_refused(tmp_path, monkeypatch, capsys) -> None: + _seed(monkeypatch, tmp_path) + rc = main(["lapse", "--list", "--code", "provenance-missing"]) + assert rc != 0 + assert "without a positional" in capsys.readouterr().err + + +def test_lapse_bare_and_explicit_list_still_work_after_the_guard( + tmp_path, monkeypatch, capsys +) -> None: + """The guard keys off record-intent flags, never off `--list` alone.""" + _seed(monkeypatch, tmp_path) + assert main(["lapse"]) == 0 + assert "no lapses filed yet" in capsys.readouterr().out + assert main(["lapse", "--list"]) == 0 + assert "no lapses filed yet" in capsys.readouterr().out + assert main(["lapse", "--list", "--json"]) == 0 + assert json.loads(capsys.readouterr().out)["lapses"] == [] + + +def test_lapse_origin_still_defaults_to_user_when_what_is_given( + tmp_path, monkeypatch, capsys +) -> None: + """Detecting an explicit --origin must not change the default it resolves to.""" + _seed(monkeypatch, tmp_path) + main(["lapse", "a lapse", "--code", "control-absent"]) + capsys.readouterr() + rec = store.load("sharper-method").lapses[0] + assert rec.origin == "user" + assert rec.status == "approved" diff --git a/tests/test_convergence.py b/tests/test_convergence.py index 8928251..1289bec 100644 --- a/tests/test_convergence.py +++ b/tests/test_convergence.py @@ -1,7 +1,12 @@ from __future__ import annotations +import dataclasses +import json + +import pytest + from devague.convergence import evaluate, suggest_move -from devague.frame import Frame +from devague.frame import LAPSE_CODES, Frame _REQUIRED_KINDS = ( "announcement", @@ -203,3 +208,129 @@ def test_suggest_move_for_blocking_hard_question_names_interrogate_resolve_verba 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 + + +# --- Reasoning Degradation Ledger (issue #97, t4): the gate stays lapse-inert - +# +# The ledger (Frame.lapses) records reasoning degradation; it must never GATE. +# convergence.evaluate touches only frame.claims and frame.open_vagueness — a +# new list field on Frame (the scope_entries precedent) is invisible to it by +# default. These tests pin that invisibility as a property, not an accident: +# if a future change wires lapses into the gate, these fail loudly instead of +# silently degrading the "an honest ledger costs you nothing" contract. + + +def _serialize(res) -> str: + """Canonical string form so "byte-identical" is checked literally, not just + via dataclass ``==`` (which these tests also assert separately).""" + return json.dumps(dataclasses.asdict(res), sort_keys=True) + + +_LAPSE_SENTINEL_WHAT = "SENTINEL-LAPSE-WHAT-4f8a1c9d" +_LAPSE_SENTINEL_SKIPPED_CHECK = "SENTINEL-LAPSE-SKIPPED-CHECK-9be27a01" + + +def _file_lapse(f: Frame, origin: str, final_status: str): + """File one distinctively-worded lapse on ``f``, driving it to + ``final_status``. ``origin='llm'`` lands ``proposed``; ``origin='user'`` + lands ``approved``; passing ``final_status='rejected'`` additionally moves + it to ``rejected`` via ``set_lapse_status`` after filing. + """ + lapse = f.add_lapse( + LAPSE_CODES[0], + _LAPSE_SENTINEL_WHAT, + skipped_check=_LAPSE_SENTINEL_SKIPPED_CHECK, + refs=["c1"], + origin=origin, + ) + if final_status == "rejected": + f.set_lapse_status(lapse.id, "rejected") + assert lapse.status == final_status + return lapse + + +@pytest.mark.parametrize( + "origin,final_status", + [ + ("llm", "proposed"), + ("user", "approved"), + ("user", "rejected"), + ], +) +def test_converge_byte_identical_before_and_after_filing_lapse(origin, final_status) -> None: + """AC1: converge output is byte-identical before/after filing a lapse, on + an otherwise fully converged frame — tried for every lapse status.""" + f = _full_frame() + baseline = evaluate(f) + baseline_str = _serialize(baseline) + + _file_lapse(f, origin, final_status) + + after = evaluate(f) + assert after == baseline + assert _serialize(after) == baseline_str + + +def test_converge_byte_identical_with_all_three_lapse_statuses_at_once() -> None: + """AC1, comprehensive: proposed + approved + rejected lapses filed together + on one frame must not move the needle versus the lapse-free baseline.""" + f = _full_frame() + baseline = evaluate(f) + baseline_str = _serialize(baseline) + + f.add_lapse(LAPSE_CODES[0], "a proposed lapse", origin="llm") + approved = f.add_lapse(LAPSE_CODES[1], "an approved lapse", origin="user") + rejected = f.add_lapse(LAPSE_CODES[2], "a rejected lapse", origin="user") + f.set_lapse_status(rejected.id, "rejected") + assert approved.status == "approved" + assert rejected.status == "rejected" + + after = evaluate(f) + assert after == baseline + assert _serialize(after) == baseline_str + + +@pytest.mark.parametrize( + "origin,final_status", + [ + ("llm", "proposed"), + ("user", "approved"), + ("user", "rejected"), + ], +) +def test_converge_output_stays_lapse_free_on_converged_frame(origin, final_status) -> None: + """AC2: no lapse id, code, or filed text ever appears in blockers/warnings/ + parked_items/required_next_moves — checked on a frame that DOES converge.""" + f = _full_frame() + lapse = _file_lapse(f, origin, final_status) + res = evaluate(f) + haystack = " ".join(res.blockers + res.warnings + res.parked_items + res.required_next_moves) + assert lapse.id not in haystack + assert lapse.code not in haystack + assert _LAPSE_SENTINEL_WHAT not in haystack + assert _LAPSE_SENTINEL_SKIPPED_CHECK not in haystack + + +@pytest.mark.parametrize( + "origin,final_status", + [ + ("llm", "proposed"), + ("user", "approved"), + ("user", "rejected"), + ], +) +def test_converge_output_stays_lapse_free_on_unconverged_frame(origin, final_status) -> None: + """AC2, the other half: an INCOMPLETE frame (real blockers present) must + still keep every blocker/warning/parked_item/required_next_move lapse-free + — a lapse filed alongside a real gap must not bleed lapse text into the + gate's own reporting of that gap.""" + f = Frame(slug="s", title="t") + f.add_claim("announcement", "x", origin="user") # confirmed, but far from converged + lapse = _file_lapse(f, origin, final_status) + res = evaluate(f) + assert res.ready is False # sanity: this frame genuinely does not converge + haystack = " ".join(res.blockers + res.warnings + res.parked_items + res.required_next_moves) + assert lapse.id not in haystack + assert lapse.code not in haystack + assert _LAPSE_SENTINEL_WHAT not in haystack + assert _LAPSE_SENTINEL_SKIPPED_CHECK not in haystack diff --git a/tests/test_frame.py b/tests/test_frame.py index 58e263a..c1d39dc 100644 --- a/tests/test_frame.py +++ b/tests/test_frame.py @@ -125,11 +125,13 @@ def test_dataclasses_validate_enums() -> None: # --- resolve-parked-vagueness t1: Vagueness resolution state (schema v3) ------ -def test_schema_version_is_4() -> None: +def test_schema_version_is_5() -> 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 + # (issue-backlog-sweep t2) reserved the next bump for t4's HardQuestion + # resolution field — t2 itself only hardened load order/tolerance; v5 + # (issue #97 t1) adds Frame.lapses / LapseRecord, the Reasoning + # Degradation Ledger. + assert SCHEMA_VERSION == 5 def test_vagueness_gains_resolved_and_resolution_defaults() -> None: diff --git a/tests/test_frame_lapse.py b/tests/test_frame_lapse.py new file mode 100644 index 0000000..0b5e7cf --- /dev/null +++ b/tests/test_frame_lapse.py @@ -0,0 +1,301 @@ +"""Tests for the Lapse domain model on Frame (issue-97 t1, schema v5). + +The Reasoning Degradation Ledger's record shape mirrors ``DeviationRecord`` +(``devague/delivery.py``) — prefix-generic id minting via ``Frame._next``, +origin-driven initial status, fail-closed ``__post_init__`` validation for +enum-like fields, append-only records with no delete path. It deliberately +refines that chassis in one place (c21): ``code`` is validated at the +*filing* path (``add_lapse``), not in ``__post_init__``, so retiring a code +after a dogfood cycle never bricks a frame that already filed it — a closed +load-time enum would refuse to load any frame carrying a retired code, +because ``from_dict`` constructs the dataclass directly. + +Covers targets: c2, h2, c17, h12, c20, c21, h16. +""" + +from __future__ import annotations + +import json + +import pytest + +from devague import store +from devague.frame import ( + LAPSE_CODES, + LAPSE_STATUSES, + SCHEMA_VERSION, + Frame, + LapseRecord, + from_dict, + to_dict, +) + +# ── AC1: Frame.lapses, add_lapse id minting, origin-driven status, round-trip ─ + + +def test_frame_lapses_defaults_empty() -> None: + f = Frame(slug="s", title="t") + assert f.lapses == [] + + +def test_add_lapse_mints_sequential_ids() -> None: + f = Frame(slug="s", title="t") + r1 = f.add_lapse("grader-unverified", "graded without a rubric") + r2 = f.add_lapse("control-absent", "no control group used") + assert (r1.id, r2.id) == ("l1", "l2") + assert f.lapses == [r1, r2] + + +def test_add_lapse_user_origin_lands_approved() -> None: + f = Frame(slug="s", title="t") + rec = f.add_lapse("provenance-missing", "cited without a source", origin="user") + assert rec.status == "approved" + + +def test_add_lapse_llm_origin_lands_proposed() -> None: + f = Frame(slug="s", title="t") + rec = f.add_lapse("provenance-missing", "cited without a source", origin="llm") + assert rec.status == "proposed" + + +def test_add_lapse_defaults_origin_to_user_and_auto_approves() -> None: + f = Frame(slug="s", title="t") + rec = f.add_lapse("n-below-claim", "claimed generality from n=1") + assert rec.origin == "user" + assert rec.status == "approved" + + +def test_add_lapse_stores_skipped_check_and_refs() -> None: + f = Frame(slug="s", title="t") + rec = f.add_lapse( + "instrument-changed-mid-series", + "swapped grader mid-run", + skipped_check="re-baseline after swap", + refs=["t3", "c7"], + ) + assert rec.skipped_check == "re-baseline after swap" + assert rec.refs == ["t3", "c7"] + + +def test_add_lapse_skipped_check_and_refs_default_empty() -> None: + f = Frame(slug="s", title="t") + rec = f.add_lapse("assumption-for-measurement", "assumed the metric measured intent") + assert rec.skipped_check == "" + assert rec.refs == [] + + +def test_find_lapse() -> None: + f = Frame(slug="s", title="t") + f.add_lapse("grader-unverified", "x") + assert f.find_lapse("l1") is not None + assert f.find_lapse("nope") is None + + +def test_roundtrip_lapses_via_dict_verbatim() -> None: + f = Frame(slug="s", title="t") + f.add_lapse( + "control-absent", + "no control group", + skipped_check="ran an A/B check", + refs=["c3"], + origin="llm", + ) + f2 = from_dict(to_dict(f)) + assert to_dict(f2) == to_dict(f) + assert f2.lapses == f.lapses + rec = f2.lapses[0] + assert (rec.id, rec.code, rec.what) == ("l1", "control-absent", "no control group") + assert rec.skipped_check == "ran an A/B check" + assert rec.refs == ["c3"] + assert rec.origin == "llm" + assert rec.status == "proposed" + + +def test_roundtrip_lapses_via_store(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + f = Frame(slug="demo", title="Demo") + f.add_lapse("grader-unverified", "no rubric applied", origin="user") + store.save(f) + loaded = store.load("demo") + assert to_dict(loaded) == to_dict(f) + assert loaded.lapses[0].code == "grader-unverified" + + +def test_lapse_codes_include_the_six_starting_codes() -> None: + assert set(LAPSE_CODES) == { + "assumption-for-measurement", + "grader-unverified", + "control-absent", + "n-below-claim", + "instrument-changed-mid-series", + "provenance-missing", + } + + +@pytest.mark.parametrize("code", list(LAPSE_CODES)) +def test_add_lapse_accepts_every_starting_code(code: str) -> None: + f = Frame(slug="s", title="t") + rec = f.add_lapse(code, "some degradation") + assert rec.code == code + + +# ── AC2: code validated at filing time only, tolerant at load time (c21/h16) ── + + +def test_add_lapse_rejects_unknown_code() -> None: + f = Frame(slug="s", title="t") + with pytest.raises(ValueError, match="unknown lapse code"): + f.add_lapse("not-a-real-code", "something") + assert f.lapses == [] + + +def test_lapse_record_constructed_directly_with_unknown_code_does_not_raise() -> None: + # from_dict constructs LapseRecord directly (never through add_lapse), so + # __post_init__ must NOT validate `code` — only `origin`/`status` do. + rec = LapseRecord(id="l1", code="a-code-nobody-filed-through-add-lapse", what="x") + assert rec.code == "a-code-nobody-filed-through-add-lapse" + + +def test_retired_code_still_loads_and_survives_roundtrip(monkeypatch) -> None: + """Files a lapse, simulates the code's retirement, then round-trips the + frame through to_dict/from_dict and asserts it loads cleanly and the + record survives verbatim (h16's pinning test).""" + f = Frame(slug="s", title="t") + rec = f.add_lapse("grader-unverified", "no rubric applied") + payload = to_dict(f) + + # Simulate the dogfood-cycle retirement of "grader-unverified": it is no + # longer a filable code, but frames that already filed it must still load. + retired_codes = tuple(c for c in LAPSE_CODES if c != "grader-unverified") + monkeypatch.setattr("devague.frame.LAPSE_CODES", retired_codes) + + # Filing it now is refused... + f2 = Frame(slug="s", title="t") + with pytest.raises(ValueError, match="unknown lapse code"): + f2.add_lapse("grader-unverified", "no rubric applied") + + # ...but loading the frame that filed it before retirement still works. + reloaded = from_dict(payload) + assert reloaded.lapses[0].code == "grader-unverified" + assert reloaded.lapses[0].id == rec.id + assert to_dict(reloaded) == payload + + +def test_retired_code_still_loads_via_store_roundtrip(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + f = Frame(slug="demo", title="Demo") + f.add_lapse("n-below-claim", "generalized from n=1") + store.save(f) + + retired_codes = tuple(c for c in LAPSE_CODES if c != "n-below-claim") + monkeypatch.setattr("devague.frame.LAPSE_CODES", retired_codes) + + loaded = store.load("demo") + assert loaded.lapses[0].code == "n-below-claim" + + +# ── AC3: SCHEMA_VERSION == 5, fail-closed on 6, v4 frames load + re-save as v5 ─ + + +def test_schema_version_is_5() -> None: + assert SCHEMA_VERSION == 5 + + +def test_load_rejects_newer_schema_version_before_parsing_malformed_lapses( + tmp_path, monkeypatch +) -> None: + """A frame declaring a newer schema (6) is refused fail-closed BEFORE + from_dict attempts to parse it — proven here with a `lapses` entry + missing required keys, which would otherwise raise a raw KeyError + instead of the intended IncompatibleSchemaError (mirrors store.py's + documented check-before-parse order, t2 of issue-backlog-sweep).""" + monkeypatch.chdir(tmp_path) + store.FRAMES_DIR.mkdir(parents=True, exist_ok=True) + raw = { + "slug": "demo", + "title": "Demo", + "schema_version": SCHEMA_VERSION + 1, + "claims": [], + "open_vagueness": [], + "lapses": [{"id": "l1"}], # missing code/what -- would KeyError if parsed + } + store.path_for("demo").write_text(json.dumps(raw), encoding="utf-8") + with pytest.raises(store.IncompatibleSchemaError, match="schema_version"): + store.load("demo") + + +def test_v4_frame_without_lapses_loads_clean_and_resaves_as_v5(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + store.FRAMES_DIR.mkdir(parents=True, exist_ok=True) + legacy_v4 = { + "slug": "demo", + "title": "Demo", + "schema_version": 4, + "claims": [], + "open_vagueness": [], + } + store.path_for("demo").write_text(json.dumps(legacy_v4), encoding="utf-8") + + loaded = store.load("demo") + assert loaded.schema_version == 4 + assert loaded.lapses == [] + + store.save(loaded) + reloaded_raw = json.loads(store.path_for("demo").read_text(encoding="utf-8")) + assert reloaded_raw["schema_version"] == SCHEMA_VERSION == 5 + assert store.load("demo").lapses == [] + + +def test_legacy_dict_without_lapses_key_loads_empty_list() -> None: + f = from_dict( + {"slug": "s", "title": "t", "schema_version": 4, "claims": [], "open_vagueness": []} + ) + assert f.lapses == [] + + +# ── AC4: no amend/delete API; only set_lapse_status mutates; refs unvalidated ─ + + +def test_no_amend_or_delete_lapse_method_exists() -> None: + for name in ("amend_lapse", "delete_lapse", "remove_lapse"): + assert not hasattr(Frame, name), f"Frame must not expose {name}" + + +def test_set_lapse_status_transitions_and_reports_unknown() -> None: + f = Frame(slug="s", title="t") + f.add_lapse("grader-unverified", "x", origin="llm") # proposed + assert f.set_lapse_status("l1", "approved") is True + assert f.find_lapse("l1").status == "approved" + assert f.set_lapse_status("lX", "rejected") is False + + +def test_set_lapse_status_rejects_unknown_status_without_mutating() -> None: + f = Frame(slug="s", title="t") + f.add_lapse("grader-unverified", "x", origin="llm") + before = f.find_lapse("l1").status + with pytest.raises(ValueError, match="unknown lapse status"): + f.set_lapse_status("l1", "not-a-status") + assert f.find_lapse("l1").status == before + + +def test_lapse_statuses_are_proposed_approved_rejected() -> None: + assert set(LAPSE_STATUSES) == {"proposed", "approved", "rejected"} + + +def test_lapse_refs_stored_verbatim_never_validated() -> None: + f = Frame(slug="s", title="t") + rec = f.add_lapse( + "provenance-missing", + "x", + refs=["not-a-real-id", "t99", "some free text", ""], + ) + assert rec.refs == ["not-a-real-id", "t99", "some free text", ""] + + +def test_lapse_record_dataclass_validates_origin_and_status_but_not_code() -> None: + with pytest.raises(ValueError, match="unknown lapse origin"): + LapseRecord(id="l1", code="grader-unverified", what="x", origin="alien") + with pytest.raises(ValueError, match="unknown lapse status"): + LapseRecord(id="l1", code="grader-unverified", what="x", status="weird") + # An unknown/retired code never raises at construction time. + LapseRecord(id="l1", code="anything-goes-here", what="x") diff --git a/tests/test_frame_schema_v2.py b/tests/test_frame_schema_v2.py index 6a82d3f..8997858 100644 --- a/tests/test_frame_schema_v2.py +++ b/tests/test_frame_schema_v2.py @@ -27,9 +27,9 @@ def test_schema_version_bumped_exactly_once() -> None: # 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 + # field; then issue #97 t1's v5, Frame.lapses / LapseRecord) moves this pin + # forward rather than leaving a stale, now-false assertion. + assert SCHEMA_VERSION == 5 def test_claim_and_honesty_instruction_default_empty() -> None: diff --git a/tests/test_plan_convergence.py b/tests/test_plan_convergence.py index 404922f..b2dfbd4 100644 --- a/tests/test_plan_convergence.py +++ b/tests/test_plan_convergence.py @@ -10,7 +10,13 @@ from __future__ import annotations -from devague.plan import CoverageTarget, Plan, Task, dependency_waves +import dataclasses +import json + +import pytest + +from devague.frame import LAPSE_CODES, Frame +from devague.plan import CoverageTarget, Plan, Task, dependency_waves, targets_from_frame from devague.plan_convergence import evaluate, suggest_move # ── helpers ─────────────────────────────────────────────────────────────────── @@ -553,3 +559,146 @@ def test_shell_cli_shape_90_covered_12_deferred_converges() -> None: assert len(deferred_parked) == 12 for tid in deferred_ids: assert any(tid in item for item in deferred_parked) + + +# --- Reasoning Degradation Ledger (issue #97, t4): the plan gate is lapse-inert +# +# Frame.lapses records reasoning degradation; it must never GATE — on either +# side. Plan itself carries no lapses field at all, and plan_convergence.evaluate +# never sees the source frame directly (only `plan` + optional `targets`), so the +# property under test here is really about `targets_from_frame`: filing a lapse +# on the source frame — in any status — must not perturb the coverage targets a +# plan derives from it, and therefore must not perturb the plan gate's output. + + +def _frame_with_confirmed_requirement() -> Frame: + f = Frame(slug="src", title="Source frame") + f.add_claim("requirement", "must round-trip", origin="user") # confirmed -> c1 + return f + + +def _plan_from_targets(targets: list[CoverageTarget], frame_slug: str) -> Plan: + """A plan that covers every target with acceptance + instruction on every + confirmed task — the plan-side twin of test_convergence's ``_full_frame``: + a plan that converges cleanly given ``targets``.""" + p = Plan(slug="demo", title="Demo", frame_slug=frame_slug) + p.targets = list(targets) + for tg in targets: + t = p.add_task(f"deliver {tg.id}") + t.instruction = f"implement {tg.id}" + p.add_acceptance(t, f"{tg.id} verified") + p.add_cover(t, tg.id) + return p + + +def _serialize(res) -> str: + """Canonical string form so "converges identically" is checked literally, + not just via dataclass ``==`` (which these tests also assert separately).""" + return json.dumps(dataclasses.asdict(res), sort_keys=True) + + +_LAPSE_SENTINEL_WHAT = "SENTINEL-PLAN-LAPSE-WHAT-7c1e9b02" +_LAPSE_SENTINEL_SKIPPED_CHECK = "SENTINEL-PLAN-LAPSE-SKIPPED-CHECK-3fa88d17" + + +def _file_lapse(f: Frame, origin: str, final_status: str): + """File one distinctively-worded lapse on ``f``, driving it to + ``final_status`` — mirrors test_convergence.py's helper of the same name.""" + lapse = f.add_lapse( + LAPSE_CODES[0], + _LAPSE_SENTINEL_WHAT, + skipped_check=_LAPSE_SENTINEL_SKIPPED_CHECK, + refs=["c1"], + origin=origin, + ) + if final_status == "rejected": + f.set_lapse_status(lapse.id, "rejected") + assert lapse.status == final_status + return lapse + + +@pytest.mark.parametrize( + "origin,final_status", + [ + ("llm", "proposed"), + ("user", "approved"), + ("user", "rejected"), + ], +) +def test_plan_converges_identically_whether_source_frame_carries_a_lapse_or_not( + origin, final_status +) -> None: + """A plan seeded from a lapse-free frame and a plan seeded from an + otherwise-identical frame that ALSO carries a filed lapse (in any status) + must converge byte-identically — the ledger must never leak into + coverage-target derivation or the plan gate.""" + clean_frame = _frame_with_confirmed_requirement() + clean_targets = targets_from_frame(clean_frame) + clean_plan = _plan_from_targets(clean_targets, clean_frame.slug) + baseline = evaluate(clean_plan) + baseline_str = _serialize(baseline) + + lapsy_frame = _frame_with_confirmed_requirement() + _file_lapse(lapsy_frame, origin, final_status) + lapsy_targets = targets_from_frame(lapsy_frame) + lapsy_plan = _plan_from_targets(lapsy_targets, lapsy_frame.slug) + + # Sanity: filing the lapse changed nothing about the derived targets either. + assert lapsy_targets == clean_targets + + after = evaluate(lapsy_plan) + assert after == baseline + assert _serialize(after) == baseline_str + + +@pytest.mark.parametrize( + "origin,final_status", + [ + ("llm", "proposed"), + ("user", "approved"), + ("user", "rejected"), + ], +) +def test_plan_convergence_output_stays_lapse_free_on_converged_plan(origin, final_status) -> None: + """AC2 on the plan side: the plan gate's blockers/warnings/parked_items/ + required_next_moves never name a lapse id, code, or filed text, in any + lapse status — for a converged plan derived from a lapse-carrying frame.""" + frame = _frame_with_confirmed_requirement() + lapse = _file_lapse(frame, origin, final_status) + targets = targets_from_frame(frame) + plan = _plan_from_targets(targets, frame.slug) + + res = evaluate(plan) + assert res.ready is True # sanity: this plan genuinely converges + haystack = " ".join(res.blockers + res.warnings + res.parked_items + res.required_next_moves) + assert lapse.id not in haystack + assert lapse.code not in haystack + assert _LAPSE_SENTINEL_WHAT not in haystack + assert _LAPSE_SENTINEL_SKIPPED_CHECK not in haystack + + +@pytest.mark.parametrize( + "origin,final_status", + [ + ("llm", "proposed"), + ("user", "approved"), + ("user", "rejected"), + ], +) +def test_plan_convergence_output_stays_lapse_free_on_unconverged_plan(origin, final_status) -> None: + """AC2, the other half: an INCOMPLETE plan (real coverage blockers present, + since nothing covers the target) derived from a lapse-carrying frame must + still keep every blocker/warning/parked_item/required_next_move lapse-free.""" + frame = _frame_with_confirmed_requirement() + lapse = _file_lapse(frame, origin, final_status) + targets = targets_from_frame(frame) + plan = Plan(slug="demo", title="Demo", frame_slug=frame.slug) + plan.targets = targets # uncovered — genuinely unconverged, no tasks at all + + res = evaluate(plan) + assert res.ready is False # sanity: this plan genuinely does not converge + haystack = " ".join(res.blockers + res.warnings + res.parked_items + res.required_next_moves) + assert lapse.id not in haystack + assert lapse.code not in haystack + assert _LAPSE_SENTINEL_WHAT not in haystack + assert _LAPSE_SENTINEL_SKIPPED_CHECK not in haystack diff --git a/tests/test_render_sharper.py b/tests/test_render_sharper.py index 9191cc1..e9b7b5b 100644 --- a/tests/test_render_sharper.py +++ b/tests/test_render_sharper.py @@ -214,6 +214,37 @@ def test_scope_section_absent_in_frame_md_when_no_entries() -> None: assert "## Scope exploration" not in out +def _backtick_surface_frame() -> Frame: + """A frame whose scope surface carries its own code span (issue 97 dogfood): + blind-wrapping it in another backtick pair renders broken spans (MD038). + """ + f = Frame(slug="b", title="Backtick Surface") + ann = f.add_claim("announcement", "Shipped", origin="user") + f.add_honesty(ann, "must be honest", origin="user") + f.add_scope_entry( + "challenge pass / failure-mode lens: frame.py `__post_init__` validation", + "probe: an unknown kind raises ValueError at construction", + ) + return f + + +def test_backtick_bearing_surface_is_not_double_wrapped_in_spec_md() -> None: + out = render_spec(_backtick_surface_frame()) + # The surface renders with its own code span intact, not nested in another. + assert "frame.py `__post_init__` validation" in out + assert "— `challenge pass" not in out + + +def test_backtick_bearing_surface_is_not_double_wrapped_in_frame_md() -> None: + out = render_frame(_backtick_surface_frame()) + assert "frame.py `__post_init__` validation" in out + assert "— `challenge pass" not in out + + +def test_backtick_bearing_surface_spec_md_is_markdownlint_clean() -> None: + assert_markdownlint_clean(render_spec(_backtick_surface_frame())) + + def test_scope_entry_without_seeds_omits_seeds_line() -> None: out = render_spec(_sharper_frame()) # s2 carries no seeds -> no "seeds:" line directly under its bullet. @@ -238,3 +269,80 @@ def test_golden_spec_md() -> None: def test_golden_frame_md() -> None: expected = (GOLDENS / "sharper_frame.md").read_text(encoding="utf-8") assert render_frame(_sharper_frame()) == expected + + +# ── Lapse ledger (issue #97 t3): frame_md renders it, spec_md never does ───── +# +# devague show (frame_md) gets a new "## Lapse ledger" section listing every +# filed lapse's id, code, status, and what — omitted entirely when +# frame.lapses is empty, mirroring _scope_lines' omitted-when-empty shape. +# Unlike summary_md's approved/pending/rejected discipline (a rejected +# deviation/lapse is dropped there), frame_md is the working-state view, so +# every lapse renders here regardless of status — the same way open_vagueness +# shows both resolved and unresolved items in one flat list. +# +# spec_md.py gets NO code change: the exported spec overwrites the same dated +# file on every re-export, so execution-time lapses rendering there would +# rewrite the what-to-build artifact. That is pinned below as a pure +# regression test — filing lapses in every status must never change +# render_spec's output. + + +def _frame_with_lapses() -> Frame: + """A frame carrying one lapse in each status: approved (l1), proposed + (l2), and rejected (l3).""" + f = _bare_frame() + f.add_lapse("grader-unverified", "graded without a rubric", origin="user") # l1: approved + f.add_lapse("control-absent", "no control group used", origin="llm") # l2: proposed + rejected = f.add_lapse( + "n-below-claim", "claimed generality from n=1", origin="user" + ) # l3, then rejected + f.set_lapse_status(rejected.id, "rejected") + return f + + +def test_lapse_ledger_absent_in_frame_md_when_no_lapses() -> None: + out = render_frame(_bare_frame()) + assert "## Lapse ledger" not in out + + +def test_lapse_ledger_lists_id_code_status_and_what_in_frame_md() -> None: + out = render_frame(_frame_with_lapses()) + assert "## Lapse ledger" in out + assert "`l1`" in out + assert "`grader-unverified`" in out + assert "(approved)" in out + assert "graded without a rubric" in out + assert "`l2`" in out + assert "(proposed)" in out + assert "no control group used" in out + assert "`l3`" in out + assert "(rejected)" in out + assert "claimed generality from n=1" in out + + +def test_lapse_ledger_frame_md_is_markdownlint_clean() -> None: + assert_markdownlint_clean(render_frame(_frame_with_lapses())) + + +def test_lapse_ledger_never_appears_in_spec_md() -> None: + out = render_spec(_frame_with_lapses()) + assert "## Lapse ledger" not in out + assert "l1" not in out + assert "l2" not in out + assert "l3" not in out + + +def test_filing_lapses_does_not_change_render_spec_output() -> None: + """Acceptance criterion 3 (issue #97 t3): re-exporting the spec after + filing lapses must produce a byte-identical spec-md. Files a lapse in + every status (approved, proposed, rejected) on an already-rendered frame + and diffs render_spec's output before/after.""" + f = _sharper_frame() + before = render_spec(f) + f.add_lapse("grader-unverified", "graded without a rubric", origin="user") # approved + f.add_lapse("control-absent", "no control group used", origin="llm") # proposed + rejected = f.add_lapse("n-below-claim", "claimed generality from n=1", origin="user") + f.set_lapse_status(rejected.id, "rejected") + after = render_spec(f) + assert after == before diff --git a/tests/test_summary.py b/tests/test_summary.py index db7fbc2..76381c7 100644 --- a/tests/test_summary.py +++ b/tests/test_summary.py @@ -35,6 +35,7 @@ from devague.cli import main from devague.cli._commands import summary as summary_cmd from devague.delivery import DELIVERY_SCHEMA_VERSION, Delivery +from devague.frame import Frame from devague.plan import Plan from devague.render import summary_md from tests.test_render import assert_markdownlint_clean @@ -371,6 +372,168 @@ def test_no_deviations_recorded_yet_empty_state() -> None: assert "(no deviations recorded yet)" in out +# ── lapse ledger evidence in Delivery Claims (issue #97 t3) ────────────────── +# +# devague summary cites approved reasoning-degradation lapses (Frame.lapses, +# issue #97 t1) as evidence grounding the Delivery Claims confidence column, +# following the exact approved/pending/rejected discipline _mid_work_lines and +# _drift_lines already apply to deviation records: approved lapses render +# fully, a proposed (not-yet-adjudicated) lapse renders as visibly pending, a +# rejected lapse is omitted entirely, and a frame with no lapses at all (or no +# frame — a degraded load, criterion 4) leaves the existing hardcoded +# placeholder row as the section's only content. + + +def test_delivery_claims_keeps_placeholder_row_when_frame_is_none() -> None: + plan, frame = _bare_plan_and_frame() # frame is None + out = summary_md.render_summary(plan, frame, Delivery(plan_slug=plan.slug)) + claims = out.split("## Delivery Claims")[1].split("## Remaining Work")[0] + assert "``" in claims + assert "``" in claims + assert "Lapse ledger evidence" not in claims + + +def test_delivery_claims_keeps_placeholder_row_when_lapses_list_is_empty() -> None: + plan, _ = _bare_plan_and_frame() + frame = Frame(slug="demo", title="Demo Frame") + out = summary_md.render_summary(plan, frame, Delivery(plan_slug=plan.slug)) + claims = out.split("## Delivery Claims")[1].split("## Remaining Work")[0] + assert "``" in claims + assert "Lapse ledger evidence" not in claims + + +def test_delivery_claims_cites_approved_lapse_as_evidence() -> None: + plan, _ = _bare_plan_and_frame() + frame = Frame(slug="demo", title="Demo Frame") + frame.add_lapse("grader-unverified", "graded without a rubric", origin="user") + out = summary_md.render_summary(plan, frame, Delivery(plan_slug=plan.slug)) + claims = out.split("## Delivery Claims")[1].split("## Remaining Work")[0] + assert "Lapse ledger evidence" in claims + assert "`l1`" in claims + assert "`grader-unverified`" in claims + assert "graded without a rubric" in claims + + +def test_delivery_claims_proposed_lapse_renders_visibly_pending_not_approved() -> None: + plan, _ = _bare_plan_and_frame() + frame = Frame(slug="demo", title="Demo Frame") + frame.add_lapse("control-absent", "no control group used", origin="llm") # -> proposed + out = summary_md.render_summary(plan, frame, Delivery(plan_slug=plan.slug)) + claims = out.split("## Delivery Claims")[1].split("## Remaining Work")[0] + assert "`l1`" in claims + assert "pending approval" in claims + # never rendered as a row of the approved-evidence table + assert "| `l1` |" not in claims + assert "no control group used" not in claims + + +def test_delivery_claims_omits_rejected_lapse_entirely() -> None: + plan, _ = _bare_plan_and_frame() + frame = Frame(slug="demo", title="Demo Frame") + rec = frame.add_lapse("n-below-claim", "claimed generality from n=1", origin="user") + frame.set_lapse_status(rec.id, "rejected") + out = summary_md.render_summary(plan, frame, Delivery(plan_slug=plan.slug)) + assert "l1" not in out + assert "claimed generality from n=1" not in out + + +def test_delivery_claims_lapse_evidence_table_escapes_pipe_and_flattens_newline() -> None: + plan, _ = _bare_plan_and_frame() + frame = Frame(slug="demo", title="Demo Frame") + frame.add_lapse("provenance-missing", "cited | without\na source", origin="user") + out = summary_md.render_summary(plan, frame, Delivery(plan_slug=plan.slug)) + claims = out.split("## Delivery Claims")[1].split("## Remaining Work")[0] + row = next(ln for ln in claims.splitlines() if ln.startswith("| `l1`")) + assert "\n" not in row + assert "\\|" in row + # splitting on an *unescaped* pipe still yields exactly 5 fields: leading + # '', 3 columns, trailing '' — the raw pipe in `what` never adds a column. + cells = re.split(r"(? None: + plan, _ = _bare_plan_and_frame() + frame = Frame(slug="demo", title="Demo Frame") + frame.add_lapse("grader-unverified", "graded without a rubric", origin="user") + frame.add_lapse("control-absent", "no control group used", origin="llm") + out = summary_md.render_summary(plan, frame, Delivery(plan_slug=plan.slug)) + assert_markdownlint_clean(out) + + +@pytest.mark.skipif( + _MARKDOWNLINT is None, + reason="markdownlint-cli2 not on PATH (dev tooling; not installed by this repo's CI)", +) +def test_render_summary_with_lapses_passes_real_markdownlint_cli2(tmp_path) -> None: + plan, _ = _bare_plan_and_frame() + frame = Frame(slug="demo", title="Demo Frame") + frame.add_lapse("grader-unverified", "graded without a rubric", origin="user") + frame.add_lapse("control-absent", "no control group used", origin="llm") + out = summary_md.render_summary(plan, frame, Delivery(plan_slug=plan.slug)) + summary_path = tmp_path / "summary_lapses.md" + summary_path.write_text(out, encoding="utf-8") + result = _run_markdownlint(summary_path) + assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + + +def test_summary_data_lapse_evidence_shape() -> None: + plan, _ = _bare_plan_and_frame() + frame = Frame(slug="demo", title="Demo Frame") + frame.add_lapse("grader-unverified", "graded without a rubric", origin="user") + frame.add_lapse("control-absent", "no control group used", origin="llm") + data = summary_md.summary_data(plan, frame, Delivery(plan_slug=plan.slug)) + evidence = data["sections"]["lapse_evidence"] + assert evidence["approved"] == [ + {"id": "l1", "code": "grader-unverified", "what": "graded without a rubric"} + ] + assert evidence["pending"] == ["l2"] + + +def test_summary_data_lapse_evidence_empty_when_no_frame() -> None: + plan, frame = _bare_plan_and_frame() # frame is None + data = summary_md.summary_data(plan, frame, Delivery(plan_slug=plan.slug)) + assert data["sections"]["lapse_evidence"] == {"approved": [], "pending": []} + + +def test_cli_summary_cites_lapse_filed_directly_on_frame(tmp_path, monkeypatch, capsys) -> None: + # #97 t3: no `devague lapse` CLI verb exists yet in this worktree (t2 is a + # sibling task) — files the lapse straight onto the stored Frame the way + # t1's own tests do, then drives `devague summary` end to end through the + # real CLI + store round-trip. + _plan_with_two_tasks(monkeypatch, tmp_path, capsys) + slug = store.current_slug() + f = store.load(slug) + f.add_lapse("grader-unverified", "graded without a rubric", origin="user") + f.add_lapse("control-absent", "no control group used", origin="llm") + store.save(f) + capsys.readouterr() + rc = main(["summary"]) + assert rc == 0 + out = capsys.readouterr().out + claims = out.split("## Delivery Claims")[1].split("## Remaining Work")[0] + assert "`l1`" in claims + assert "graded without a rubric" in claims + assert "`l2`" in claims + assert "pending approval" in claims + + +def test_cli_summary_degrades_with_lapses_exactly_as_today_when_frame_missing( + tmp_path, monkeypatch, capsys +) -> None: + # Acceptance criterion 4: a frame that fails to load degrades in summary + # exactly as today — no new failure mode introduced by the lapse ledger. + slug = _plan_with_two_tasks(monkeypatch, tmp_path, capsys) + store.path_for(slug).unlink() + capsys.readouterr() + rc = main(["summary"]) + assert rc == 0 + out = capsys.readouterr().out + assert "No source frame available" in out + claims = out.split("## Delivery Claims")[1].split("## Remaining Work")[0] + assert "Lapse ledger evidence" not in claims + + # ── drift table safety: a raw '|'/newline in `reason` cannot break the table (Q2) ── diff --git a/uv.lock b/uv.lock index 25d5465..2eea69a 100644 --- a/uv.lock +++ b/uv.lock @@ -183,7 +183,7 @@ wheels = [ [[package]] name = "devague" -version = "0.21.0" +version = "0.22.0" source = { editable = "." } [package.dev-dependencies]