diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 586eec3..b1b547c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ "name": "implementation-plugin", "source": "./", "description": "Run approved implementation programs one reviewable increment at a time.", - "version": "0.1.1" + "version": "0.1.2" } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 4f82d39..408796d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "implementation-plugin", "displayName": "Implementation Plugin", -"version": "0.1.1", + "version": "0.1.2", "description": "Run approved implementation programs one reviewable increment at a time.", "repository": "https://github.com/CoveMB/implementation-plugin", "skills": "./skills/" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index b4ec289..31447be 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "implementation-plugin", - "version": "0.1.1", + "version": "0.1.2", "description": "Run approved implementation programs one reviewable increment at a time.", "skills": "./skills/" } diff --git a/docs/installation.md b/docs/installation.md index 2613c54..cfe0597 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -258,7 +258,7 @@ Claude Code 2.1.128 or later also accepts a local `.zip` archive through the same option: ```bash -claude --plugin-dir /absolute/path/to/implementation-plugin-0.1.1.zip +claude --plugin-dir /absolute/path/to/implementation-plugin-0.1.2.zip ``` Neither command installs the plugin permanently. The archive must contain a @@ -266,7 +266,7 @@ valid plugin at its root. Claude Code 2.1.129 or later can also load a packaged `.zip` archive from a trusted URL for one session: ```bash -claude --plugin-url https://example.com/implementation-plugin-0.1.1.zip +claude --plugin-url https://example.com/implementation-plugin-0.1.2.zip ``` This repository does not currently publish a `.zip` archive. Do not point diff --git a/docs/maintainers.md b/docs/maintainers.md index 91ca4d4..c2aa32e 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -53,6 +53,11 @@ to reach installed users, update the synchronized package version deliberately and document the release. Do not bump versions merely to make local validation pass. +Version `0.1.2` is the current package owner for typed continuation and blocked +recovery. The frozen `tests/fixtures/program-bootstrap/v0.1.1/` tree remains +historical compatibility evidence and must not be rewritten during a version +sync. + ## Refresh platform instructions Installation commands and schemas can change. Before publication, compare this @@ -120,6 +125,31 @@ claude --plugin-dir /absolute/path/to/implementation-plugin Loading a plugin starts a host session and is separate from static package validation. Do not perform it silently in a maintenance-only task. +## Optional continuation replay + +The continuation replay catalog under +`tests/pressure/continuation-replay/` has deterministic immediate and later +routes. Its loader, evidence validator, isolated-session command shape, and +atomic no-overwrite result boundary are covered by the offline suite. Passing +those checks does not prove live front-door behavior. + +Run the live campaign only with explicit authority to invoke the selected +evaluator, transmit the prompt and packaged skill to that provider, and create +raw evidence under `tests/pressure/continuation-replay/results/`. Before a run, +record the exact evaluator and client version, authentication and model-capacity +implications, two fresh ephemeral tasks, result targets, and confirmation that +every target is absent. The authorized command shape is: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 tests/integrated_pressure_support.py evaluate-continuation-replay --scenario-catalog tests/pressure/continuation-replay/scenarios.json --output-directory tests/pressure/continuation-replay/results --evaluator codex +``` + +The runner stops on the first evaluator failure and never overwrites a result. +Raw outputs are evidence, not authority. A human must review them and create a +digest-bound `verdicts.json` before any runtime-success claim. When the campaign +is not separately authorized, leave both results and verdicts absent and report +`live continuation replay: not run`. + ## What these checks prove The local tests can establish that required files exist, metadata stays diff --git a/docs/reference.md b/docs/reference.md index eef046e..1068dd9 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -83,14 +83,17 @@ reconciliation succeeds. ## Lifecycle at a glance -The implemented Plan A order is: **Create a New Program**, **Activate a -Generated Program**, **Before Production Modification**, **Prepare Review and -Diff Disposition**, then **Close a Final Program**. Creation publishes only the -proposal control plane. Activation uses one exact prompt and separate typed -receipts. Planning persists the execution baseline before product work. Review -uses a typed preparation transaction. The only Plan A diff disposition is -`accept-stop`. A final program derives its closure files from -`implementation-closure-storage/v1` and closes only after another exact prompt. +The implemented order is: **Create a New Program**, **Activate a Generated +Program**, **Before Production Modification**, **Prepare Review and Diff +Disposition**, **Dispose the Current Diff**, **Continue an Accepted Program** +when requested, **Authorize a Successor Increment** when one is allocated, +**Resolve a Blocked Program** when typed recovery is required, and **Close a +Final Program** when no successor remains. Creation and first-increment behavior +remain owned by Plan A. Plan B adds exact `accept-continue`, the distinct +`accepted-state-continuation` route, status-current successor grants through +`current_increment_authority_binding`, and prompt-bound `blocked-recovery`. +Final programs reuse the unchanged Plan A closure transaction and derive paths +from `implementation-closure-storage/v1`. Every typed transaction writes controlling status last and adopts only byte-identical prefixes. A divergent prefix stops for recovery without cleanup. @@ -100,9 +103,11 @@ before relying on an earlier state. ## Approval modes -Approval modes define policy, but the 0.1.1 Plan A persistence surface supports -only first-increment `accept-stop`; successor continuation remains deferred. -Modes do not grant action authority. +Approval modes define routine interruption policy. New-model typed dispositions +in version 0.1.2 always offer `accept-stop` and conditionally offer exact +`accept-continue` for one satisfied successor. Modes do not grant action authority +or automatic successor rollover. Legacy `approval:full` and +`approval:full-diff` modes retain their automatic acceptance behavior. | Mode | Scope | Routine plan pause | Diff acceptance | Continuation and mandatory stop | | --- | --- | --- | --- | --- | @@ -110,7 +115,7 @@ Modes do not grant action authority. | `approval:pre-approve` | One increment | No | User | Stops for user-owned decisions, program amendments, contradictions, hard stops, and diff acceptance | | `approval:full-increment` | One increment | No | User | Runs through verification, then stops for diff acceptance unless a hard stop occurs | | `approval:full-diff` | Legacy current increment | No | Legacy policy permits automatic acceptance only after verification and a valid bound packet | Cannot begin another increment | -| `approval:full` | Legacy current increment | No | Legacy policy permits automatic acceptance only after verification and a valid bound packet | Cannot begin another increment; discovery stops at `legacy-rollover-upgrade-required` | +| `approval:full` | Legacy current increment | No | Legacy policy permits automatic acceptance only after verification and a valid bound packet | Cannot begin another increment automatically; discovery stops at `legacy-rollover-upgrade-required` | New-program proposal construction, bootstrap, and launch reject `approval:full-diff` and `approval:full` before every write. Those modes are @@ -154,11 +159,14 @@ The workflow stops instead of guessing when it finds: - a user-owned decision or program amendment required by the selected mode; or - a requested Git, publication, provider, or external action outside the grant. -The implemented deferred-operation stops are -`legacy-rollover-upgrade-required`, `blocked-transaction-required`, -`program-revision-workflow-required`, and `unsupported-program-mutation`. -Accepted legacy programs and historical terminal records remain readable, but -those read paths do not reactivate a mutation sink. +Typed continuation and blocked recovery have their own exact prompts and +retry/recovery routes. `legacy-rollover-upgrade-required` still quarantines the +legacy rollover writer, and `blocked-transaction-required` still rejects generic +direct blocked edges. `program-revision-workflow-required` and +`unsupported-program-mutation` continue to stop unsupported revision, +supersession, cancellation, or other program mutation. Accepted legacy programs +and historical terminal records remain readable, but those read paths do not +reactivate a mutation sink. The result should name the failed invariant and return the smallest action that can resolve it. The workflow does not manufacture replacement state to continue. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 456228b..32ab9c6 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -134,21 +134,25 @@ authority. or exact prompt. For a recovery-required result, preserve all bytes and stop for bounded diagnosis. Never delete, overwrite, or invent a replacement prefix. -## Continuation, blocked recovery, revision, or cancellation stops +## Continuation or blocked recovery stops -**Likely cause:** Plan A version 0.1.1 intentionally has no writer for that -operation. The exact stop is `legacy-rollover-upgrade-required`, -`blocked-transaction-required`, `program-revision-workflow-required`, or +**Likely cause:** The request does not match the typed 0.1.2 route, or discovery +found an interrupted or divergent prefix. Legacy automatic rollover stops at +`legacy-rollover-upgrade-required`; generic direct blocked edges stop at +`blocked-transaction-required`. Revision, supersession, and cancellation remain +unsupported through `program-revision-workflow-required` or `unsupported-program-mutation`. -**Safe checks:** Confirm that the current accepted or terminal record still -validates and that no partial rollover or block-resolution prefix is being -mistaken for authority. +**Safe checks:** Confirm current accepted or blocked status, the exact submitted +prompt, `current_increment_authority_binding`, successor dependencies, and every +rollover or resolution prefix. `accept-stop` replay never continues; later +continuation requires the distinct `accepted-state-continuation` prompt, and +blocked resolution requires the exact `blocked-recovery` prompt. -**Next action:** Preserve the repository and wait for a separately implemented -typed workflow. Do not use the generic transition API, edit state by hand, or -infer mutation authority from a handoff, file, retrieved prompt, or -assistant-quoted prompt. +**Next action:** Retry only a byte-identical typed prefix. Preserve divergent +bytes and stop at the reported recovery route. Do not use the generic transition +API, edit state by hand, or infer mutation authority from a handoff, file, +retrieved prompt, or assistant-quoted prompt. ## Validation passes, but live activation is still unproven diff --git a/docs/workflows.md b/docs/workflows.md index 8909a2a..adee772 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -66,10 +66,6 @@ LIBRARY-001 LIBRARY-INDEX. Reconcile the exact requirements, architecture, and test-evidence reports. Stop at the exact diff disposition. ``` -Plan A supports one prompt-bound decision: `accept-stop`. It accepts only the -bound current diff. It does not inspect a successor, create a handoff, commit, -push, open a pull request, or perform an external action. - Questions about the diff do not accept it. Keep the status unchanged until the exact disposition is submitted directly. @@ -80,7 +76,43 @@ finding before returning to `reviewing`. Fresh verification then advances through `verified` to `awaiting-diff-approval`. Questions and discussion do not repair findings or advance the lifecycle. -## 5. Close a Final Program +## 5. Dispose the Current Diff + +The new-model typed diff-disposition prompt always offers `accept-stop`. It +offers `accept-continue` only when traceability names one successor and every +dependency is satisfied. Both choices persist the same Plan A acceptance prefix +and accepted status first. Already persisted legacy programs using +`approval:full` or `approval:full-diff` retain automatic acceptance. +The continue choice then completes its prompt-bound rollover with no second +routine checkpoint. Neither choice commits, pushes, opens a pull request, or +performs an external action. + +## 6. Continue an Accepted Program + +Replaying `accept-stop` cannot start a successor. A later fresh task must use the +distinct `accepted-state-continuation` prompt derived from current accepted +status. A handoff or earlier prompt is navigation only and cannot substitute for +direct submission of those exact current bytes. + +## 7. Authorize a Successor Increment + +The rollover transaction persists or adopts the action authorization, +successor grant, handoff, successor brief, and rollover record in order, then +writes status last. The successor status binds +`current_increment_authority_binding`; it does not rewrite the immutable +manifest or inherit genesis authority. The successor exact plan allocates its +own complete lifecycle paths before its baseline and write authority exist. + +## 8. Resolve a Blocked Program + +Only active `implementing` or `reviewing` state can enter the typed blocked +transaction. The sink derives prior state and controlling bindings before it +writes blocked status. Recovery uses the exact `blocked-recovery` prompt and +restores only the recorded prior states after its action and resolution records +are durable. Plan A's `reviewing -> remediating -> reviewing` path remains +separate. + +## 9. Close a Final Program Use this route only after the accepted increment is final and traceability allocates no successor. @@ -98,20 +130,21 @@ reconciliation, then the closure packet, and writes awaiting-closure status last. The exact closure prompt appends or adopts its approval and writes closed status last. Closure approval authorizes no commit or later action. -## Deferred routes and mandatory stops +## Unsupported routes and mandatory stops -Plan A version 0.1.1 does not persist successor rollover, blocked recovery, -program revision, supersession, or cancellation: +Version 0.1.2 adds typed successor rollover and blocked recovery while +preserving these sink guards: -- caller-authored successor rollover returns `legacy-rollover-upgrade-required`; -- direct blocked transitions return `blocked-transaction-required`; +- legacy automatic or caller-authored rollover returns + `legacy-rollover-upgrade-required`; +- generic direct blocked transitions return `blocked-transaction-required`; - revision or supersession returns `program-revision-workflow-required`; and - cancellation or another unsupported mutation returns `unsupported-program-mutation`. -Preserve the repository and partial evidence at these boundaries. A future -typed workflow may resume them; prose, a handoff, or an approval for a different -action cannot bypass the stop. +Preserve the repository and partial evidence at these boundaries. Prose, a +handoff, or an approval for a different action cannot bypass the stop. Final +programs continue through the unchanged Plan A closure transaction. ## Recovery from an interrupted transaction @@ -126,7 +159,9 @@ routine recovery step. - Program creation is not program activation. - Program activation is not implementation authority. - Plan approval is not action authorization. -- Diff acceptance is not continuation or commit authority. +- Diff acceptance is not continuation or commit authority unless the submitted + exact disposition is the bound `accept-continue` route; it never grants commit + authority. - Final-increment acceptance is not program closure. - Closure is not pull-request, merge, publication, deployment, destructive, or provider authority. diff --git a/implementing-staged-plans-bootstrap-execution-review-runbook.md b/implementing-staged-plans-bootstrap-execution-review-runbook.md index 04ee9e3..7abe157 100644 --- a/implementing-staged-plans-bootstrap-execution-review-runbook.md +++ b/implementing-staged-plans-bootstrap-execution-review-runbook.md @@ -1,8 +1,8 @@ # Implementing Staged Plans — Bootstrap, Execution, and Review Runbook -**Version boundary:** Plan A 0.1.1 -**Purpose:** Operate the implemented first-increment lifecycle without claiming -the deferred Plan B continuation, blocked recovery, or program revision routes. +**Version boundary:** Plan A 0.1.1 plus Plan B 0.1.2 +**Purpose:** Operate the implemented multi-increment lifecycle without claiming +unsupported program revision, supersession, or cancellation routes. This runbook supplements the canonical [`implementing-staged-plans` skill](skills/implementing-staged-plans/SKILL.md). @@ -72,21 +72,49 @@ their identities, scopes, findings, risk predicates, product-delta binding, and fresh final verification. It creates review evidence, then the review packet, then writes verified and awaiting-diff statuses in order. -Plan A exposes one exact disposition: +Every new-model typed exact disposition preserves the Plan A stop choice. +Already persisted legacy programs using `approval:full` or `approval:full-diff` +retain automatic acceptance: ```text accept-stop ``` -The prompt is independent of successor state. Direct submission appends or -adopts the exact diff approval and writes accepted status last. It never starts -another increment, creates successor navigation, stages or commits files, or -performs an external action. +When exactly one traceability successor is dependency-ready, the prompt also +offers `accept-continue`. Direct submission appends or adopts the exact diff +approval and writes accepted status last. Stop ends there. Continue completes +its bound rollover with no second routine checkpoint. Neither choice stages or +commits files or performs an external action. Questions and review feedback do not constitute acceptance. If a validated material defect is repaired, renew affected review and final verification before rendering another exact disposition. +## Continue an Accepted Program + +An accepted-stop status never continues by replay. A later fresh task uses the +distinct `accepted-state-continuation` prompt derived from current status and the +one canonical successor. The handoff is navigation only. Persist or adopt the +rollover action, successor grant, handoff, successor brief, rollover record, and +successor status in that order. Status last binds +`current_increment_authority_binding` and leaves the manifest byte-identical. + +## Authorize a Successor Increment + +Every successor repeats Plan A's exact-plan allocation and materialization +contract. The successor baseline uses the existing `inherited_paths` field only +for accepted product bytes proven by the canonical rollover chain and owned as +`Modify` or `Preserve`. It keeps those bytes separate from user-work baselines. +All first-increment and frozen 0.1.1 baselines retain `inherited_paths: []`. + +## Resolve a Blocked Program + +Typed block entry is legal only from `implementing` or `reviewing` and derives +the prior states and all controlling bindings at the sink. `remediating` stays +under Plan A's typed review lifecycle. Resolution requires the exact +`blocked-recovery` prompt, persists action then ledger, and restores only the +recorded prior states with status last. + ## Close a Final Program Closure is available only when the accepted increment is final and no successor @@ -119,15 +147,16 @@ After interruption: `*-recovery-required` stop; and - recovery never deletes, overwrites, substitutes, or silently skips a prefix. -## Deferred Plan B routes +## Unsupported routes -Plan A does not implement live successor rollover, blocked recovery, program -revision, supersession, or cancellation. +Version 0.1.2 implements typed successor rollover and blocked recovery. It does +not implement program revision, supersession, or cancellation, and it does not +reactivate legacy automatic rollover. | Requested operation | Mandatory result | | --- | --- | -| caller-authored successor rollover | `legacy-rollover-upgrade-required` | -| transition into or out of blocked | `blocked-transaction-required` | +| legacy automatic or caller-authored rollover | `legacy-rollover-upgrade-required` | +| generic transition into or out of blocked | `blocked-transaction-required` | | revise or supersede a live program | `program-revision-workflow-required` | | cancel or another unsupported mutation | `unsupported-program-mutation` | @@ -137,23 +166,26 @@ or invoke a generic transition to bypass the stop. ## Review checklist -Before accepting the Plan A candidate, verify: +Before accepting the 0.1.2 candidate, verify: 1. the source, branch, HEAD, workspace, and dirty-state bindings are current; 2. every product change appears in the exact plan and execution baseline; 3. user-owned work remains byte-identical; 4. required raw reviews, findings, dispositions, and final verification match the accepted delta; -5. `accept-stop` contains no continuation dependency or later authority; -6. closure is final-only and uses only manifest-derived paths; -7. interruption tests cover every durable write boundary; -8. deferred mutation calls preserve every repository byte; and -9. no test or static document check is described as proof of live provider, +5. `accept-stop` remains byte-compatible and `accept-continue` appears only for + one dependency-ready successor; +6. accepted-state continuation uses a distinct prompt and status-current grant; +7. blocked recovery restores only sink-recorded prior states; +8. Plan A closure is final-only and uses only manifest-derived paths; +9. interruption tests cover every durable write boundary; +10. unsupported mutation calls preserve every repository byte; and +11. no test or static document check is described as proof of live provider, deployment, accessibility, human-review, or production behavior. ## Verification commands -Run focused checks while implementing each task. On the final unchanged 0.1.1 +Run focused checks while implementing each task. On the final unchanged 0.1.2 candidate, run package validation and the full deterministic suite exactly once, then obtain one bounded independent material review. A review finding is repair authority only when the controller validates it as material and in scope. diff --git a/implementing-staged-plans-consolidated-design-plan-final.md b/implementing-staged-plans-consolidated-design-plan-final.md index 85de6eb..1f4702d 100644 --- a/implementing-staged-plans-consolidated-design-plan-final.md +++ b/implementing-staged-plans-consolidated-design-plan-final.md @@ -1,33 +1,34 @@ # Implementing Staged Plans — Consolidated Design Plan **Date:** 2026-08-07 -**Last amended:** 2026-08-18 — Plan A version 0.1.1 lifecycle alignment -**Status:** Historical consolidated design with the implemented Plan A boundary below +**Last amended:** 2026-08-19 — Plan B version 0.1.2 lifecycle alignment +**Status:** Historical consolidated design with the implemented 0.1.2 boundary below **Scope:** Architecture and behavior of the `implementing-staged-plans` skill. This is not an exact-file implementation plan for a specific repository. **Source basis:** Consolidates the strongest elements of the prior proposed design and copy-ready increment prompts, with a revised lean-prompt model. --- -## Implemented Plan A boundary +## Implemented 0.1.2 boundary -The current 0.1.1 implementation routes **Create a New Program**, **Activate a +The current implementation routes **Create a New Program**, **Activate a Generated Program**, **Before Production Modification**, **Prepare Review and -Diff Disposition**, and **Close a Final Program** in that order. Program genesis -publishes only an owner-bound control plane. Direct submission of one exact -launch prompt persists separate typed approval, workspace-selection, and grant -receipts. Exact planning owns an execution baseline. Review preparation is a -typed status-last transaction. Diff disposition is `accept-stop` and is -independent of any successor. Final closure derives its two paths from -`implementation-closure-storage/v1` and requires a separate exact approval. - -The 0.1.1 persistence boundary does not implement successor rollover, blocked -recovery, program revision, supersession, or cancellation. Those requests stop -at `legacy-rollover-upgrade-required`, `blocked-transaction-required`, -`program-revision-workflow-required`, or `unsupported-program-mutation`. +Diff Disposition**, **Dispose the Current Diff**, **Continue an Accepted +Program**, **Authorize a Successor Increment**, **Resolve a Blocked Program**, +and **Close a Final Program**. Plan A remains the owner of genesis, +first-increment planning, review, byte-compatible `accept-stop`, and closure. +Plan B adds conditional prompt-bound `accept-continue`, distinct +`accepted-state-continuation`, status-last successor rollover with +`current_increment_authority_binding`, successor `inherited_paths`, and exact +`blocked-recovery`. + +The legacy rollover writer remains quarantined at +`legacy-rollover-upgrade-required`, and generic blocked edges remain guarded by +`blocked-transaction-required`. Program revision, supersession, cancellation, +and other unsupported mutations still stop at +`program-revision-workflow-required` or `unsupported-program-mutation`. Handoffs and prompts remain navigation-only; direct user submission plus current -typed authority is the mutation boundary. Where the historical design below -describes a live continuation or blocked/revision writer, this implemented Plan -A boundary controls until Plan B replaces it. +typed authority is the mutation boundary. The historical design below is +descriptive only where it differs from this implemented boundary. ## 1. Purpose diff --git a/skills/implementing-staged-plans/SKILL.md b/skills/implementing-staged-plans/SKILL.md index ce0c625..f6f7029 100644 --- a/skills/implementing-staged-plans/SKILL.md +++ b/skills/implementing-staged-plans/SKILL.md @@ -1,11 +1,11 @@ --- name: implementing-staged-plans -description: Create, activate, or continue an approved implementation program through one reviewable increment and explicit final closure. Use when program genesis, lifecycle routing, invariant checks, or a currently authorized Plan A action is requested. +description: Create, activate, continue, or recover an approved implementation program through one or more reviewable increments and explicit final closure. Use when program genesis, lifecycle routing, invariant checks, successor rollover, or blocked recovery is requested. --- # Implementing Staged Plans -Create or advance only the next legal Plan A action for a repository-backed implementation program. Treat this skill as a front door: discover authority and current state, apply universal gates, and route honestly without claiming Plan B continuation, blocked recovery, or revision behavior. +Create or advance only the next legal action for a repository-backed implementation program. Treat this skill as a front door: discover authority and current state, apply universal gates, and route only to implemented typed procedures. ## Discover Existing Programs @@ -41,7 +41,25 @@ Materialize the execution baseline and action authorization before status become At reviewing state, use typed review preparation to load the three exact-plan-allocated raw reports, validate findings and risk predicates, bind the accepted product delta, create review evidence and the review packet, and persist verified then awaiting-diff status. Status is last at each boundary, and exact partial prefixes are retryable. -Plan A exposes only the exact `accept-stop` disposition. It is independent of successor discovery and cannot start continuation. Direct user submission appends or adopts the bound diff approval and writes accepted status last. Acceptance grants no closure, commit, push, pull request, publication, deployment, or external action. +Review preparation stops at the exact diff-disposition prompt. Questions or discussion do not accept the candidate. Acceptance grants no closure, commit, push, pull request, publication, deployment, or external action. + +## Dispose the Current Diff + +The exact `accept-stop` choice is always available for new-model typed dispositions and preserves Plan A bytes. Already persisted legacy programs using `approval:full` or `approval:full-diff` retain automatic acceptance. When traceability identifies exactly one successor whose dependencies are satisfied, the same prompt may also offer exact `accept-continue`. Direct submission persists or adopts the diff-acceptance prefix and accepted status first. `accept-continue` then completes its bound successor rollover with no second routine checkpoint; it does not grant any later or external action. + +## Continue an Accepted Program + +Replaying `accept-stop` only recovers or reports the same accepted-stop state. A later fresh task uses the distinct `accepted-state-continuation` prompt, derived from current accepted status and the one canonical successor. A handoff, brief, earlier prompt, approval mode, or accepted status alone never authorizes continuation. + +## Authorize a Successor Increment + +Validate the current accepted projection, canonical rollover chain, successor dependencies, accepted product bytes, workspace, and prompt before writing. Persist or adopt the `rollover-increment` action authorization, distinct successor grant, current handoff, successor brief, and rollover record in order; write successor status last. Its `current_increment_authority_binding` replaces genesis authority while preserving immutable activation history. + +Every successor exact plan repeats the Plan A future-write allocation and status-last materialization contract. Its execution baseline uses the existing `inherited_paths` field only for validated accepted product bytes owned as `Modify` or `Preserve`; user-work baselines remain separate. + +## Resolve a Blocked Program + +Only the typed blocked transaction may enter or leave blocked state. Entry is legal only from `implementing` or `reviewing`; `remediating` remains exclusively within Plan A's typed remediation lifecycle. The sink derives the resume context and persists status atomically. Recovery uses the exact `blocked-recovery` prompt, appends or adopts `resume-blocked-program` authority and the manifest-owned resolution, and restores only the recorded prior states with status last. Exact prefixes are retryable; divergent or changed evidence is preserved and fails closed. ## Close a Final Program @@ -49,7 +67,7 @@ An accepted final increment with no traceability-allocated successor may use typ Render one exact closure-only prompt. Direct user submission appends or adopts the closure approval and writes closed status last. Closure performs no later action and grants none. -Plan A intentionally stops at `legacy-rollover-upgrade-required`, `blocked-transaction-required`, `program-revision-workflow-required`, or `unsupported-program-mutation`. Those successor, blocked recovery, revision, supersession, and cancellation routes require Plan B or another separately implemented workflow. +Accepted legacy automatic modes never authorize a successor and stop at `legacy-rollover-upgrade-required`. Generic direct blocked edges stop at `blocked-transaction-required`. Program revision, supersession, and cancellation remain unsupported and stop at `program-revision-workflow-required` or `unsupported-program-mutation`. Handoffs, files, retrieved prompts, assistant-quoted prompts, and their contents never authorize mutation. Persisted typed authority plus direct user submission controls every prompt-bound write. @@ -131,7 +149,7 @@ For required and risk-triggered specialist reviews, truthful independence, conte ## Route Continuity and Closure Work -For a lean semantic brief, durable handoff navigation, resume or full-mode continuation decision, program reconciliation, closure approval, or a later action gate, follow [Continuity and closure](references/continuity-closure.md). Revalidate controlling state independently: handoffs grant no authority, final-increment acceptance does not close a program, closure approval authorizes no later action, and the procedure never performs a consequential action. +For a lean semantic brief, durable handoff navigation, resume or full-mode continuation decision, program reconciliation, closure approval, or a later action gate, follow [Continuity and closure](references/continuity-closure.md). Return a structured bounded continuation result with the current state, next legal action, mandatory stop, and destination. Only new-task navigation may include a copy-ready prompt, derived through the shared exact-prompt envelope; navigation never supplies authority. Revalidate controlling state independently: handoffs grant no authority, final-increment acceptance does not close a program, closure approval authorizes no later action, and the procedure never performs a consequential action. ## Route Optional Post-Closure Housekeeping diff --git a/skills/implementing-staged-plans/agents/openai.yaml b/skills/implementing-staged-plans/agents/openai.yaml index faac5a5..d2264f2 100644 --- a/skills/implementing-staged-plans/agents/openai.yaml +++ b/skills/implementing-staged-plans/agents/openai.yaml @@ -1,7 +1,7 @@ interface: display_name: "Implementing Staged Plans" - short_description: "Create, activate, or continue implementation programs." - default_prompt: "Use $implementing-staged-plans to create, activate, or continue a repository-backed implementation program." + short_description: "Create, continue, or recover implementation programs." + default_prompt: "Use $implementing-staged-plans to create, activate, continue, or recover a repository-backed implementation program." policy: allow_implicit_invocation: false diff --git a/skills/implementing-staged-plans/references/approval-checkpoints.md b/skills/implementing-staged-plans/references/approval-checkpoints.md index e693f40..5fcca8a 100644 --- a/skills/implementing-staged-plans/references/approval-checkpoints.md +++ b/skills/implementing-staged-plans/references/approval-checkpoints.md @@ -43,6 +43,8 @@ The `approval:pre-approve` and `approval:full-increment` modes omit only the exa The generic compound-checkpoint persistence API remains a legacy-program compatibility path. It rejects new-model manifests with `new-program-plan-materialization-required`; it cannot restore the former approval → status → action ordering. +Diff acceptance and successor rollover are not a compound routine checkpoint. The exact `accept-continue` prompt authorizes its bound acceptance and immediate rollover transaction; after accepted status is durable, rollover continues without another user question. A later accepted-stop continuation requires the distinct accepted-state prompt. Neither route authorizes a commit or consequential action. + Each file operation is atomic, but the sequence is not a multi-file transaction. Preserve partial records. New-model discovery reconstructs the controlling exact-plan transaction and classifies only byte-identical ordered prefixes as retry-ready. A divergent plan, approval, baseline, action record, or status returns the corresponding recovery-required stop. Never delete, roll back, duplicate, or silently skip a partial write. ## Preserve Narrow Gates diff --git a/skills/implementing-staged-plans/references/continuity-closure.md b/skills/implementing-staged-plans/references/continuity-closure.md index d746940..4b1f214 100644 --- a/skills/implementing-staged-plans/references/continuity-closure.md +++ b/skills/implementing-staged-plans/references/continuity-closure.md @@ -22,6 +22,12 @@ After accepted work, bind the handoff to the program revision, current increment The next legal action explains navigation only. It never renews conversational authority or grants a pull request, merge, release, deployment, migration, destructive, provider, or external-state action. +## Return Bounded Continuation Navigation + +Return every route as a structured bounded continuation result with the current state, one concrete next legal action, a mandatory-stop boolean, and a destination of `current-task`, `new-task`, or `none`. This result is navigation only: it creates no approval, action authorization, grant, status, or continuation receipt. + +Only `new-task` may carry a continuation command, and it must set the mandatory stop. Derive its copy-ready prompt at render time through the shared exact-prompt envelope so `$implementing-staged-plans` is the first line. Store no caller-authored Markdown prompt. A `current-task` or `none` result carries no command and renders no prompt. + ## Assess Conversation Suitability For automatic continuation, record evidence for each approved suitability predicate: program-part boundary, risk or architecture domain, workspace or base, superseded discussion, evidence or expertise, and lossless summary. A failed or missing predicate requires a durable handoff. @@ -36,11 +42,15 @@ Build renewed authority from the complete validated `ResumeContext`, the current Do not use handoff prose to repair a controlling mismatch. Return the first authority boundary and the smallest legal recovery action. -## Defer Rollover to the Continuation Upgrade +## Apply Prompt-Bound Successor Rollover + +Version `0.1.2` keeps the Plan A accept-stop bytes unchanged and adds two explicit successor routes. An immediate accept-and-continue prompt first persists the diff-acceptance prefix and then completes rollover without another user checkpoint. A later continuation from accepted-stop uses its own exact accepted-state prompt. Neither route derives authority from a handoff, brief, approval mode, or accepted status alone. + +The legacy caller-authored rollover writer remains quarantined at `legacy-rollover-upgrade-required`; accepted legacy automatic modes never grant successor authority. Only the typed prompt-bound routes below can persist new rollover bytes. -Version `0.1.1` does not apply a new-model rollover. The legacy caller-authored rollover surface stops before every write with `legacy-rollover-upgrade-required`. Keep an accepted current increment valid and preserve all existing bytes. +Validate the complete prompt, status-current projection, canonical successor, dependencies, workspace, and accepted product bytes before writing. Persist or adopt the `rollover-increment` authorization, distinct successor grant, current handoff, successor brief, and rollover record in that order; replace successor status last. Every durable prefix is discoverable and retryable with the same prompt. Divergent bytes are preserved and require the matching continuation recovery route. -Prompt-bound successor authority, managed rollover records, and retry-safe successor status belong to the separately approved Plan B version `0.1.2` upgrade. Do not infer them from a handoff, brief, approval mode, or accepted status while running Plan A. +Successor execution baselines use the existing `inherited_paths` field. Each inherited path must come from the canonical rollover chain, match the accepted product bytes, have exactly one baseline, be owned as `Modify` or `Preserve`, and remain separate from user-work baselines. First-increment baselines remain byte-compatible with `inherited_paths: []`. ## Reconcile a Program diff --git a/skills/implementing-staged-plans/references/execution-discipline.md b/skills/implementing-staged-plans/references/execution-discipline.md index 058d4ee..3b6177d 100644 --- a/skills/implementing-staged-plans/references/execution-discipline.md +++ b/skills/implementing-staged-plans/references/execution-discipline.md @@ -8,6 +8,8 @@ Load the manifest-owned current status, brief, preparation, exact-file plan, exe Before the execution baseline exists, repository dirt must equal the normalized launch observation. After it exists, validate product paths by disposition and lifecycle state: `authorized` permits no product delta; `implementing` permits any subset of declared Create and Modify work; `reviewing` and later require every declared Create path to exist and every declared Modify path to differ from its baseline while Preserve remains byte-identical. Reject new staged, conflicted, unmapped, deleted, unsafe, or changed user-owned paths. +For successor increments, validate every nonempty `inherited_paths` entry against the canonical rollover chain, matching accepted product bytes, and exact `Modify` or `Preserve` ownership. Inherited accepted history is not user-owned dirt and must not be merged into `user_work_baselines`. First-increment and frozen legacy baselines remain byte-compatible with `inherited_paths: []`. + ## Meaningful test-first evidence For behavioral work, write one focused test before production code and observe it fail nonzero for the intended missing behavior. Record a stable slice identifier, purpose, exact command, expected and observed failure, exit status, confirmation that RED preceded the production change, intended-reason match, focused GREEN command and result, and RED-before-GREEN ordering. A test that passes immediately, fails from a harness error, or records a different reason is not test-first evidence. diff --git a/skills/implementing-staged-plans/references/program-discovery.md b/skills/implementing-staged-plans/references/program-discovery.md index 3df0d42..86b06a0 100644 --- a/skills/implementing-staged-plans/references/program-discovery.md +++ b/skills/implementing-staged-plans/references/program-discovery.md @@ -14,8 +14,9 @@ Every repository, manifest, program root, and manifest-owned logical role must r - A new-program proposal at sequence zero uses proposal validation. An empty proposal returns `program-activation-ready`. An ordered program-approval, workspace-approval, or first-increment-grant prefix returns `program-activation-retry-ready`; an out-of-order, duplicate, unrelated, or divergent prefix fails closed. - Approved new-program status selects approved validation. Discovery inspects manifest-allocated plan, baseline, review, acceptance, closure, approval, authorization, and grant prefixes before applying generic state rejection. Exact controlled prefixes route to the corresponding `*-retry-ready` transaction; unsafe, malformed, unexpected, or state-incompatible artifacts stop without repair. - A new-model accepted status with an exact `accept-stop` binding returns `accepted-stop`. Closure files are a retryable preparation prefix. Complete closure preparation returns `closure-approval-ready`; an exact approval prefix returns `closure-approval-retry-ready`. Closed and superseded programs are non-controlling terminal history. -- A new-model rollover or block-resolution prefix is preserved and returns `continuation-recovery-required` or `blocked-recovery-required`. Plan A does not write or complete either deferred Plan B operation. -- Classify caller intent with `classify_requested_program_operation`. Plan A supports only `create`, `activate`, and `continue` routing. A live `revise` or `supersede` intent returns `program-revision-workflow-required`; `cancel` and every other mutation return `unsupported-program-mutation`. Classification is pure and always stops before any unsupported live write. +- A byte-exact immediate rollover prefix returns `increment-continuation-retry-ready` before navigation or `increment-rollover-retry-ready` after navigation begins. A later accepted-state prefix returns the corresponding `accepted-state-*` route. The same exact prompt adopts the prefix; divergence returns the domain-specific continuation recovery route. A completed successor status resumes normally. +- A valid sink-authored blocked status returns `blocked-recovery-ready` and reports only its recorded prior program and increment states. An exact resolution action or ledger prefix returns `blocked-resolution-retry-ready`; a completed resumed status returns ordinary `resume`. Malformed context, changed bound evidence, out-of-order records, or divergent prefix bytes return `blocked-recovery-required` without repair. +- Classify caller intent with `classify_requested_program_operation`. The implemented front door supports `create`, `activate`, and typed `continue` routing, including exact continuation and blocked-recovery prefixes. A live `revise` or `supersede` intent returns `program-revision-workflow-required`; `cancel` and every other mutation return `unsupported-program-mutation`. Classification is pure and always stops before any unsupported live write. - One valid legacy `active` or `blocked` program: select it, inspect its bound workspace afresh, validate program and state authority, and build resume expectations from the manifest, persisted status, and fresh observation. An accepted legacy automatic mode stops at `legacy-rollover-upgrade-required` before successor writes. Do not request the original documentation-plan path. - More than one valid `active` or `blocked` program: return sorted manifest candidates and stop for human selection. - Only legacy `closed` programs: return the sorted closed manifests and stop until the user states new-program or closed-program inspection intent. Only new-model `closed` or `superseded` programs return `terminal-programs` and require explicit new-program or terminal-inspection intent. diff --git a/skills/implementing-staged-plans/references/repository-preparation.md b/skills/implementing-staged-plans/references/repository-preparation.md index f094cd2..1ebc218 100644 --- a/skills/implementing-staged-plans/references/repository-preparation.md +++ b/skills/implementing-staged-plans/references/repository-preparation.md @@ -60,6 +60,8 @@ The plan contains exactly one `## File map`, followed by exactly one ordered `## For new-model programs, compare the parsed map with the manifest-derived lifecycle requirements before any plan, baseline, approval, authorization, review, acceptance, rollover, blocked-resolution, or closure sink writes. Final increments allocate closure files and no successor navigation. Nonfinal increments with one traceability successor allocate the current handoff and successor brief and no closure files. Allocation records ownership; it does not grant write authority. +Repeat this full allocation for every successor exact plan. A successor execution baseline may populate only the existing `inherited_paths` field, and only from a canonical rollover chain whose accepted product bytes match one-for-one with `Modify` or `Preserve` plan ownership. Keep inherited accepted history separate from pre-existing user-work baselines. First-increment baselines retain `inherited_paths: []`. + Reject a missing, symlinked, stale, digest-mismatched, or structurally incomplete plan. Before the first plan write, validate every manifest-derived future lifecycle allocation, every product path disposition, the status-current increment grant, and the complete execution-baseline and action-authorization candidates. Bind pre-existing user work separately so it cannot be claimed as Create or Modify. A content-valid plan is not write authority. ## Approval and Action Gate diff --git a/skills/implementing-staged-plans/references/state-authorization.md b/skills/implementing-staged-plans/references/state-authorization.md index 777bd2c..00475af 100644 --- a/skills/implementing-staged-plans/references/state-authorization.md +++ b/skills/implementing-staged-plans/references/state-authorization.md @@ -33,7 +33,7 @@ Approval modes control routine interruption, diff acceptance, and continuation o - Legacy `approval:full-diff` may accept one verified, packet-bound current-increment diff automatically; it does not continue to another increment. - Legacy `approval:full` may automatically accept one verified, packet-bound current-increment diff; it does not continue to another increment. -`approval:full-diff` and `approval:full` are dual-read compatibility modes for already persisted legacy programs only. New-model proposal construction, bootstrap, and launch reject either mode before every write. New programs accept only `approval:standard`, `approval:pre-approve`, or `approval:full-increment` and always stop at the typed `accept-stop` boundary in version 0.1.1. +`approval:full-diff` and `approval:full` are dual-read compatibility modes for already persisted legacy programs only. New-model proposal construction, bootstrap, and launch reject either mode before every write. New programs accept only `approval:standard`, `approval:pre-approve`, or `approval:full-increment`; version `0.1.2` continues only through an exact typed continuation prompt. For both legacy modes, automatic behavior ends with acceptance of the current increment. A successor requires the typed continuation route; neither legacy mode supplies successor authority. @@ -73,9 +73,15 @@ Before `apply_state_transition`, revalidate authority and provide a `TransitionR Legacy `implementation-program-status/v1` transitions keep their existing action-authorization contract. New v2 status is dual-read and records an explicit authority union. The exact approval-driven edges are program approval to active, standard-mode plan approval to authorized, diff approval to accepted, and closure approval to closed. Those governance transitions rely on the matching approved event and do not falsely claim `modify-workspace` authority. Every other declared state change still requires an exact live `modify-workspace` authorization. -New-model diff acceptance uses [`diff_disposition.py`](../scripts/diff_disposition.py), not the generic transition sink. Its acyclic base seed binds the prior status, review evidence and packet, final verification, exact plan, execution baseline, and accepted product delta. It derives the checkpoint, then approval event, then accepted status. The accepted `implementation-diff-disposition-binding/v1` excludes its own status digest and submitted-prompt digest. The exact prompt contains only **Accept and stop** in Plan A. Direct submission appends or adopts the prompt-bound diff approval, then replaces status last. It cannot inspect or start a successor. +New-model diff acceptance uses [`diff_disposition.py`](../scripts/diff_disposition.py), not the generic transition sink. Its acyclic base seed binds the prior status, review evidence and packet, final verification, exact plan, execution baseline, and accepted product delta. It derives the checkpoint, then approval event, then accepted status. The accepted `implementation-diff-disposition-binding/v1` excludes its own status digest and submitted-prompt digest. Accept-stop remains byte-compatible and grants no successor action. When one allocated successor has satisfied dependencies, the rendered disposition may also contain an exact accept-and-continue prompt. The front-door coordinator persists acceptance first and delegates its status-last successor suffix to [`program_rollover.py`](../scripts/program_rollover.py). -For a new-model manifest, `apply_state_transition` rejects generic diff acceptance with `typed-diff-disposition-required`. It rejects every direct transition into or out of `blocked` with `blocked-transaction-required`, and every direct supersession with `program-revision-workflow-required`, before general transition validation or persistence. Plan A has no typed blocked or revision writer. New-model closure likewise uses only the typed closure preparation and exact approval sinks. These guards do not change accepted legacy read validation or legacy state-transition compatibility. +Rollover registers `rollover-increment` as `explicit-local`. Its authorization, successor grant, handoff, successor brief, rollover record, and successor status form an ordered, retry-safe persistence sequence, with status last. Status retains immutable activation history, replaces the status-current grant with the distinct successor grant in `current_increment_authority_binding`, binds the canonical rollover and inherited workspace, and clears prior plan, execution-baseline, review, diff, and closure bindings. The manifest is never rewritten. + +For a new-model manifest, `apply_state_transition` rejects generic diff acceptance with `typed-diff-disposition-required`. It rejects every generic direct transition into or out of `blocked` with `blocked-transaction-required`, and every direct supersession with `program-revision-workflow-required`, before general transition validation or persistence. New-model closure likewise uses only the typed closure preparation and exact approval sinks. These guards do not change accepted legacy read validation or legacy state-transition compatibility. + +Typed blocked entry uses [`blocked_recovery.py`](../scripts/blocked_recovery.py). Only active `implementing` or `reviewing` state may enter; `remediating` and all plan, preapproval, final, accepted, terminal, or already-blocked states fail before candidate construction or writes. The sink derives the stable block identifier, prior states, status sequence and digest, current plan, baseline, grant, workspace and inherited-workspace bindings, ordered unique criteria, and safe plan-allocated evidence bytes. It atomically replaces status with both states blocked. Callers cannot supply a resume target or fabricated context. + +Resolution requires a transport-only candidate whose criteria exactly match the blocked context and are all satisfied. The exact prompt authorizes only `resume-blocked-program`, which is `explicit-local`. Its sink appends or adopts the action authorization, then the manifest-owned block-resolution record, and restores only the context-recorded prior states in status last. Exact prefixes are retryable; changed plan, baseline, grant, evidence, prompt, status, action, or ledger bytes are preserved and fail closed. This path does not intercept Plan A's typed `reviewing -> remediating -> reviewing` lifecycle. The transition must be a declared matrix edge and satisfy its conditional evidence gates. Blocked state may resume only to its recorded legal target. Terminal state has no same-entity outgoing edge. Starting another increment is a separate operation and requires renewed authority; conversation suitability and legacy approval modes do not supply it. diff --git a/skills/implementing-staged-plans/scripts/approval_checkpoint.py b/skills/implementing-staged-plans/scripts/approval_checkpoint.py index 9b2207c..9cb44eb 100644 --- a/skills/implementing-staged-plans/scripts/approval_checkpoint.py +++ b/skills/implementing-staged-plans/scripts/approval_checkpoint.py @@ -48,6 +48,8 @@ "modify-workspace": "routine-local", "run-local-verification": "routine-local", "create-workspace": "explicit-local", + "rollover-increment": "explicit-local", + "resume-blocked-program": "explicit-local", "create-local-commit": "explicit-local", "create-draft-pull-request": "bounded-external", "merge": "high-consequence", diff --git a/skills/implementing-staged-plans/scripts/blocked_recovery.py b/skills/implementing-staged-plans/scripts/blocked_recovery.py new file mode 100644 index 0000000..e340f9d --- /dev/null +++ b/skills/implementing-staged-plans/scripts/blocked_recovery.py @@ -0,0 +1,1145 @@ +#!/usr/bin/env python3 +"""Persist sink-derived blocked state and prompt-bound managed recovery.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from collections.abc import Mapping, Sequence +from dataclasses import asdict, dataclass +from pathlib import Path, PurePosixPath + +from program_activation import ( + _canonical_json_bytes, + _canonical_json_line, + _identifier, + _replace_or_adopt_status, + _without_owned_program_paths, +) +from program_authority import ( + NEW_PROGRAM_MANIFEST_SCHEMA, + load_json_lines, + load_json_object, + resolve_managed_path, + sha256_file, +) +from repository_preparation import ( + ExecutionBaseline, + REPOSITORY_INSPECTION_SCHEMA, + RepositoryInspection, + execution_baseline_from_value, + inspect_repository, + validate_execution_workspace, +) +from state_authority import ( + ACTION_AUTHORIZATION_SCHEMA, + RepositoryObservation, + StateTransitionReceipt, + atomic_append_json_line, + atomic_replace_json, + validate_state_authority, +) +from task_prompt import parse_exact_prompt, render_exact_prompt + + +BLOCKED_CONTEXT_SCHEMA = "implementation-blocked-context/v1" +BLOCK_RESOLUTION_RECORD_SCHEMA = "implementation-block-resolution/v1" +BLOCK_RESOLUTION_COMMAND_SCHEMA = "implementation-block-resolution-command/v1" +BLOCK_RESOLUTION_CANDIDATE_SCHEMA = "implementation-block-resolution-candidate/v1" +BLOCK_RESOLUTION_BINDING_SCHEMA = "implementation-block-resolution-binding/v1" + + +@dataclass(frozen=True) +class EvidenceBinding: + path: str + sha256: str + + +@dataclass(frozen=True) +class BlockedTransitionRequest: + reason_code: str + recovery_criteria: tuple[str, ...] + evidence_bindings: tuple[EvidenceBinding, ...] = () + + +@dataclass(frozen=True) +class BlockResolutionCandidate: + prompt: str + continuation_checkpoint_id: str + action_authorization_id: str + resolution_id: str + action_record: dict[str, object] + resolution_record: dict[str, object] + resumed_status: dict[str, object] + blocked_status_sha256: str + + +@dataclass(frozen=True) +class BlockedRecoveryInspection: + disposition: str | None + prior_program_state: str | None + prior_increment_state: str | None + completed_steps: tuple[str, ...] + issues: tuple[str, ...] + + +class _UsageError(ValueError): + pass + + +class _ArgumentParser(argparse.ArgumentParser): + def error(self, message: str) -> None: + raise _UsageError(message) + + +def _sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _fresh_observation( + root: Path, supplied: RepositoryObservation +) -> RepositoryObservation: + fresh = inspect_repository(Path(supplied.path), supplied.base_commit).observation + normalized_fresh = _without_owned_program_paths(root, fresh) + normalized_supplied = _without_owned_program_paths(root, supplied) + if asdict(normalized_fresh) != asdict(normalized_supplied): + raise ValueError("workspace observation changed before blocked transaction") + return normalized_fresh + + +def _load_manifest_status( + root: Path, +) -> tuple[dict[str, object], dict[str, object], Path]: + manifest, manifest_issues = load_json_object(root / "manifest.json") + if manifest is None: + raise ValueError("; ".join(manifest_issues)) + if manifest.get("schema_version") != NEW_PROGRAM_MANIFEST_SCHEMA: + raise ValueError("blocked recovery requires a new-model v2 manifest") + roles = manifest.get("logical_roles") + if not isinstance(roles, dict): + raise ValueError("manifest logical_roles must be an object") + status_path, status_path_issues = resolve_managed_path( + root, roles.get("status"), role="logical role status" + ) + if status_path is None: + raise ValueError("; ".join(status_path_issues)) + status, status_issues = load_json_object(status_path) + if status is None: + raise ValueError("; ".join(status_issues)) + return manifest, status, status_path + + +def _role_path(root: Path, manifest: Mapping[str, object], role: str) -> Path: + roles = manifest.get("logical_roles") + if not isinstance(roles, Mapping): + raise ValueError("manifest logical_roles must be an object") + path, issues = resolve_managed_path( + root, roles.get(role), role=f"logical role {role}" + ) + if path is None: + raise ValueError("; ".join(issues)) + return path + + +def _safe_relative_path(value: object) -> str: + if not isinstance(value, str) or not value or "\\" in value: + raise ValueError("evidence path must be a relative POSIX path") + path = PurePosixPath(value) + if ( + path.is_absolute() + or path.as_posix() != value + or any(part in {"", ".", ".."} for part in path.parts) + ): + raise ValueError(f"evidence path is unsafe: {value!r}") + return value + + +def _execution_contract( + root: Path, + manifest: Mapping[str, object], + status: Mapping[str, object], +) -> tuple[ + ExecutionBaseline, + dict[str, object], + dict[str, object], + dict[str, object], +]: + baseline_binding = status.get("execution_baseline_binding") + authority_binding = status.get("current_increment_authority_binding") + if not isinstance(baseline_binding, dict) or not isinstance( + authority_binding, dict + ): + raise ValueError("blocking requires execution baseline and current grant") + baseline_path, baseline_path_issues = resolve_managed_path( + root, + baseline_binding.get("path"), + role="blocked execution baseline", + ) + if baseline_path is None: + raise ValueError("; ".join(baseline_path_issues)) + if baseline_binding.get("sha256") != sha256_file(baseline_path): + raise ValueError("execution baseline digest mismatch") + baseline_value, baseline_issues = load_json_object(baseline_path) + if baseline_value is None: + raise ValueError("; ".join(baseline_issues)) + baseline = execution_baseline_from_value(baseline_value) + storage = manifest.get("increment_storage") + if not isinstance(storage, Mapping): + raise ValueError("manifest increment_storage must be an object") + plan_relative = ( + f"{storage.get('root')}/{status.get('current_increment_id')}/" + f"{storage.get('exact_file_plan_filename')}" + ) + plan_path, plan_issues = resolve_managed_path( + root, plan_relative, role="blocked exact-file plan" + ) + if plan_path is None: + raise ValueError("; ".join(plan_issues)) + expected_plan_sha256 = status.get("approved_exact_file_plan_sha256") + if expected_plan_sha256 != sha256_file(plan_path): + raise ValueError("exact-file plan digest mismatch") + workspace_path = _role_path(root, manifest, "workspace") + return ( + baseline, + {"path": plan_relative, "sha256": expected_plan_sha256}, + dict(baseline_binding), + {"path": workspace_path.relative_to(root).as_posix(), "sha256": sha256_file(workspace_path)}, + ) + + +def _validate_evidence_bindings( + workspace: Path, + baseline: ExecutionBaseline, + bindings: Sequence[EvidenceBinding], +) -> tuple[dict[str, str], ...]: + allowed = { + *baseline.file_map.create, + *baseline.file_map.modify, + *baseline.file_map.preserve, + } + normalized: list[dict[str, str]] = [] + seen: set[str] = set() + for binding in bindings: + if not isinstance(binding, EvidenceBinding): + raise ValueError("evidence bindings must use EvidenceBinding") + path = _safe_relative_path(binding.path) + if path in seen: + raise ValueError("blocked evidence paths must be unique") + seen.add(path) + if path not in allowed: + raise ValueError(f"blocked evidence path is not plan-allocated: {path}") + target = workspace / path + if target.is_symlink() or not target.is_file(): + raise ValueError(f"blocked evidence must already be a regular file: {path}") + if not re.fullmatch(r"[0-9a-f]{64}", binding.sha256): + raise ValueError(f"blocked evidence digest is invalid: {path}") + if sha256_file(target) != binding.sha256: + raise ValueError(f"blocked evidence bytes changed: {path}") + normalized.append({"path": path, "sha256": binding.sha256}) + return tuple(normalized) + + +def _validate_reason_and_criteria( + reason_code: object, recovery_criteria: object +) -> tuple[str, tuple[str, ...]]: + if not isinstance(reason_code, str) or re.fullmatch( + r"[a-z0-9]+(?:-[a-z0-9]+)*", reason_code + ) is None: + raise ValueError("blocked reason_code must be a lowercase hyphenated token") + if not isinstance(recovery_criteria, (list, tuple)): + raise ValueError("blocked recovery criteria must be an ordered collection") + criteria = tuple(recovery_criteria) + if ( + not criteria + or any( + not isinstance(item, str) + or not item.strip() + or item != item.strip() + or len(item) > 500 + for item in criteria + ) + or len(set(criteria)) != len(criteria) + ): + raise ValueError("blocked recovery criteria must be ordered, unique text") + return reason_code, criteria + + +def _build_blocked_context( + root: Path, + request: BlockedTransitionRequest, + observation: RepositoryObservation, + manifest: Mapping[str, object], + status: Mapping[str, object], + status_path: Path, +) -> dict[str, object]: + reason_code, criteria = _validate_reason_and_criteria( + request.reason_code, request.recovery_criteria + ) + baseline, plan_binding, baseline_binding, workspace_binding = ( + _execution_contract(root, manifest, status) + ) + evidence = _validate_evidence_bindings( + Path(observation.path), baseline, request.evidence_bindings + ) + seed = { + "schema_domain": BLOCKED_CONTEXT_SCHEMA, + "reason_code": reason_code, + "prior_program_state": status["program_state"], + "prior_increment_state": status["current_increment_state"], + "prior_status_sha256": sha256_file(status_path), + "prior_status_sequence": status["state_sequence"], + "current_increment_id": status["current_increment_id"], + "exact_file_plan_binding": plan_binding, + "execution_baseline_binding": baseline_binding, + "current_increment_authority_binding": status[ + "current_increment_authority_binding" + ], + "workspace_binding": workspace_binding, + "inherited_workspace_binding": status.get( + "inherited_workspace_binding" + ), + "recovery_criteria": list(criteria), + "evidence_bindings": list(evidence), + } + return { + "schema_version": BLOCKED_CONTEXT_SCHEMA, + "block_id": _identifier("program-block", seed), + **{key: value for key, value in seed.items() if key != "schema_domain"}, + } + + +def block_current_program( + program_root: Path, + request: BlockedTransitionRequest, + observation: RepositoryObservation, +) -> StateTransitionReceipt: + """Atomically enter blocked state from implementing or reviewing only.""" + root = Path(program_root) + manifest, status, status_path = _load_manifest_status(root) + state = status.get("current_increment_state") + if state == "remediating": + raise ValueError( + "remediating cannot enter blocked; use the typed remediation return" + ) + if status.get("program_state") != "active" or state not in { + "implementing", + "reviewing", + }: + raise ValueError("blocking requires active implementing or reviewing state") + normalized = _fresh_observation(root, observation) + authority_issues = validate_state_authority(root, normalized) + if authority_issues: + raise ValueError("; ".join(authority_issues)) + context = _build_blocked_context( + root, request, normalized, manifest, status, status_path + ) + prior_sha256 = sha256_file(status_path) + blocked = dict(status) + blocked.pop("block_resolution_binding", None) + blocked.update( + state_sequence=int(status["state_sequence"]) + 1, + program_state="blocked", + current_increment_state="blocked", + blocked_context=context, + previous_state={ + "schema_version": status["schema_version"], + "state_sequence": status["state_sequence"], + "status_sha256": prior_sha256, + }, + transition_authority={ + "kind": "blocked-context", + "event_id": context["block_id"], + }, + ) + receipt = atomic_replace_json(status_path, blocked, prior_sha256) + return StateTransitionReceipt( + prior_sha256=receipt.prior_sha256, + current_sha256=receipt.current_sha256, + state_sequence=int(blocked["state_sequence"]), + program_state="blocked", + increment_id=str(blocked["current_increment_id"]), + increment_state="blocked", + ) + + +def validate_blocked_context( + program_root: Path, + status: Mapping[str, object], + observation: RepositoryObservation, +) -> tuple[str, ...]: + """Validate one sink-authored blocked context and its live evidence.""" + root = Path(program_root) + issues: list[str] = [] + context = status.get("blocked_context") + expected_keys = { + "schema_version", + "block_id", + "reason_code", + "prior_program_state", + "prior_increment_state", + "prior_status_sha256", + "prior_status_sequence", + "current_increment_id", + "exact_file_plan_binding", + "execution_baseline_binding", + "current_increment_authority_binding", + "workspace_binding", + "inherited_workspace_binding", + "recovery_criteria", + "evidence_bindings", + } + if not isinstance(context, Mapping) or set(context) != expected_keys: + return ("blocked context shape is invalid",) + try: + _validate_reason_and_criteria( + context.get("reason_code"), context.get("recovery_criteria") + ) + manifest, manifest_issues = load_json_object(root / "manifest.json") + if manifest is None: + raise ValueError("; ".join(manifest_issues)) + baseline, plan_binding, baseline_binding, workspace_binding = ( + _execution_contract(root, manifest, status) + ) + evidence = tuple( + EvidenceBinding(path=item["path"], sha256=item["sha256"]) + for item in context["evidence_bindings"] + if isinstance(item, dict) + ) + validated_evidence = _validate_evidence_bindings( + Path(observation.path), baseline, evidence + ) + inspection = RepositoryInspection( + schema_version=REPOSITORY_INSPECTION_SCHEMA, + observation=observation, + git_directory="", + git_common_directory="", + selected_base_is_ancestor=True, + status_format="porcelain-v2-z", + ) + assessment = validate_execution_workspace( + root, + baseline, + inspection, + increment_state=str(context["prior_increment_state"]), + ) + if not assessment.valid: + issues.extend(assessment.issues) + except (KeyError, OSError, TypeError, ValueError) as error: + return (str(error),) + if status.get("program_state") != "blocked" or status.get( + "current_increment_state" + ) != "blocked": + issues.append("blocked context requires both controlling states blocked") + if context.get("schema_version") != BLOCKED_CONTEXT_SCHEMA: + issues.append("blocked context schema is invalid") + if context.get("prior_program_state") != "active" or context.get( + "prior_increment_state" + ) not in {"implementing", "reviewing"}: + issues.append("blocked context prior states are invalid") + if context.get("prior_status_sequence") != status.get("state_sequence", -1) - 1: + issues.append("blocked context prior sequence is invalid") + previous = status.get("previous_state") + if ( + not isinstance(previous, Mapping) + or previous.get("state_sequence") + != context.get("prior_status_sequence") + or previous.get("status_sha256") + != context.get("prior_status_sha256") + or not isinstance(context.get("prior_status_sha256"), str) + or re.fullmatch(r"[0-9a-f]{64}", context["prior_status_sha256"]) + is None + ): + issues.append("blocked context prior-status binding differs") + transition = status.get("transition_authority") + if ( + not isinstance(transition, Mapping) + or transition.get("kind") != "blocked-context" + or transition.get("event_id") != context.get("block_id") + ): + issues.append("blocked context transition authority differs") + if context.get("current_increment_id") != status.get("current_increment_id"): + issues.append("blocked context increment differs") + if context.get("exact_file_plan_binding") != plan_binding: + issues.append("blocked context exact-plan binding differs") + if context.get("execution_baseline_binding") != baseline_binding: + issues.append("blocked context execution-baseline binding differs") + if context.get("current_increment_authority_binding") != status.get( + "current_increment_authority_binding" + ): + issues.append("blocked context current-grant binding differs") + authority = status.get("current_increment_authority_binding") + if isinstance(authority, Mapping): + try: + grants, grant_issues = load_json_lines( + _role_path(root, manifest, "increment_grants") + ) + except (OSError, TypeError, ValueError) as error: + issues.append(str(error)) + else: + issues.extend(grant_issues) + matches = ( + [] + if grants is None + else [ + record + for record in grants + if record.get("grant_id") == authority.get("grant_id") + ] + ) + if ( + len(matches) != 1 + or _sha256_bytes(_canonical_json_line(matches[0])) + != authority.get("grant_sha256") + ): + issues.append("blocked context current grant bytes differ") + if context.get("workspace_binding") != workspace_binding: + issues.append("blocked context workspace binding differs") + if context.get("inherited_workspace_binding") != status.get( + "inherited_workspace_binding" + ): + issues.append("blocked context inherited-workspace binding differs") + if context.get("evidence_bindings") != list(validated_evidence): + issues.append("blocked context evidence inventory differs") + seed = { + "schema_domain": BLOCKED_CONTEXT_SCHEMA, + **{ + key: value + for key, value in context.items() + if key not in {"schema_version", "block_id"} + }, + } + if context.get("block_id") != _identifier("program-block", seed): + issues.append("blocked context identifier differs") + return tuple(sorted(set(issues))) + + +def blocked_workspace_paths( + program_root: Path, status: Mapping[str, object] +) -> tuple[str, ...]: + """Return plan-owned product paths only after context validation elsewhere.""" + root = Path(program_root) + manifest, manifest_issues = load_json_object(root / "manifest.json") + if manifest is None: + raise ValueError("; ".join(manifest_issues)) + baseline, _plan, _binding, _workspace = _execution_contract( + root, manifest, status + ) + return tuple(sorted({*baseline.file_map.create, *baseline.file_map.modify})) + + +def _candidate_value(value: object) -> dict[str, object]: + if not isinstance(value, dict) or set(value) != { + "schema_version", + "block_id", + "criterion_results", + "evidence_bindings", + }: + raise ValueError("block-resolution candidate shape is invalid") + if value.get("schema_version") != BLOCK_RESOLUTION_CANDIDATE_SCHEMA: + raise ValueError("block-resolution candidate schema is invalid") + return value + + +def build_block_resolution_candidate( + program_root: Path, + candidate_value: object, + observation: RepositoryObservation, +) -> BlockResolutionCandidate: + """Build every prompt-bound recovery byte before persistence.""" + root = Path(program_root) + normalized = _fresh_observation(root, observation) + _manifest, status, status_path = _load_manifest_status(root) + context_issues = validate_blocked_context(root, status, normalized) + if context_issues: + raise ValueError("; ".join(context_issues)) + value = _candidate_value(candidate_value) + context = status["blocked_context"] + if value["block_id"] != context["block_id"]: + raise ValueError("block-resolution candidate block_id differs") + results = value["criterion_results"] + evidence_bindings = value["evidence_bindings"] + if ( + not isinstance(results, list) + or not all( + isinstance(item, dict) + and set(item) == {"criterion", "satisfied"} + and type(item["criterion"]) is str + and type(item["satisfied"]) is bool + for item in results + ) + or not isinstance(evidence_bindings, list) + or not all( + isinstance(item, dict) + and set(item) == {"path", "sha256"} + and type(item["path"]) is str + and type(item["sha256"]) is str + for item in evidence_bindings + ) + ): + raise ValueError("candidate nested field types are invalid") + expected_results = [ + {"criterion": criterion, "satisfied": True} + for criterion in context["recovery_criteria"] + ] + if results != expected_results: + raise ValueError("every recovery criterion must be satisfied exactly once") + if evidence_bindings != context["evidence_bindings"]: + raise ValueError("resolution evidence must equal the blocked evidence inventory") + blocked_status_sha256 = sha256_file(status_path) + base_seed = { + "schema_domain": BLOCK_RESOLUTION_COMMAND_SCHEMA, + "block_id": context["block_id"], + "blocked_context_sha256": _sha256_bytes( + _canonical_json_bytes(dict(context)) + ), + "blocked_status_sha256": blocked_status_sha256, + "blocked_status_sequence": status["state_sequence"], + "criterion_results": results, + "evidence_bindings": evidence_bindings, + "prior_program_state": context["prior_program_state"], + "prior_increment_state": context["prior_increment_state"], + } + base_seed_sha256 = _sha256_bytes(_canonical_json_bytes(base_seed)) + checkpoint_id = _identifier( + "block-resolution-checkpoint", {"base_seed_sha256": base_seed_sha256} + ) + authorization_id = _identifier( + "block-resolution-action", + {"base_seed_sha256": base_seed_sha256, "checkpoint_id": checkpoint_id}, + ) + command = { + "schema_version": BLOCK_RESOLUTION_COMMAND_SCHEMA, + "base_seed_sha256": base_seed_sha256, + "checkpoint_id": checkpoint_id, + "action_authorization_id": authorization_id, + "block_id": context["block_id"], + "blocked_status_sha256": blocked_status_sha256, + "criterion_results": results, + "evidence_bindings": value["evidence_bindings"], + } + prompt = render_exact_prompt(command) + prompt_sha256 = _sha256_bytes(prompt.encode("utf-8")) + source = status["source_binding"] + program = status["program_binding"] + action_record = { + "schema_version": ACTION_AUTHORIZATION_SCHEMA, + "authorization_id": authorization_id, + "decision": "authorized", + "actions": ["resume-blocked-program"], + "scope": ["restore only the states recorded by the blocked context"], + "constraints": ["persist resolution evidence before resumed status"], + "excluded": [ + "create-local-commit", + "create-draft-pull-request", + "merge", + "publish", + "release", + "deploy", + "migrate", + "destructive-operation", + "modify-provider-state", + "modify-external-state", + ], + "program_id": status["program_id"], + "program_revision": status["program_revision"], + "source_id": source["source_id"], + "source_sha256": source["sha256"], + "program_sha256": program["sha256"], + "semantic_requirements_sha256": program[ + "semantic_requirements_sha256" + ], + "increment_id": status["current_increment_id"], + "block_id": context["block_id"], + "blocked_status_sha256": blocked_status_sha256, + "checkpoint_id": checkpoint_id, + "criterion_results": results, + "evidence_bindings": value["evidence_bindings"], + "submitted_prompt_sha256": prompt_sha256, + } + action_sha256 = _sha256_bytes(_canonical_json_line(action_record)) + resolution_id = _identifier( + "block-resolution", + { + "base_seed_sha256": base_seed_sha256, + "checkpoint_id": checkpoint_id, + "action_authorization_id": authorization_id, + "action_authorization_sha256": action_sha256, + }, + ) + resolution_record = { + "schema_version": BLOCK_RESOLUTION_RECORD_SCHEMA, + "resolution_id": resolution_id, + "block_id": context["block_id"], + "blocked_context_sha256": base_seed["blocked_context_sha256"], + "blocked_status_sha256": blocked_status_sha256, + "criterion_results": results, + "evidence_bindings": value["evidence_bindings"], + "submitted_prompt_sha256": prompt_sha256, + "action_authorization_id": authorization_id, + "action_authorization_sha256": action_sha256, + "prior_program_state": "blocked", + "prior_increment_state": "blocked", + "restored_program_state": context["prior_program_state"], + "restored_increment_state": context["prior_increment_state"], + } + resolution_sha256 = _sha256_bytes(_canonical_json_line(resolution_record)) + resumed = dict(status) + resumed.update( + state_sequence=int(status["state_sequence"]) + 1, + program_state=context["prior_program_state"], + current_increment_state=context["prior_increment_state"], + block_resolution_binding={ + "schema_version": BLOCK_RESOLUTION_BINDING_SCHEMA, + "block_id": context["block_id"], + "resolution_id": resolution_id, + "resolution_sha256": resolution_sha256, + "action_authorization_id": authorization_id, + "action_authorization_sha256": action_sha256, + "submitted_prompt_sha256": prompt_sha256, + "blocked_status_sha256": blocked_status_sha256, + "blocked_status_sequence": status["state_sequence"], + "restored_program_state": context["prior_program_state"], + "restored_increment_state": context["prior_increment_state"], + }, + previous_state={ + "schema_version": status["schema_version"], + "state_sequence": status["state_sequence"], + "status_sha256": blocked_status_sha256, + }, + transition_authority={ + "kind": "action-authorization", + "event_id": resolution_id, + "authorization_id": authorization_id, + "checkpoint_id": checkpoint_id, + }, + ) + return BlockResolutionCandidate( + prompt=prompt, + continuation_checkpoint_id=checkpoint_id, + action_authorization_id=authorization_id, + resolution_id=resolution_id, + action_record=action_record, + resolution_record=resolution_record, + resumed_status=resumed, + blocked_status_sha256=blocked_status_sha256, + ) + + +def _candidate_from_prompt( + program_root: Path, + submitted_prompt: str, + observation: RepositoryObservation, +) -> BlockResolutionCandidate: + command = parse_exact_prompt( + submitted_prompt, BLOCK_RESOLUTION_COMMAND_SCHEMA + ) + value = { + "schema_version": BLOCK_RESOLUTION_CANDIDATE_SCHEMA, + "block_id": command.get("block_id"), + "criterion_results": command.get("criterion_results"), + "evidence_bindings": command.get("evidence_bindings"), + } + candidate = build_block_resolution_candidate( + program_root, value, observation + ) + if candidate.prompt != submitted_prompt: + raise ValueError("submitted block-resolution prompt is stale") + return candidate + + +def render_block_resolution_prompt( + program_root: Path, + candidate_value: object, + observation: RepositoryObservation, +) -> str: + return build_block_resolution_candidate( + program_root, candidate_value, observation + ).prompt + + +def _append_or_adopt( + path: Path, + record: dict[str, object], + identifier_field: str, + label: str, +) -> bool: + records, issues = load_json_lines(path) + if records is None: + raise ValueError("; ".join(issues)) + matches = [ + item + for item in records + if item.get(identifier_field) == record.get(identifier_field) + ] + if matches: + if len(matches) != 1 or matches[0] != record: + raise ValueError(f"blocked-recovery-required: divergent {label}") + return True + atomic_append_json_line(path, record, sha256_file(path)) + return False + + +def _after_persist(_label: str) -> None: + """Test seam after each durable recovery step.""" + + +def validate_block_resolution_history( + program_root: Path, + status: Mapping[str, object], + observation: RepositoryObservation, +) -> tuple[str, ...]: + root = Path(program_root) + binding = status.get("block_resolution_binding") + if not isinstance(binding, Mapping): + return () + context = status.get("blocked_context") + if not isinstance(context, Mapping): + return ("resolved status requires its sink-authored blocked context",) + try: + manifest, manifest_issues = load_json_object(root / "manifest.json") + if manifest is None: + raise ValueError("; ".join(manifest_issues)) + resolutions, resolution_issues = load_json_lines( + _role_path(root, manifest, "block_resolutions") + ) + actions, action_issues = load_json_lines( + _role_path(root, manifest, "action_authorizations") + ) + if resolutions is None or actions is None: + raise ValueError("; ".join((*resolution_issues, *action_issues))) + resolution_matches = [ + item + for item in resolutions + if item.get("resolution_id") == binding.get("resolution_id") + ] + action_matches = [ + item + for item in actions + if item.get("authorization_id") + == binding.get("action_authorization_id") + ] + except (KeyError, OSError, TypeError, ValueError) as error: + return (str(error),) + issues: list[str] = [] + if ( + binding.get("schema_version") != BLOCK_RESOLUTION_BINDING_SCHEMA + or binding.get("block_id") != context.get("block_id") + or binding.get("restored_program_state") + != context.get("prior_program_state") + or binding.get("restored_increment_state") + != context.get("prior_increment_state") + ): + issues.append("block-resolution status binding is invalid") + if len(resolution_matches) != 1: + issues.append("status-current block resolution must exist exactly once") + else: + record = resolution_matches[0] + if ( + record.get("schema_version") != BLOCK_RESOLUTION_RECORD_SCHEMA + or _sha256_bytes(_canonical_json_line(record)) + != binding.get("resolution_sha256") + or record.get("block_id") != binding.get("block_id") + or record.get("action_authorization_id") + != binding.get("action_authorization_id") + or record.get("action_authorization_sha256") + != binding.get("action_authorization_sha256") + or record.get("submitted_prompt_sha256") + != binding.get("submitted_prompt_sha256") + or record.get("restored_program_state") + != binding.get("restored_program_state") + or record.get("restored_increment_state") + != binding.get("restored_increment_state") + ): + issues.append("block-resolution ledger binding differs") + if len(action_matches) != 1: + issues.append("block-resolution action must exist exactly once") + elif ( + action_matches[0].get("schema_version") != ACTION_AUTHORIZATION_SCHEMA + or action_matches[0].get("actions") != ["resume-blocked-program"] + or _sha256_bytes(_canonical_json_line(action_matches[0])) + != binding.get("action_authorization_sha256") + ): + issues.append("block-resolution action binding differs") + return tuple(sorted(set(issues))) + + +def _completed_receipt( + root: Path, + status: Mapping[str, object], + submitted_prompt: str, + observation: RepositoryObservation, +) -> StateTransitionReceipt | None: + binding = status.get("block_resolution_binding") + if not isinstance(binding, Mapping): + return None + if binding.get("submitted_prompt_sha256") != _sha256_bytes( + submitted_prompt.encode("utf-8") + ): + raise ValueError("blocked-recovery-required: submitted prompt differs") + issues = validate_block_resolution_history(root, status, observation) + if issues: + raise ValueError("; ".join(issues)) + status_path = _load_manifest_status(root)[2] + return StateTransitionReceipt( + prior_sha256=str(binding["blocked_status_sha256"]), + current_sha256=sha256_file(status_path), + state_sequence=int(status["state_sequence"]), + program_state=str(status["program_state"]), + increment_id=str(status["current_increment_id"]), + increment_state=str(status["current_increment_state"]), + ) + + +def persist_blocked_resolution( + program_root: Path, + submitted_prompt: str, + observation: RepositoryObservation, +) -> StateTransitionReceipt: + """Append/adopt action and resolution before restoring status.""" + root = Path(program_root) + normalized = _fresh_observation(root, observation) + manifest, status, status_path = _load_manifest_status(root) + completed = _completed_receipt(root, status, submitted_prompt, normalized) + if completed is not None: + authority_issues = validate_state_authority(root, normalized) + if authority_issues: + raise ValueError("; ".join(authority_issues)) + return completed + candidate = _candidate_from_prompt(root, submitted_prompt, normalized) + action_path = _role_path(root, manifest, "action_authorizations") + resolution_path = _role_path(root, manifest, "block_resolutions") + _append_or_adopt( + action_path, + candidate.action_record, + "authorization_id", + "block-resolution action", + ) + _after_persist("action-authorization") + _append_or_adopt( + resolution_path, + candidate.resolution_record, + "resolution_id", + "block-resolution record", + ) + _after_persist("resolution-record") + _replace_or_adopt_status( + status_path, + candidate.resumed_status, + candidate.blocked_status_sha256, + "blocked-resolution", + ) + _after_persist("resumed-status") + authority_issues = validate_state_authority( + root, _fresh_observation(root, normalized) + ) + if authority_issues: + raise ValueError("; ".join(authority_issues)) + return StateTransitionReceipt( + prior_sha256=candidate.blocked_status_sha256, + current_sha256=sha256_file(status_path), + state_sequence=int(candidate.resumed_status["state_sequence"]), + program_state=str(candidate.resumed_status["program_state"]), + increment_id=str(candidate.resumed_status["current_increment_id"]), + increment_state=str(candidate.resumed_status["current_increment_state"]), + ) + + +def inspect_blocked_recovery( + program_root: Path, + observation: RepositoryObservation, +) -> BlockedRecoveryInspection: + """Classify exact blocked and resolution prefixes without writes.""" + root = Path(program_root) + try: + manifest, status, _status_path = _load_manifest_status(root) + except (OSError, TypeError, ValueError) as error: + return BlockedRecoveryInspection(None, None, None, (), (str(error),)) + context = status.get("blocked_context") + if isinstance(status.get("block_resolution_binding"), Mapping): + issues = validate_block_resolution_history(root, status, observation) + binding = status["block_resolution_binding"] + is_just_resumed = ( + status.get("state_sequence") + == binding.get("blocked_status_sequence", -1) + 1 + and status.get("program_state") + == binding.get("restored_program_state") + and status.get("current_increment_state") + == binding.get("restored_increment_state") + ) + return BlockedRecoveryInspection( + ( + "resume" + if not issues and is_just_resumed + else "blocked-recovery-required" + if issues + else None + ), + str(binding.get("restored_program_state")), + str(binding.get("restored_increment_state")), + ("resumed-status",), + issues, + ) + if status.get("program_state") != "blocked" or status.get( + "current_increment_state" + ) != "blocked": + return BlockedRecoveryInspection(None, None, None, (), ()) + context_issues = validate_blocked_context(root, status, observation) + if context_issues or not isinstance(context, Mapping): + return BlockedRecoveryInspection( + "blocked-recovery-required", None, None, (), context_issues + ) + actions, action_issues = load_json_lines( + _role_path(root, manifest, "action_authorizations") + ) + resolutions, resolution_issues = load_json_lines( + _role_path(root, manifest, "block_resolutions") + ) + if actions is None or resolutions is None: + return BlockedRecoveryInspection( + "blocked-recovery-required", + str(context["prior_program_state"]), + str(context["prior_increment_state"]), + (), + tuple((*action_issues, *resolution_issues)), + ) + matching_actions = [ + record + for record in actions + if record.get("actions") == ["resume-blocked-program"] + and record.get("block_id") == context.get("block_id") + ] + matching_resolutions = [ + record + for record in resolutions + if record.get("block_id") == context.get("block_id") + ] + if not matching_actions and not matching_resolutions: + return BlockedRecoveryInspection( + "blocked-recovery-ready", + str(context["prior_program_state"]), + str(context["prior_increment_state"]), + (), + (), + ) + if len(matching_actions) != 1: + return BlockedRecoveryInspection( + "blocked-recovery-required", None, None, (), ("ambiguous resolution action",) + ) + action = matching_actions[0] + candidate_value = { + "schema_version": BLOCK_RESOLUTION_CANDIDATE_SCHEMA, + "block_id": context["block_id"], + "criterion_results": action.get("criterion_results"), + "evidence_bindings": action.get("evidence_bindings"), + } + try: + candidate = build_block_resolution_candidate( + root, candidate_value, observation + ) + except (KeyError, OSError, TypeError, ValueError) as error: + return BlockedRecoveryInspection( + "blocked-recovery-required", None, None, (), (str(error),) + ) + if action != candidate.action_record: + return BlockedRecoveryInspection( + "blocked-recovery-required", None, None, (), ("divergent resolution action",) + ) + steps = ["action-authorization"] + if matching_resolutions: + if ( + len(matching_resolutions) != 1 + or matching_resolutions[0] != candidate.resolution_record + ): + return BlockedRecoveryInspection( + "blocked-recovery-required", None, None, tuple(steps), ("divergent resolution record",) + ) + steps.append("resolution-record") + return BlockedRecoveryInspection( + "blocked-resolution-retry-ready", + str(context["prior_program_state"]), + str(context["prior_increment_state"]), + tuple(steps), + (), + ) + + +def _selected_observation(root: Path) -> RepositoryObservation: + manifest, _status, _status_path = _load_manifest_status(root) + workspace_path = _role_path(root, manifest, "workspace") + workspace, issues = load_json_object(workspace_path) + if workspace is None: + raise ValueError("; ".join(issues)) + selected = workspace.get("implementation_workspace") + if not isinstance(selected, Mapping): + raise ValueError("workspace selection is incomplete") + return _without_owned_program_paths( + root, + inspect_repository( + Path(str(selected["path"])), str(selected["base_commit"]) + ).observation, + ) + + +def build_argument_parser() -> argparse.ArgumentParser: + parser = _ArgumentParser(prog="blocked_recovery.py") + subparsers = parser.add_subparsers(dest="command", required=True) + render_parser = subparsers.add_parser("render") + render_parser.add_argument("program_root") + render_parser.add_argument("--candidate-file", required=True) + apply_parser = subparsers.add_parser("apply") + apply_parser.add_argument("program_root") + apply_parser.add_argument("--prompt-file", required=True) + apply_parser.add_argument("--repository", required=True) + apply_parser.add_argument("--base-commit", required=True) + return parser + + +def _regular_file(path_value: str, label: str) -> Path: + path = Path(path_value) + if path.is_symlink() or not path.is_file(): + raise ValueError(f"{label} must be a regular non-symlink file") + return path + + +def main(argv: Sequence[str] | None = None) -> int: + try: + arguments = build_argument_parser().parse_args( + list(sys.argv[1:] if argv is None else argv) + ) + root = Path(arguments.program_root) + if arguments.command == "render": + candidate_path = _regular_file( + arguments.candidate_file, "candidate file" + ) + value = json.loads(candidate_path.read_text(encoding="utf-8")) + prompt = render_block_resolution_prompt( + root, value, _selected_observation(root) + ) + sys.stdout.write(prompt) + return 0 + prompt_path = _regular_file(arguments.prompt_file, "prompt file") + observation = inspect_repository( + Path(arguments.repository), arguments.base_commit + ).observation + receipt = persist_blocked_resolution( + root, prompt_path.read_text(encoding="utf-8"), observation + ) + except ( + _UsageError, + json.JSONDecodeError, + KeyError, + OSError, + TypeError, + ValueError, + ) as error: + print(str(error), file=sys.stderr) + return 2 if isinstance(error, _UsageError) else 1 + print(json.dumps(asdict(receipt), sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/implementing-staged-plans/scripts/continuity_closure.py b/skills/implementing-staged-plans/scripts/continuity_closure.py index e0c2cbd..2dc8ff6 100644 --- a/skills/implementing-staged-plans/scripts/continuity_closure.py +++ b/skills/implementing-staged-plans/scripts/continuity_closure.py @@ -17,6 +17,7 @@ approval_mode_policy, validate_state_authority, ) +from task_prompt import render_exact_prompt CONTINUITY_SCHEMA = "implementation-continuity-evidence/v1" @@ -24,6 +25,7 @@ RECONCILIATION_SCHEMA = "implementation-closure-reconciliation/v1" CLOSURE_PACKET_SCHEMA = "implementation-closure-packet/v1" EXPLICIT_SKILL_INVOCATION = "$implementing-staged-plans" +CONTINUATION_DESTINATIONS = frozenset({"current-task", "new-task", "none"}) APPROVAL_MODES = frozenset(APPROVAL_MODE_POLICIES) CONVERSATION_SUITABILITY_PREDICATES = ( @@ -130,6 +132,15 @@ class HandoffRecord: first_read_files: tuple[str, ...] +@dataclass(frozen=True) +class BoundedContinuationResult: + current_state: str + next_legal_action: str + mandatory_stop: bool + destination: str + continuation_command: Mapping[str, object] | None + + @dataclass(frozen=True) class ConversationAssessment: schema_version: str @@ -596,6 +607,146 @@ def render_handoff(candidate: HandoffRecord) -> str: ) +def build_bounded_continuation_result( + current_state: str, + next_legal_action: str, + *, + destination: str, + mandatory_stop: bool, + continuation_command: Mapping[str, object] | None = None, +) -> BoundedContinuationResult: + """Build one navigation-only result without storing rendered prompt text.""" + candidate = BoundedContinuationResult( + current_state=current_state, + next_legal_action=next_legal_action, + mandatory_stop=mandatory_stop, + destination=destination, + continuation_command=continuation_command, + ) + issues = validate_bounded_continuation_result(candidate) + if issues: + raise ValueError("; ".join(issues)) + return candidate + + +def _bounded_continuation_issues( + candidate: BoundedContinuationResult, +) -> list[str]: + issues: list[str] = [] + for label, value in ( + ("current_state", candidate.current_state), + ("next_legal_action", candidate.next_legal_action), + ): + if not _nonempty(value) or "\n" in value or "\r" in value: + issues.append(f"bounded continuation {label} must be non-empty single-line text") + elif _SECRET_LIKE.search(value): + issues.append(f"bounded continuation {label} contains secret-like material") + if isinstance(candidate.next_legal_action, str) and _AUTHORIZING_TEXT.search( + candidate.next_legal_action + ): + issues.append("bounded continuation navigation cannot authorize an action") + if candidate.destination not in CONTINUATION_DESTINATIONS: + issues.append("bounded continuation destination is unsupported") + if not isinstance(candidate.mandatory_stop, bool): + issues.append("bounded continuation mandatory_stop must be a boolean") + if candidate.destination == "new-task": + if candidate.mandatory_stop is not True: + issues.append("new-task continuation requires a mandatory stop") + if not isinstance(candidate.continuation_command, Mapping): + issues.append("new-task continuation requires one command mapping") + elif candidate.continuation_command is not None: + issues.append("only a new-task continuation may carry a command") + return issues + + +def validate_bounded_continuation_result( + candidate: BoundedContinuationResult, +) -> list[str]: + """Validate bounded navigation and its derived exact-prompt command.""" + issues = _bounded_continuation_issues(candidate) + if not issues and candidate.destination == "new-task": + try: + prompt = render_exact_prompt(candidate.continuation_command) + except ValueError as error: + issues.append(f"bounded continuation command is invalid: {error}") + else: + if prompt.splitlines()[0] != EXPLICIT_SKILL_INVOCATION: + issues.append("bounded continuation prompt has an invalid first line") + return sorted(set(issues)) + + +def render_bounded_continuation_result( + candidate: BoundedContinuationResult, +) -> str: + """Render deterministic navigation and derive a new-task prompt once.""" + issues = _bounded_continuation_issues(candidate) + if issues: + raise ValueError("; ".join(sorted(set(issues)))) + lines = ( + f"Current state: {candidate.current_state}", + f"Next legal action: {candidate.next_legal_action}", + f"Mandatory stop: {'yes' if candidate.mandatory_stop else 'no'}", + f"Destination: {candidate.destination}", + ) + rendered = "\n".join(lines) + "\n" + if candidate.destination != "new-task": + return rendered + try: + prompt = render_exact_prompt(candidate.continuation_command) + except ValueError as error: + raise ValueError(f"bounded continuation command is invalid: {error}") from error + if prompt.splitlines()[0] != EXPLICIT_SKILL_INVOCATION: + raise ValueError("bounded continuation prompt has an invalid first line") + return f"{rendered}\nCopy-ready prompt:\n\n{prompt}" + + +def select_unique_satisfied_successor( + atomic_requirements: Sequence[Mapping[str, object]], + current_increment_id: str, + accepted_increment_ids: set[str] | frozenset[str], +) -> tuple[str | None, str]: + """Select one directly allocated successor whose dependencies are accepted.""" + if not _nonempty(current_increment_id): + raise ValueError("current increment id is required") + if not isinstance(accepted_increment_ids, (set, frozenset)) or not all( + _nonempty(item) for item in accepted_increment_ids + ): + raise ValueError("accepted increment ids must be a string set") + normalized: list[tuple[str, ...]] = [] + candidates: set[str] = set() + for requirement in atomic_requirements: + if not isinstance(requirement, Mapping): + raise ValueError("atomic requirement must be an object") + assigned = requirement.get("assigned_increments") + if ( + not isinstance(assigned, list) + or not assigned + or not all(_nonempty(item) for item in assigned) + or len(assigned) != len(set(assigned)) + ): + raise ValueError( + "atomic requirement assigned_increments must be unique strings" + ) + allocation = tuple(assigned) + normalized.append(allocation) + if current_increment_id in allocation: + successor_index = allocation.index(current_increment_id) + 1 + if successor_index < len(allocation): + candidates.add(allocation[successor_index]) + if not candidates: + return None, "no allocated successor" + if len(candidates) != 1: + return None, "multiple allocated successors" + successor = next(iter(candidates)) + for allocation in normalized: + if successor not in allocation: + continue + dependencies = allocation[: allocation.index(successor)] + if any(item not in accepted_increment_ids for item in dependencies): + return None, "successor dependencies are unsatisfied" + return successor, "" + + def evaluate_continuation(candidate: ConversationAssessment) -> tuple[bool, tuple[str, ...]]: issues: list[str] = [] if candidate.schema_version != CONTINUITY_SCHEMA: diff --git a/skills/implementing-staged-plans/scripts/diff_disposition.py b/skills/implementing-staged-plans/scripts/diff_disposition.py index 52286f7..95a53be 100644 --- a/skills/implementing-staged-plans/scripts/diff_disposition.py +++ b/skills/implementing-staged-plans/scripts/diff_disposition.py @@ -68,9 +68,11 @@ def _status_prior( if ( state != "accepted" or not isinstance(binding, dict) - or binding.get("decision") != "accept-stop" + or binding.get("decision") not in {"accept-stop", "accept-continue"} ): - raise ValueError("accept-stop requires awaiting-diff-approval or its exact accepted status") + raise ValueError( + "diff disposition requires awaiting-diff-approval or its exact accepted status" + ) return str(binding["prior_status_sha256"]), int(binding["prior_status_sequence"]) @@ -92,7 +94,9 @@ def build_diff_acceptance_candidate( root = Path(program_root) normalized = _fresh_observation(root, observation) state_issues = validate_state_authority(root, normalized) - if state_issues: + if state_issues and state_issues != [ + "accepted status diff disposition binding is invalid" + ]: raise ValueError("; ".join(state_issues)) manifest, manifest_issues = load_json_object(root / "manifest.json") if manifest is None: @@ -108,6 +112,14 @@ def build_diff_acceptance_candidate( status, status_issues = load_json_object(status_path) if status is None: raise ValueError("; ".join(status_issues)) + if state_issues: + binding = status.get("diff_disposition_binding") + if ( + status.get("current_increment_state") != "accepted" + or not isinstance(binding, dict) + or binding.get("decision") != "accept-continue" + ): + raise ValueError("; ".join(state_issues)) prior_status_sha256, prior_sequence = _status_prior(status_path, status) evidence_binding = status.get("review_evidence_binding") packet_binding = status.get("review_packet_binding") @@ -247,7 +259,7 @@ def build_diff_acceptance_candidate( def render_diff_disposition_prompt(program_root: Path) -> str: - """Render the one currently supported diff decision.""" + """Render stop plus one optional uniquely bound continuation choice.""" root = Path(program_root) manifest, manifest_issues = load_json_object(root / "manifest.json") if manifest is None: @@ -266,7 +278,21 @@ def render_diff_disposition_prompt(program_root: Path) -> str: Path(selected["path"]), selected["base_commit"] ).observation candidate = build_diff_acceptance_candidate(root, observation) - return "Accept and stop.\n\n" + candidate.prompt + stop_prompt = "Accept and stop.\n\n" + candidate.prompt + extension = _continuation.build_continuation_extension( + root, candidate, observation + ) + if extension is None: + reason = _continuation.continuation_unavailability_reason(root, candidate) + if reason == "no allocated successor": + return stop_prompt + return f"{stop_prompt}\nContinuation unavailable: {reason}.\n" + continued = _continuation.build_accept_continue_candidate(candidate, extension) + return ( + f"{stop_prompt}\n" + f"Accept and continue to `{extension.successor_increment_id}`.\n\n" + f"{continued.prompt}" + ) def _append_or_adopt_approval( @@ -353,3 +379,114 @@ def persist_accept_stop( status_sha256=sha256_file(status_path), recovered=recovered, ) + + +def _persist_diff_acceptance_prefix( + program_root: Path, + submitted_prompt: str, + observation: RepositoryObservation, +) -> DiffDispositionReceipt: + """Persist either exact stop or the acceptance prefix of exact continue.""" + root = Path(program_root) + acceptance = build_diff_acceptance_candidate(root, observation) + stop_prompt = "Accept and stop.\n\n" + acceptance.prompt + if submitted_prompt == stop_prompt: + return persist_accept_stop(root, submitted_prompt, observation) + extension = _continuation.build_continuation_extension( + root, acceptance, observation + ) + if extension is None: + reason = _continuation.continuation_unavailability_reason(root, acceptance) + rendered_stop_only = ( + f"{stop_prompt}\nContinuation unavailable: {reason}.\n" + ) + if submitted_prompt == rendered_stop_only: + return persist_accept_stop(root, stop_prompt, observation) + raise ValueError("submitted diff disposition prompt does not match current bytes") + candidate = _continuation.build_accept_continue_candidate(acceptance, extension) + expected_prompt = ( + f"Accept and continue to `{extension.successor_increment_id}`.\n\n" + f"{candidate.prompt}" + ) + if submitted_prompt != expected_prompt: + raise ValueError("submitted diff disposition prompt does not match current bytes") + parse_exact_prompt(candidate.prompt, DIFF_DISPOSITION_COMMAND_SCHEMA) + manifest, manifest_issues = load_json_object(root / "manifest.json") + if manifest is None: + raise ValueError("; ".join(manifest_issues)) + roles = manifest["logical_roles"] + approvals_path, approval_issues = resolve_managed_path( + root, roles["approvals"], role="logical role approvals" + ) + status_path, status_issues = resolve_managed_path( + root, roles["status"], role="logical role status" + ) + if approvals_path is None or status_path is None: + raise ValueError("; ".join([*approval_issues, *status_issues])) + recovered = _append_or_adopt_approval(approvals_path, candidate) + _after_persist("diff-approval") + binding = candidate.accepted_status["diff_disposition_binding"] + recovered = ( + _replace_or_adopt_status( + status_path, + candidate.accepted_status, + str(binding["prior_status_sha256"]), + "increment-acceptance", + ) + or recovered + ) + _after_persist("accepted-status") + if status_path.read_bytes() != candidate.accepted_status_bytes: + raise ValueError("increment-acceptance-recovery-required: accepted status differs") + return DiffDispositionReceipt( + decision="accept-continue", + approval_event_id=candidate.approval_event_id, + increment_state="accepted", + status_sha256=sha256_file(status_path), + recovered=recovered, + ) + + +def persist_diff_disposition( + program_root: Path, + submitted_prompt: str, + observation: RepositoryObservation, +) -> object: + """Persist a stop decision or complete immediate successor rollover.""" + root = Path(program_root) + manifest, manifest_issues = load_json_object(root / "manifest.json") + if manifest is None: + raise ValueError("; ".join(manifest_issues)) + roles = manifest.get("logical_roles") + if not isinstance(roles, dict): + raise ValueError("manifest logical_roles must be an object") + status_path, status_path_issues = resolve_managed_path( + root, roles.get("status"), role="logical role status" + ) + if status_path is None: + raise ValueError("; ".join(status_path_issues)) + status, status_issues = load_json_object(status_path) + if status is None: + raise ValueError("; ".join(status_issues)) + if ( + status.get("current_increment_state") == "preparing" + and isinstance(status.get("rollover_binding"), dict) + ): + from program_rollover import persist_increment_rollover + + return persist_increment_rollover(root, submitted_prompt, observation) + receipt = _persist_diff_acceptance_prefix( + root, submitted_prompt, observation + ) + if receipt.decision == "accept-stop": + return receipt + from program_rollover import persist_increment_rollover + + return persist_increment_rollover( + root, submitted_prompt, observation + ) + + +# Imported after Plan A's public records and functions exist so the continuation +# module can consume them without redefining the acceptance implementation. +import program_continuation as _continuation # noqa: E402 diff --git a/skills/implementing-staged-plans/scripts/program_activation.py b/skills/implementing-staged-plans/scripts/program_activation.py index 9624bdd..1aae406 100644 --- a/skills/implementing-staged-plans/scripts/program_activation.py +++ b/skills/implementing-staged-plans/scripts/program_activation.py @@ -574,6 +574,8 @@ def _user_work_baselines( root: Path, observation: RepositoryObservation, file_map: ExactFileMap, + *, + inherited_paths: Sequence[str] = (), ) -> list[dict[str, object]]: workspace = Path(observation.path).resolve() try: @@ -588,6 +590,8 @@ def _user_work_baselines( ("conflicted", observation.conflicted_paths), ): for relative in paths: + if relative in inherited_paths: + continue if relative == control_prefix or relative.startswith(control_prefix + "/"): continue categories.setdefault(relative, set()).add(category) @@ -627,7 +631,11 @@ def _stable_status_fields(status: dict[str, object]) -> dict[str, object]: "activation_binding", "current_increment_authority_binding", ) - return {field: status[field] for field in fields} + stable = {field: status[field] for field in fields} + for field in ("rollover_binding", "inherited_workspace_binding"): + if field in status: + stable[field] = status[field] + return stable def _build_plan_candidate( @@ -686,8 +694,35 @@ def _build_plan_candidate( else: raise ValueError("plan-bound status lacks preparation binding") - user_work_baselines = _user_work_baselines(root, observation, file_map) + if isinstance(status.get("rollover_binding"), dict): + from program_rollover import validated_inherited_paths + + inherited_paths = validated_inherited_paths(root, status, observation) + else: + inherited_paths = () + user_work_baselines = _user_work_baselines( + root, + observation, + file_map, + inherited_paths=inherited_paths, + ) path_baselines = _path_baselines(root, Path(observation.path), file_map) + inherited_set = set(inherited_paths) + baseline_observation = replace( + observation, + staged_paths=tuple( + path for path in observation.staged_paths if path not in inherited_set + ), + modified_paths=tuple( + path for path in observation.modified_paths if path not in inherited_set + ), + untracked_paths=tuple( + path for path in observation.untracked_paths if path not in inherited_set + ), + conflicted_paths=tuple( + path for path in observation.conflicted_paths if path not in inherited_set + ), + ) baseline = { "schema_version": EXECUTION_BASELINE_SCHEMA, "program_id": manifest["program_id"], @@ -697,12 +732,13 @@ def _build_plan_candidate( "current_increment_authority_binding": status[ "current_increment_authority_binding" ], - "workspace_observation": _observation_value(observation), + "workspace_observation": _observation_value(baseline_observation), "file_map": asdict(file_map), "path_baselines": path_baselines, "user_work_baselines": user_work_baselines, - "inherited_paths": [], + "inherited_paths": list(inherited_paths), } + execution_baseline_from_value(baseline) baseline_bytes = _canonical_json_bytes(baseline) baseline_sha256 = _sha256_bytes(baseline_bytes) file_map_sha256 = _sha256_bytes(_canonical_json_bytes(asdict(file_map))) @@ -955,8 +991,17 @@ def _append_or_adopt_record( raise ValueError(f"{label}-recovery-required: conflicting record") return True if identifier_field == "event_id": + semantic_fields = ( + "type", + "program_id", + "program_revision", + "increment_id", + "exact_file_plan_sha256", + ) semantic_conflicts = [ - item for item in records if item.get("type") == record.get("type") + item + for item in records + if all(item.get(field) == record.get(field) for field in semantic_fields) ] else: semantic_fields = ( diff --git a/skills/implementing-staged-plans/scripts/program_continuation.py b/skills/implementing-staged-plans/scripts/program_continuation.py new file mode 100644 index 0000000..10ca02d --- /dev/null +++ b/skills/implementing-staged-plans/scripts/program_continuation.py @@ -0,0 +1,892 @@ +#!/usr/bin/env python3 +"""Derive prompt-bound immediate and accepted-state continuation authority.""" + +from __future__ import annotations + +import argparse +import hashlib +import sys +from collections.abc import Mapping, Sequence +from dataclasses import asdict, dataclass, replace +from pathlib import Path + +from diff_disposition import ( + DIFF_DISPOSITION_BINDING_SCHEMA, + DIFF_DISPOSITION_COMMAND_SCHEMA, + DiffAcceptanceCandidate, +) +from continuity_closure import select_unique_satisfied_successor +from program_activation import ( + _canonical_json_bytes, + _canonical_json_line, + _identifier, + _without_owned_program_paths, +) +from program_authority import ( + load_json_lines, + load_json_object, + resolve_managed_path, + sha256_file, +) +from repository_preparation import ( + execution_baseline_from_value, + inspect_repository, + validate_execution_workspace, +) +from state_authority import RepositoryObservation +from task_prompt import parse_exact_prompt, render_exact_prompt + + +SUCCESSOR_PROJECTION_SCHEMA = "implementation-successor-authority-projection/v1" +ACCEPTED_STATE_CONTINUATION_SCHEMA = ( + "implementation-accepted-state-continuation-binding/v1" +) + + +@dataclass(frozen=True) +class ProductDeltaPath: + path: str + disposition: str + sha256: str + + +@dataclass(frozen=True) +class ContinuationExtension: + successor_increment_id: str + successor_brief_bytes: bytes + accepted_product_delta: tuple[ProductDeltaPath, ...] + checkpoint_id: str + rollover_authorization_id: str + successor_grant_id: str + successor_projection: Mapping[str, object] + + +@dataclass(frozen=True) +class ContinuationCommand: + schema_version: str + base_seed_sha256: str + checkpoint_id: str + rollover_authorization_id: str + successor_grant_id: str + accepted_status_sha256: str + accepted_status_sequence: int + program_id: str + program_revision: int + current_increment_id: str + successor_increment_id: str + successor_brief_sha256: str + accepted_product_delta_sha256: str + successor_approval_mode: str + selected_workspace: Mapping[str, object] + inherited_workspace: Mapping[str, object] + allowed_conditional_action_ceiling: tuple[str, ...] + + +def _sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _load_role( + root: Path, + manifest: dict[str, object], + role: str, +) -> tuple[dict[str, object], Path]: + roles = manifest.get("logical_roles") + if not isinstance(roles, dict): + raise ValueError("manifest logical_roles must be an object") + path, path_issues = resolve_managed_path( + root, roles.get(role), role=f"logical role {role}" + ) + if path is None: + raise ValueError("; ".join(path_issues)) + value, value_issues = load_json_object(path) + if value is None: + raise ValueError("; ".join(value_issues)) + return value, path + + +def _accepted_increment_ids( + root: Path, + status: Mapping[str, object], + *, + allow_unbound_rollover_suffix: bool, +) -> set[str]: + current_increment_id = status.get("current_increment_id") + if not isinstance(current_increment_id, str) or not current_increment_id: + raise ValueError("status current increment is required") + from program_rollover import _validated_completed_rollover_records + + completed = _validated_completed_rollover_records( + root, + status, + allow_unbound_suffix=allow_unbound_rollover_suffix, + ) + accepted = {current_increment_id} + accepted.update(str(record["current_increment_id"]) for record in completed) + return accepted + + +def _successor_selection( + root: Path, + manifest: dict[str, object], + status: dict[str, object], + *, + allow_unbound_rollover_suffix: bool = False, +) -> tuple[str | None, str]: + traceability, _ = _load_role(root, manifest, "traceability") + requirements = traceability.get("atomic_requirements") + if not isinstance(requirements, list): + raise ValueError("traceability atomic_requirements must be a list") + current = status.get("current_increment_id") + if not isinstance(current, str) or not current: + raise ValueError("status current increment is required") + for requirement in requirements: + assigned = requirement.get("assigned_increments") if isinstance(requirement, dict) else None + if not isinstance(assigned, list): + raise ValueError("traceability assigned_increments must be a list") + accepted = _accepted_increment_ids( + root, + status, + allow_unbound_rollover_suffix=allow_unbound_rollover_suffix, + ) + return select_unique_satisfied_successor(requirements, current, accepted) + + +def continuation_unavailability_reason( + program_root: Path, + acceptance: DiffAcceptanceCandidate, + *, + allow_unbound_rollover_suffix: bool = False, +) -> str: + root = Path(program_root) + manifest, manifest_issues = load_json_object(root / "manifest.json") + if manifest is None: + raise ValueError("; ".join(manifest_issues)) + successor, reason = _successor_selection( + root, + manifest, + acceptance.accepted_status, + allow_unbound_rollover_suffix=allow_unbound_rollover_suffix, + ) + return "" if successor is not None else reason + + +def _live_product_delta( + root: Path, + status: dict[str, object], + observation: RepositoryObservation, +) -> tuple[tuple[ProductDeltaPath, ...], str]: + baseline_binding = status.get("execution_baseline_binding") + if not isinstance(baseline_binding, dict): + raise ValueError("execution baseline binding is required") + baseline_path, path_issues = resolve_managed_path( + root, + baseline_binding.get("path"), + role="execution baseline binding", + ) + if baseline_path is None: + raise ValueError("; ".join(path_issues)) + baseline_value, baseline_issues = load_json_object(baseline_path) + if baseline_value is None: + raise ValueError("; ".join(baseline_issues)) + baseline = execution_baseline_from_value(baseline_value) + inspection = inspect_repository(Path(observation.path), observation.base_commit) + inspection = replace( + inspection, + observation=_without_owned_program_paths(root, inspection.observation), + ) + assessment = validate_execution_workspace( + root, + baseline, + inspection, + increment_state=str(status.get("current_increment_state")), + ) + if not assessment.valid: + raise ValueError("; ".join(assessment.issues)) + expected = status.get("execution_transition_binding") + expected_sha256 = ( + expected.get("product_delta_sha256") if isinstance(expected, dict) else None + ) + if assessment.product_delta_sha256 != expected_sha256: + raise ValueError("live accepted product delta changed") + product_delta = tuple( + ProductDeltaPath( + path=str(item["path"]), + disposition=str(item["disposition"]), + sha256=str(item["sha256"]), + ) + for item in assessment.product_delta + ) + return product_delta, assessment.product_delta_sha256 + + +def _successor_brief_bytes( + traceability: dict[str, object], + status: dict[str, object], + successor_increment_id: str, + selected_workspace: Mapping[str, object], +) -> bytes: + requirements = [ + requirement + for requirement in traceability.get("atomic_requirements", []) + if isinstance(requirement, dict) + and successor_increment_id in requirement.get("assigned_increments", []) + ] + if not requirements: + raise ValueError("successor has no traceability-allocated requirements") + lines = [ + f"# {successor_increment_id} increment brief", + "", + f"- Program: `{status['program_id']}` revision `{status['program_revision']}`", + f"- Increment: `{successor_increment_id}`", + f"- Approval mode: `{status['approval_mode']}`", + ( + "- Workspace: " + f"`{selected_workspace['path']}` on `{selected_workspace['branch']}`" + ), + "- Requirements:", + ] + for requirement in sorted(requirements, key=lambda item: str(item["id"])): + criteria = "; ".join(str(item) for item in requirement["acceptance_criteria"]) + lines.append( + f" - `{requirement['id']}`: {requirement['normalized_requirement']} " + f"Acceptance: {criteria}" + ) + lines.extend( + ( + "- Authority: navigation only until the successor grant is status-current.", + "", + ) + ) + return "\n".join(lines).encode("utf-8") + + +def _continuation_inputs( + root: Path, + acceptance: DiffAcceptanceCandidate, + observation: RepositoryObservation, + *, + allow_unbound_rollover_suffix: bool, +) -> tuple[ + dict[str, object], + dict[str, object], + dict[str, object], + str, + bytes, + tuple[ProductDeltaPath, ...], + str, + dict[str, object], + tuple[str, ...], +]: + manifest, manifest_issues = load_json_object(root / "manifest.json") + if manifest is None: + raise ValueError("; ".join(manifest_issues)) + status, _ = _load_role(root, manifest, "status") + workspace, _workspace_path = _load_role(root, manifest, "workspace") + traceability, _ = _load_role(root, manifest, "traceability") + successor, reason = _successor_selection( + root, + manifest, + status, + allow_unbound_rollover_suffix=allow_unbound_rollover_suffix, + ) + if successor is None: + raise ValueError(reason) + selected = workspace.get("implementation_workspace") + if not isinstance(selected, dict): + raise ValueError("workspace selection is incomplete") + selected_workspace = { + "path": observation.path, + "branch": observation.branch, + "base_commit": observation.base_commit, + "head_commit": observation.head_commit, + } + selected_pairs = ( + ("path", selected.get("path"), observation.path), + ("branch", selected.get("branch"), observation.branch), + ("base_commit", selected.get("base_commit"), observation.base_commit), + ) + if any(persisted != observed for _label, persisted, observed in selected_pairs): + raise ValueError("selected workspace changed before continuation") + product_delta, product_delta_sha256 = _live_product_delta( + root, status, observation + ) + inherited_workspace = { + "selected_workspace": selected_workspace, + "accepted_product_delta": [asdict(item) for item in product_delta], + "accepted_product_delta_sha256": product_delta_sha256, + } + authority = status.get("current_increment_authority_binding") + if not isinstance(authority, dict): + raise ValueError("status-current increment authority is required") + roles = manifest.get("logical_roles") + if not isinstance(roles, dict): + raise ValueError("manifest logical_roles must be an object") + grants_path, grant_path_issues = resolve_managed_path( + root, roles.get("increment_grants"), role="logical role increment_grants" + ) + if grants_path is None: + raise ValueError("; ".join(grant_path_issues)) + grants, grant_issues = load_json_lines(grants_path) + if grants is None: + raise ValueError("; ".join(grant_issues)) + matching = [ + record for record in grants if record.get("grant_id") == authority.get("grant_id") + ] + if len(matching) != 1: + raise ValueError("status-current increment grant must exist exactly once") + allowed = matching[0].get("allowed_conditional_actions") + if not isinstance(allowed, list) or not all( + isinstance(item, str) and item for item in allowed + ): + raise ValueError("current grant conditional-action ceiling is invalid") + brief_bytes = _successor_brief_bytes( + traceability, status, successor, selected_workspace + ) + return ( + manifest, + status, + workspace, + successor, + brief_bytes, + product_delta, + product_delta_sha256, + inherited_workspace, + tuple(allowed), + ) + + +def _immediate_base_seed( + acceptance: DiffAcceptanceCandidate, + *, + successor_increment_id: str, + successor_brief_sha256: str, + accepted_product_delta_sha256: str, + successor_approval_mode: str, + selected_workspace: Mapping[str, object], + workspace_selection_sha256: str, + inherited_workspace_sha256: str, + allowed_conditional_action_ceiling: tuple[str, ...], +) -> dict[str, object]: + binding = acceptance.accepted_status["diff_disposition_binding"] + return { + "schema_domain": SUCCESSOR_PROJECTION_SCHEMA, + "program_id": binding["program_id"], + "program_revision": binding["program_revision"], + "current_increment_id": binding["increment_id"], + "successor_increment_id": successor_increment_id, + "prior_status_sha256": binding["prior_status_sha256"], + "prior_status_sequence": binding["prior_status_sequence"], + "decision": "accept-continue", + "review_evidence_sha256": binding["review_evidence_sha256"], + "review_packet_sha256": binding["review_packet_sha256"], + "verification_sha256": binding["verification_sha256"], + "exact_file_plan_sha256": binding["exact_file_plan_sha256"], + "execution_baseline_sha256": binding["execution_baseline_sha256"], + "accepted_product_delta_sha256": accepted_product_delta_sha256, + "successor_brief_sha256": successor_brief_sha256, + "successor_approval_mode": successor_approval_mode, + "selected_workspace": dict(selected_workspace), + "workspace_selection_sha256": workspace_selection_sha256, + "inherited_workspace_sha256": inherited_workspace_sha256, + "allowed_conditional_action_ceiling": list( + allowed_conditional_action_ceiling + ), + } + + +def _build_continuation_extension( + program_root: Path, + acceptance: DiffAcceptanceCandidate, + observation: RepositoryObservation, + *, + allow_unbound_rollover_suffix: bool, +) -> ContinuationExtension | None: + """Derive a continuation extension only for one satisfied successor.""" + root = Path(program_root) + manifest, manifest_issues = load_json_object(root / "manifest.json") + if manifest is None: + raise ValueError("; ".join(manifest_issues)) + successor, _reason = _successor_selection( + root, + manifest, + acceptance.accepted_status, + allow_unbound_rollover_suffix=allow_unbound_rollover_suffix, + ) + if successor is None: + return None + ( + _manifest, + status, + _workspace, + successor, + brief_bytes, + product_delta, + product_delta_sha256, + inherited_workspace, + allowed, + ) = _continuation_inputs( + root, + acceptance, + observation, + allow_unbound_rollover_suffix=allow_unbound_rollover_suffix, + ) + roles = manifest["logical_roles"] + workspace_path, workspace_path_issues = resolve_managed_path( + root, roles.get("workspace"), role="logical role workspace" + ) + if workspace_path is None: + raise ValueError("; ".join(workspace_path_issues)) + selected_workspace = inherited_workspace["selected_workspace"] + brief_sha256 = _sha256_bytes(brief_bytes) + inherited_sha256 = _sha256_bytes(_canonical_json_bytes(inherited_workspace)) + base_seed = _immediate_base_seed( + acceptance, + successor_increment_id=successor, + successor_brief_sha256=brief_sha256, + accepted_product_delta_sha256=product_delta_sha256, + successor_approval_mode=str(status["approval_mode"]), + selected_workspace=selected_workspace, + workspace_selection_sha256=sha256_file(workspace_path), + inherited_workspace_sha256=inherited_sha256, + allowed_conditional_action_ceiling=allowed, + ) + base_seed_sha256 = _sha256_bytes(_canonical_json_bytes(base_seed)) + checkpoint_id = _identifier( + "diff-checkpoint", {"base_seed_sha256": base_seed_sha256} + ) + approval_event_id = _identifier( + "diff-approval", + { + "base_seed_sha256": base_seed_sha256, + "checkpoint_id": checkpoint_id, + }, + ) + rollover_authorization_id = _identifier( + "rollover-action", + { + "base_seed_sha256": base_seed_sha256, + "checkpoint_id": checkpoint_id, + "approval_event_id": approval_event_id, + }, + ) + successor_grant_id = _identifier( + "successor-grant", + { + "base_seed_sha256": base_seed_sha256, + "checkpoint_id": checkpoint_id, + "approval_event_id": approval_event_id, + "rollover_authorization_id": rollover_authorization_id, + }, + ) + projection = { + "schema_version": SUCCESSOR_PROJECTION_SCHEMA, + "program_id": status["program_id"], + "program_revision": status["program_revision"], + "current_increment_id": status["current_increment_id"], + "successor_increment_id": successor, + "prior_status_sha256": base_seed["prior_status_sha256"], + "prior_status_sequence": base_seed["prior_status_sequence"], + "checkpoint_id": checkpoint_id, + "approval_event_id": approval_event_id, + "successor_brief_sha256": brief_sha256, + "accepted_product_delta_sha256": product_delta_sha256, + "successor_approval_mode": status["approval_mode"], + "selected_workspace": selected_workspace, + "workspace_selection_sha256": sha256_file(workspace_path), + "inherited_workspace_sha256": inherited_sha256, + "allowed_conditional_action_ceiling": list(allowed), + "rollover_authorization_id": rollover_authorization_id, + "successor_grant_id": successor_grant_id, + } + return ContinuationExtension( + successor_increment_id=successor, + successor_brief_bytes=brief_bytes, + accepted_product_delta=product_delta, + checkpoint_id=checkpoint_id, + rollover_authorization_id=rollover_authorization_id, + successor_grant_id=successor_grant_id, + successor_projection=projection, + ) + + +def build_continuation_extension( + program_root: Path, + acceptance: DiffAcceptanceCandidate, + observation: RepositoryObservation, +) -> ContinuationExtension | None: + """Derive a continuation extension only for one satisfied successor.""" + try: + return _build_continuation_extension( + program_root, + acceptance, + observation, + allow_unbound_rollover_suffix=False, + ) + except ValueError as error: + if str(error) != "unbound rollover history is not lifecycle authority": + raise + from program_rollover import inspect_increment_rollover + + inspection = inspect_increment_rollover(program_root, observation) + if ( + inspection.issues + or inspection.disposition + not in { + "increment-rollover-retry-ready", + "accepted-state-rollover-retry-ready", + } + or inspection.completed_steps + != ( + "action-authorization", + "successor-grant", + "handoff", + "successor-brief", + "rollover-record", + ) + ): + raise error + return _build_continuation_extension( + program_root, + acceptance, + observation, + allow_unbound_rollover_suffix=True, + ) + + +def successor_projection_sha256(projection: Mapping[str, object]) -> str: + return _sha256_bytes(_canonical_json_bytes(dict(projection))) + + +def build_accept_continue_candidate( + acceptance: DiffAcceptanceCandidate, + extension: ContinuationExtension | None, +) -> DiffAcceptanceCandidate: + """Extend a validated Plan A acceptance without changing its stop candidate.""" + if extension is None: + raise ValueError("accept-continue requires one satisfied successor") + projection = dict(extension.successor_projection) + base_seed = _immediate_base_seed( + acceptance, + successor_increment_id=extension.successor_increment_id, + successor_brief_sha256=str(projection["successor_brief_sha256"]), + accepted_product_delta_sha256=str( + projection["accepted_product_delta_sha256"] + ), + successor_approval_mode=str(projection["successor_approval_mode"]), + selected_workspace=projection["selected_workspace"], + workspace_selection_sha256=str(projection["workspace_selection_sha256"]), + inherited_workspace_sha256=str(projection["inherited_workspace_sha256"]), + allowed_conditional_action_ceiling=tuple( + str(item) for item in projection["allowed_conditional_action_ceiling"] + ), + ) + base_seed_sha256 = _sha256_bytes(_canonical_json_bytes(base_seed)) + checkpoint_id = extension.checkpoint_id + approval_event_id = str(projection["approval_event_id"]) + binding = { + "schema_version": DIFF_DISPOSITION_BINDING_SCHEMA, + **{ + key: value + for key, value in acceptance.accepted_status[ + "diff_disposition_binding" + ].items() + if key + not in { + "schema_version", + "decision", + "base_seed_sha256", + "checkpoint_id", + "approval_event_id", + } + }, + "decision": "accept-continue", + "base_seed_sha256": base_seed_sha256, + "checkpoint_id": checkpoint_id, + "approval_event_id": approval_event_id, + "successor_increment_id": extension.successor_increment_id, + "successor_brief_sha256": projection["successor_brief_sha256"], + "rollover_action_authorization_id": extension.rollover_authorization_id, + "successor_grant_id": extension.successor_grant_id, + "inherited_product_delta_sha256": projection[ + "accepted_product_delta_sha256" + ], + "successor_authority_projection": projection, + } + accepted_status = dict(acceptance.accepted_status) + accepted_status["transition_authority"] = { + "kind": "approval-event", + "event_id": approval_event_id, + "checkpoint_id": checkpoint_id, + } + accepted_status["diff_disposition_binding"] = binding + accepted_status_bytes = _canonical_json_bytes(accepted_status) + command = { + "schema_version": DIFF_DISPOSITION_COMMAND_SCHEMA, + "decision": "accept-continue", + "base_seed_sha256": base_seed_sha256, + "checkpoint_id": checkpoint_id, + "approval_event_id": approval_event_id, + "accepted_status_sha256": _sha256_bytes(accepted_status_bytes), + "successor_authority_projection": projection, + } + prompt = render_exact_prompt(command) + approval_record = { + **acceptance.approval_record, + "event_id": approval_event_id, + "scope": [ + "accept the bound current increment and continue to the bound successor" + ], + "diff_decision": "accept-continue", + "base_seed_sha256": base_seed_sha256, + "checkpoint_id": checkpoint_id, + "submitted_prompt_sha256": _sha256_bytes(prompt.encode("utf-8")), + "successor_increment_id": extension.successor_increment_id, + "successor_authority_projection_sha256": successor_projection_sha256( + projection + ), + } + return DiffAcceptanceCandidate( + base_seed_sha256=base_seed_sha256, + checkpoint_id=checkpoint_id, + approval_event_id=approval_event_id, + decision="accept-continue", + approval_bytes=_canonical_json_line(approval_record), + accepted_status_bytes=accepted_status_bytes, + prompt=prompt, + approval_record=approval_record, + accepted_status=accepted_status, + ) + + +def _render_accept_continue_prompt( + program_root: Path, + *, + allow_unbound_rollover_suffix: bool = False, +) -> str: + root = Path(program_root) + manifest, manifest_issues = load_json_object(root / "manifest.json") + if manifest is None: + raise ValueError("; ".join(manifest_issues)) + workspace, _ = _load_role(root, manifest, "workspace") + selected = workspace["implementation_workspace"] + observation = inspect_repository( + Path(selected["path"]), selected["base_commit"] + ).observation + from diff_disposition import build_diff_acceptance_candidate + + acceptance = build_diff_acceptance_candidate(root, observation) + extension = _build_continuation_extension( + root, + acceptance, + observation, + allow_unbound_rollover_suffix=allow_unbound_rollover_suffix, + ) + if extension is None: + raise ValueError( + continuation_unavailability_reason( + root, + acceptance, + allow_unbound_rollover_suffix=allow_unbound_rollover_suffix, + ) + ) + candidate = build_accept_continue_candidate(acceptance, extension) + return ( + f"Accept and continue to `{extension.successor_increment_id}`.\n\n" + f"{candidate.prompt}" + ) + + +def render_accept_continue_prompt(program_root: Path) -> str: + return _render_accept_continue_prompt(program_root) + + +def _build_accepted_state_command( + program_root: Path, + *, + allow_unbound_rollover_suffix: bool = False, +) -> ContinuationCommand: + root = Path(program_root) + manifest, manifest_issues = load_json_object(root / "manifest.json") + if manifest is None: + raise ValueError("; ".join(manifest_issues)) + status, status_path = _load_role(root, manifest, "status") + binding = status.get("diff_disposition_binding") + if ( + status.get("program_state") != "active" + or status.get("current_increment_state") != "accepted" + or not isinstance(binding, dict) + or binding.get("decision") != "accept-stop" + ): + raise ValueError("accepted-state continuation requires exact accept-stop status") + workspace, workspace_path = _load_role(root, manifest, "workspace") + selected = workspace["implementation_workspace"] + observation = inspect_repository( + Path(selected["path"]), selected["base_commit"] + ).observation + from diff_disposition import build_diff_acceptance_candidate + + acceptance = build_diff_acceptance_candidate(root, observation) + extension = _build_continuation_extension( + root, + acceptance, + observation, + allow_unbound_rollover_suffix=allow_unbound_rollover_suffix, + ) + if extension is None: + raise ValueError( + continuation_unavailability_reason( + root, + acceptance, + allow_unbound_rollover_suffix=allow_unbound_rollover_suffix, + ) + ) + projection = dict(extension.successor_projection) + selected_workspace = projection["selected_workspace"] + inherited_workspace = { + "selected_workspace": selected_workspace, + "accepted_product_delta": [asdict(item) for item in extension.accepted_product_delta], + "accepted_product_delta_sha256": projection[ + "accepted_product_delta_sha256" + ], + } + base_seed = { + "schema_domain": ACCEPTED_STATE_CONTINUATION_SCHEMA, + "accepted_status_sha256": sha256_file(status_path), + "accepted_status_sequence": status["state_sequence"], + "program_id": status["program_id"], + "program_revision": status["program_revision"], + "current_increment_id": status["current_increment_id"], + "successor_increment_id": extension.successor_increment_id, + "successor_brief_sha256": projection["successor_brief_sha256"], + "accepted_product_delta_sha256": projection[ + "accepted_product_delta_sha256" + ], + "successor_approval_mode": projection["successor_approval_mode"], + "selected_workspace": selected_workspace, + "workspace_selection_sha256": sha256_file(workspace_path), + "inherited_workspace": inherited_workspace, + "allowed_conditional_action_ceiling": projection[ + "allowed_conditional_action_ceiling" + ], + } + base_seed_sha256 = _sha256_bytes(_canonical_json_bytes(base_seed)) + checkpoint_id = _identifier( + "accepted-continuation-checkpoint", + {"base_seed_sha256": base_seed_sha256}, + ) + rollover_authorization_id = _identifier( + "accepted-rollover-action", + { + "base_seed_sha256": base_seed_sha256, + "checkpoint_id": checkpoint_id, + }, + ) + successor_grant_id = _identifier( + "accepted-successor-grant", + { + "base_seed_sha256": base_seed_sha256, + "checkpoint_id": checkpoint_id, + "rollover_authorization_id": rollover_authorization_id, + }, + ) + return ContinuationCommand( + schema_version=ACCEPTED_STATE_CONTINUATION_SCHEMA, + base_seed_sha256=base_seed_sha256, + checkpoint_id=checkpoint_id, + rollover_authorization_id=rollover_authorization_id, + successor_grant_id=successor_grant_id, + accepted_status_sha256=sha256_file(status_path), + accepted_status_sequence=int(status["state_sequence"]), + program_id=str(status["program_id"]), + program_revision=int(status["program_revision"]), + current_increment_id=str(status["current_increment_id"]), + successor_increment_id=extension.successor_increment_id, + successor_brief_sha256=str(projection["successor_brief_sha256"]), + accepted_product_delta_sha256=str( + projection["accepted_product_delta_sha256"] + ), + successor_approval_mode=str(projection["successor_approval_mode"]), + selected_workspace=selected_workspace, + inherited_workspace=inherited_workspace, + allowed_conditional_action_ceiling=tuple( + str(item) for item in projection["allowed_conditional_action_ceiling"] + ), + ) + + +def _render_accepted_state_continuation_prompt( + program_root: Path, + *, + allow_unbound_rollover_suffix: bool = False, +) -> str: + command = _build_accepted_state_command( + program_root, + allow_unbound_rollover_suffix=allow_unbound_rollover_suffix, + ) + return render_exact_prompt(asdict(command)) + + +def render_accepted_state_continuation_prompt(program_root: Path) -> str: + return _render_accepted_state_continuation_prompt(program_root) + + +def validate_submitted_continuation_prompt( + program_root: Path, + submitted_prompt: str, +) -> ContinuationCommand: + parse_exact_prompt(submitted_prompt, ACCEPTED_STATE_CONTINUATION_SCHEMA) + expected = _build_accepted_state_command(program_root) + if render_exact_prompt(asdict(expected)) != submitted_prompt: + raise ValueError("submitted accepted-state continuation prompt is stale") + return expected + + +def _validate_submitted_continuation_prompt_for_rollover_retry( + program_root: Path, + submitted_prompt: str, +) -> ContinuationCommand: + parse_exact_prompt(submitted_prompt, ACCEPTED_STATE_CONTINUATION_SCHEMA) + expected = _build_accepted_state_command( + program_root, + allow_unbound_rollover_suffix=True, + ) + if render_exact_prompt(asdict(expected)) != submitted_prompt: + raise ValueError("submitted accepted-state continuation prompt is stale") + return expected + + +class _UsageError(ValueError): + pass + + +class _ArgumentParser(argparse.ArgumentParser): + def error(self, message: str) -> None: + raise _UsageError(message) + + +def build_argument_parser() -> argparse.ArgumentParser: + parser = _ArgumentParser(prog="program_continuation.py") + subparsers = parser.add_subparsers(dest="command", required=True) + render_parser = subparsers.add_parser("render") + render_parser.add_argument("program_root") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + try: + arguments = build_argument_parser().parse_args( + list(sys.argv[1:] if argv is None else argv) + ) + prompt = render_accepted_state_continuation_prompt( + Path(arguments.program_root) + ) + except (_UsageError, OSError, TypeError, ValueError) as error: + print(str(error), file=sys.stderr) + return 2 if isinstance(error, _UsageError) else 1 + sys.stdout.write(prompt) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/implementing-staged-plans/scripts/program_discovery.py b/skills/implementing-staged-plans/scripts/program_discovery.py index 3525ac5..0e50f77 100644 --- a/skills/implementing-staged-plans/scripts/program_discovery.py +++ b/skills/implementing-staged-plans/scripts/program_discovery.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import hashlib import json import os import sys @@ -59,6 +60,7 @@ "plan-materialization-retry-ready", "review-preparation-retry-ready", "increment-acceptance-retry-ready", + "accepted-continuation-retry-ready", "closure-preparation-retry-ready", "closure-approval-retry-ready", "resume", @@ -133,6 +135,36 @@ "Preserve the diff-acceptance prefix and resolve its divergence before retrying.", True, ), + "accepted-continuation-retry-ready": ( + None, + "Resubmit the same exact accept-and-continue prompt to complete its suffix.", + False, + ), + "increment-continuation-retry-ready": ( + None, + "Resubmit the same immediate continuation prompt to adopt its authority prefix.", + False, + ), + "increment-rollover-retry-ready": ( + None, + "Resubmit the same immediate continuation prompt to adopt its rollover prefix.", + False, + ), + "accepted-state-continuation-retry-ready": ( + None, + "Resubmit the same accepted-state continuation prompt to adopt its authority prefix.", + False, + ), + "accepted-state-rollover-retry-ready": ( + None, + "Resubmit the same accepted-state continuation prompt to adopt its rollover prefix.", + False, + ), + "accepted-state-continuation-recovery-required": ( + "plan-b-accepted-state-continuation-recovery", + "Preserve the accepted-state successor prefix and resolve its divergence before retrying.", + True, + ), "closure-preparation-retry-ready": ( None, "Resubmit the same exact closure-preparation operation to adopt the valid prefix.", @@ -179,6 +211,16 @@ "Preserve the blocked prefix and use the Plan B blocked recovery workflow.", True, ), + "blocked-recovery-ready": ( + "block-resolution-candidate", + "Provide complete satisfied criteria and render the exact block-resolution prompt.", + True, + ), + "blocked-resolution-retry-ready": ( + None, + "Resubmit the same exact block-resolution prompt to adopt its valid prefix.", + False, + ), "legacy-rollover-upgrade-required": ( "legacy-rollover-upgrade", "Stop before successor writes; the accepted legacy program requires an explicit upgrade workflow.", @@ -209,6 +251,8 @@ class ProgramCandidate: status_path: str status_sha256: str status_sequence: int + resume_program_state: str | None = None + resume_increment_state: str | None = None @dataclass(frozen=True) @@ -302,6 +346,30 @@ def _canonical_json_bytes(value: object) -> bytes: ).encode("utf-8") +def _has_unbound_block_resolution( + ledgers: dict[str, list[dict[str, object]]], +) -> bool: + actions = ledgers.get("action_authorizations", []) + for resolution in ledgers.get("block_resolutions", []): + authorization_id = resolution.get("action_authorization_id") + matches = [ + record + for record in actions + if record.get("authorization_id") == authorization_id + ] + if ( + resolution.get("schema_version") + != "implementation-block-resolution/v1" + or not isinstance(authorization_id, str) + or len(matches) != 1 + or matches[0].get("actions") != ["resume-blocked-program"] + or hashlib.sha256(_canonical_json_line(matches[0])).hexdigest() + != resolution.get("action_authorization_sha256") + ): + return True + return False + + def _exact_activation_prefix( root: Path, manifest: dict[str, object], @@ -477,8 +545,14 @@ def _exact_plan_prefix_disposition( record for record in ledgers.get("approvals", []) if record.get("type") == "exact-file-plan-approval" + and record.get("increment_id") == status.get("current_increment_id") + ] + actions = [ + record + for record in ledgers.get("action_authorizations", []) + if record.get("increment_id") == status.get("current_increment_id") + and record.get("actions") == ["modify-workspace", "run-local-verification"] ] - actions = ledgers.get("action_authorizations", []) material_prefix = bool(plan_approvals or baseline_present or actions) recovery = ( "plan-materialization-recovery-required" @@ -633,7 +707,17 @@ def _exact_acceptance_prefix_disposition( for record in ledgers.get("approvals", []) if record.get("type") == "increment-diff-approval" ] - if state == "awaiting-diff-approval" and not approvals: + current_approvals = [ + record + for record in approvals + if record.get("increment_id") == status.get("current_increment_id") + ] + if state == "awaiting-diff-approval" and not current_approvals: + if any( + not isinstance(record.get("increment_id"), str) + for record in approvals + ): + return "increment-acceptance-recovery-required" return None try: roles = manifest["logical_roles"] @@ -652,13 +736,45 @@ def _exact_acceptance_prefix_disposition( candidate = build_diff_acceptance_candidate(root, observation) except (KeyError, OSError, TypeError, ValueError): return "increment-acceptance-recovery-required" - if approvals != [candidate.approval_record]: + candidate_approvals = [ + record + for record in approvals + if record.get("event_id") == candidate.approval_event_id + ] + if candidate_approvals == [candidate.approval_record]: + if state == "awaiting-diff-approval": + return "increment-acceptance-retry-ready" + if _canonical_json_bytes(status) != candidate.accepted_status_bytes: + return "increment-acceptance-recovery-required" + return "accepted-stop" + try: + from program_continuation import ( + build_accept_continue_candidate, + build_continuation_extension, + ) + + extension = build_continuation_extension(root, candidate, observation) + continued = build_accept_continue_candidate(candidate, extension) + except (KeyError, OSError, TypeError, ValueError): + return "increment-acceptance-recovery-required" + continued_approvals = [ + record + for record in approvals + if record.get("event_id") == continued.approval_event_id + ] + if ( + state == "awaiting-diff-approval" + and not candidate_approvals + and not continued_approvals + ): + return None + if continued_approvals != [continued.approval_record]: return "increment-acceptance-recovery-required" if state == "awaiting-diff-approval": return "increment-acceptance-retry-ready" - if _canonical_json_bytes(status) != candidate.accepted_status_bytes: + if _canonical_json_bytes(status) != continued.accepted_status_bytes: return "increment-acceptance-recovery-required" - return "accepted-stop" + return "accepted-continuation-retry-ready" def _exact_closure_prefix_disposition( @@ -857,7 +973,17 @@ def _load_new_candidate( status_sequence=int(sequence), ) - has_rollover_prefix = bool(ledgers.get("rollovers")) + has_rollover_prefix = ( + bool(ledgers.get("rollovers")) + or any( + record.get("actions") == ["rollover-increment"] + for record in ledgers.get("action_authorizations", []) + ) + or any( + record.get("continuation_domain") in {"immediate", "accepted-state"} + for record in ledgers.get("increment_grants", []) + ) + ) has_blocked_prefix = bool(ledgers.get("block_resolutions")) or program_state == "blocked" if program_state == "awaiting-program-approval": @@ -917,10 +1043,99 @@ def _load_new_candidate( return candidate, None, tuple( f"{display_path}: {issue}" for issue in authority_issues ) - if has_rollover_prefix: - return candidate, "continuation-recovery-required", () - if has_blocked_prefix: + lifecycle_observation = None + try: + workspace_path, workspace_path_issues = resolve_managed_path( + root, roles.get("workspace"), role="logical role workspace" + ) + if workspace_path is None or workspace_path_issues: + raise ValueError("workspace binding is unavailable") + workspace, workspace_issues = load_json_object(workspace_path) + if workspace is None or workspace_issues: + raise ValueError("workspace binding is invalid") + selected = workspace["implementation_workspace"] + lifecycle_observation = _without_owned_program_paths( + root, + inspect_repository( + Path(selected["path"]), selected["base_commit"] + ).observation, + ) + except (KeyError, OSError, TypeError, ValueError): + pass + blocked_inspection = None + if lifecycle_observation is not None and ( + has_blocked_prefix + or isinstance(status.get("block_resolution_binding"), dict) + ): + try: + from blocked_recovery import inspect_blocked_recovery + + blocked_inspection = inspect_blocked_recovery( + root, lifecycle_observation + ) + except (ImportError, KeyError, OSError, TypeError, ValueError): + blocked_inspection = None + if blocked_inspection is not None and blocked_inspection.disposition is not None: + candidate = replace( + candidate, + resume_program_state=blocked_inspection.prior_program_state, + resume_increment_state=blocked_inspection.prior_increment_state, + ) + return candidate, blocked_inspection.disposition, () + if has_blocked_prefix and status.get("program_state") == "blocked": return candidate, "blocked-recovery-required", () + if _has_unbound_block_resolution(ledgers): + return candidate, "blocked-recovery-required", () + rollover_inspection = None + if lifecycle_observation is not None and ( + has_rollover_prefix + or isinstance(status.get("rollover_binding"), dict) + or ( + status.get("current_increment_state") == "accepted" + and isinstance(status.get("diff_disposition_binding"), dict) + ) + ): + try: + from program_rollover import inspect_increment_rollover + + rollover_inspection = inspect_increment_rollover( + root, lifecycle_observation + ) + except (ImportError, KeyError, OSError, TypeError, ValueError): + if has_rollover_prefix: + return candidate, "continuation-recovery-required", () + if rollover_inspection is not None and rollover_inspection.disposition is not None: + if rollover_inspection.disposition != "resume": + return candidate, rollover_inspection.disposition, () + storage = manifest.get("increment_storage") + current_plan_exists = False + if isinstance(storage, dict): + relative_plan = ( + f"{storage.get('root')}/{status.get('current_increment_id')}/" + f"{storage.get('exact_file_plan_filename')}" + ) + current_plan, current_plan_issues = resolve_managed_path( + root, + relative_plan, + role="status-current exact-file plan", + require_file=False, + ) + current_plan_exists = ( + not current_plan_issues + and current_plan is not None + and current_plan.is_file() + and not current_plan.is_symlink() + ) + if ( + status.get("current_increment_state") == "preparing" + and not current_plan_exists + ): + return candidate, "resume", () + if has_rollover_prefix and ( + rollover_inspection is None + or rollover_inspection.disposition is None + ) and not isinstance(status.get("rollover_binding"), dict): + return candidate, "continuation-recovery-required", () transaction_files, transaction_issues = _inspect_transaction_files( root, manifest, status ) diff --git a/skills/implementing-staged-plans/scripts/program_rollover.py b/skills/implementing-staged-plans/scripts/program_rollover.py new file mode 100644 index 0000000..3763838 --- /dev/null +++ b/skills/implementing-staged-plans/scripts/program_rollover.py @@ -0,0 +1,1353 @@ +#!/usr/bin/env python3 +"""Persist prompt-bound successor rollover authority with status last.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +from collections.abc import Mapping, Sequence +from dataclasses import asdict, dataclass +from pathlib import Path + +from program_activation import ( + CURRENT_INCREMENT_AUTHORITY_SCHEMA, + INCREMENT_GRANT_SCHEMA, + _canonical_json_bytes, + _canonical_json_line, + _create_or_adopt_bytes, + _identifier, + _replace_or_adopt_status, + _without_owned_program_paths, +) +from program_authority import ( + NEW_PROGRAM_MANIFEST_SCHEMA, + load_json_lines, + load_json_object, + resolve_managed_path, + sha256_file, +) +from repository_preparation import ( + ExactFileMap, + execution_baseline_from_value, + inspect_repository, +) +from state_authority import ( + ACTION_AUTHORIZATION_SCHEMA, + ManagedWriteRequirement, + RepositoryObservation, + atomic_append_json_line, + required_future_lifecycle_writes, + validate_state_authority, +) + + +ROLLOVER_RECORD_SCHEMA = "implementation-increment-rollover/v1" +ROLLOVER_BINDING_SCHEMA = "implementation-increment-rollover-binding/v1" +INHERITED_WORKSPACE_SCHEMA = "implementation-inherited-workspace/v1" + + +@dataclass(frozen=True) +class IncrementRolloverReceipt: + prior_status_sha256: str + current_status_sha256: str + current_increment_id: str + successor_increment_id: str + rollover_authorization_id: str + successor_grant_id: str + created_steps: tuple[str, ...] + adopted_steps: tuple[str, ...] + status_replaced: bool + requires_retry: bool + + +@dataclass(frozen=True) +class IncrementRolloverInspection: + continuation_domain: str | None + disposition: str | None + completed_steps: tuple[str, ...] + issues: tuple[str, ...] + + +@dataclass(frozen=True) +class _RolloverCandidate: + continuation_domain: str + prior_status_sha256: str + prior_status_sequence: int + current_increment_id: str + successor_increment_id: str + rollover_authorization_id: str + successor_grant_id: str + action_record: dict[str, object] + action_bytes: bytes + action_sha256: str + grant_record: dict[str, object] + grant_bytes: bytes + grant_sha256: str + handoff_path: Path + handoff_bytes: bytes + successor_brief_path: Path + successor_brief_bytes: bytes + rollover_record: dict[str, object] + rollover_bytes: bytes + rollover_sha256: str + successor_status: dict[str, object] + successor_status_bytes: bytes + action_path: Path + grant_path: Path + rollover_path: Path + status_path: Path + + +class _UsageError(ValueError): + pass + + +class _ArgumentParser(argparse.ArgumentParser): + def error(self, message: str) -> None: + raise _UsageError(message) + + +def _sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _fresh_observation( + root: Path, + supplied: RepositoryObservation, +) -> RepositoryObservation: + fresh = inspect_repository(Path(supplied.path), supplied.base_commit).observation + normalized_fresh = _without_owned_program_paths(root, fresh) + normalized_supplied = _without_owned_program_paths(root, supplied) + if asdict(normalized_fresh) != asdict(normalized_supplied): + raise ValueError("workspace observation changed before increment rollover") + return normalized_fresh + + +def _load_role_path( + root: Path, + manifest: dict[str, object], + role: str, +) -> Path: + roles = manifest.get("logical_roles") + if not isinstance(roles, dict): + raise ValueError("manifest logical_roles must be an object") + path, issues = resolve_managed_path( + root, roles.get(role), role=f"logical role {role}" + ) + if path is None: + raise ValueError("; ".join(issues)) + return path + + +def _load_role_object( + root: Path, + manifest: dict[str, object], + role: str, +) -> tuple[dict[str, object], Path]: + path = _load_role_path(root, manifest, role) + value, issues = load_json_object(path) + if value is None: + raise ValueError("; ".join(issues)) + return value, path + + +def _increment_paths( + root: Path, + manifest: dict[str, object], + current_increment_id: str, + successor_increment_id: str, +) -> tuple[Path, Path]: + storage = manifest.get("increment_storage") + if not isinstance(storage, dict): + raise ValueError("manifest increment_storage must be an object") + relative_paths = ( + ( + f"{storage.get('root')}/{current_increment_id}/" + f"{storage.get('handoff_filename')}" + ), + ( + f"{storage.get('root')}/{successor_increment_id}/" + f"{storage.get('brief_filename')}" + ), + ) + resolved: list[Path] = [] + for label, relative in zip( + ("current handoff", "successor brief"), relative_paths, strict=True + ): + path, issues = resolve_managed_path( + root, relative, role=label, require_file=False + ) + if path is None: + raise ValueError("; ".join(issues)) + resolved.append(path) + return resolved[0], resolved[1] + + +def required_increment_rollover_writes( + program_root: Path, + workspace_root: Path, + successor_increment_id: str, +) -> tuple[ManagedWriteRequirement, ...]: + """Return Plan A's full managed allocation for the bound successor boundary.""" + return _required_increment_rollover_writes( + program_root, + workspace_root, + successor_increment_id, + allow_unbound_rollover_suffix=False, + ) + + +def _required_increment_rollover_writes( + program_root: Path, + workspace_root: Path, + successor_increment_id: str, + *, + allow_unbound_rollover_suffix: bool, +) -> tuple[ManagedWriteRequirement, ...]: + root = Path(program_root) + manifest, manifest_issues = load_json_object(root / "manifest.json") + if manifest is None: + raise ValueError("; ".join(manifest_issues)) + status, _ = _load_role_object(root, manifest, "status") + from program_continuation import _build_continuation_extension + from diff_disposition import build_diff_acceptance_candidate + + workspace, _ = _load_role_object(root, manifest, "workspace") + selected = workspace["implementation_workspace"] + observation = inspect_repository( + Path(selected["path"]), selected["base_commit"] + ).observation + acceptance = build_diff_acceptance_candidate(root, observation) + extension = _build_continuation_extension( + root, + acceptance, + observation, + allow_unbound_rollover_suffix=allow_unbound_rollover_suffix, + ) + if extension is None or extension.successor_increment_id != successor_increment_id: + raise ValueError("requested successor is not uniquely allocated and satisfied") + return required_future_lifecycle_writes( + root, Path(workspace_root), str(status["current_increment_id"]) + ) + + +def _validate_rollover_file_map( + file_map: ExactFileMap, + required: Sequence[ManagedWriteRequirement], +) -> None: + actual = { + path: disposition + for disposition, paths in ( + ("Create", file_map.create), + ("Modify", file_map.modify), + ("Preserve", file_map.preserve), + ) + for path in paths + } + issues = [ + f"rollover allocation {item.path} must be {item.disposition}" + for item in required + if actual.get(item.path) != item.disposition + ] + if issues: + raise ValueError("; ".join(issues)) + + +def _render_handoff( + *, + program_id: str, + program_revision: int, + current_increment_id: str, + successor_increment_id: str, + accepted_status_sha256: str, + rollover_authorization_id: str, + successor_grant_id: str, +) -> bytes: + return ( + "# Accepted increment handoff\n\n" + f"- Program: `{program_id}` revision `{program_revision}`\n" + f"- Accepted increment: `{current_increment_id}`\n" + f"- Successor increment: `{successor_increment_id}`\n" + f"- Accepted status: `{accepted_status_sha256}`\n" + f"- Rollover action: `{rollover_authorization_id}`\n" + f"- Successor grant: `{successor_grant_id}`\n" + "- Next legal action: prepare the successor exact-file plan from status-current authority.\n" + "- Authority: this handoff is navigation only and grants no action.\n" + ).encode("utf-8") + + +def _continuation_candidate( + root: Path, + submitted_prompt: str, + observation: RepositoryObservation, +) -> tuple[ + str, + dict[str, object], + object, + str, + str, + str, +]: + from diff_disposition import build_diff_acceptance_candidate + from program_continuation import ( + _build_continuation_extension, + _validate_submitted_continuation_prompt_for_rollover_retry, + build_accept_continue_candidate, + ) + + manifest, manifest_issues = load_json_object(root / "manifest.json") + if manifest is None: + raise ValueError("; ".join(manifest_issues)) + status, _status_path = _load_role_object(root, manifest, "status") + if status.get("current_increment_state") != "accepted": + raise ValueError("increment rollover requires an accepted current increment") + binding = status.get("diff_disposition_binding") + if not isinstance(binding, dict): + raise ValueError("accepted status diff disposition binding is required") + acceptance = build_diff_acceptance_candidate(root, observation) + extension = _build_continuation_extension( + root, + acceptance, + observation, + allow_unbound_rollover_suffix=True, + ) + if extension is None: + raise ValueError("no uniquely satisfied successor is available") + decision = binding.get("decision") + if decision == "accept-continue": + continued = build_accept_continue_candidate(acceptance, extension) + expected_prompt = ( + f"Accept and continue to `{extension.successor_increment_id}`.\n\n" + f"{continued.prompt}" + ) + if submitted_prompt != expected_prompt: + raise ValueError("submitted immediate continuation prompt is stale") + if _canonical_json_bytes(status) != continued.accepted_status_bytes: + raise ValueError("accepted continuation status differs from prompt binding") + domain = "immediate" + checkpoint_id = extension.checkpoint_id + authorization_id = extension.rollover_authorization_id + grant_id = extension.successor_grant_id + elif decision == "accept-stop": + command = _validate_submitted_continuation_prompt_for_rollover_retry( + root, submitted_prompt + ) + if command.successor_increment_id != extension.successor_increment_id: + raise ValueError("accepted-state successor changed") + domain = "accepted-state" + checkpoint_id = command.checkpoint_id + authorization_id = command.rollover_authorization_id + grant_id = command.successor_grant_id + else: + raise ValueError("accepted status has an unsupported diff decision") + return ( + domain, + status, + extension, + checkpoint_id, + authorization_id, + grant_id, + ) + + +def _build_rollover_candidate( + program_root: Path, + submitted_prompt: str, + observation: RepositoryObservation, +) -> _RolloverCandidate: + root = Path(program_root) + normalized = _fresh_observation(root, observation) + manifest, manifest_issues = load_json_object(root / "manifest.json") + if manifest is None: + raise ValueError("; ".join(manifest_issues)) + if manifest.get("schema_version") != NEW_PROGRAM_MANIFEST_SCHEMA: + raise ValueError("increment rollover requires a new-model v2 manifest") + ( + domain, + status, + extension, + checkpoint_id, + authorization_id, + grant_id, + ) = _continuation_candidate(root, submitted_prompt, normalized) + status_path = _load_role_path(root, manifest, "status") + action_path = _load_role_path(root, manifest, "action_authorizations") + grant_path = _load_role_path(root, manifest, "increment_grants") + rollover_path = _load_role_path(root, manifest, "rollovers") + _workspace, workspace_path = _load_role_object(root, manifest, "workspace") + current_increment_id = str(status["current_increment_id"]) + successor_increment_id = extension.successor_increment_id + handoff_path, successor_brief_path = _increment_paths( + root, manifest, current_increment_id, successor_increment_id + ) + baseline_binding = status.get("execution_baseline_binding") + if not isinstance(baseline_binding, dict): + raise ValueError("accepted status execution baseline binding is required") + baseline_path, baseline_path_issues = resolve_managed_path( + root, baseline_binding.get("path"), role="accepted execution baseline" + ) + if baseline_path is None: + raise ValueError("; ".join(baseline_path_issues)) + baseline_value, baseline_issues = load_json_object(baseline_path) + if baseline_value is None: + raise ValueError("; ".join(baseline_issues)) + baseline = execution_baseline_from_value(baseline_value) + required = _required_increment_rollover_writes( + root, + Path(normalized.path), + successor_increment_id, + allow_unbound_rollover_suffix=True, + ) + _validate_rollover_file_map(baseline.file_map, required) + + prompt_sha256 = _sha256_bytes(submitted_prompt.encode("utf-8")) + prior_status_sha256 = sha256_file(status_path) + prior_status_sequence = int(status["state_sequence"]) + projection = dict(extension.successor_projection) + if domain == "accepted-state": + from program_continuation import ( + _validate_submitted_continuation_prompt_for_rollover_retry, + ) + + accepted_command = _validate_submitted_continuation_prompt_for_rollover_retry( + root, submitted_prompt + ) + selected_workspace = dict(accepted_command.selected_workspace) + inherited_workspace = dict(accepted_command.inherited_workspace) + accepted_product_delta_sha256 = ( + accepted_command.accepted_product_delta_sha256 + ) + allowed_actions = list( + accepted_command.allowed_conditional_action_ceiling + ) + else: + selected_workspace = dict(projection["selected_workspace"]) + inherited_workspace = { + "selected_workspace": selected_workspace, + "accepted_product_delta": [ + asdict(item) for item in extension.accepted_product_delta + ], + "accepted_product_delta_sha256": projection[ + "accepted_product_delta_sha256" + ], + } + accepted_product_delta_sha256 = str( + projection["accepted_product_delta_sha256"] + ) + allowed_actions = list(projection["allowed_conditional_action_ceiling"]) + + source = status["source_binding"] + program = status["program_binding"] + action_record = { + "schema_version": ACTION_AUTHORIZATION_SCHEMA, + "authorization_id": authorization_id, + "decision": "authorized", + "actions": ["rollover-increment"], + "scope": ["roll over the accepted increment to the bound successor"], + "constraints": ["persist authority first and successor status last"], + "excluded": [ + "create-local-commit", + "create-draft-pull-request", + "merge", + "publish", + "release", + "deploy", + "migrate", + "destructive-operation", + "modify-provider-state", + "modify-external-state", + ], + "program_id": status["program_id"], + "program_revision": status["program_revision"], + "source_id": source["source_id"], + "source_sha256": source["sha256"], + "program_sha256": program["sha256"], + "semantic_requirements_sha256": program[ + "semantic_requirements_sha256" + ], + "current_increment_id": current_increment_id, + "successor_increment_id": successor_increment_id, + "continuation_domain": domain, + "continuation_checkpoint_id": checkpoint_id, + "accepted_status_sha256": prior_status_sha256, + "accepted_status_sequence": prior_status_sequence, + "accepted_product_delta_sha256": accepted_product_delta_sha256, + "workspace": selected_workspace, + "submitted_prompt_sha256": prompt_sha256, + } + action_bytes = _canonical_json_line(action_record) + action_sha256 = _sha256_bytes(action_bytes) + brief_binding = { + "path": successor_brief_path.relative_to(root).as_posix(), + "sha256": _sha256_bytes(extension.successor_brief_bytes), + } + grant_record = { + "schema_version": INCREMENT_GRANT_SCHEMA, + "grant_id": grant_id, + "decision": "granted", + "program_id": status["program_id"], + "program_revision": status["program_revision"], + "increment_id": successor_increment_id, + "approval_mode": projection["successor_approval_mode"], + "continuation_domain": domain, + "continuation_checkpoint_id": checkpoint_id, + "rollover_authorization_id": authorization_id, + "brief_binding": brief_binding, + "workspace_selection_sha256": sha256_file(workspace_path), + "allowed_conditional_actions": allowed_actions, + "submitted_prompt_sha256": prompt_sha256, + } + grant_bytes = _canonical_json_line(grant_record) + grant_sha256 = _sha256_bytes(grant_bytes) + handoff_bytes = _render_handoff( + program_id=str(status["program_id"]), + program_revision=int(status["program_revision"]), + current_increment_id=current_increment_id, + successor_increment_id=successor_increment_id, + accepted_status_sha256=prior_status_sha256, + rollover_authorization_id=authorization_id, + successor_grant_id=grant_id, + ) + rollover_id = _identifier( + "increment-rollover", + { + "continuation_domain": domain, + "continuation_checkpoint_id": checkpoint_id, + "accepted_status_sha256": prior_status_sha256, + "rollover_authorization_id": authorization_id, + "successor_grant_id": grant_id, + }, + ) + rollover_record = { + "schema_version": ROLLOVER_RECORD_SCHEMA, + "rollover_id": rollover_id, + "continuation_domain": domain, + "continuation_checkpoint_id": checkpoint_id, + "program_id": status["program_id"], + "program_revision": status["program_revision"], + "current_increment_id": current_increment_id, + "successor_increment_id": successor_increment_id, + "accepted_status_sha256": prior_status_sha256, + "accepted_status_sequence": prior_status_sequence, + "submitted_prompt_sha256": prompt_sha256, + "rollover_authorization_id": authorization_id, + "rollover_authorization_sha256": action_sha256, + "successor_grant_id": grant_id, + "successor_grant_sha256": grant_sha256, + "handoff_binding": { + "path": handoff_path.relative_to(root).as_posix(), + "sha256": _sha256_bytes(handoff_bytes), + }, + "successor_brief_binding": brief_binding, + "accepted_product_delta": [ + asdict(item) for item in extension.accepted_product_delta + ], + "accepted_product_delta_sha256": accepted_product_delta_sha256, + "selected_workspace": selected_workspace, + "inherited_workspace": inherited_workspace, + "prior_increment_authority_binding": status[ + "current_increment_authority_binding" + ], + } + rollover_bytes = _canonical_json_line(rollover_record) + rollover_sha256 = _sha256_bytes(rollover_bytes) + prior_inherited_binding = status.get("inherited_workspace_binding", {}) + if not isinstance(prior_inherited_binding, Mapping): + raise ValueError("prior inherited workspace inventory is invalid") + prior_inherited = prior_inherited_binding.get("inherited_paths", []) + if not isinstance(prior_inherited, list) or not all( + isinstance(item, str) for item in prior_inherited + ): + raise ValueError("prior inherited workspace inventory is invalid") + cumulative_inherited_paths = sorted( + { + *prior_inherited, + *(item.path for item in extension.accepted_product_delta), + } + ) + successor_status = dict(status) + for field in ( + "approved_exact_file_plan_sha256", + "pending_exact_file_plan_sha256", + "plan_preparation_binding", + "execution_authorization", + "execution_baseline_binding", + "execution_transition_binding", + "review_preparation_binding", + "review_evidence_binding", + "review_packet_binding", + "review_remediation_binding", + "diff_disposition_binding", + "closure_preparation_binding", + "closure_binding", + ): + successor_status.pop(field, None) + successor_status.update( + state_sequence=prior_status_sequence + 1, + program_state="active", + current_increment_id=successor_increment_id, + current_increment_state="preparing", + approval_mode=projection["successor_approval_mode"], + brief_binding={ + **brief_binding, + "workspace_sha256": sha256_file(workspace_path), + "head_commit": normalized.head_commit, + }, + current_increment_authority_binding={ + "schema_version": "implementation-current-increment-authority-binding/v1", + "kind": "increment-grant", + "increment_id": successor_increment_id, + "grant_id": grant_id, + "grant_sha256": grant_sha256, + }, + rollover_binding={ + "schema_version": ROLLOVER_BINDING_SCHEMA, + "rollover_id": rollover_id, + "rollover_sha256": rollover_sha256, + "continuation_domain": domain, + "continuation_checkpoint_id": checkpoint_id, + "prior_status_sha256": prior_status_sha256, + "prior_status_sequence": prior_status_sequence, + "current_increment_id": current_increment_id, + "successor_increment_id": successor_increment_id, + "rollover_authorization_id": authorization_id, + "rollover_authorization_sha256": action_sha256, + "successor_grant_id": grant_id, + "successor_grant_sha256": grant_sha256, + "submitted_prompt_sha256": prompt_sha256, + }, + inherited_workspace_binding={ + "schema_version": INHERITED_WORKSPACE_SCHEMA, + "workspace_selection_sha256": sha256_file(workspace_path), + "accepted_product_delta_sha256": accepted_product_delta_sha256, + "inherited_paths": cumulative_inherited_paths, + }, + previous_state={ + "schema_version": status["schema_version"], + "state_sequence": prior_status_sequence, + "status_sha256": prior_status_sha256, + }, + transition_authority={ + "kind": "action-authorization", + "authorization_id": authorization_id, + "event_id": rollover_id, + "checkpoint_id": checkpoint_id, + }, + ) + return _RolloverCandidate( + continuation_domain=domain, + prior_status_sha256=prior_status_sha256, + prior_status_sequence=prior_status_sequence, + current_increment_id=current_increment_id, + successor_increment_id=successor_increment_id, + rollover_authorization_id=authorization_id, + successor_grant_id=grant_id, + action_record=action_record, + action_bytes=action_bytes, + action_sha256=action_sha256, + grant_record=grant_record, + grant_bytes=grant_bytes, + grant_sha256=grant_sha256, + handoff_path=handoff_path, + handoff_bytes=handoff_bytes, + successor_brief_path=successor_brief_path, + successor_brief_bytes=extension.successor_brief_bytes, + rollover_record=rollover_record, + rollover_bytes=rollover_bytes, + rollover_sha256=rollover_sha256, + successor_status=successor_status, + successor_status_bytes=_canonical_json_bytes(successor_status), + action_path=action_path, + grant_path=grant_path, + rollover_path=rollover_path, + status_path=status_path, + ) + + +def _append_or_adopt_record( + path: Path, + record: dict[str, object], + *, + identifier_field: str, + label: str, +) -> bool: + records, issues = load_json_lines(path) + if records is None: + raise ValueError("; ".join(issues)) + identifier = record[identifier_field] + matches = [item for item in records if item.get(identifier_field) == identifier] + if matches: + if len(matches) != 1 or matches[0] != record: + raise ValueError(f"continuation-recovery-required: divergent {label}") + return True + atomic_append_json_line(path, record, sha256_file(path)) + return False + + +def _after_persist(_label: str) -> None: + """Test seam after each durable rollover transaction step.""" + + +def _completed_receipt( + root: Path, + manifest: dict[str, object], + status: dict[str, object], + submitted_prompt: str, +) -> IncrementRolloverReceipt | None: + binding = status.get("rollover_binding") + if not isinstance(binding, dict): + return None + if status.get("current_increment_state") != "preparing": + return None + prompt_sha256 = _sha256_bytes(submitted_prompt.encode("utf-8")) + if binding.get("submitted_prompt_sha256") != prompt_sha256: + raise ValueError("continuation-recovery-required: submitted prompt differs") + rollover_path = _load_role_path(root, manifest, "rollovers") + rollovers, issues = load_json_lines(rollover_path) + if rollovers is None: + raise ValueError("; ".join(issues)) + matches = [ + record + for record in rollovers + if record.get("rollover_id") == binding.get("rollover_id") + ] + if ( + len(matches) != 1 + or _sha256_bytes(_canonical_json_line(matches[0])) + != binding.get("rollover_sha256") + or matches[0].get("successor_increment_id") + != status.get("current_increment_id") + ): + raise ValueError("continuation-recovery-required: rollover binding differs") + return IncrementRolloverReceipt( + prior_status_sha256=str(binding["prior_status_sha256"]), + current_status_sha256=sha256_file(_load_role_path(root, manifest, "status")), + current_increment_id=str(status["current_increment_id"]), + successor_increment_id=str(status["current_increment_id"]), + rollover_authorization_id=str(binding["rollover_authorization_id"]), + successor_grant_id=str(binding["successor_grant_id"]), + created_steps=(), + adopted_steps=( + "action-authorization", + "successor-grant", + "handoff", + "successor-brief", + "rollover-record", + "successor-status", + ), + status_replaced=False, + requires_retry=False, + ) + + +def _preflight_rollover_history( + root: Path, + status: Mapping[str, object], + candidate: _RolloverCandidate, + observation: RepositoryObservation, +) -> None: + completed = _validated_completed_rollover_records( + root, + status, + allow_unbound_suffix=True, + ) + records, issues = load_json_lines(candidate.rollover_path) + if records is None: + raise ValueError("; ".join(issues)) + suffix = records[len(completed) :] + if suffix and suffix != [candidate.rollover_record]: + raise ValueError( + "continuation-recovery-required: divergent rollover history" + ) + inherited_paths = _validated_inherited_paths( + root, + status, + observation, + allow_unbound_suffix=True, + ) + if isinstance(status.get("rollover_binding"), Mapping): + inherited = status.get("inherited_workspace_binding") + if ( + not isinstance(inherited, Mapping) + or inherited.get("inherited_paths") != list(inherited_paths) + ): + raise ValueError("rollover inherited workspace inventory mismatch") + candidate_inherited = candidate.successor_status.get( + "inherited_workspace_binding" + ) + current_delta = candidate.rollover_record.get("accepted_product_delta") + if not isinstance(candidate_inherited, Mapping) or not isinstance( + current_delta, list + ): + raise ValueError("rollover candidate inherited workspace is invalid") + expected_cumulative = sorted( + { + *inherited_paths, + *( + item["path"] + for item in current_delta + if isinstance(item, Mapping) and isinstance(item.get("path"), str) + ), + } + ) + if candidate_inherited.get("inherited_paths") != expected_cumulative: + raise ValueError("rollover candidate inherited workspace inventory mismatch") + + +def persist_increment_rollover( + program_root: Path, + submitted_prompt: str, + observation: RepositoryObservation, +) -> IncrementRolloverReceipt: + """Persist or adopt the authority-first, status-last rollover transaction.""" + root = Path(program_root) + normalized = _fresh_observation(root, observation) + manifest, manifest_issues = load_json_object(root / "manifest.json") + if manifest is None: + raise ValueError("; ".join(manifest_issues)) + status, _ = _load_role_object(root, manifest, "status") + completed = _completed_receipt(root, manifest, status, submitted_prompt) + if completed is not None: + issues = validate_state_authority(root, normalized) + if issues: + raise ValueError("; ".join(issues)) + return completed + candidate = _build_rollover_candidate(root, submitted_prompt, normalized) + _preflight_rollover_history(root, status, candidate, normalized) + created: list[str] = [] + adopted: list[str] = [] + + def record(label: str, was_adopted: bool) -> None: + (adopted if was_adopted else created).append(label) + _after_persist(label) + + record( + "action-authorization", + _append_or_adopt_record( + candidate.action_path, + candidate.action_record, + identifier_field="authorization_id", + label="rollover action authorization", + ), + ) + record( + "successor-grant", + _append_or_adopt_record( + candidate.grant_path, + candidate.grant_record, + identifier_field="grant_id", + label="successor grant", + ), + ) + record( + "handoff", + _create_or_adopt_bytes( + candidate.handoff_path, + candidate.handoff_bytes, + "continuation", + ), + ) + record( + "successor-brief", + _create_or_adopt_bytes( + candidate.successor_brief_path, + candidate.successor_brief_bytes, + "continuation", + ), + ) + record( + "rollover-record", + _append_or_adopt_record( + candidate.rollover_path, + candidate.rollover_record, + identifier_field="rollover_id", + label="rollover record", + ), + ) + status_adopted = _replace_or_adopt_status( + candidate.status_path, + candidate.successor_status, + candidate.prior_status_sha256, + "continuation", + ) + record("successor-status", status_adopted) + final_issues = validate_state_authority(root, _fresh_observation(root, normalized)) + if final_issues: + raise ValueError("; ".join(final_issues)) + return IncrementRolloverReceipt( + prior_status_sha256=candidate.prior_status_sha256, + current_status_sha256=sha256_file(candidate.status_path), + current_increment_id=candidate.successor_increment_id, + successor_increment_id=candidate.successor_increment_id, + rollover_authorization_id=candidate.rollover_authorization_id, + successor_grant_id=candidate.successor_grant_id, + created_steps=tuple(created), + adopted_steps=tuple(adopted), + status_replaced=not status_adopted, + requires_retry=False, + ) + + +def _candidate_prefix_inspection(candidate: _RolloverCandidate) -> IncrementRolloverInspection: + steps: list[str] = [] + checks = ( + ( + "action-authorization", + candidate.action_path, + "authorization_id", + candidate.rollover_authorization_id, + candidate.action_record, + ), + ( + "successor-grant", + candidate.grant_path, + "grant_id", + candidate.successor_grant_id, + candidate.grant_record, + ), + ) + for label, path, identifier_field, identifier, expected in checks: + records, issues = load_json_lines(path) + if records is None: + return IncrementRolloverInspection( + candidate.continuation_domain, + None, + tuple(steps), + tuple(issues), + ) + matches = [item for item in records if item.get(identifier_field) == identifier] + if not matches: + break + if len(matches) != 1 or matches[0] != expected: + return IncrementRolloverInspection( + candidate.continuation_domain, + _recovery_disposition(candidate.continuation_domain), + tuple(steps), + (f"divergent {label}",), + ) + steps.append(label) + else: + for label, path, expected in ( + ("handoff", candidate.handoff_path, candidate.handoff_bytes), + ( + "successor-brief", + candidate.successor_brief_path, + candidate.successor_brief_bytes, + ), + ): + if not path.exists() and not path.is_symlink(): + break + if path.is_symlink() or not path.is_file() or path.read_bytes() != expected: + return IncrementRolloverInspection( + candidate.continuation_domain, + _recovery_disposition(candidate.continuation_domain), + tuple(steps), + (f"divergent {label}",), + ) + steps.append(label) + else: + records, issues = load_json_lines(candidate.rollover_path) + if records is None: + return IncrementRolloverInspection( + candidate.continuation_domain, None, tuple(steps), tuple(issues) + ) + matches = [ + item + for item in records + if item.get("rollover_id") + == candidate.rollover_record.get("rollover_id") + ] + if matches: + if len(matches) != 1 or matches[0] != candidate.rollover_record: + return IncrementRolloverInspection( + candidate.continuation_domain, + _recovery_disposition(candidate.continuation_domain), + tuple(steps), + ("divergent rollover-record",), + ) + steps.append("rollover-record") + if not steps: + return IncrementRolloverInspection( + candidate.continuation_domain, None, (), () + ) + early = len(steps) <= 2 + if candidate.continuation_domain == "immediate": + disposition = ( + "increment-continuation-retry-ready" + if early + else "increment-rollover-retry-ready" + ) + else: + disposition = ( + "accepted-state-continuation-retry-ready" + if early + else "accepted-state-rollover-retry-ready" + ) + return IncrementRolloverInspection( + candidate.continuation_domain, disposition, tuple(steps), () + ) + + +def _recovery_disposition(domain: str) -> str: + return ( + "accepted-state-continuation-recovery-required" + if domain == "accepted-state" + else "continuation-recovery-required" + ) + + +def inspect_increment_rollover( + program_root: Path, + observation: RepositoryObservation, +) -> IncrementRolloverInspection: + """Classify an exact rollover prefix without modifying it.""" + root = Path(program_root) + manifest, manifest_issues = load_json_object(root / "manifest.json") + if manifest is None: + return IncrementRolloverInspection(None, None, (), tuple(manifest_issues)) + status, _ = _load_role_object(root, manifest, "status") + if ( + status.get("current_increment_state") == "preparing" + and isinstance(status.get("rollover_binding"), dict) + ): + return IncrementRolloverInspection(None, "resume", ("successor-status",), ()) + if status.get("current_increment_state") != "accepted": + return IncrementRolloverInspection(None, None, (), ()) + binding = status.get("diff_disposition_binding") + if not isinstance(binding, dict) or binding.get("decision") not in { + "accept-stop", + "accept-continue", + }: + return IncrementRolloverInspection(None, None, (), ()) + try: + if binding["decision"] == "accept-continue": + from program_continuation import _render_accept_continue_prompt + + prompt = _render_accept_continue_prompt( + root, + allow_unbound_rollover_suffix=True, + ) + else: + from program_continuation import ( + _render_accepted_state_continuation_prompt, + ) + + prompt = _render_accepted_state_continuation_prompt( + root, + allow_unbound_rollover_suffix=True, + ) + candidate = _build_rollover_candidate(root, prompt, observation) + except (KeyError, OSError, TypeError, ValueError) as error: + action_path = _load_role_path(root, manifest, "action_authorizations") + grant_path = _load_role_path(root, manifest, "increment_grants") + rollover_path = _load_role_path(root, manifest, "rollovers") + actions, _ = load_json_lines(action_path) + grants, _ = load_json_lines(grant_path) + rollovers, _ = load_json_lines(rollover_path) + rollover_records = rollovers or [] + completed_rollovers: list[dict[str, object]] = [] + prior_rollover = status.get("rollover_binding") + if isinstance(prior_rollover, dict): + bound_indices = [ + index + for index, record in enumerate(rollover_records) + if record.get("rollover_id") == prior_rollover.get("rollover_id") + ] + if len(bound_indices) == 1: + completed_rollovers = rollover_records[: bound_indices[0] + 1] + completed_action_ids = { + record.get("rollover_authorization_id") + for record in completed_rollovers + } + completed_grant_ids = { + record.get("successor_grant_id") for record in completed_rollovers + } + rollover_actions = [ + record + for record in actions or [] + if record.get("actions") == ["rollover-increment"] + and record.get("authorization_id") not in completed_action_ids + ] + continuation_grants = [ + record + for record in grants or [] + if record.get("continuation_domain") in {"immediate", "accepted-state"} + and record.get("grant_id") not in completed_grant_ids + ] + active_rollovers = rollover_records[len(completed_rollovers) :] + domains = [ + record.get("continuation_domain") + for record in ( + *rollover_actions, + *continuation_grants, + *active_rollovers, + ) + ] + if domains: + domain = ( + "accepted-state" + if domains[-1] == "accepted-state" + else "immediate" + ) + return IncrementRolloverInspection( + domain, _recovery_disposition(domain), (), (str(error),) + ) + return IncrementRolloverInspection(None, None, (), ()) + return _candidate_prefix_inspection(candidate) + + +def validated_inherited_paths( + program_root: Path, + status: Mapping[str, object], + observation: RepositoryObservation, +) -> tuple[str, ...]: + """Validate the canonical rollover chain and return latest accepted paths.""" + return _validated_inherited_paths( + program_root, + status, + observation, + allow_unbound_suffix=False, + ) + + +def _validated_completed_rollover_records( + program_root: Path, + status: Mapping[str, object], + *, + allow_unbound_suffix: bool, +) -> tuple[dict[str, object], ...]: + root = Path(program_root) + manifest, manifest_issues = load_json_object(root / "manifest.json") + if manifest is None: + raise ValueError("; ".join(manifest_issues)) + rollover_path = _load_role_path(root, manifest, "rollovers") + records, record_issues = load_json_lines(rollover_path) + if records is None: + raise ValueError("; ".join(record_issues)) + binding = status.get("rollover_binding") + if not isinstance(binding, Mapping): + if records and not allow_unbound_suffix: + raise ValueError("unbound rollover history is not lifecycle authority") + if len(records) > 1: + raise ValueError("rollover history contains multiple unbound records") + return () + bound_indices = [ + index + for index, record in enumerate(records) + if record.get("rollover_id") == binding.get("rollover_id") + and _sha256_bytes(_canonical_json_line(record)) + == binding.get("rollover_sha256") + ] + if len(bound_indices) != 1: + raise ValueError("status rollover binding does not identify one record") + bound_index = bound_indices[0] + suffix = records[bound_index + 1 :] + if suffix and not allow_unbound_suffix: + raise ValueError("unbound rollover history is not lifecycle authority") + if len(suffix) > 1: + raise ValueError("rollover history contains multiple unbound records") + completed = records[: bound_index + 1] + if not completed: + raise ValueError("successor status requires a canonical rollover chain") + + grant_path = _load_role_path(root, manifest, "increment_grants") + action_path = _load_role_path(root, manifest, "action_authorizations") + grants, grant_issues = load_json_lines(grant_path) + actions, action_issues = load_json_lines(action_path) + if grants is None: + raise ValueError("; ".join(grant_issues)) + if actions is None: + raise ValueError("; ".join(action_issues)) + activation = status.get("activation_binding") + if not isinstance(activation, Mapping): + raise ValueError("rollover chain activation authority is required") + genesis_grants = [ + grant + for grant in grants + if grant.get("grant_id") == activation.get("increment_grant_id") + ] + if len(genesis_grants) != 1: + raise ValueError("rollover chain genesis grant must exist exactly once") + genesis_grant = genesis_grants[0] + if ( + genesis_grant.get("schema_version") != INCREMENT_GRANT_SCHEMA + or genesis_grant.get("decision") != "granted" + or genesis_grant.get("program_id") != status.get("program_id") + or genesis_grant.get("program_revision") + != status.get("program_revision") + or genesis_grant.get("launch_checkpoint_id") + != activation.get("launch_checkpoint_id") + or genesis_grant.get("program_approval_event_id") + != activation.get("program_approval_event_id") + or genesis_grant.get("workspace_approval_event_id") + != activation.get("workspace_approval_event_id") + ): + raise ValueError("rollover chain genesis grant authority is invalid") + expected_authority: dict[str, object] = { + "schema_version": CURRENT_INCREMENT_AUTHORITY_SCHEMA, + "kind": "increment-grant", + "increment_id": genesis_grant.get("increment_id"), + "grant_id": genesis_grant.get("grant_id"), + "grant_sha256": _sha256_bytes(_canonical_json_line(genesis_grant)), + } + expected_current: str | None = None + for index, record in enumerate(completed): + if record.get("schema_version") != ROLLOVER_RECORD_SCHEMA: + raise ValueError("rollover chain contains an unsupported record") + current = record.get("current_increment_id") + successor = record.get("successor_increment_id") + if ( + not isinstance(current, str) + or not isinstance(successor, str) + or not current + or not successor + or current == successor + or current != expected_authority.get("increment_id") + or record.get("program_id") != status.get("program_id") + or record.get("program_revision") != status.get("program_revision") + ): + raise ValueError("rollover chain increment authority is invalid") + if index and current != expected_current: + raise ValueError("rollover chain is not contiguous") + if record.get("prior_increment_authority_binding") != expected_authority: + raise ValueError("rollover chain prior increment authority is invalid") + matching_actions = [ + action + for action in actions + if action.get("authorization_id") + == record.get("rollover_authorization_id") + ] + if len(matching_actions) != 1: + raise ValueError("rollover chain action authority must exist exactly once") + action = matching_actions[0] + if ( + action.get("schema_version") != ACTION_AUTHORIZATION_SCHEMA + or action.get("decision") != "authorized" + or action.get("actions") != ["rollover-increment"] + or action.get("program_id") != status.get("program_id") + or action.get("program_revision") != status.get("program_revision") + or action.get("current_increment_id") != current + or action.get("successor_increment_id") != successor + or _sha256_bytes(_canonical_json_line(action)) + != record.get("rollover_authorization_sha256") + ): + raise ValueError("rollover chain action authority is invalid") + matching_grants = [ + grant + for grant in grants + if grant.get("grant_id") == record.get("successor_grant_id") + ] + if len(matching_grants) != 1: + raise ValueError("rollover chain successor grant must exist exactly once") + grant = matching_grants[0] + grant_sha256 = _sha256_bytes(_canonical_json_line(grant)) + if ( + grant.get("schema_version") != INCREMENT_GRANT_SCHEMA + or grant.get("decision") != "granted" + or grant.get("program_id") != status.get("program_id") + or grant.get("program_revision") != status.get("program_revision") + or grant.get("increment_id") != successor + or grant_sha256 != record.get("successor_grant_sha256") + ): + raise ValueError("rollover chain successor grant authority is invalid") + expected_authority = { + "schema_version": CURRENT_INCREMENT_AUTHORITY_SCHEMA, + "kind": "increment-grant", + "increment_id": successor, + "grant_id": grant.get("grant_id"), + "grant_sha256": grant_sha256, + } + expected_current = successor + if ( + expected_current != status.get("current_increment_id") + or status.get("current_increment_authority_binding") != expected_authority + ): + raise ValueError("rollover chain does not reach status-current authority") + return tuple(completed) + + +def _validated_inherited_paths( + program_root: Path, + status: Mapping[str, object], + observation: RepositoryObservation, + *, + allow_unbound_suffix: bool, +) -> tuple[str, ...]: + records = _validated_completed_rollover_records( + program_root, + status, + allow_unbound_suffix=allow_unbound_suffix, + ) + if not records: + return () + root = Path(program_root) + latest: dict[str, str] = {} + for record in records: + delta = record.get("accepted_product_delta") + if not isinstance(delta, list): + raise ValueError("rollover accepted product delta is invalid") + for item in delta: + if ( + not isinstance(item, dict) + or not isinstance(item.get("path"), str) + or not isinstance(item.get("sha256"), str) + ): + raise ValueError("rollover accepted product entry is invalid") + latest[item["path"]] = item["sha256"] + if status.get("current_increment_state") in { + "preparing", + "awaiting-plan-approval", + "authorized", + }: + workspace = Path(observation.path) + for relative, expected_sha256 in latest.items(): + path = workspace / relative + if ( + path.is_symlink() + or not path.is_file() + or sha256_file(path) != expected_sha256 + ): + raise ValueError( + f"inherited accepted product bytes changed: {relative}" + ) + return tuple(sorted(latest)) + + +def build_argument_parser() -> argparse.ArgumentParser: + parser = _ArgumentParser(prog="program_rollover.py") + subparsers = parser.add_subparsers(dest="command", required=True) + apply_parser = subparsers.add_parser("apply") + apply_parser.add_argument("program_root") + apply_parser.add_argument("--prompt-file", required=True) + apply_parser.add_argument("--repository", required=True) + apply_parser.add_argument("--base-commit", required=True) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + try: + arguments = build_argument_parser().parse_args( + list(sys.argv[1:] if argv is None else argv) + ) + prompt_path = Path(arguments.prompt_file) + if prompt_path.is_symlink() or not prompt_path.is_file(): + raise ValueError("prompt file must be a regular non-symlink file") + inspection = inspect_repository( + Path(arguments.repository), arguments.base_commit + ) + receipt = persist_increment_rollover( + Path(arguments.program_root), + prompt_path.read_text(encoding="utf-8"), + inspection.observation, + ) + except (_UsageError, OSError, TypeError, ValueError) as error: + print(str(error), file=sys.stderr) + return 2 if isinstance(error, _UsageError) else 1 + print(json.dumps(asdict(receipt), sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/implementing-staged-plans/scripts/repository_preparation.py b/skills/implementing-staged-plans/scripts/repository_preparation.py index ddf6833..cad7095 100644 --- a/skills/implementing-staged-plans/scripts/repository_preparation.py +++ b/skills/implementing-staged-plans/scripts/repository_preparation.py @@ -1041,6 +1041,11 @@ def execution_baseline_from_value(value: object) -> ExecutionBaseline: workspace = value["workspace_observation"] if not isinstance(authority, dict) or not isinstance(workspace, dict): raise TypeError("binding") + if any(not isinstance(path, str) for path in inherited_values): + raise TypeError("inherited_paths") + inherited_paths = tuple( + _normalized_file_map_path(path) for path in inherited_values + ) baseline = ExecutionBaseline( schema_version=str(value["schema_version"]), program_id=str(value["program_id"]), @@ -1052,7 +1057,7 @@ def execution_baseline_from_value(value: object) -> ExecutionBaseline: file_map=file_map, path_baselines=path_baselines, user_work_baselines=user_baselines, - inherited_paths=tuple(str(path) for path in inherited_values), + inherited_paths=inherited_paths, ) except (KeyError, TypeError, ValueError) as error: raise ValueError("execution baseline structure is invalid") from error @@ -1081,8 +1086,25 @@ def execution_baseline_from_value(value: object) -> ExecutionBaseline: baseline.user_work_baselines ): raise ValueError("execution baseline user-work inventory is duplicated") - if baseline.inherited_paths: - raise ValueError("first-increment execution baseline inherited_paths must be empty") + if len(set(baseline.inherited_paths)) != len(baseline.inherited_paths): + raise ValueError("execution baseline inherited inventory is duplicated") + user_paths = {item.path for item in baseline.user_work_baselines} + for inherited_path in baseline.inherited_paths: + matches = [ + item for item in baseline.path_baselines if item.path == inherited_path + ] + if len(matches) != 1: + raise ValueError( + "each inherited path requires exactly one path baseline" + ) + if dispositions.get(inherited_path) not in {"Modify", "Preserve"}: + raise ValueError( + "inherited paths must be owned as Modify or Preserve" + ) + if inherited_path in user_paths: + raise ValueError( + "inherited paths must be disjoint from user-work baselines" + ) return baseline @@ -1134,14 +1156,25 @@ def validate_execution_workspace( issues.append("execution workspace active Git operation changed") control_prefix = _relative_control_prefix(Path(program_root), workspace) - current_staged = _without_control_paths(observation.staged_paths, control_prefix) + inherited_paths = set(baseline.inherited_paths) + current_staged = _without_control_paths( + tuple( + path for path in observation.staged_paths if path not in inherited_paths + ), + control_prefix, + ) recorded_staged = _without_control_paths( tuple(recorded.get("staged_paths", ())), control_prefix ) if current_staged != recorded_staged: issues.append("execution workspace has new or changed staged paths") current_conflicted = _without_control_paths( - observation.conflicted_paths, control_prefix + tuple( + path + for path in observation.conflicted_paths + if path not in inherited_paths + ), + control_prefix, ) recorded_conflicted = _without_control_paths( tuple(recorded.get("conflicted_paths", ())), control_prefix @@ -1167,7 +1200,11 @@ def validate_execution_workspace( ), control_prefix, ) - product_paths = set(baseline.file_map.create) | set(baseline.file_map.modify) + product_paths = ( + set(baseline.file_map.create) + | set(baseline.file_map.modify) + | inherited_paths + ) unexpected_dirty = current_dirty - recorded_dirty - product_paths if unexpected_dirty: issues.append( diff --git a/skills/implementing-staged-plans/scripts/state_authority.py b/skills/implementing-staged-plans/scripts/state_authority.py index 35785f6..4c09bc7 100644 --- a/skills/implementing-staged-plans/scripts/state_authority.py +++ b/skills/implementing-staged-plans/scripts/state_authority.py @@ -9,7 +9,8 @@ import sys import tempfile from collections.abc import Sequence -from dataclasses import asdict, dataclass +from contextvars import ContextVar +from dataclasses import asdict, dataclass, replace from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -56,6 +57,11 @@ APPROVAL_SCHEMA = "implementation-approval/v1" ACTION_AUTHORIZATION_SCHEMA = "implementation-action-authorization/v1" +_INSPECTING_UNBOUND_ROLLOVER_SUFFIX = ContextVar( + "inspecting_unbound_rollover_suffix", + default=False, +) + PROGRAM_TRANSITIONS = { "captured": frozenset( {"awaiting-program-approval", "blocked", "superseded"} @@ -156,6 +162,8 @@ class ApprovalModePolicy: ACTION_NAMES = frozenset( { "write-program-artifact", + "rollover-increment", + "resume-blocked-program", "create-workspace", "modify-workspace", "run-local-verification", @@ -215,25 +223,32 @@ def _traceability_successor( atomic_requirements = traceability.get("atomic_requirements") if not isinstance(atomic_requirements, list): raise ValueError("traceability atomic_requirements must be a list") - increments: list[str] = [] + current_found = False + candidates: set[str] = set() for requirement in atomic_requirements: assigned = ( requirement.get("assigned_increments") if isinstance(requirement, dict) else None ) - if not isinstance(assigned, list): - raise ValueError("traceability assigned_increments must be a list") - for candidate in assigned: - if not isinstance(candidate, str) or not candidate: - raise ValueError("traceability increment identifiers must be strings") - if candidate not in increments: - increments.append(candidate) - if increment_id not in increments: + if ( + not isinstance(assigned, list) + or not assigned + or not all(isinstance(candidate, str) and candidate for candidate in assigned) + or len(assigned) != len(set(assigned)) + ): + raise ValueError( + "traceability assigned_increments must be unique strings" + ) + if increment_id not in assigned: + continue + current_found = True + successor_index = assigned.index(increment_id) + 1 + if successor_index < len(assigned): + candidates.add(assigned[successor_index]) + if not current_found: raise ValueError("current increment is absent from traceability allocation") - index = increments.index(increment_id) - successor_index = index + 1 - return increments[successor_index] if successor_index < len(increments) else None + return next(iter(candidates)) if len(candidates) == 1 else None def required_future_lifecycle_writes( @@ -729,6 +744,57 @@ def _derived_identifier(label: str, seed: dict[str, object]) -> str: return f"{label.upper()}-{digest[:24]}" +def _rollover_history_authority_issues( + program_root: Path, + status: dict[str, object], + observation: RepositoryObservation, +) -> list[str]: + try: + from program_rollover import ( + _validated_completed_rollover_records, + inspect_increment_rollover, + ) + + _validated_completed_rollover_records( + program_root, + status, + allow_unbound_suffix=False, + ) + except (ImportError, KeyError, OSError, TypeError, ValueError) as error: + if str(error) != "unbound rollover history is not lifecycle authority": + return [str(error)] + else: + return [] + + if _INSPECTING_UNBOUND_ROLLOVER_SUFFIX.get(): + return [] + token = _INSPECTING_UNBOUND_ROLLOVER_SUFFIX.set(True) + try: + inspection = inspect_increment_rollover(program_root, observation) + except (ImportError, KeyError, OSError, TypeError, ValueError): + return ["unbound rollover history is not lifecycle authority"] + finally: + _INSPECTING_UNBOUND_ROLLOVER_SUFFIX.reset(token) + if ( + not inspection.issues + and inspection.disposition + in { + "increment-rollover-retry-ready", + "accepted-state-rollover-retry-ready", + } + and inspection.completed_steps + == ( + "action-authorization", + "successor-grant", + "handoff", + "successor-brief", + "rollover-record", + ) + ): + return [] + return ["unbound rollover history is not lifecycle authority"] + + def _validate_new_program_state( program_root: Path, manifest: dict[str, object], @@ -828,6 +894,189 @@ def _validate_new_program_state( "status-current increment grant brief binding mismatch" ) + issues.extend( + _rollover_history_authority_issues(program_root, status, observation) + ) + rollover = status.get("rollover_binding") + if rollover is not None: + inherited = status.get("inherited_workspace_binding") + transition = status.get("transition_authority") + increment_state = status.get("current_increment_state") + rollover_transition_valid = ( + isinstance(rollover, dict) + and ( + increment_state != "preparing" + or ( + isinstance(transition, dict) + and transition.get("kind") == "action-authorization" + and transition.get("authorization_id") + == rollover.get("rollover_authorization_id") + and transition.get("event_id") == rollover.get("rollover_id") + and transition.get("checkpoint_id") + == rollover.get("continuation_checkpoint_id") + ) + ) + ) + rollover_valid = ( + isinstance(rollover, dict) + and rollover.get("schema_version") + == "implementation-increment-rollover-binding/v1" + and status.get("program_state") == "active" + and increment_state + in { + "preparing", + "awaiting-plan-approval", + "authorized", + "implementing", + "reviewing", + "remediating", + "verified", + "awaiting-diff-approval", + "accepted", + } + and rollover.get("successor_increment_id") + == status.get("current_increment_id") + and rollover.get("current_increment_id") + != rollover.get("successor_increment_id") + and rollover_transition_valid + and isinstance(inherited, dict) + and inherited.get("schema_version") + == "implementation-inherited-workspace/v1" + ) + if not rollover_valid: + issues.append("successor rollover binding is invalid") + else: + try: + from program_rollover import _validated_inherited_paths + + inherited_paths = _validated_inherited_paths( + program_root, + status, + observation, + allow_unbound_suffix=True, + ) + except (ImportError, KeyError, OSError, TypeError, ValueError) as error: + issues.append(str(error)) + else: + if inherited.get("inherited_paths") != list(inherited_paths): + issues.append("inherited workspace inventory mismatch") + + rollover_path, rollover_path_issues = resolve_managed_path( + program_root, + logical_roles.get("rollovers"), + role="logical role rollovers", + ) + action_path, action_path_issues = resolve_managed_path( + program_root, + logical_roles.get("action_authorizations"), + role="logical role action_authorizations", + ) + issues.extend((*rollover_path_issues, *action_path_issues)) + rollover_records = None + action_records = None + if rollover_path is not None: + rollover_records, load_issues = load_json_lines(rollover_path) + issues.extend(load_issues) + if action_path is not None: + action_records, load_issues = load_json_lines(action_path) + issues.extend(load_issues) + rollover_matches = ( + [] + if rollover_records is None + else [ + record + for record in rollover_records + if record.get("rollover_id") == rollover.get("rollover_id") + ] + ) + action_matches = ( + [] + if action_records is None + else [ + record + for record in action_records + if record.get("authorization_id") + == rollover.get("rollover_authorization_id") + ] + ) + if len(rollover_matches) != 1: + issues.append("status-current rollover must exist exactly once") + else: + record = rollover_matches[0] + delta = record.get("accepted_product_delta") + delta_paths = ( + sorted(item.get("path") for item in delta) + if isinstance(delta, list) + and all( + isinstance(item, dict) + and isinstance(item.get("path"), str) + for item in delta + ) + else None + ) + inherited_inventory = inherited.get("inherited_paths") + if ( + record.get("schema_version") + != "implementation-increment-rollover/v1" + or _canonical_json_line_sha256(record) + != rollover.get("rollover_sha256") + or record.get("current_increment_id") + != rollover.get("current_increment_id") + or record.get("successor_increment_id") + != rollover.get("successor_increment_id") + or record.get("rollover_authorization_id") + != rollover.get("rollover_authorization_id") + or record.get("rollover_authorization_sha256") + != rollover.get("rollover_authorization_sha256") + or record.get("successor_grant_id") + != rollover.get("successor_grant_id") + or record.get("successor_grant_sha256") + != rollover.get("successor_grant_sha256") + or record.get("accepted_status_sha256") + != rollover.get("prior_status_sha256") + or record.get("accepted_status_sequence") + != rollover.get("prior_status_sequence") + or record.get("submitted_prompt_sha256") + != rollover.get("submitted_prompt_sha256") + or inherited.get("accepted_product_delta_sha256") + != record.get("accepted_product_delta_sha256") + or delta_paths is None + or not isinstance(inherited_inventory, list) + or not all( + isinstance(item, str) for item in inherited_inventory + ) + or not set(delta_paths).issubset( + set(inherited_inventory) + ) + ): + issues.append("status-current rollover record binding mismatch") + if len(action_matches) != 1: + issues.append("rollover action authorization must exist exactly once") + elif ( + action_matches[0].get("schema_version") + != ACTION_AUTHORIZATION_SCHEMA + or action_matches[0].get("decision") != "authorized" + or action_matches[0].get("actions") != ["rollover-increment"] + or action_matches[0].get("current_increment_id") + != rollover.get("current_increment_id") + or action_matches[0].get("successor_increment_id") + != rollover.get("successor_increment_id") + or _canonical_json_line_sha256(action_matches[0]) + != rollover.get("rollover_authorization_sha256") + ): + issues.append("rollover action authorization binding mismatch") + if isinstance(authority, dict) and ( + authority.get("grant_id") != rollover.get("successor_grant_id") + or authority.get("grant_sha256") + != rollover.get("successor_grant_sha256") + or ( + isinstance(activation, dict) + and authority.get("grant_id") + == activation.get("increment_grant_id") + ) + ): + issues.append("successor grant must be distinct and rollover-bound") + brief = status.get("brief_binding") if not isinstance(brief, dict): issues.append("status brief_binding must be an object") @@ -973,7 +1222,12 @@ def validate_state( else None ) authority_invalid = ( - authority_kind not in {"approval-event", "action-authorization"} + authority_kind + not in { + "approval-event", + "action-authorization", + "blocked-context", + } or not isinstance(event_id, str) or not event_id or ( @@ -984,6 +1238,10 @@ def validate_state( authority_kind == "approval-event" and authorization_id is not None ) + or ( + authority_kind == "blocked-context" + and authorization_id is not None + ) or ( authority_kind == "action-authorization" and ( @@ -1008,6 +1266,28 @@ def validate_state( if not isinstance(logical_roles, dict): return sorted(set([*issues, "manifest logical_roles must be an object"])) if manifest.get("schema_version") == NEW_PROGRAM_MANIFEST_SCHEMA: + if status.get("program_state") == "blocked" or status.get( + "current_increment_state" + ) == "blocked": + try: + from blocked_recovery import validate_blocked_context + + issues.extend( + validate_blocked_context(program_root, status, observation) + ) + except ImportError as error: + issues.append(str(error)) + if status.get("block_resolution_binding") is not None: + try: + from blocked_recovery import validate_block_resolution_history + + issues.extend( + validate_block_resolution_history( + program_root, status, observation + ) + ) + except ImportError as error: + issues.append(str(error)) issues.extend( _validate_new_program_state( program_root, manifest, status, observation @@ -1870,6 +2150,77 @@ def validate_state_authority( != command.get("checkpoint_id") ): issues.append("closed status closure command binding is invalid") + workspace_observation = observation + if status is not None and isinstance(status.get("rollover_binding"), dict): + try: + from program_rollover import validated_inherited_paths + + inherited_paths = set( + validated_inherited_paths(root, status, observation) + ) + except (ImportError, KeyError, OSError, TypeError, ValueError) as error: + issues.append(str(error)) + inherited_paths = set() + if inherited_paths: + workspace_observation = replace( + observation, + staged_paths=tuple( + path + for path in observation.staged_paths + if path not in inherited_paths + ), + modified_paths=tuple( + path + for path in observation.modified_paths + if path not in inherited_paths + ), + untracked_paths=tuple( + path + for path in observation.untracked_paths + if path not in inherited_paths + ), + conflicted_paths=tuple( + path + for path in observation.conflicted_paths + if path not in inherited_paths + ), + ) + if ( + status is not None + and status.get("program_state") == "blocked" + and isinstance(status.get("blocked_context"), dict) + ): + try: + from blocked_recovery import blocked_workspace_paths + + blocked_paths = set(blocked_workspace_paths(root, status)) + except (ImportError, KeyError, OSError, TypeError, ValueError) as error: + issues.append(str(error)) + blocked_paths = set() + if blocked_paths: + workspace_observation = replace( + workspace_observation, + staged_paths=tuple( + path + for path in workspace_observation.staged_paths + if path not in blocked_paths + ), + modified_paths=tuple( + path + for path in workspace_observation.modified_paths + if path not in blocked_paths + ), + untracked_paths=tuple( + path + for path in workspace_observation.untracked_paths + if path not in blocked_paths + ), + conflicted_paths=tuple( + path + for path in workspace_observation.conflicted_paths + if path not in blocked_paths + ), + ) if workspace is not None: if workspace.get("program_id") != manifest.get("program_id"): issues.append("workspace program_id mismatch") @@ -1884,7 +2235,9 @@ def validate_state_authority( observable_workspace["schema_version"] = WORKSPACE_SCHEMA if not execution_workspace_validated: issues.extend( - validate_workspace_selection(observable_workspace, observation) + validate_workspace_selection( + observable_workspace, workspace_observation + ) ) activation = status.get("activation_binding") if status is not None else None if isinstance(activation, dict): @@ -1923,7 +2276,9 @@ def validate_state_authority( f"activation {event_type} record must exist exactly once" ) elif not execution_workspace_validated: - issues.extend(validate_workspace_selection(workspace, observation)) + issues.extend( + validate_workspace_selection(workspace, workspace_observation) + ) return sorted(set(issues)) diff --git a/skills/implementing-staged-plans/scripts/validate_package.py b/skills/implementing-staged-plans/scripts/validate_package.py index ee6fd37..f0e1600 100644 --- a/skills/implementing-staged-plans/scripts/validate_package.py +++ b/skills/implementing-staged-plans/scripts/validate_package.py @@ -17,7 +17,7 @@ CLAUDE_MANIFEST = Path(".claude-plugin/plugin.json") CLAUDE_MARKETPLACE = Path(".claude-plugin/marketplace.json") PACKAGE_CONTENT_ROOT = Path("skills/implementing-staged-plans") -PACKAGE_VERSION = "0.1.1" +PACKAGE_VERSION = "0.1.2" SKILL_MARKDOWN = Path("skills/implementing-staged-plans/SKILL.md") OPENAI_METADATA = Path("skills/implementing-staged-plans/agents/openai.yaml") PROGRAM_AUTHORITY_REFERENCE = Path( @@ -80,6 +80,14 @@ "program_closure", ) ) +PLAN_B_PRODUCTION_SCRIPTS = tuple( + Path(f"skills/implementing-staged-plans/scripts/{name}.py") + for name in ( + "program_continuation", + "program_rollover", + "blocked_recovery", + ) +) EXPECTED_MANIFEST: dict[str, object] = { "name": "implementation-plugin", @@ -514,6 +522,7 @@ def validate_authority_assets(repository_root: Path) -> list[str]: PROGRAM_DISCOVERY_REFERENCE, PROGRAM_DISCOVERY_SCRIPT, *PLAN_A_PRODUCTION_SCRIPTS, + *PLAN_B_PRODUCTION_SCRIPTS, ): path = repository_root / relative_path if not path.is_file() or path.is_symlink(): diff --git a/tests/integrated_pressure_support.py b/tests/integrated_pressure_support.py index 76d90e8..f57b88a 100644 --- a/tests/integrated_pressure_support.py +++ b/tests/integrated_pressure_support.py @@ -32,8 +32,15 @@ "non-triggering-request", "unsupported-action", ) +EXPECTED_CONTINUATION_REPLAY_IDS = ( + "immediate-continuation", + "later-continuation", +) INTEGRATION_EVIDENCE_SCHEMA = "implementation-integration-evidence/v1" FRESH_CONTEXT_EVIDENCE_SCHEMA = "fresh-context-evidence/v1" +CONTINUATION_REPLAY_EVIDENCE_SCHEMA = ( + "implementation-continuation-replay-evidence/v1" +) _SHA256 = re.compile(r"^[0-9a-f]{64}$") sys.path.insert(0, str(SCRIPT_ROOT)) @@ -58,6 +65,14 @@ class FreshContextScenario: expected_boundary: str +@dataclass(frozen=True) +class ContinuationReplayScenario: + scenario_id: str + prompt_path: str + result_path: str + expected_boundary: str + + def sha256_bytes(value: bytes) -> str: return hashlib.sha256(value).hexdigest() @@ -138,35 +153,417 @@ def load_scenario_catalog(path: Path) -> tuple[FreshContextScenario, ...]: return tuple(scenarios) -def _atomic_create_text(path: Path, value: str) -> None: +def load_continuation_replay( + path: Path, +) -> tuple[ContinuationReplayScenario, ...]: + catalog_path = Path(path) + if catalog_path.is_symlink() or not catalog_path.is_file(): + raise ValueError( + "continuation replay catalog must be a regular non-symlink file" + ) + value = json.loads(catalog_path.read_text(encoding="utf-8")) + if ( + not isinstance(value, dict) + or value.get("schema_version") + != "implementation-continuation-replay-catalog/v1" + or set(value) != {"schema_version", "scenarios"} + ): + raise ValueError("unsupported continuation replay catalog schema") + raw_scenarios = value.get("scenarios") + if not isinstance(raw_scenarios, list): + raise ValueError("continuation replay scenarios must be a list") + scenarios: list[ContinuationReplayScenario] = [] + for raw in raw_scenarios: + if not isinstance(raw, dict) or set(raw) != { + "id", + "prompt_path", + "result_path", + "expected_boundary", + }: + raise ValueError("continuation replay scenario fields are invalid") + if type(raw["expected_boundary"]) is not str: + raise ValueError( + "continuation replay expected_boundary must be a string" + ) + scenario = ContinuationReplayScenario( + scenario_id=str(raw["id"]), + prompt_path=str(raw["prompt_path"]), + result_path=str(raw["result_path"]), + expected_boundary=raw["expected_boundary"], + ) + prompt = PurePosixPath(scenario.prompt_path) + result = PurePosixPath(scenario.result_path) + if ( + prompt.is_absolute() + or ".." in prompt.parts + or prompt.parent + != PurePosixPath("tests/pressure/continuation-replay/prompts") + or prompt.suffix != ".md" + ): + raise ValueError( + f"continuation replay prompt path is invalid: {scenario.prompt_path}" + ) + if ( + result.is_absolute() + or ".." in result.parts + or result.parent + != PurePosixPath("tests/pressure/continuation-replay/results") + or result.suffix != ".txt" + ): + raise ValueError( + f"continuation replay result path is invalid: {scenario.result_path}" + ) + if ( + not scenario.expected_boundary + or prompt.stem != scenario.scenario_id + or result.stem != scenario.scenario_id + ): + raise ValueError( + f"continuation replay scenario binding is invalid: {scenario.scenario_id}" + ) + scenarios.append(scenario) + if tuple(item.scenario_id for item in scenarios) != ( + EXPECTED_CONTINUATION_REPLAY_IDS + ): + raise ValueError( + "continuation replay requires immediate and later scenarios once and in order" + ) + return tuple(scenarios) + + +def _evidence_headers(value: str) -> dict[str, str]: + header, separator, _response = value.partition("\n--- response ---\n") + if not separator: + raise ValueError("continuation replay result lacks a response boundary") + fields: dict[str, str] = {} + for line in header.splitlines(): + name, separator, field_value = line.partition(": ") + if not separator or not name or name in fields: + raise ValueError("continuation replay result headers are invalid") + fields[name] = field_value + return fields + + +def validate_continuation_replay_evidence(root: Path) -> list[str]: + """Validate optional raw replay evidence; absence means not run, not pass.""" + repository = Path(root) + issues: list[str] = [] + try: + scenarios = load_continuation_replay( + repository / "tests/pressure/continuation-replay/scenarios.json" + ) + except (OSError, ValueError, json.JSONDecodeError) as error: + return [str(error)] + for scenario in scenarios: + prompt_path = repository / scenario.prompt_path + if prompt_path.is_symlink() or not prompt_path.is_file(): + issues.append( + f"continuation replay prompt is not a regular file: {scenario.prompt_path}" + ) + continue + try: + first_line = prompt_path.read_text(encoding="utf-8").splitlines()[0] + except (IndexError, OSError, UnicodeDecodeError): + first_line = "" + if first_line != "$implementing-staged-plans": + issues.append( + f"continuation replay prompt lacks explicit skill invocation: {scenario.scenario_id}" + ) + result_paths = tuple(repository / item.result_path for item in scenarios) + present_results_list: list[bool] = [] + for scenario, path in zip(scenarios, result_paths, strict=True): + try: + _reject_symlink_components( + path, label=scenario.scenario_id, trusted_root=repository + ) + except ValueError as error: + issues.append(str(error)) + is_regular = path.is_file() and not path.is_symlink() + if (path.exists() or path.is_symlink()) and not is_regular: + issues.append( + f"{scenario.scenario_id}: result is not a regular non-symlink file" + ) + present_results_list.append(is_regular) + present_results = tuple(present_results_list) + verdict_path = repository / "tests/pressure/continuation-replay/verdicts.json" + try: + _reject_symlink_components( + verdict_path, + label="continuation replay verdict", + trusted_root=repository, + ) + except ValueError as error: + issues.append(str(error)) + verdict_present = verdict_path.is_file() and not verdict_path.is_symlink() + if (verdict_path.exists() or verdict_path.is_symlink()) and not verdict_present: + issues.append( + "continuation replay verdict is not a regular non-symlink file" + ) + if not any(present_results) and not verdict_present: + return sorted(set(issues)) + if not all(present_results): + issues.append("continuation replay results must be complete or entirely absent") + if not verdict_present: + issues.append("continuation replay results require digest-bound human verdicts") + + evidence_prompt_sha256: dict[str, str] = {} + for scenario, result_path, present in zip( + scenarios, result_paths, present_results, strict=True + ): + if not present: + continue + try: + headers = _evidence_headers(result_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, ValueError) as error: + issues.append(f"{scenario.scenario_id}: {error}") + continue + prompt_sha256 = headers.get("prompt_sha256") + if ( + not isinstance(prompt_sha256, str) + or len(prompt_sha256) != 64 + or any( + character not in "0123456789abcdef" + for character in prompt_sha256 + ) + ): + issues.append( + f"{scenario.scenario_id}: continuation replay prompt_sha256 mismatch" + ) + else: + evidence_prompt_sha256[scenario.scenario_id] = prompt_sha256 + expected = { + "schema_version": CONTINUATION_REPLAY_EVIDENCE_SCHEMA, + "scenario_id": scenario.scenario_id, + "sandbox": "read-only", + "session": "ephemeral", + "exit_code": "0", + "expected_boundary": scenario.expected_boundary, + } + for field, expected_value in expected.items(): + if headers.get(field) != expected_value: + issues.append( + f"{scenario.scenario_id}: continuation replay {field} mismatch" + ) + for field in ("evaluator", "client_version"): + if not headers.get(field): + issues.append( + f"{scenario.scenario_id}: continuation replay {field} is required" + ) + + if verdict_present: + try: + verdict_document = json.loads(verdict_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + issues.append(f"continuation replay verdicts are invalid: {error}") + verdict_document = None + verdicts = ( + verdict_document.get("verdicts") + if isinstance(verdict_document, dict) + else None + ) + if ( + not isinstance(verdict_document, dict) + or verdict_document.get("schema_version") + != "implementation-continuation-replay-verdicts/v1" + or not isinstance(verdicts, list) + or len(verdicts) != len(scenarios) + or not all(isinstance(item, dict) for item in verdicts) + or [item.get("id") for item in verdicts] + != list(EXPECTED_CONTINUATION_REPLAY_IDS) + ): + issues.append("continuation replay verdict document is incomplete") + else: + for scenario, verdict, result_path in zip( + scenarios, verdicts, result_paths, strict=True + ): + if not isinstance(verdict, dict): + issues.append( + f"{scenario.scenario_id}: continuation replay verdict is invalid" + ) + continue + if verdict.get("prompt_sha256") != evidence_prompt_sha256.get( + scenario.scenario_id + ): + issues.append( + f"{scenario.scenario_id}: prompt digest mismatch" + ) + if result_path.is_file() and verdict.get( + "result_sha256" + ) != sha256_file(result_path): + issues.append( + f"{scenario.scenario_id}: result digest mismatch" + ) + if verdict.get("outcome") not in {"pass", "fail"}: + issues.append( + f"{scenario.scenario_id}: verdict outcome is invalid" + ) + for field in ("evidence", "limitations"): + if not isinstance(verdict.get(field), str) or not verdict[field]: + issues.append( + f"{scenario.scenario_id}: verdict {field} is required" + ) + return sorted(set(issues)) + + +def _reject_symlink_components( + path: Path, *, label: str, trusted_root: Path +) -> None: + candidate = Path(os.path.abspath(path)) + trusted = Path(os.path.abspath(trusted_root)) + try: + relative = candidate.relative_to(trusted) + except ValueError as error: + raise ValueError(f"{label} escapes its trusted root") from error + component = trusted + descendants: list[Path] = [] + for part in relative.parts: + component /= part + descendants.append(component) + for component in descendants: + if component.is_symlink(): + raise ValueError( + f"{label} has symlinked path component: {component}" + ) + + +def _supports_result_descriptor_apis() -> bool: + dir_fd_names = { + getattr(function, "__name__", "") for function in os.supports_dir_fd + } + follow_symlink_names = { + getattr(function, "__name__", "") + for function in os.supports_follow_symlinks + } + return {"open", "link", "unlink"} <= dir_fd_names and ( + "link" in follow_symlink_names + ) + + +def _exclusive_create_text(path: Path, value: str) -> tuple[int, int]: + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + flags |= getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags, 0o600) + except FileExistsError as error: + raise ValueError(f"result target appeared before creation: {path}") from error + created_identity = os.fstat(descriptor) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + stream.write(value) + stream.flush() + os.fsync(stream.fileno()) + except BaseException: + try: + current_identity = path.stat(follow_symlinks=False) + if ( + current_identity.st_dev, + current_identity.st_ino, + ) == (created_identity.st_dev, created_identity.st_ino): + path.unlink() + except FileNotFoundError: + pass + raise + return (created_identity.st_dev, created_identity.st_ino) + + +def _atomic_create_text( + path: Path, value: str, *, trusted_root: Path | None = None +) -> tuple[int, int]: target = Path(path) + boundary = target.parent.parent if trusted_root is None else trusted_root + _reject_symlink_components( + target, label="result target", trusted_root=boundary + ) if target.exists() or target.is_symlink(): raise ValueError(f"result target already exists: {target}") target.parent.mkdir(parents=True, exist_ok=True) - temporary_path: Path | None = None + _reject_symlink_components( + target, label="result target", trusted_root=boundary + ) + if not _supports_result_descriptor_apis(): + return _exclusive_create_text(target, value) + directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) + directory_flags |= getattr(os, "O_NOFOLLOW", 0) + directory_descriptor = os.open(target.parent, directory_flags) + temporary_name: str | None = None + created_identity: tuple[int, int] | None = None try: - with tempfile.NamedTemporaryFile( - mode="w", - encoding="utf-8", - dir=target.parent, - prefix=f".{target.name}.", - suffix=".tmp", - delete=False, - ) as temporary: - temporary_path = Path(temporary.name) + for candidate_index in range(100): + name = f".{target.name}.{os.getpid()}.{candidate_index}.tmp" + try: + temporary_descriptor = os.open( + name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + dir_fd=directory_descriptor, + ) + except FileExistsError: + continue + temporary_name = name + break + if temporary_name is None: + raise ValueError("unable to reserve a temporary result target") + with os.fdopen(temporary_descriptor, "w", encoding="utf-8") as temporary: temporary.write(value) temporary.flush() os.fsync(temporary.fileno()) - if target.exists() or target.is_symlink(): - raise ValueError(f"result target appeared before creation: {target}") - os.replace(temporary_path, target) - temporary_path = None + temporary_identity = os.fstat(temporary.fileno()) + created_identity = ( + temporary_identity.st_dev, + temporary_identity.st_ino, + ) + _reject_symlink_components( + target, label="result target", trusted_root=boundary + ) + try: + os.link( + temporary_name, + target.name, + src_dir_fd=directory_descriptor, + dst_dir_fd=directory_descriptor, + follow_symlinks=False, + ) + except FileExistsError as error: + raise ValueError( + f"result target appeared before creation: {target}" + ) from error + try: + os.unlink(temporary_name, dir_fd=directory_descriptor) + except OSError: + pass + else: + temporary_name = None finally: - if temporary_path is not None: + if temporary_name is not None: try: - temporary_path.unlink() - except FileNotFoundError: + os.unlink(temporary_name, dir_fd=directory_descriptor) + except OSError: pass + try: + os.close(directory_descriptor) + except OSError: + pass + if created_identity is None: + raise RuntimeError("result creation completed without a filesystem identity") + return created_identity + + +def _remove_created_result(path: Path, expected_identity: tuple[int, int]) -> bool: + try: + current_identity = path.stat(follow_symlinks=False) + except FileNotFoundError: + return True + except OSError: + return False + if (current_identity.st_dev, current_identity.st_ino) != expected_identity: + return False + try: + path.unlink() + except FileNotFoundError: + return True + except OSError: + return False + return True def _extract_agent_message(json_lines: str) -> str: @@ -303,6 +700,134 @@ def evaluate_fresh_contexts( return tuple(completed_paths) +def evaluate_continuation_replay( + *, + catalog_path: Path, + output_directory: Path, + evaluator: str, +) -> tuple[Path, ...]: + """Run the separately authorized continuation campaign in isolated tasks.""" + scenarios = load_continuation_replay(catalog_path) + output_root = Path(os.path.abspath(output_directory)) + expected_output_root = Path( + os.path.abspath( + REPOSITORY_ROOT / "tests/pressure/continuation-replay/results" + ) + ) + if output_root != expected_output_root: + raise ValueError( + "continuation replay output directory does not match the approved result root" + ) + _reject_symlink_components( + output_root, + label="continuation replay output directory", + trusted_root=REPOSITORY_ROOT, + ) + result_paths = tuple(REPOSITORY_ROOT / item.result_path for item in scenarios) + if any(path.exists() or path.is_symlink() for path in result_paths): + raise ValueError( + "continuation replay result targets must all be absent before the campaign" + ) + evaluator_version = run_command((evaluator, "--version"), cwd=REPOSITORY_ROOT) + client_version = evaluator_version.stdout.strip() + if evaluator_version.returncode != 0 or not client_version: + raise ValueError("continuation replay evaluator capability preflight failed") + pending_results: list[tuple[Path, str]] = [] + for scenario, result_path in zip(scenarios, result_paths, strict=True): + prompt_path = REPOSITORY_ROOT / scenario.prompt_path + if prompt_path.is_symlink() or not prompt_path.is_file(): + raise ValueError( + f"continuation replay prompt is not a regular file: {scenario.prompt_path}" + ) + prompt = prompt_path.read_text(encoding="utf-8") + if not prompt.startswith("$implementing-staged-plans\n"): + raise ValueError( + f"continuation replay prompt is invalid: {scenario.scenario_id}" + ) + evaluator_prompt = prompt.strip() + prompt_sha256 = hashlib.sha256( + evaluator_prompt.encode("utf-8") + ).hexdigest() + with tempfile.TemporaryDirectory( + prefix="continuation-replay-" + ) as directory: + isolated_root = Path(directory) + codex_home = _build_isolated_evaluation_root(isolated_root) + completed = run_command( + ( + evaluator, + "exec", + "--ephemeral", + "--ignore-user-config", + "--sandbox", + "read-only", + "--skip-git-repo-check", + "--json", + "--cd", + str(isolated_root), + evaluator_prompt, + ), + cwd=isolated_root, + timeout=300, + environment=build_isolated_evaluator_environment(codex_home), + ) + if completed.returncode != 0: + concise_error = (completed.stderr or completed.stdout).strip().splitlines() + detail = ( + " | ".join(concise_error[-40:]) + if concise_error + else "no evaluator error text" + ) + raise ValueError( + f"continuation replay evaluator failed for {scenario.scenario_id}: {detail}" + ) + response = _extract_agent_message(completed.stdout).replace( + str(REPOSITORY_ROOT), "" + ) + evidence = ( + f"schema_version: {CONTINUATION_REPLAY_EVIDENCE_SCHEMA}\n" + f"scenario_id: {scenario.scenario_id}\n" + f"prompt_sha256: {prompt_sha256}\n" + f"evaluator: {evaluator}\n" + f"client_version: {client_version}\n" + "sandbox: read-only\n" + "session: ephemeral\n" + "exit_code: 0\n" + f"expected_boundary: {scenario.expected_boundary}\n" + "\n--- response ---\n" + f"{response}\n" + ) + pending_results.append((result_path, evidence)) + + created_results: list[tuple[Path, tuple[int, int]]] = [] + try: + for result_path, evidence in pending_results: + created_identity = _atomic_create_text( + result_path, evidence, trusted_root=REPOSITORY_ROOT + ) + created_results.append((result_path, created_identity)) + except BaseException as publication_error: + recovery_failures = tuple( + path + for path, identity in reversed(created_results) + if not _remove_created_result(path, identity) + ) + if recovery_failures: + failed_paths = ", ".join( + path.relative_to(REPOSITORY_ROOT).as_posix() + for path in recovery_failures + ) + add_note = getattr(publication_error, "add_note", None) + if callable(add_note): + add_note( + "continuation replay publication recovery failed for: " + f"{failed_paths}" + ) + raise + completed_paths = [path for path, _identity in created_results] + return tuple(completed_paths) + + def _git_output(repository: Path, *arguments: str) -> str: completed = run_command(("git", *arguments), cwd=repository) if completed.returncode != 0: @@ -911,6 +1436,10 @@ def build_argument_parser() -> argparse.ArgumentParser: evaluate.add_argument("--scenario-catalog", required=True) evaluate.add_argument("--output-directory", required=True) evaluate.add_argument("--evaluator", required=True) + continuation = subparsers.add_parser("evaluate-continuation-replay") + continuation.add_argument("--scenario-catalog", required=True) + continuation.add_argument("--output-directory", required=True) + continuation.add_argument("--evaluator", required=True) validate = subparsers.add_parser("validate-evidence") validate.add_argument("--evidence", required=True) validate.add_argument("--repository", required=True) @@ -929,6 +1458,14 @@ def main(argv: Sequence[str] | None = None) -> int: ) print(f"Fresh-context evaluation completed: {len(paths)} results") return 0 + if arguments.command == "evaluate-continuation-replay": + paths = evaluate_continuation_replay( + catalog_path=Path(arguments.scenario_catalog), + output_directory=Path(arguments.output_directory), + evaluator=arguments.evaluator, + ) + print(f"Continuation replay completed: {len(paths)} results") + return 0 repository = Path(arguments.repository).resolve(strict=True) if repository != REPOSITORY_ROOT: raise ValueError("integration evidence repository does not match the selected workspace") diff --git a/tests/pressure/continuation-replay/prompts/immediate-continuation.md b/tests/pressure/continuation-replay/prompts/immediate-continuation.md new file mode 100644 index 0000000..d2e5657 --- /dev/null +++ b/tests/pressure/continuation-replay/prompts/immediate-continuation.md @@ -0,0 +1,5 @@ +$implementing-staged-plans + +Treat this as a synthetic, read-only continuation-routing replay. A managed program is at its exact diff gate, the current increment has passed review and verification, and traceability identifies exactly one satisfied successor. The user submits the exact `accept-continue` choice. + +State the production route and its authority/persistence boundary. Require no second user checkpoint between accepted current status and successor rollover. Do not invent program files, invoke a provider, or mutate any repository. Stop explicitly because this replay supplies no concrete persisted program root. diff --git a/tests/pressure/continuation-replay/prompts/later-continuation.md b/tests/pressure/continuation-replay/prompts/later-continuation.md new file mode 100644 index 0000000..0f74654 --- /dev/null +++ b/tests/pressure/continuation-replay/prompts/later-continuation.md @@ -0,0 +1,5 @@ +$implementing-staged-plans + +Treat this as a synthetic, read-only continuation-routing replay. A managed increment was previously accepted with the exact `accept-stop` choice. In a later fresh task, the user now asks to continue to the one satisfied successor. + +State the distinct accepted-state continuation route and its prompt-bound authority/persistence boundary. Do not replay or reinterpret the earlier stop prompt, invent program files, invoke a provider, or mutate any repository. Stop explicitly because this replay supplies no concrete persisted program root. diff --git a/tests/pressure/continuation-replay/scenarios.json b/tests/pressure/continuation-replay/scenarios.json new file mode 100644 index 0000000..6a03881 --- /dev/null +++ b/tests/pressure/continuation-replay/scenarios.json @@ -0,0 +1,17 @@ +{ + "scenarios": [ + { + "expected_boundary": "route exact accept-continue authority without a second user checkpoint and stop before unavailable concrete program state", + "id": "immediate-continuation", + "prompt_path": "tests/pressure/continuation-replay/prompts/immediate-continuation.md", + "result_path": "tests/pressure/continuation-replay/results/immediate-continuation.txt" + }, + { + "expected_boundary": "distinguish accepted-state continuation from the prior stop choice and stop before unavailable concrete program state", + "id": "later-continuation", + "prompt_path": "tests/pressure/continuation-replay/prompts/later-continuation.md", + "result_path": "tests/pressure/continuation-replay/results/later-continuation.txt" + } + ], + "schema_version": "implementation-continuation-replay-catalog/v1" +} diff --git a/tests/program_bootstrap_support.py b/tests/program_bootstrap_support.py index 56a7350..9807fb8 100644 --- a/tests/program_bootstrap_support.py +++ b/tests/program_bootstrap_support.py @@ -7,7 +7,7 @@ import subprocess import sys import tempfile -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import Sequence @@ -80,14 +80,16 @@ def repository_snapshot(root: Path) -> dict[str, tuple[str, str]]: } -def raw_review_report(scope: str) -> dict[str, object]: +def raw_review_report( + scope: str, increment_id: str = "ARCHIVE-INDEX" +) -> dict[str, object]: """Return one deterministic raw first-increment review report fixture.""" value: dict[str, object] = { "schema_version": "implementation-raw-review-report/v1", "scope": scope, "program_id": "ARCHIVE-PROGRAM", "program_revision": 1, - "increment_id": "ARCHIVE-INDEX", + "increment_id": increment_id, "reviewer_role": "controller-self-review", "independent": False, "reduced_assurance": True, @@ -178,11 +180,53 @@ def raw_review_report(scope: str) -> dict[str, object]: return value -def write_raw_review_reports(repository: Path) -> None: - reviews = Path(repository) / "reviews" - reviews.mkdir(exist_ok=True) +def write_raw_review_reports( + repository: Path, + increment_id: str = "ARCHIVE-INDEX", + relative_directory: str = "reviews", +) -> None: + reviews = Path(repository) / relative_directory + reviews.mkdir(parents=True, exist_ok=True) for scope in ("requirements", "architecture", "test-evidence"): - (reviews / f"{scope}.json").write_bytes(canonical_json(raw_review_report(scope))) + (reviews / f"{scope}.json").write_bytes( + canonical_json(raw_review_report(scope, increment_id)) + ) + + +def _rewrite_inherited_review_reports( + repository: Path, + status: dict[str, object], + increment_id: str, +) -> None: + binding = status.get("inherited_workspace_binding", {}) + inherited_paths = ( + binding.get("inherited_paths", []) if isinstance(binding, dict) else [] + ) + if not isinstance(inherited_paths, list): + raise ValueError("inherited review paths must be a list") + report_names = { + "architecture.json", + "requirements.json", + "test-evidence.json", + } + for relative in inherited_paths: + if not isinstance(relative, str) or "\\" in relative: + raise ValueError("inherited review path must be a relative POSIX path") + relative_path = PurePosixPath(relative) + if relative_path.is_absolute() or any( + part in {"", ".", ".."} for part in relative_path.parts + ): + raise ValueError("inherited review path must be a relative POSIX path") + if relative_path.name not in report_names: + continue + report_path = Path(repository).joinpath(*relative_path.parts) + if not report_path.is_file() or report_path.is_symlink(): + raise ValueError(f"inherited review report is invalid: {relative}") + report = json.loads(report_path.read_text(encoding="utf-8")) + if not isinstance(report, dict): + raise ValueError(f"inherited review report is invalid: {relative}") + report["increment_id"] = increment_id + report_path.write_bytes(canonical_json(report)) class BootstrapFixture: @@ -220,6 +264,198 @@ def write_json(self, relative_path: str, value: object) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(canonical_json(value)) + def configure_successors( + self, + successors: dict[str, tuple[str, ...]], + ) -> None: + """Allocate deterministic successor semantics before program activation.""" + traceability = self.load_json("program/traceability.json") + source_units = traceability["source_units"] + requirement_unit = next( + unit for unit in source_units if unit["classification"] == "requirement" + ) + atomic_requirements = traceability["atomic_requirements"] + for successor_index, (successor_id, dependencies) in enumerate( + successors.items(), start=1 + ): + requirement_id = f"SUCCESSOR-OUTCOME-{successor_index}" + requirement_unit["requirement_ids"].append(requirement_id) + atomic_requirements.append( + { + "id": requirement_id, + "group_id": "INTEGRITY", + "source_unit_ids": [requirement_unit["id"]], + "source_locator": "Archive Plan, line 3", + "normalized_requirement": f"Complete {successor_id}.", + "acceptance_criteria": [f"{successor_id} is complete."], + "assigned_parts": ["Archive integrity"], + "assigned_tasks": [f"Complete {successor_id}"], + "assigned_increments": ["ARCHIVE-INDEX", successor_id], + "current_disposition": "allocated", + "decision_references": [], + "implementation_evidence": [], + "verification_evidence": [], + } + ) + for dependency_index, dependency_id in enumerate(dependencies, start=1): + if dependency_id == "ARCHIVE-INDEX": + continue + dependency_requirement_id = ( + f"SUCCESSOR-DEPENDENCY-{successor_index}-{dependency_index}" + ) + requirement_unit["requirement_ids"].append( + dependency_requirement_id + ) + atomic_requirements.append( + { + "id": dependency_requirement_id, + "group_id": "INTEGRITY", + "source_unit_ids": [requirement_unit["id"]], + "source_locator": "Archive Plan, line 3", + "normalized_requirement": ( + f"Complete {dependency_id} before {successor_id}." + ), + "acceptance_criteria": [ + f"{dependency_id} precedes {successor_id}." + ], + "assigned_parts": ["Archive integrity"], + "assigned_tasks": [f"Complete {dependency_id}"], + "assigned_increments": [dependency_id, successor_id], + "current_disposition": "allocated", + "decision_references": [], + "implementation_evidence": [], + "verification_evidence": [], + } + ) + semantic_records = [ + { + field: record[field] + for field in ( + "id", + "group_id", + "source_unit_ids", + "normalized_requirement", + "acceptance_criteria", + "assigned_parts", + "assigned_tasks", + "assigned_increments", + ) + } + for record in atomic_requirements + ] + semantic_sha256 = hashlib.sha256( + json.dumps( + semantic_records, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + ).hexdigest() + traceability["coverage_assertion"][ + "semantic_requirements_sha256" + ] = semantic_sha256 + self.write_json("program/traceability.json", traceability) + manifest = self.load_json("manifest.json") + manifest["program_binding"]["traceability_sha256"] = hashlib.sha256( + (self.candidate / "program/traceability.json").read_bytes() + ).hexdigest() + self.write_json("manifest.json", manifest) + status = self.load_json("state/status.json") + status["program_binding"][ + "semantic_requirements_sha256" + ] = semantic_sha256 + self.write_json("state/status.json", status) + + def configure_successor_chain(self, increment_ids: tuple[str, ...]) -> None: + """Allocate one deterministic causal increment chain before activation.""" + if ( + len(increment_ids) < 2 + or increment_ids[0] != "ARCHIVE-INDEX" + or any(not item for item in increment_ids) + or len(increment_ids) != len(set(increment_ids)) + ): + raise ValueError("successor chain must start at ARCHIVE-INDEX and be unique") + traceability = self.load_json("program/traceability.json") + source_units = traceability["source_units"] + requirement_unit = next( + unit for unit in source_units if unit["classification"] == "requirement" + ) + requirement_id = "SUCCESSOR-CHAIN" + requirement_unit["requirement_ids"].append(requirement_id) + atomic_requirements = traceability["atomic_requirements"] + atomic_requirements.append( + { + "id": requirement_id, + "group_id": "INTEGRITY", + "source_unit_ids": [requirement_unit["id"]], + "source_locator": "Archive Plan, line 3", + "normalized_requirement": "Complete the archive increment chain.", + "acceptance_criteria": [ + "Each archive increment follows its allocated predecessor." + ], + "assigned_parts": ["Archive integrity"], + "assigned_tasks": ["Complete the archive increment chain"], + "assigned_increments": list(increment_ids), + "current_disposition": "allocated", + "decision_references": [], + "implementation_evidence": [], + "verification_evidence": [], + } + ) + semantic_records = [ + { + field: record[field] + for field in ( + "id", + "group_id", + "source_unit_ids", + "normalized_requirement", + "acceptance_criteria", + "assigned_parts", + "assigned_tasks", + "assigned_increments", + ) + } + for record in atomic_requirements + ] + semantic_sha256 = hashlib.sha256( + json.dumps( + semantic_records, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + ).hexdigest() + traceability["coverage_assertion"][ + "semantic_requirements_sha256" + ] = semantic_sha256 + self.write_json("program/traceability.json", traceability) + manifest = self.load_json("manifest.json") + manifest["program_binding"]["traceability_sha256"] = hashlib.sha256( + (self.candidate / "program/traceability.json").read_bytes() + ).hexdigest() + self.write_json("manifest.json", manifest) + status = self.load_json("state/status.json") + status["program_binding"][ + "semantic_requirements_sha256" + ] = semantic_sha256 + self.write_json("state/status.json", status) + + def configure_approval_mode(self, approval_mode: str) -> None: + """Select one supported Plan A approval mode before publication.""" + if approval_mode not in { + "approval:standard", + "approval:pre-approve", + "approval:full-increment", + }: + raise ValueError("unsupported fixture approval mode") + manifest = self.load_json("manifest.json") + manifest["approval_mode"] = approval_mode + self.write_json("manifest.json", manifest) + status = self.load_json("state/status.json") + status["approval_mode"] = approval_mode + self.write_json("state/status.json", status) + def _configure_candidate(self) -> None: manifest = self.load_json("manifest.json") source_bytes = self.source_plan.read_bytes() @@ -408,16 +644,35 @@ def _exact_plan_bytes(program_root: Path, observation: object) -> bytes: required = required_future_lifecycle_writes( program_root, Path(observation.path), status["current_increment_id"] ) + inherited = set( + status.get("inherited_workspace_binding", {}).get("inherited_paths", []) + ) + increment_id = str(status["current_increment_id"]) + review_root = ( + "reviews" + if increment_id == "ARCHIVE-INDEX" + else f"reviews/{increment_id}" + ) + raw_review_paths = { + scope: f"{review_root}/{scope}.json" + for scope in ("architecture", "requirements", "test-evidence") + } + product_paths = { + "archive-output.txt", + *raw_review_paths.values(), + } create = sorted( { - "archive-output.txt", - "reviews/architecture.json", - "reviews/requirements.json", - "reviews/test-evidence.json", + *(product_paths - inherited), *(item.path for item in required if item.disposition == "Create"), } ) - modify = sorted(item.path for item in required if item.disposition == "Modify") + modify = sorted( + { + *inherited, + *(item.path for item in required if item.disposition == "Modify"), + } + ) source = status["source_binding"] program = status["program_binding"] lines = [ @@ -470,9 +725,9 @@ def _exact_plan_bytes(program_root: Path, observation: object) -> bytes: "Run `python3 -m unittest tests.test_archive_output`; expected exit 0.", "", "## Review scopes and specialist predicates", - "- requirements: `reviews/requirements.json`", - "- architecture: `reviews/architecture.json`", - "- test-evidence: `reviews/test-evidence.json`", + f"- requirements: `{raw_review_paths['requirements']}`", + f"- architecture: `{raw_review_paths['architecture']}`", + f"- test-evidence: `{raw_review_paths['test-evidence']}`", "", "## Commit boundaries", "One logical local commit boundary; no commit authority is granted.", @@ -525,11 +780,14 @@ def run_lifecycle_phase(arguments: argparse.Namespace) -> dict[str, object]: sys.path.insert(0, str(script_root)) try: import diff_disposition + import blocked_recovery import program_activation import program_bootstrap import program_closure + import program_continuation import program_launch import program_review + import program_rollover repository = Path(arguments.repository).resolve(strict=True) program_root = _program_root(repository) @@ -559,11 +817,31 @@ def run_lifecycle_phase(arguments: argparse.Namespace) -> dict[str, object]: if phase == "prepare-plan": _install_failure_hook(program_activation, arguments.fail_label) observation = _fresh_observation(repository) + plan_bytes = ( + Path(arguments.exact_plan_file).read_bytes() + if arguments.exact_plan_file is not None + else _exact_plan_bytes(program_root, observation) + ) receipt = program_activation.prepare_exact_plan( - program_root, _exact_plan_bytes(program_root, observation), observation + program_root, plan_bytes, observation ) return asdict(receipt) + if phase == "render-exact-plan": + observation = _fresh_observation(repository) + status = json.loads( + (program_root / "state/status.json").read_text(encoding="utf-8") + ) + required = program_activation.required_future_lifecycle_writes( + program_root, + repository, + str(status["current_increment_id"]), + ) + return { + "plan": _exact_plan_bytes(program_root, observation).decode("utf-8"), + "required_future_paths": [item.path for item in required], + } + if phase == "materialize-plan": _install_failure_hook(program_activation, arguments.fail_label) receipt = program_activation.materialize_exact_plan( @@ -576,10 +854,33 @@ def run_lifecycle_phase(arguments: argparse.Namespace) -> dict[str, object]: if phase in {"implementing", "reviewing"}: _install_failure_hook(program_activation, arguments.fail_label) if phase == "reviewing": + status = json.loads( + (program_root / "state/status.json").read_text(encoding="utf-8") + ) + increment_id = status["current_increment_id"] + output = ( + "verified archive output\n" + if increment_id == "ARCHIVE-INDEX" + else f"verified {increment_id.lower()} output\n" + ) (repository / "archive-output.txt").write_text( - "verified archive output\n", encoding="utf-8" + output, encoding="utf-8" + ) + _rewrite_inherited_review_reports( + repository, + status, + str(increment_id), + ) + relative_review_directory = ( + "reviews" + if increment_id == "ARCHIVE-INDEX" + else f"reviews/{increment_id}" + ) + write_raw_review_reports( + repository, + str(increment_id), + relative_review_directory, ) - write_raw_review_reports(repository) receipt = program_activation.advance_execution_state( program_root, phase, _fresh_observation(repository) ) @@ -587,9 +888,12 @@ def run_lifecycle_phase(arguments: argparse.Namespace) -> dict[str, object]: if phase == "prepare-review": _install_failure_hook(program_review, arguments.fail_label) - receipt = program_review.persist_review_preparation( + observation = program_activation._without_owned_program_paths( program_root, _fresh_observation(repository) ) + receipt = program_review.persist_review_preparation( + program_root, observation + ) return { **asdict(receipt), "prompt": diff_disposition.render_diff_disposition_prompt(program_root) @@ -606,6 +910,100 @@ def run_lifecycle_phase(arguments: argparse.Namespace) -> dict[str, object]: ) return asdict(receipt) + if phase == "render-accept-stop": + candidate = diff_disposition.build_diff_acceptance_candidate( + program_root, _fresh_observation(repository) + ) + return {"prompt": "Accept and stop.\n\n" + candidate.prompt} + + if phase == "render-accept-continue": + return { + "prompt": program_continuation.render_accept_continue_prompt( + program_root + ) + } + + if phase == "dispose-diff": + _install_failure_hook(diff_disposition, arguments.fail_label) + _install_failure_hook(program_rollover, arguments.fail_label) + receipt = diff_disposition.persist_diff_disposition( + program_root, + _load_prompt(arguments.prompt_file), + _fresh_observation(repository), + ) + return asdict(receipt) + + if phase == "render-later-continuation": + return { + "prompt": program_continuation.render_accepted_state_continuation_prompt( + program_root + ) + } + + if phase == "rollover": + _install_failure_hook(program_rollover, arguments.fail_label) + receipt = program_rollover.persist_increment_rollover( + program_root, + _load_prompt(arguments.prompt_file), + _fresh_observation(repository), + ) + return asdict(receipt) + + if phase == "block": + _install_failure_hook(blocked_recovery, arguments.fail_label) + observation = _fresh_observation(repository) + receipt = blocked_recovery.block_current_program( + program_root, + blocked_recovery.BlockedTransitionRequest( + reason_code="verification-environment-unavailable", + recovery_criteria=( + "The local verification environment is available.", + "The preserved catalog evidence remains unchanged.", + ), + evidence_bindings=( + blocked_recovery.EvidenceBinding( + path="catalog.txt", + sha256=hashlib.sha256( + (repository / "catalog.txt").read_bytes() + ).hexdigest(), + ), + ), + ), + observation, + ) + return asdict(receipt) + + if phase == "render-block-resolution": + status = json.loads( + (program_root / "state/status.json").read_text(encoding="utf-8") + ) + context = status["blocked_context"] + candidate_value = { + "schema_version": blocked_recovery.BLOCK_RESOLUTION_CANDIDATE_SCHEMA, + "block_id": context["block_id"], + "criterion_results": [ + {"criterion": criterion, "satisfied": True} + for criterion in context["recovery_criteria"] + ], + "evidence_bindings": context["evidence_bindings"], + } + return { + "prompt": blocked_recovery.render_block_resolution_prompt( + program_root, + candidate_value, + _fresh_observation(repository), + ) + } + + if phase == "resolve-block": + _install_failure_hook(blocked_recovery, arguments.fail_label) + receipt = blocked_recovery.persist_blocked_resolution( + program_root, + _load_prompt(arguments.prompt_file), + _fresh_observation(repository), + ) + return asdict(receipt) + if phase == "prepare-closure": _install_failure_hook(program_closure, arguments.fail_label) receipt = program_closure.prepare_program_closure( @@ -793,11 +1191,20 @@ def build_argument_parser() -> argparse.ArgumentParser: "publish", "activate", "prepare-plan", + "render-exact-plan", "materialize-plan", "implementing", "reviewing", "prepare-review", "accept", + "render-accept-stop", + "render-accept-continue", + "dispose-diff", + "render-later-continuation", + "rollover", + "block", + "render-block-resolution", + "resolve-block", "prepare-closure", "activate-to-diff", "accept-and-prepare-closure", @@ -810,6 +1217,7 @@ def build_argument_parser() -> argparse.ArgumentParser: parser.add_argument("--source-sha256", required=True) parser.add_argument("--prompt-file") parser.add_argument("--fail-label") + parser.add_argument("--exact-plan-file") return parser diff --git a/tests/test_approval_checkpoint.py b/tests/test_approval_checkpoint.py index 8140ed2..e9d9a66 100644 --- a/tests/test_approval_checkpoint.py +++ b/tests/test_approval_checkpoint.py @@ -148,6 +148,8 @@ def test_every_action_has_one_fail_closed_risk_class(self) -> None: self.assertEqual(set(classified), set(CHECKPOINT.AUTHORITY_ACTIONS)) self.assertEqual(classified["modify-workspace"], "routine-local") self.assertEqual(classified["create-local-commit"], "explicit-local") + self.assertEqual(classified["rollover-increment"], "explicit-local") + self.assertEqual(classified["resume-blocked-program"], "explicit-local") self.assertEqual(classified["create-draft-pull-request"], "bounded-external") self.assertEqual(classified["destructive-operation"], "high-consequence") with self.assertRaisesRegex(ValueError, "unsupported action"): diff --git a/tests/test_blocked_recovery.py b/tests/test_blocked_recovery.py new file mode 100644 index 0000000..1154486 --- /dev/null +++ b/tests/test_blocked_recovery.py @@ -0,0 +1,567 @@ +import importlib.util +import json +import subprocess +import sys +import unittest +from dataclasses import asdict, replace +from pathlib import Path +from unittest import mock + +from tests.program_bootstrap_support import BootstrapFixture, repository_snapshot +from tests.test_program_activation import ACTIVATION, activated_program, exact_plan_bytes + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +SCRIPT_ROOT = REPOSITORY_ROOT / "skills/implementing-staged-plans/scripts" +SCRIPT_PATH = SCRIPT_ROOT / "blocked_recovery.py" +DISCOVERY_PATH = SCRIPT_ROOT / "program_discovery.py" +LIFECYCLE_SUPPORT_PATH = REPOSITORY_ROOT / "tests/program_bootstrap_support.py" + +sys.path.insert(0, str(SCRIPT_ROOT)) +try: + SPEC = importlib.util.spec_from_file_location("blocked_recovery", SCRIPT_PATH) + if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"Unable to load blocked recovery from {SCRIPT_PATH}") + BLOCKED = importlib.util.module_from_spec(SPEC) + sys.modules[SPEC.name] = BLOCKED + SPEC.loader.exec_module(BLOCKED) +finally: + sys.path.remove(str(SCRIPT_ROOT)) + + +def implementing_program(): + fixture = BootstrapFixture() + fixture.configure_successors({"ARCHIVE-VERIFY": ("ARCHIVE-INDEX",)}) + program_root, observation = activated_program(fixture) + plan = exact_plan_bytes(program_root, observation) + prepared = ACTIVATION.prepare_exact_plan(program_root, plan, observation) + ACTIVATION.materialize_exact_plan( + program_root, prepared.plan_prompt, observation + ) + ACTIVATION.advance_execution_state(program_root, "implementing", observation) + fresh = ACTIVATION._without_owned_program_paths( + program_root, + ACTIVATION.inspect_repository(fixture.repository, fixture.head).observation, + ) + return fixture, program_root, fresh + + +def block_request(fixture: BootstrapFixture): + return BLOCKED.BlockedTransitionRequest( + reason_code="verification-environment-unavailable", + recovery_criteria=( + "The local verification environment is available.", + "The preserved catalog evidence remains unchanged.", + ), + evidence_bindings=( + BLOCKED.EvidenceBinding( + path="catalog.txt", + sha256=BLOCKED.sha256_file(fixture.repository / "catalog.txt"), + ), + ), + ) + + +def resolution_candidate(program_root: Path) -> dict[str, object]: + status = json.loads( + (program_root / "state/status.json").read_text(encoding="utf-8") + ) + context = status["blocked_context"] + return { + "schema_version": BLOCKED.BLOCK_RESOLUTION_CANDIDATE_SCHEMA, + "block_id": context["block_id"], + "criterion_results": [ + {"criterion": criterion, "satisfied": True} + for criterion in context["recovery_criteria"] + ], + "evidence_bindings": context["evidence_bindings"], + } + + +class BlockedRecoveryTests(unittest.TestCase): + def discover(self, fixture: BootstrapFixture) -> dict[str, object]: + completed = subprocess.run( + [sys.executable, str(DISCOVERY_PATH), "discover", str(fixture.repository)], + cwd=REPOSITORY_ROOT, + text=True, + capture_output=True, + check=False, + ) + self.assertIn(completed.returncode, {0, 1}, completed.stderr) + return json.loads(completed.stdout) + + def test_production_block_fresh_discovery_and_exact_resume(self) -> None: + fixture, program_root, observation = implementing_program() + try: + blocked_process = subprocess.run( + [ + sys.executable, + str(LIFECYCLE_SUPPORT_PATH), + "block", + "--repository", + str(fixture.repository), + "--candidate", + str(fixture.candidate), + "--source-plan", + str(fixture.source_plan), + "--source-sha256", + fixture.source_sha256, + ], + cwd=REPOSITORY_ROOT, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(blocked_process.returncode, 0, blocked_process.stderr) + blocked = json.loads(blocked_process.stdout) + self.assertEqual(blocked["program_state"], "blocked") + self.assertEqual(blocked["increment_state"], "blocked") + discovered = self.discover(fixture) + self.assertEqual(discovered["disposition"], "blocked-recovery-ready") + self.assertEqual( + discovered["candidates"][0]["resume_program_state"], "active" + ) + self.assertEqual( + discovered["candidates"][0]["resume_increment_state"], + "implementing", + ) + + candidate = resolution_candidate(program_root) + prompt = BLOCKED.render_block_resolution_prompt( + program_root, candidate, observation + ) + receipt = BLOCKED.persist_blocked_resolution( + program_root, prompt, observation + ) + self.assertEqual(receipt.program_state, "active") + self.assertEqual(receipt.increment_state, "implementing") + actions = [ + json.loads(line) + for line in ( + program_root / "state/action-authorizations.jsonl" + ).read_text(encoding="utf-8").splitlines() + ] + resolutions = [ + json.loads(line) + for line in ( + program_root / "state/block-resolutions.jsonl" + ).read_text(encoding="utf-8").splitlines() + ] + resume_actions = [ + item + for item in actions + if item.get("actions") == ["resume-blocked-program"] + ] + self.assertEqual(len(resume_actions), 1) + self.assertEqual(len(resolutions), 1) + for record in (*resume_actions, *resolutions): + self.assertTrue( + all( + type(result["satisfied"]) is bool + for result in record["criterion_results"] + ) + ) + finally: + fixture.close() + + def test_second_blocking_episode_replaces_current_resolution_binding(self) -> None: + fixture, program_root, observation = implementing_program() + try: + BLOCKED.block_current_program( + program_root, block_request(fixture), observation + ) + first_block_id = json.loads( + (program_root / "state/status.json").read_text(encoding="utf-8") + )["blocked_context"]["block_id"] + prompt = BLOCKED.render_block_resolution_prompt( + program_root, resolution_candidate(program_root), observation + ) + BLOCKED.persist_blocked_resolution(program_root, prompt, observation) + + BLOCKED.block_current_program( + program_root, block_request(fixture), observation + ) + status = json.loads( + (program_root / "state/status.json").read_text(encoding="utf-8") + ) + self.assertNotEqual(status["blocked_context"]["block_id"], first_block_id) + self.assertNotIn("block_resolution_binding", status) + self.assertEqual( + self.discover(fixture)["disposition"], "blocked-recovery-ready" + ) + resolutions = ( + program_root / "state/block-resolutions.jsonl" + ).read_text(encoding="utf-8").splitlines() + self.assertEqual(len(resolutions), 1) + finally: + fixture.close() + + def test_resolution_prefixes_are_discoverable_and_idempotent(self) -> None: + for label in ("action-authorization", "resolution-record", "resumed-status"): + with self.subTest(label=label): + fixture, program_root, observation = implementing_program() + try: + BLOCKED.block_current_program( + program_root, block_request(fixture), observation + ) + prompt = BLOCKED.render_block_resolution_prompt( + program_root, resolution_candidate(program_root), observation + ) + + def interrupt( + completed_label: str, *, expected_label: str = label + ) -> None: + if completed_label == expected_label: + raise RuntimeError("injected blocked recovery interruption") + + with mock.patch.object( + BLOCKED, "_after_persist", side_effect=interrupt + ): + with self.assertRaisesRegex(RuntimeError, "injected"): + BLOCKED.persist_blocked_resolution( + program_root, prompt, observation + ) + discovered = self.discover(fixture) + expected = ( + "resume" + if label == "resumed-status" + else "blocked-resolution-retry-ready" + ) + self.assertEqual(discovered["disposition"], expected, discovered) + completed = BLOCKED.persist_blocked_resolution( + program_root, prompt, observation + ) + self.assertEqual(completed.increment_state, "implementing") + snapshot = repository_snapshot(fixture.repository) + recovered = BLOCKED.persist_blocked_resolution( + program_root, prompt, observation + ) + self.assertEqual(recovered.increment_state, "implementing") + self.assertEqual(repository_snapshot(fixture.repository), snapshot) + finally: + fixture.close() + + def test_remediating_and_invalid_entry_requests_fail_before_writes(self) -> None: + from tests.program_bootstrap_support import canonical_json + from tests.test_program_review import REVIEW, reviewing_program + + def add_open_finding(fixture: BootstrapFixture) -> None: + path = fixture.repository / "reviews/requirements.json" + report = json.loads(path.read_text(encoding="utf-8")) + report["findings"] = [ + { + "finding_id": "F-BLOCK-BOUNDARY", + "report_id": "requirements-initial", + "scope": "requirements", + "classification": "material", + "summary": "review found a material defect", + "evidence": "exact evidence", + "impact": "requested behavior is not met", + "confidence": "high", + "remediation": "repair before diff approval", + "disposition": "open", + "affected_requirement_or_invariant": "archive output", + "severity": "high", + "inspection_path": "archive-output.txt", + "decision_reference": "none", + } + ] + path.write_bytes(canonical_json(report)) + + fixture, program_root, observation = reviewing_program(add_open_finding) + try: + REVIEW.persist_review_remediation(program_root, observation) + observation = ACTIVATION._without_owned_program_paths( + program_root, + ACTIVATION.inspect_repository( + fixture.repository, fixture.head + ).observation, + ) + before = repository_snapshot(fixture.repository) + with self.assertRaisesRegex(ValueError, "remediating"): + BLOCKED.block_current_program( + program_root, block_request(fixture), observation + ) + self.assertEqual(repository_snapshot(fixture.repository), before) + finally: + fixture.close() + + for state in ("preparing", "awaiting-plan-approval", "authorized", "verified", "accepted"): + with self.subTest(state=state): + fixture, program_root, observation = implementing_program() + try: + status_path = program_root / "state/status.json" + status = json.loads(status_path.read_text(encoding="utf-8")) + status["current_increment_state"] = state + status_path.write_text( + json.dumps(status, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + before = repository_snapshot(fixture.repository) + with self.assertRaises(ValueError): + BLOCKED.block_current_program( + program_root, block_request(fixture), observation + ) + self.assertEqual(repository_snapshot(fixture.repository), before) + finally: + fixture.close() + + def test_candidate_tampering_and_stale_authority_fail_before_writes(self) -> None: + for case in ( + "resume-target", + "unsatisfied", + "duplicate", + "outside-evidence", + "changed-evidence", + "prompt", + "status", + ): + with self.subTest(case=case): + fixture, program_root, observation = implementing_program() + try: + BLOCKED.block_current_program( + program_root, block_request(fixture), observation + ) + candidate = resolution_candidate(program_root) + if case == "resume-target": + candidate["resume_increment_state"] = "reviewing" + elif case == "unsatisfied": + candidate["criterion_results"][0]["satisfied"] = False + elif case == "duplicate": + candidate["criterion_results"].append( + dict(candidate["criterion_results"][0]) + ) + elif case == "outside-evidence": + candidate["evidence_bindings"] = [ + {"path": "outside.txt", "sha256": "0" * 64} + ] + elif case == "changed-evidence": + (fixture.repository / "catalog.txt").write_text( + "changed\n", encoding="utf-8" + ) + prompt = None + if case not in {"status", "prompt"}: + before = repository_snapshot(fixture.repository) + with self.assertRaises(ValueError): + BLOCKED.build_block_resolution_candidate( + program_root, candidate, observation + ) + self.assertEqual(repository_snapshot(fixture.repository), before) + continue + prompt = BLOCKED.render_block_resolution_prompt( + program_root, candidate, observation + ) + if case == "prompt": + prompt += "tampered\n" + else: + status_path = program_root / "state/status.json" + status = json.loads(status_path.read_text(encoding="utf-8")) + status["state_sequence"] += 1 + status_path.write_text( + json.dumps(status, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + before = repository_snapshot(fixture.repository) + with self.assertRaises(ValueError): + BLOCKED.persist_blocked_resolution( + program_root, prompt, observation + ) + self.assertEqual(repository_snapshot(fixture.repository), before) + finally: + fixture.close() + + def test_resolution_candidate_requires_exact_nested_field_types(self) -> None: + for case in ( + "integer-satisfied", + "float-satisfied", + "criterion-type", + "criterion-extra-key", + "evidence-path-type", + "evidence-sha-type", + "evidence-extra-key", + ): + with self.subTest(case=case): + fixture, program_root, observation = implementing_program() + try: + BLOCKED.block_current_program( + program_root, block_request(fixture), observation + ) + candidate = resolution_candidate(program_root) + if case == "integer-satisfied": + candidate["criterion_results"][0]["satisfied"] = 1 + elif case == "float-satisfied": + candidate["criterion_results"][0]["satisfied"] = 1.0 + elif case == "criterion-type": + candidate["criterion_results"][0]["criterion"] = 1 + elif case == "criterion-extra-key": + candidate["criterion_results"][0]["extra"] = "value" + elif case == "evidence-path-type": + candidate["evidence_bindings"][0]["path"] = 1 + elif case == "evidence-sha-type": + candidate["evidence_bindings"][0]["sha256"] = 1 + else: + candidate["evidence_bindings"][0]["extra"] = "value" + before = repository_snapshot(fixture.repository) + with self.assertRaisesRegex( + ValueError, "candidate nested field types are invalid" + ): + BLOCKED.build_block_resolution_candidate( + program_root, candidate, observation + ) + self.assertEqual( + repository_snapshot(fixture.repository), before + ) + finally: + fixture.close() + + def test_changed_plan_baseline_grant_context_or_evidence_fails_closed(self) -> None: + for case in ("plan", "baseline", "grant", "context", "symlink-evidence"): + with self.subTest(case=case): + fixture, program_root, observation = implementing_program() + try: + BLOCKED.block_current_program( + program_root, block_request(fixture), observation + ) + if case == "plan": + path = program_root / "increments/ARCHIVE-INDEX/exact-file-plan.md" + path.write_bytes(path.read_bytes() + b"drift\n") + elif case == "baseline": + path = program_root / "increments/ARCHIVE-INDEX/execution-baseline.json" + path.write_bytes(path.read_bytes() + b" ") + elif case == "grant": + path = program_root / "state/increment-grants.jsonl" + records = [json.loads(line) for line in path.read_text().splitlines()] + records[-1]["decision"] = "revoked" + path.write_text( + "".join( + json.dumps(item, separators=(",", ":"), sort_keys=True) + + "\n" + for item in records + ), + encoding="utf-8", + ) + elif case == "context": + path = program_root / "state/status.json" + status = json.loads(path.read_text(encoding="utf-8")) + status["blocked_context"]["block_id"] = "FABRICATED" + path.write_text( + json.dumps(status, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + else: + evidence = fixture.repository / "catalog.txt" + evidence.unlink() + evidence.symlink_to("archive-output.txt") + before = repository_snapshot(fixture.repository) + with self.assertRaises(ValueError): + BLOCKED.build_block_resolution_candidate( + program_root, resolution_candidate(program_root), observation + ) + self.assertEqual(repository_snapshot(fixture.repository), before) + finally: + fixture.close() + + def test_divergent_resolution_action_is_preserved_and_stops(self) -> None: + fixture, program_root, observation = implementing_program() + try: + BLOCKED.block_current_program( + program_root, block_request(fixture), observation + ) + prompt = BLOCKED.render_block_resolution_prompt( + program_root, resolution_candidate(program_root), observation + ) + + def interrupt(label: str) -> None: + if label == "action-authorization": + raise RuntimeError("injected") + + with mock.patch.object(BLOCKED, "_after_persist", side_effect=interrupt): + with self.assertRaises(RuntimeError): + BLOCKED.persist_blocked_resolution( + program_root, prompt, observation + ) + path = program_root / "state/action-authorizations.jsonl" + records = [json.loads(line) for line in path.read_text().splitlines()] + records[-1]["checkpoint_id"] = "DIVERGENT" + path.write_text( + "".join( + json.dumps(item, separators=(",", ":"), sort_keys=True) + "\n" + for item in records + ), + encoding="utf-8", + ) + before = repository_snapshot(fixture.repository) + discovered = self.discover(fixture) + self.assertEqual( + discovered["disposition"], "blocked-recovery-required", discovered + ) + with self.assertRaisesRegex(ValueError, "recovery-required"): + BLOCKED.persist_blocked_resolution( + program_root, prompt, observation + ) + self.assertEqual(repository_snapshot(fixture.repository), before) + finally: + fixture.close() + + def test_render_and_apply_clis_use_transport_only_as_input(self) -> None: + fixture, program_root, observation = implementing_program() + try: + BLOCKED.block_current_program( + program_root, block_request(fixture), observation + ) + candidate_path = fixture.root / "block-candidate.json" + candidate_path.write_text( + json.dumps(resolution_candidate(program_root), sort_keys=True) + "\n", + encoding="utf-8", + ) + before = repository_snapshot(fixture.repository) + rendered = subprocess.run( + [ + sys.executable, + str(SCRIPT_PATH), + "render", + str(program_root), + "--candidate-file", + str(candidate_path), + ], + cwd=REPOSITORY_ROOT, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(rendered.returncode, 0, rendered.stderr) + self.assertEqual(repository_snapshot(fixture.repository), before) + prompt_path = fixture.root / "block-prompt.md" + prompt_path.write_text(rendered.stdout, encoding="utf-8") + applied = subprocess.run( + [ + sys.executable, + str(SCRIPT_PATH), + "apply", + str(program_root), + "--prompt-file", + str(prompt_path), + "--repository", + str(fixture.repository), + "--base-commit", + fixture.head, + ], + cwd=REPOSITORY_ROOT, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(applied.returncode, 0, applied.stderr) + self.assertEqual(json.loads(applied.stdout)["increment_state"], "implementing") + self.assertFalse( + any( + path.name == "block-candidate.json" + for path in program_root.rglob("*") + ) + ) + finally: + fixture.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_continuity_closure.py b/tests/test_continuity_closure.py index 9716258..1a21c71 100644 --- a/tests/test_continuity_closure.py +++ b/tests/test_continuity_closure.py @@ -308,6 +308,128 @@ def test_handoff_rejects_stale_incomplete_secret_like_or_authorizing_text(self) self.assertTrue(CONTINUITY.validate_handoff(candidate)) +class BoundedContinuationResultTests(unittest.TestCase): + def test_semantic_successor_selection_requires_one_satisfied_candidate(self) -> None: + current = "ARCHIVE-INDEX" + direct = { + "id": "DIRECT", + "assigned_increments": [current, "ARCHIVE-VERIFY"], + } + blocked = { + "id": "BLOCKED", + "assigned_increments": ["ARCHIVE-BLOCKER", "ARCHIVE-VERIFY"], + } + alternate = { + "id": "ALTERNATE", + "assigned_increments": [current, "ARCHIVE-EXPORT"], + } + self.assertEqual( + CONTINUITY.select_unique_satisfied_successor( + [direct], current, {current} + ), + ("ARCHIVE-VERIFY", ""), + ) + self.assertEqual( + CONTINUITY.select_unique_satisfied_successor([], current, {current}), + (None, "no allocated successor"), + ) + self.assertEqual( + CONTINUITY.select_unique_satisfied_successor( + [direct, alternate], current, {current} + ), + (None, "multiple allocated successors"), + ) + self.assertEqual( + CONTINUITY.select_unique_satisfied_successor( + [direct, blocked], current, {current} + ), + (None, "successor dependencies are unsatisfied"), + ) + + def test_new_task_result_renders_one_exact_derived_prompt(self) -> None: + command = { + "schema_version": "implementation-continuation-command/v1", + "program_id": "portable-catalog", + "next_increment_id": "catalog-index", + } + candidate = CONTINUITY.build_bounded_continuation_result( + "accepted", + "Continue to the uniquely allocated catalog-index increment.", + destination="new-task", + mandatory_stop=True, + continuation_command=command, + ) + + self.assertEqual(CONTINUITY.validate_bounded_continuation_result(candidate), []) + rendered = CONTINUITY.render_bounded_continuation_result(candidate) + self.assertEqual(rendered.count("$implementing-staged-plans"), 1) + self.assertIn("Mandatory stop: yes", rendered) + self.assertEqual( + rendered.split("Copy-ready prompt:\n\n", 1)[1], + CONTINUITY.render_exact_prompt(command), + ) + + def test_invalid_destinations_prompts_and_authorizing_prose_are_rejected(self) -> None: + command = {"schema_version": "implementation-continuation-command/v1"} + cases = ( + CONTINUITY.BoundedContinuationResult( + "accepted", "Continue safely.", False, "new-task", command + ), + CONTINUITY.BoundedContinuationResult( + "accepted", "Continue safely.", True, "new-task", None + ), + CONTINUITY.BoundedContinuationResult( + "accepted", "Continue safely.", False, "current-task", command + ), + CONTINUITY.BoundedContinuationResult( + "accepted", "Continue safely.", True, "elsewhere", None + ), + CONTINUITY.BoundedContinuationResult( + "accepted", + "You are authorized to modify the workspace.", + True, + "none", + None, + ), + CONTINUITY.BoundedContinuationResult( + "accepted", None, True, "none", None + ), + ) + for candidate in cases: + with self.subTest(candidate=candidate): + self.assertTrue( + CONTINUITY.validate_bounded_continuation_result(candidate) + ) + with self.assertRaises(ValueError): + CONTINUITY.render_bounded_continuation_result(candidate) + + def test_current_task_and_none_results_never_render_a_prompt(self) -> None: + for destination, mandatory_stop in ( + ("current-task", False), + ("none", True), + ): + with self.subTest(destination=destination): + candidate = CONTINUITY.build_bounded_continuation_result( + "implementing", + "Use the current status to select the next legal action.", + destination=destination, + mandatory_stop=mandatory_stop, + ) + rendered = CONTINUITY.render_bounded_continuation_result(candidate) + self.assertNotIn("$implementing-staged-plans", rendered) + self.assertNotIn("Copy-ready prompt", rendered) + + def test_legacy_brief_and_handoff_bytes_are_unchanged(self) -> None: + self.assertEqual( + digest_text(CONTINUITY.render_increment_brief(brief())), + "c4aa34b929aae944868d4e47c77db5aa2c5193146fc570f2407e2d2c35ea6c2e", + ) + self.assertEqual( + digest_text(CONTINUITY.render_handoff(handoff())), + "8f45d7ef13e2519a73febed483a5bd565753dba47170299ee4f1efd97ba9efcb", + ) + + class ContinuationAndResumeTests(unittest.TestCase): def test_legacy_full_mode_stops_in_a_suitable_same_conversation(self) -> None: allowed, issues = CONTINUITY.evaluate_continuation(assessment()) diff --git a/tests/test_diff_disposition.py b/tests/test_diff_disposition.py index e68b623..076425a 100644 --- a/tests/test_diff_disposition.py +++ b/tests/test_diff_disposition.py @@ -6,7 +6,13 @@ from pathlib import Path from unittest import mock -from tests.program_bootstrap_support import canonical_json, repository_snapshot +from tests.program_bootstrap_support import ( + BootstrapFixture, + canonical_json, + repository_snapshot, + write_raw_review_reports, +) +from tests.test_program_activation import ACTIVATION, activated_program, exact_plan_bytes from tests.test_program_review import REVIEW as PROGRAM_REVIEW from tests.test_program_review import reviewing_program @@ -28,7 +34,35 @@ sys.path.remove(str(SCRIPT_ROOT)) -def awaiting_diff_program(): +def setUpModule() -> None: + sys.path.insert(0, str(SCRIPT_ROOT)) + + +def tearDownModule() -> None: + sys.path.remove(str(SCRIPT_ROOT)) + + +def awaiting_diff_program(successors: dict[str, tuple[str, ...]] | None = None): + if successors is not None: + fixture = BootstrapFixture() + fixture.configure_successors(successors) + program_root, observation = activated_program( + fixture, "approval:full-increment" + ) + ACTIVATION.prepare_exact_plan( + program_root, exact_plan_bytes(program_root, observation), observation + ) + ACTIVATION.advance_execution_state(program_root, "implementing", observation) + (fixture.repository / "archive-output.txt").write_text( + "archive output\n", encoding="utf-8" + ) + write_raw_review_reports(fixture.repository) + observation = ACTIVATION.inspect_repository( + fixture.repository, fixture.head + ).observation + ACTIVATION.advance_execution_state(program_root, "reviewing", observation) + PROGRAM_REVIEW.persist_review_preparation(program_root, observation) + return fixture, program_root, observation fixture, program_root, observation = reviewing_program() PROGRAM_REVIEW.persist_review_preparation(program_root, observation) return fixture, program_root, observation @@ -70,6 +104,93 @@ def recording_load(path): finally: fixture.close() + def test_unique_satisfied_successor_adds_one_bound_continue_choice(self) -> None: + fixture, program_root, observation = awaiting_diff_program( + {"ARCHIVE-VERIFY": ("ARCHIVE-INDEX",)} + ) + try: + prompt = DIFF.render_diff_disposition_prompt(program_root) + self.assertEqual(prompt.count("Accept and stop."), 1) + self.assertEqual( + prompt.count("Accept and continue to `ARCHIVE-VERIFY`."), 1 + ) + self.assertEqual(prompt.count("$implementing-staged-plans"), 2) + candidate = DIFF.build_diff_acceptance_candidate(program_root, observation) + self.assertEqual(candidate.decision, "accept-stop") + finally: + fixture.close() + + def test_unavailable_successor_never_blocks_or_changes_stop_choice(self) -> None: + cases = ( + (None, "no allocated successor"), + ( + { + "ARCHIVE-VERIFY": ("ARCHIVE-INDEX",), + "ARCHIVE-EXPORT": ("ARCHIVE-INDEX",), + }, + "multiple allocated successors", + ), + ( + {"ARCHIVE-VERIFY": ("ARCHIVE-BLOCKER",)}, + "successor dependencies are unsatisfied", + ), + ) + for successors, reason in cases: + with self.subTest(reason=reason): + fixture, program_root, _observation = awaiting_diff_program(successors) + try: + prompt = DIFF.render_diff_disposition_prompt(program_root) + self.assertEqual(prompt.count("Accept and stop."), 1) + self.assertNotIn("Accept and continue", prompt) + if successors is None: + from program_continuation import ( + build_continuation_extension, + continuation_unavailability_reason, + ) + + candidate = DIFF.build_diff_acceptance_candidate( + program_root, _observation + ) + self.assertIsNone( + build_continuation_extension( + program_root, candidate, _observation + ) + ) + self.assertEqual( + continuation_unavailability_reason( + program_root, candidate + ), + reason, + ) + else: + self.assertIn(reason, prompt) + self.assertEqual(prompt.count("$implementing-staged-plans"), 1) + finally: + fixture.close() + + def test_stop_submission_does_not_derive_continuation(self) -> None: + fixture, program_root, observation = awaiting_diff_program( + {"ARCHIVE-VERIFY": ("ARCHIVE-INDEX",)} + ) + try: + acceptance = DIFF.build_diff_acceptance_candidate( + program_root, observation + ) + stop_prompt = "Accept and stop.\n\n" + acceptance.prompt + with mock.patch.object( + DIFF._continuation, + "build_continuation_extension", + side_effect=ValueError("unrelated continuation failure"), + ): + receipt = DIFF._persist_diff_acceptance_prefix( + program_root, stop_prompt, observation + ) + + self.assertEqual(receipt.decision, "accept-stop") + self.assertEqual(receipt.increment_state, "accepted") + finally: + fixture.close() + def test_approval_only_and_status_lost_response_prefixes_are_exactly_retry_safe(self) -> None: for failure_label in ("diff-approval", "accepted-status"): with self.subTest(label=failure_label): @@ -77,8 +198,10 @@ def test_approval_only_and_status_lost_response_prefixes_are_exactly_retry_safe( try: prompt = DIFF.render_diff_disposition_prompt(program_root) - def interrupt(label: str) -> None: - if label == failure_label: + def interrupt( + label: str, *, expected_label: str = failure_label + ) -> None: + if label == expected_label: raise RuntimeError("injected diff disposition interruption") with mock.patch.object(DIFF, "_after_persist", side_effect=interrupt): diff --git a/tests/test_distribution_documentation.py b/tests/test_distribution_documentation.py index fcaacd5..2bf574b 100644 --- a/tests/test_distribution_documentation.py +++ b/tests/test_distribution_documentation.py @@ -47,7 +47,7 @@ def test_platform_metadata_is_present_and_consistent(self) -> None: claude_marketplace = load_json(CLAUDE_MARKETPLACE) self.assertEqual(codex_manifest["name"], "implementation-plugin") - self.assertEqual(codex_manifest["version"], "0.1.1") + self.assertEqual(codex_manifest["version"], "0.1.2") self.assertEqual(codex_manifest["skills"], "./skills/") self.assertEqual(claude_manifest["name"], codex_manifest["name"]) self.assertEqual(claude_manifest["version"], codex_manifest["version"]) @@ -162,7 +162,7 @@ def test_windows_and_current_claude_routes_are_documented(self) -> None: for required_text in ( "Claude Code in VS Code", "/plugins", - "claude --plugin-dir /absolute/path/to/implementation-plugin-0.1.1.zip", + "claude --plugin-dir /absolute/path/to/implementation-plugin-0.1.2.zip", "```powershell", "if (Test-Path $skillDestination)", 'throw "Destination already exists: $skillDestination"', @@ -177,7 +177,7 @@ def test_windows_and_current_claude_routes_are_documented(self) -> None: 4, ) - def test_plan_a_reader_routes_are_truthful_and_deferred_operations_are_named(self) -> None: + def test_reader_routes_describe_the_complete_supported_lifecycle(self) -> None: documents = { path: reader_text(path) for path in ( @@ -193,8 +193,16 @@ def test_plan_a_reader_routes_are_truthful_and_deferred_operations_are_named(sel "Activate a Generated Program", "Before Production Modification", "Prepare Review and Diff Disposition", + "Dispose the Current Diff", + "Continue an Accepted Program", + "Authorize a Successor Increment", + "Resolve a Blocked Program", "Close a Final Program", "accept-stop", + "accept-continue", + "accepted-state-continuation", + "current_increment_authority_binding", + "blocked-recovery", "implementation-closure-storage/v1", "legacy-rollover-upgrade-required", "blocked-transaction-required", @@ -202,12 +210,12 @@ def test_plan_a_reader_routes_are_truthful_and_deferred_operations_are_named(sel "unsupported-program-mutation", ): self.assertIn(required, combined) - self.assertNotIn("Accept an increment and authorize the next increment", combined) + self.assertIn("Plan A closure", combined) self.assertNotIn("execute `INC-001` under `approval:full-increment`", combined) continuity = reader_text( Path("skills/implementing-staged-plans/references/continuity-closure.md") ) - self.assertNotIn("## Apply an Authorized Rollover", continuity) + self.assertIn("## Apply Prompt-Bound Successor Rollover", continuity) self.assertIn("legacy-rollover-upgrade-required", continuity) for path, text in documents.items(): with self.subTest(path=path): @@ -216,6 +224,28 @@ def test_plan_a_reader_routes_are_truthful_and_deferred_operations_are_named(sel r"(?:handoff|retrieved prompt|assistant-quoted prompt).*authoriz(?:e|es) mutation", ) + def test_distribution_descriptions_do_not_claim_unsupported_program_mutations(self) -> None: + descriptions = ( + ("codex manifest", str(load_json(CODEX_MANIFEST)["description"])), + ("claude manifest", str(load_json(CLAUDE_MANIFEST)["description"])), + ( + "claude marketplace", + str(load_json(CLAUDE_MARKETPLACE)["plugins"][0]["description"]), + ), + ( + "openai agent metadata", + reader_text( + Path("skills/implementing-staged-plans/agents/openai.yaml") + ), + ), + ) + for label, description in descriptions: + with self.subTest(label=label): + self.assertNotRegex( + description.lower(), + r"\b(?:revise|revision|supersede|supersession|cancel|cancellation)\b", + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_front_door_contract.py b/tests/test_front_door_contract.py index 0437a36..a9376a9 100644 --- a/tests/test_front_door_contract.py +++ b/tests/test_front_door_contract.py @@ -22,7 +22,7 @@ EXPECTED_MANIFEST = { "name": "implementation-plugin", - "version": "0.1.1", + "version": "0.1.2", "description": "Run approved implementation programs one reviewable increment at a time.", "skills": "./skills/", } @@ -184,8 +184,8 @@ def test_ui_metadata_explicitly_invokes_the_approved_skill(self) -> None: metadata, 'interface:\n' ' display_name: "Implementing Staged Plans"\n' - ' short_description: "Create, activate, or continue implementation programs."\n' - ' default_prompt: "Use $implementing-staged-plans to create, activate, or continue a repository-backed implementation program."\n' + ' short_description: "Create, continue, or recover implementation programs."\n' + ' default_prompt: "Use $implementing-staged-plans to create, activate, continue, or recover a repository-backed implementation program."\n' '\n' 'policy:\n' ' allow_implicit_invocation: false\n', @@ -198,6 +198,10 @@ def test_plan_a_lifecycle_routes_are_ordered_and_bounded(self) -> None: "## Activate a Generated Program", "## Before Production Modification", "## Prepare Review and Diff Disposition", + "## Dispose the Current Diff", + "## Continue an Accepted Program", + "## Authorize a Successor Increment", + "## Resolve a Blocked Program", "## Close a Final Program", ) positions = tuple(skill_markdown.index(heading) for heading in headings) @@ -218,6 +222,49 @@ def test_plan_a_lifecycle_routes_are_ordered_and_bounded(self) -> None: ): self.assertIn(required, skill_markdown) + def test_plan_b_routes_are_explicit_prompt_bound_and_non_expansive(self) -> None: + skill_markdown = SKILL_PATH.read_text(encoding="utf-8") + for required in ( + "accept-stop", + "accept-continue", + "accepted-state-continuation", + "current_increment_authority_binding", + "blocked-recovery", + "legacy-rollover-upgrade-required", + ): + self.assertIn(required, skill_markdown) + self.assertRegex( + skill_markdown, + re.compile(r"accept-continue.*no second.*checkpoint", re.IGNORECASE), + ) + self.assertRegex( + skill_markdown, + re.compile(r"handoff.*never.*author", re.IGNORECASE), + ) + self.assertRegex( + skill_markdown, + re.compile(r"legacy.*automatic.*never.*successor", re.IGNORECASE), + ) + self.assertRegex( + skill_markdown, + re.compile(r"revision.*supersession.*cancellation.*unsupported", re.IGNORECASE), + ) + + def test_bounded_continuation_navigation_is_structured_and_non_authorizing(self) -> None: + skill_markdown = SKILL_PATH.read_text(encoding="utf-8") + continuation_section = skill_markdown.split( + "## Route Continuity and Closure Work", 1 + )[1].split("## Route Optional Post-Closure Housekeeping", 1)[0] + for required in ( + "bounded continuation result", + "next legal action", + "mandatory stop", + "destination", + "copy-ready prompt", + "navigation", + ): + self.assertIn(required, continuation_section.lower()) + def test_navigation_and_quoted_prompts_never_grant_mutation_authority(self) -> None: skill_markdown = SKILL_PATH.read_text(encoding="utf-8").lower() self.assertIn( diff --git a/tests/test_integrated_pressure.py b/tests/test_integrated_pressure.py index 36d1217..d1efacb 100644 --- a/tests/test_integrated_pressure.py +++ b/tests/test_integrated_pressure.py @@ -2,6 +2,8 @@ import importlib.util import json import os +import shutil +import subprocess import sys import tempfile import unittest @@ -18,6 +20,9 @@ / "tests/fixtures/integrated-pressure/portable-library-program" ) PRESSURE_ROOT = REPOSITORY_ROOT / "tests/pressure/integrated" +CONTINUATION_REPLAY_ROOT = ( + REPOSITORY_ROOT / "tests/pressure/continuation-replay" +) EVIDENCE_PATH = ( REPOSITORY_ROOT / "implementation-programs/ISP-001/increments/INC-008/integration-evidence.json" @@ -362,6 +367,734 @@ def test_raw_outputs_precede_complete_evidence_backed_verdicts(self) -> None: self.assertNotIn("score", verdict) +class ContinuationReplayContractTests(unittest.TestCase): + def test_catalog_has_exact_routes_and_explicit_skill_prompts(self) -> None: + catalog = json.loads( + (CONTINUATION_REPLAY_ROOT / "scenarios.json").read_text( + encoding="utf-8" + ) + ) + scenarios = SUPPORT.load_continuation_replay( + CONTINUATION_REPLAY_ROOT / "scenarios.json" + ) + self.assertEqual( + tuple(item.scenario_id for item in scenarios), + ("immediate-continuation", "later-continuation"), + ) + self.assertEqual( + scenarios[0].expected_boundary, + catalog["scenarios"][0]["expected_boundary"], + ) + for scenario in scenarios: + prompt_path = REPOSITORY_ROOT / scenario.prompt_path + self.assertTrue(prompt_path.is_file()) + self.assertFalse(prompt_path.is_symlink()) + self.assertEqual( + prompt_path.read_text(encoding="utf-8").splitlines()[0], + "$implementing-staged-plans", + ) + + def test_catalog_rejects_wrong_boundary_types_before_evaluation(self) -> None: + for wrong_value in (1, True, [], {}): + with self.subTest(wrong_value=wrong_value): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + replay_root = root / "tests/pressure/continuation-replay" + shutil.copytree(CONTINUATION_REPLAY_ROOT, replay_root) + catalog_path = replay_root / "scenarios.json" + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + catalog["scenarios"][0]["expected_boundary"] = wrong_value + catalog_path.write_text( + json.dumps(catalog, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + output_directory = replay_root / "results" + + with ( + mock.patch.object(SUPPORT, "REPOSITORY_ROOT", root), + mock.patch.object(SUPPORT, "run_command") as run_command, + ): + with self.assertRaisesRegex( + ValueError, "expected_boundary must be a string" + ): + SUPPORT.evaluate_continuation_replay( + catalog_path=catalog_path, + output_directory=output_directory, + evaluator="codex", + ) + run_command.assert_not_called() + self.assertFalse(output_directory.exists()) + + def test_absent_live_results_are_valid_and_report_not_run(self) -> None: + if (CONTINUATION_REPLAY_ROOT / "results").exists() or ( + CONTINUATION_REPLAY_ROOT / "verdicts.json" + ).exists(): + self.skipTest("live continuation replay evidence is present") + self.assertFalse((CONTINUATION_REPLAY_ROOT / "results").exists()) + self.assertFalse((CONTINUATION_REPLAY_ROOT / "verdicts.json").exists()) + self.assertEqual( + SUPPORT.validate_continuation_replay_evidence(REPOSITORY_ROOT), [] + ) + + def test_evidence_requires_complete_digest_bound_results_and_verdicts(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + replay_root = root / "tests/pressure/continuation-replay" + shutil.copytree(CONTINUATION_REPLAY_ROOT, replay_root) + scenarios = SUPPORT.load_continuation_replay( + replay_root / "scenarios.json" + ) + verdicts = [] + for scenario in scenarios: + prompt_path = root / scenario.prompt_path + result_path = root / scenario.result_path + result_path.parent.mkdir(parents=True, exist_ok=True) + result_path.write_text( + "schema_version: implementation-continuation-replay-evidence/v1\n" + f"scenario_id: {scenario.scenario_id}\n" + f"prompt_sha256: {digest(prompt_path)}\n" + "evaluator: codex\n" + "client_version: codex 1.2.3\n" + "sandbox: read-only\n" + "session: ephemeral\n" + "exit_code: 0\n" + f"expected_boundary: {scenario.expected_boundary}\n" + "\n--- response ---\n" + "Synthetic response.\n", + encoding="utf-8", + ) + verdicts.append( + { + "id": scenario.scenario_id, + "outcome": "pass", + "prompt_sha256": digest(prompt_path), + "result_sha256": digest(result_path), + "evidence": "The raw response reached the expected boundary.", + "limitations": "Synthetic evaluator fixture only.", + } + ) + (replay_root / "verdicts.json").write_text( + json.dumps( + { + "schema_version": "implementation-continuation-replay-verdicts/v1", + "verdicts": verdicts, + }, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + self.assertEqual( + SUPPORT.validate_continuation_replay_evidence(root), [] + ) + malformed_verdicts = [*verdicts, "unexpected"] + (replay_root / "verdicts.json").write_text( + json.dumps( + { + "schema_version": "implementation-continuation-replay-verdicts/v1", + "verdicts": malformed_verdicts, + }, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + self.assertIn( + "continuation replay verdict document is incomplete", + SUPPORT.validate_continuation_replay_evidence(root), + ) + (replay_root / "verdicts.json").write_text( + json.dumps( + { + "schema_version": "implementation-continuation-replay-verdicts/v1", + "verdicts": verdicts, + }, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + first_result = root / scenarios[0].result_path + first_result.write_bytes(first_result.read_bytes() + b"tampered\n") + self.assertIn( + "result digest mismatch", + " ".join(SUPPORT.validate_continuation_replay_evidence(root)), + ) + + def test_evaluator_uses_fresh_sessions_and_never_overwrites_results(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + replay_root = root / "tests/pressure/continuation-replay" + shutil.copytree(CONTINUATION_REPLAY_ROOT, replay_root) + calls: list[tuple[tuple[str, ...], Path]] = [] + + def fake_run(arguments, *, cwd, timeout=30, environment=None): + calls.append((tuple(arguments), Path(cwd))) + if tuple(arguments) == ("codex", "--version"): + return subprocess.CompletedProcess( + arguments, 0, stdout="codex 1.2.3\n", stderr="" + ) + return subprocess.CompletedProcess( + arguments, + 0, + stdout=( + '{"type":"item.completed","item":' + '{"type":"agent_message","text":"Synthetic response."}}\n' + ), + stderr="", + ) + + def fake_isolation(isolated_root): + codex_home = Path(isolated_root) / "codex-home" + codex_home.mkdir() + return codex_home + + with ( + mock.patch.object(SUPPORT, "REPOSITORY_ROOT", root), + mock.patch.object(SUPPORT, "run_command", side_effect=fake_run), + mock.patch.object( + SUPPORT, + "_build_isolated_evaluation_root", + side_effect=fake_isolation, + ), + ): + paths = SUPPORT.evaluate_continuation_replay( + catalog_path=replay_root / "scenarios.json", + output_directory=replay_root / "results", + evaluator="codex", + ) + self.assertEqual(len(paths), 2) + evaluation_calls = [call for call in calls if "exec" in call[0]] + self.assertEqual(len(evaluation_calls), 2) + self.assertNotEqual( + evaluation_calls[0][1], evaluation_calls[1][1] + ) + for arguments, _cwd in evaluation_calls: + self.assertIn("--ephemeral", arguments) + self.assertIn("read-only", arguments) + with self.assertRaisesRegex(ValueError, "must all be absent"): + SUPPORT.evaluate_continuation_replay( + catalog_path=replay_root / "scenarios.json", + output_directory=replay_root / "results", + evaluator="codex", + ) + + def test_continuation_evaluator_failure_reports_concise_detail(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + replay_root = root / "tests/pressure/continuation-replay" + shutil.copytree(CONTINUATION_REPLAY_ROOT, replay_root) + + def fake_run(arguments, *, cwd, timeout=30, environment=None): + if tuple(arguments) == ("codex", "--version"): + return subprocess.CompletedProcess( + arguments, 0, stdout="codex 1.2.3\n", stderr="" + ) + return subprocess.CompletedProcess( + arguments, + 7, + stdout="", + stderr="provider unavailable\n", + ) + + def fake_isolation(isolated_root): + codex_home = Path(isolated_root) / "codex-home" + codex_home.mkdir() + return codex_home + + with ( + mock.patch.object(SUPPORT, "REPOSITORY_ROOT", root), + mock.patch.object(SUPPORT, "run_command", side_effect=fake_run), + mock.patch.object( + SUPPORT, + "_build_isolated_evaluation_root", + side_effect=fake_isolation, + ), + ): + with self.assertRaisesRegex( + ValueError, + "continuation replay evaluator failed for immediate-continuation: provider unavailable", + ): + SUPPORT.evaluate_continuation_replay( + catalog_path=replay_root / "scenarios.json", + output_directory=replay_root / "results", + evaluator="codex", + ) + + def test_second_evaluator_failure_leaves_no_partial_results_and_is_retryable( + self, + ) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + replay_root = root / "tests/pressure/continuation-replay" + shutil.copytree(CONTINUATION_REPLAY_ROOT, replay_root) + evaluation_calls = 0 + fail_second_evaluation = True + + def fake_run(arguments, *, cwd, timeout=30, environment=None): + nonlocal evaluation_calls + if tuple(arguments) == ("codex", "--version"): + return subprocess.CompletedProcess( + arguments, 0, stdout="codex 1.2.3\n", stderr="" + ) + evaluation_calls += 1 + if fail_second_evaluation and evaluation_calls == 2: + return subprocess.CompletedProcess( + arguments, + 7, + stdout="", + stderr="provider unavailable\n", + ) + return subprocess.CompletedProcess( + arguments, + 0, + stdout=( + '{"type":"item.completed","item":' + '{"type":"agent_message","text":"Synthetic response."}}\n' + ), + stderr="", + ) + + def fake_isolation(isolated_root): + codex_home = Path(isolated_root) / "codex-home" + codex_home.mkdir() + return codex_home + + output_directory = replay_root / "results" + with ( + mock.patch.object(SUPPORT, "REPOSITORY_ROOT", root), + mock.patch.object(SUPPORT, "run_command", side_effect=fake_run), + mock.patch.object( + SUPPORT, + "_build_isolated_evaluation_root", + side_effect=fake_isolation, + ), + ): + with self.assertRaisesRegex( + ValueError, + "continuation replay evaluator failed for later-continuation: provider unavailable", + ): + SUPPORT.evaluate_continuation_replay( + catalog_path=replay_root / "scenarios.json", + output_directory=output_directory, + evaluator="codex", + ) + self.assertFalse(output_directory.exists()) + + fail_second_evaluation = False + paths = SUPPORT.evaluate_continuation_replay( + catalog_path=replay_root / "scenarios.json", + output_directory=output_directory, + evaluator="codex", + ) + + self.assertEqual( + tuple(path.name for path in paths), + ("immediate-continuation.txt", "later-continuation.txt"), + ) + self.assertTrue(all(path.is_file() for path in paths)) + + def test_second_publication_failure_removes_owned_results_and_is_retryable( + self, + ) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + replay_root = root / "tests/pressure/continuation-replay" + shutil.copytree(CONTINUATION_REPLAY_ROOT, replay_root) + real_create = SUPPORT._atomic_create_text + publication_calls = 0 + fail_publication = True + + def fake_run(arguments, *, cwd, timeout=30, environment=None): + if tuple(arguments) == ("codex", "--version"): + return subprocess.CompletedProcess( + arguments, 0, stdout="codex 1.2.3\n", stderr="" + ) + return subprocess.CompletedProcess( + arguments, + 0, + stdout=( + '{"type":"item.completed","item":' + '{"type":"agent_message","text":"Synthetic response."}}\n' + ), + stderr="", + ) + + def fake_isolation(isolated_root): + codex_home = Path(isolated_root) / "codex-home" + codex_home.mkdir() + return codex_home + + def fail_second_create(path, value, *, trusted_root=None): + nonlocal publication_calls + publication_calls += 1 + if fail_publication and publication_calls == 2: + raise OSError("publication unavailable") + return real_create(path, value, trusted_root=trusted_root) + + output_directory = replay_root / "results" + with ( + mock.patch.object(SUPPORT, "REPOSITORY_ROOT", root), + mock.patch.object(SUPPORT, "run_command", side_effect=fake_run), + mock.patch.object( + SUPPORT, + "_build_isolated_evaluation_root", + side_effect=fake_isolation, + ), + mock.patch.object( + SUPPORT, "_atomic_create_text", side_effect=fail_second_create + ), + ): + with self.assertRaisesRegex(OSError, "publication unavailable"): + SUPPORT.evaluate_continuation_replay( + catalog_path=replay_root / "scenarios.json", + output_directory=output_directory, + evaluator="codex", + ) + self.assertEqual(tuple(output_directory.iterdir()), ()) + + fail_publication = False + paths = SUPPORT.evaluate_continuation_replay( + catalog_path=replay_root / "scenarios.json", + output_directory=output_directory, + evaluator="codex", + ) + + self.assertTrue(all(path.is_file() for path in paths)) + + def test_incomplete_publication_recovery_preserves_foreign_replacement( + self, + ) -> None: + class LegacyPublicationError(BaseException): + add_note = None + + for exception_type, note_supported in ( + (OSError, True), + (KeyboardInterrupt, True), + (SystemExit, True), + (LegacyPublicationError, False), + ): + with self.subTest(exception_type=exception_type.__name__): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + replay_root = root / "tests/pressure/continuation-replay" + shutil.copytree(CONTINUATION_REPLAY_ROOT, replay_root) + real_create = SUPPORT._atomic_create_text + publication_calls = 0 + first_result = ( + replay_root / "results/immediate-continuation.txt" + ) + + def fake_run(arguments, *, cwd, timeout=30, environment=None): + if tuple(arguments) == ("codex", "--version"): + return subprocess.CompletedProcess( + arguments, + 0, + stdout="codex 1.2.3\n", + stderr="", + ) + return subprocess.CompletedProcess( + arguments, + 0, + stdout=( + '{"type":"item.completed","item":' + '{"type":"agent_message","text":"Synthetic response."}}\n' + ), + stderr="", + ) + + def fake_isolation(isolated_root): + codex_home = Path(isolated_root) / "codex-home" + codex_home.mkdir() + return codex_home + + def replace_before_second_failure( + path, + value, + *, + trusted_root=None, + first_result=first_result, + exception_type=exception_type, + real_create=real_create, + ): + nonlocal publication_calls + publication_calls += 1 + if publication_calls == 2: + first_result.unlink() + first_result.write_text("foreign\n", encoding="utf-8") + raise exception_type("publication unavailable") + return real_create(path, value, trusted_root=trusted_root) + + with ( + mock.patch.object(SUPPORT, "REPOSITORY_ROOT", root), + mock.patch.object( + SUPPORT, "run_command", side_effect=fake_run + ), + mock.patch.object( + SUPPORT, + "_build_isolated_evaluation_root", + side_effect=fake_isolation, + ), + mock.patch.object( + SUPPORT, + "_atomic_create_text", + side_effect=replace_before_second_failure, + ), + ): + with self.assertRaises(exception_type) as raised: + SUPPORT.evaluate_continuation_replay( + catalog_path=replay_root / "scenarios.json", + output_directory=replay_root / "results", + evaluator="codex", + ) + + self.assertEqual(str(raised.exception), "publication unavailable") + recovery_note = ( + "continuation replay publication recovery failed for: " + "tests/pressure/continuation-replay/results/" + "immediate-continuation.txt" + ) + if note_supported: + self.assertIn( + recovery_note, + getattr(raised.exception, "__notes__", ()), + ) + else: + self.assertEqual( + getattr(raised.exception, "__notes__", ()), () + ) + self.assertEqual( + first_result.read_text(encoding="utf-8"), "foreign\n" + ) + self.assertFalse( + (replay_root / "results/later-continuation.txt").exists() + ) + + def test_replay_evidence_binds_exact_transmitted_prompt_bytes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + replay_root = root / "tests/pressure/continuation-replay" + shutil.copytree(CONTINUATION_REPLAY_ROOT, replay_root) + scenarios = SUPPORT.load_continuation_replay( + replay_root / "scenarios.json" + ) + transmitted: dict[str, bytes] = {} + evaluation_index = 0 + + def fake_run(arguments, *, cwd, timeout=30, environment=None): + nonlocal evaluation_index + if tuple(arguments) == ("codex", "--version"): + return subprocess.CompletedProcess( + arguments, 0, stdout="codex 1.2.3\n", stderr="" + ) + scenario = scenarios[evaluation_index] + prompt = arguments[-1] + transmitted[scenario.scenario_id] = prompt.encode("utf-8") + if evaluation_index == 0: + (root / scenario.prompt_path).write_text( + prompt + "\nmutated after evaluator input\n", + encoding="utf-8", + ) + evaluation_index += 1 + return subprocess.CompletedProcess( + arguments, + 0, + stdout=( + '{"type":"item.completed","item":' + '{"type":"agent_message","text":"Synthetic response."}}\n' + ), + stderr="", + ) + + def fake_isolation(isolated_root): + codex_home = Path(isolated_root) / "codex-home" + codex_home.mkdir() + return codex_home + + with ( + mock.patch.object(SUPPORT, "REPOSITORY_ROOT", root), + mock.patch.object(SUPPORT, "run_command", side_effect=fake_run), + mock.patch.object( + SUPPORT, + "_build_isolated_evaluation_root", + side_effect=fake_isolation, + ), + ): + result_paths = SUPPORT.evaluate_continuation_replay( + catalog_path=replay_root / "scenarios.json", + output_directory=replay_root / "results", + evaluator="codex", + ) + + verdicts = [] + for scenario, result_path in zip( + scenarios, result_paths, strict=True + ): + headers = SUPPORT._evidence_headers( + result_path.read_text(encoding="utf-8") + ) + transmitted_digest = hashlib.sha256( + transmitted[scenario.scenario_id] + ).hexdigest() + self.assertEqual(headers["prompt_sha256"], transmitted_digest) + verdicts.append( + { + "id": scenario.scenario_id, + "outcome": "pass", + "prompt_sha256": transmitted_digest, + "result_sha256": digest(result_path), + "evidence": "Synthetic response reached the boundary.", + "limitations": "Deterministic fake evaluator only.", + } + ) + self.assertNotEqual( + digest(root / scenarios[0].prompt_path), + verdicts[0]["prompt_sha256"], + ) + (replay_root / "verdicts.json").write_text( + json.dumps( + { + "schema_version": ( + "implementation-continuation-replay-verdicts/v1" + ), + "verdicts": verdicts, + }, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + self.assertEqual( + SUPPORT.validate_continuation_replay_evidence(root), [] + ) + + def test_atomic_result_creation_preserves_a_competing_target(self) -> None: + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) / "result.txt" + real_link = SUPPORT.os.link + + def competing_link(source, destination, **kwargs): + descriptor = SUPPORT.os.open( + destination, + SUPPORT.os.O_WRONLY | SUPPORT.os.O_CREAT | SUPPORT.os.O_EXCL, + 0o600, + dir_fd=kwargs["dst_dir_fd"], + ) + with SUPPORT.os.fdopen(descriptor, "w", encoding="utf-8") as stream: + stream.write("foreign\n") + return real_link(source, destination, **kwargs) + + with mock.patch.object( + SUPPORT.os, "link", side_effect=competing_link + ): + with self.assertRaisesRegex(ValueError, "appeared before creation"): + SUPPORT._atomic_create_text(target, "candidate\n") + self.assertEqual(target.read_text(encoding="utf-8"), "foreign\n") + + def test_atomic_result_creation_ignores_post_link_cleanup_failures( + self, + ) -> None: + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) / "result.txt" + real_unlink = SUPPORT.os.unlink + real_close = SUPPORT.os.close + + def unlink_then_report_failure(path, *, dir_fd=None): + real_unlink(path, dir_fd=dir_fd) + raise OSError("temporary cleanup unavailable") + + def close_then_report_failure(descriptor): + real_close(descriptor) + raise OSError("directory cleanup unavailable") + + with ( + mock.patch.object( + SUPPORT.os, "unlink", side_effect=unlink_then_report_failure + ), + mock.patch.object( + SUPPORT.os, "close", side_effect=close_then_report_failure + ), + ): + created_identity = SUPPORT._atomic_create_text( + target, "candidate\n" + ) + + target_identity = target.stat(follow_symlinks=False) + self.assertEqual( + created_identity, + (target_identity.st_dev, target_identity.st_ino), + ) + self.assertEqual(target.read_text(encoding="utf-8"), "candidate\n") + + def test_atomic_result_creation_falls_back_without_descriptor_apis(self) -> None: + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) / "result.txt" + real_open = SUPPORT.os.open + + def guarded_open(path, flags, mode=0o777, *, dir_fd=None): + if dir_fd is not None: + raise AssertionError("descriptor-relative open is unsupported") + return real_open(path, flags, mode) + + with ( + mock.patch.object(SUPPORT.os, "supports_dir_fd", set()), + mock.patch.object(SUPPORT.os, "supports_follow_symlinks", set()), + mock.patch.object(SUPPORT.os, "open", side_effect=guarded_open), + mock.patch.object( + SUPPORT.os, + "link", + side_effect=AssertionError("hard-link fallback was not selected"), + ), + ): + SUPPORT._atomic_create_text(target, "candidate\n") + + self.assertEqual(target.read_text(encoding="utf-8"), "candidate\n") + + def test_symlinked_result_is_invalid_not_absent(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + replay_root = root / "tests/pressure/continuation-replay" + shutil.copytree(CONTINUATION_REPLAY_ROOT, replay_root) + result_path = replay_root / "results/immediate-continuation.txt" + result_path.parent.mkdir() + result_path.symlink_to(replay_root / "prompts/immediate-continuation.md") + + self.assertIn( + "result is not a regular non-symlink file", + " ".join(SUPPORT.validate_continuation_replay_evidence(root)), + ) + + def test_symlinked_result_directory_is_rejected_by_validator_and_writer(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "repository" + replay_root = root / "tests/pressure/continuation-replay" + shutil.copytree(CONTINUATION_REPLAY_ROOT, replay_root) + outside = Path(directory) / "outside-results" + outside.mkdir() + (replay_root / "results").symlink_to(outside, target_is_directory=True) + + self.assertIn( + "symlinked path component", + " ".join(SUPPORT.validate_continuation_replay_evidence(root)), + ) + with ( + mock.patch.object(SUPPORT, "REPOSITORY_ROOT", root), + mock.patch.object(SUPPORT, "run_command") as run_command, + ): + with self.assertRaisesRegex(ValueError, "symlinked path component"): + SUPPORT.evaluate_continuation_replay( + catalog_path=replay_root / "scenarios.json", + output_directory=replay_root / "results", + evaluator="codex", + ) + run_command.assert_not_called() + self.assertEqual(tuple(outside.iterdir()), ()) + + class IntegratedEvidenceTests(unittest.TestCase): def test_integration_and_closure_readiness_evidence_are_bound_without_closure(self) -> None: self.assertTrue(EVIDENCE_PATH.is_file()) diff --git a/tests/test_multi_increment_lifecycle.py b/tests/test_multi_increment_lifecycle.py new file mode 100644 index 0000000..513c3eb --- /dev/null +++ b/tests/test_multi_increment_lifecycle.py @@ -0,0 +1,644 @@ +import json +import re +import subprocess +import sys +import unittest +from pathlib import Path + +from tests import program_bootstrap_support as bootstrap_support +from tests.program_bootstrap_support import ( + BootstrapFixture, + repository_snapshot, +) + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +SUPPORT_PATH = REPOSITORY_ROOT / "tests/program_bootstrap_support.py" +DISCOVERY_PATH = ( + REPOSITORY_ROOT + / "skills/implementing-staged-plans/scripts/program_discovery.py" +) +COMPATIBILITY_FIXTURE = ( + REPOSITORY_ROOT / "tests/fixtures/program-bootstrap/v0.1.1" +) + + +class MultiIncrementLifecycleTests(unittest.TestCase): + def setUp(self) -> None: + self.fixture = BootstrapFixture() + self.fixture.configure_successor_chain( + ("ARCHIVE-INDEX", "ARCHIVE-VERIFY", "ARCHIVE-PUBLISH") + ) + + def tearDown(self) -> None: + self.fixture.close() + + def test_inherited_review_rewrite_preserves_reports_and_scope(self) -> None: + inherited_paths = [ + "reviews/architecture.json", + "reviews/requirements.json", + "reviews/test-evidence.json", + ] + expected: dict[str, dict[str, object]] = {} + for scope, relative in zip( + ("architecture", "requirements", "test-evidence"), + inherited_paths, + strict=True, + ): + value = bootstrap_support.raw_review_report(scope) + value["findings"] = [{"id": f"preserved-{scope}"}] + path = self.fixture.repository / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(bootstrap_support.canonical_json(value)) + expected[relative] = value + unowned = self.fixture.repository / "reviews/unowned/requirements.json" + unowned.parent.mkdir(parents=True) + unowned.write_bytes( + bootstrap_support.canonical_json( + bootstrap_support.raw_review_report("requirements") + ) + ) + unowned_before = unowned.read_bytes() + status = { + "inherited_workspace_binding": { + "inherited_paths": inherited_paths, + } + } + + bootstrap_support._rewrite_inherited_review_reports( + self.fixture.repository, + status, + "ARCHIVE-VERIFY", + ) + + for relative, prior in expected.items(): + with self.subTest(relative=relative): + rewritten = json.loads( + (self.fixture.repository / relative).read_text(encoding="utf-8") + ) + self.assertEqual(rewritten["increment_id"], "ARCHIVE-VERIFY") + self.assertEqual( + {key: value for key, value in rewritten.items() if key != "increment_id"}, + {key: value for key, value in prior.items() if key != "increment_id"}, + ) + self.assertEqual(unowned.read_bytes(), unowned_before) + + def run_phase( + self, + phase: str, + *, + prompt: str | None = None, + fail_label: str | None = None, + exact_plan: bytes | None = None, + check: bool = True, + ) -> tuple[subprocess.CompletedProcess[str], dict[str, object] | None]: + arguments = [ + sys.executable, + str(SUPPORT_PATH), + phase, + "--repository", + str(self.fixture.repository), + "--candidate", + str(self.fixture.candidate), + "--source-plan", + str(self.fixture.source_plan), + "--source-sha256", + self.fixture.source_sha256, + ] + if prompt is not None: + prompt_path = self.fixture.root / f"{phase}-prompt.md" + prompt_path.write_text(prompt, encoding="utf-8") + arguments.extend(("--prompt-file", str(prompt_path))) + if fail_label is not None: + arguments.extend(("--fail-label", fail_label)) + if exact_plan is not None: + exact_plan_path = self.fixture.root / f"{phase}-exact-plan.md" + exact_plan_path.write_bytes(exact_plan) + arguments.extend(("--exact-plan-file", str(exact_plan_path))) + completed = subprocess.run( + arguments, + cwd=REPOSITORY_ROOT, + text=True, + capture_output=True, + check=False, + ) + if check: + self.assertEqual(completed.returncode, 0, completed.stderr) + value = json.loads(completed.stdout) if completed.stdout.strip() else None + return completed, value + + def reset_fixture( + self, + *, + approval_mode: str = "approval:standard", + with_chain: bool = True, + ) -> None: + self.fixture.close() + self.fixture = BootstrapFixture() + if with_chain: + self.fixture.configure_successor_chain( + ("ARCHIVE-INDEX", "ARCHIVE-VERIFY", "ARCHIVE-PUBLISH") + ) + self.fixture.configure_approval_mode(approval_mode) + + def discover(self) -> dict[str, object]: + completed = subprocess.run( + [ + sys.executable, + str(DISCOVERY_PATH), + "discover", + str(self.fixture.repository), + ], + cwd=REPOSITORY_ROOT, + text=True, + capture_output=True, + check=False, + ) + self.assertIn(completed.returncode, {0, 1}, completed.stderr) + return json.loads(completed.stdout) + + def load_status(self) -> dict[str, object]: + return json.loads( + (self.fixture.program_root / "state/status.json").read_text( + encoding="utf-8" + ) + ) + + def materialize_current_plan(self) -> dict[str, object]: + _completed, prepared = self.run_phase("prepare-plan") + assert prepared is not None + if prepared["plan_prompt"] is not None: + _completed, materialized = self.run_phase( + "materialize-plan", prompt=str(prepared["plan_prompt"]) + ) + assert materialized is not None + return materialized + return prepared + + def advance_current_to_diff(self) -> None: + self.materialize_current_plan() + self.run_phase("implementing") + self.run_phase("reviewing") + self.run_phase("prepare-review") + + def publish_and_advance_first_to_diff(self) -> None: + _completed, published = self.run_phase("publish") + assert published is not None + self.run_phase("activate", prompt=str(published["prompt"])) + self.advance_current_to_diff() + + def rollover(self, domain: str) -> dict[str, object]: + if domain == "immediate": + _completed, choice = self.run_phase("render-accept-continue") + assert choice is not None + _completed, receipt = self.run_phase( + "dispose-diff", prompt=str(choice["prompt"]) + ) + elif domain == "accepted-state": + _completed, stop = self.run_phase("render-accept-stop") + assert stop is not None + self.run_phase("accept", prompt=str(stop["prompt"])) + stopped = repository_snapshot(self.fixture.repository) + self.run_phase("accept", prompt=str(stop["prompt"])) + self.assertEqual(repository_snapshot(self.fixture.repository), stopped) + _completed, choice = self.run_phase("render-later-continuation") + assert choice is not None + _completed, receipt = self.run_phase( + "rollover", prompt=str(choice["prompt"]) + ) + else: + raise ValueError(f"unsupported continuation domain: {domain}") + assert receipt is not None + return receipt + + def test_later_continuation_crosses_a_fresh_process_boundary(self) -> None: + self.publish_and_advance_first_to_diff() + rollover = self.rollover("accepted-state") + self.assertEqual(rollover["successor_increment_id"], "ARCHIVE-VERIFY") + + def test_each_successor_mode_materializes_inherited_history_after_both_routes( + self, + ) -> None: + expected_inherited = { + "archive-output.txt", + "reviews/architecture.json", + "reviews/requirements.json", + "reviews/test-evidence.json", + } + for approval_mode in ( + "approval:standard", + "approval:pre-approve", + "approval:full-increment", + ): + for domain in ("immediate", "accepted-state"): + with self.subTest(approval_mode=approval_mode, domain=domain): + self.reset_fixture(approval_mode=approval_mode) + self.publish_and_advance_first_to_diff() + original_manifest = ( + self.fixture.program_root / "manifest.json" + ).read_bytes() + first_plan = ( + self.fixture.program_root + / "increments/ARCHIVE-INDEX/exact-file-plan.md" + ).read_bytes() + genesis_grant = self.load_status()[ + "current_increment_authority_binding" + ]["grant_id"] + + rollover = self.rollover(domain) + self.assertEqual( + rollover["successor_increment_id"], "ARCHIVE-VERIFY" + ) + successor = self.load_status() + self.assertEqual(successor["current_increment_state"], "preparing") + self.assertNotEqual( + successor["current_increment_authority_binding"]["grant_id"], + genesis_grant, + ) + self.materialize_current_plan() + + baseline = json.loads( + ( + self.fixture.program_root + / "increments/ARCHIVE-VERIFY/execution-baseline.json" + ).read_text(encoding="utf-8") + ) + self.assertEqual( + set(baseline["inherited_paths"]), expected_inherited + ) + self.assertEqual( + (self.fixture.program_root / "manifest.json").read_bytes(), + original_manifest, + ) + self.assertEqual( + ( + self.fixture.program_root + / "increments/ARCHIVE-INDEX/exact-file-plan.md" + ).read_bytes(), + first_plan, + ) + + def test_blocked_second_increment_resumes_and_rolls_to_third(self) -> None: + _completed, published = self.run_phase("publish") + assert published is not None + self.run_phase("activate", prompt=str(published["prompt"])) + _completed, first_rendered = self.run_phase("render-exact-plan") + assert first_rendered is not None + first_plan_text = str(first_rendered["plan"]).replace( + "### Create\n\n", + "### Create\n\n- `historical-only.txt` — exact owned path.\n", + 1, + ) + _completed, first_prepared = self.run_phase( + "prepare-plan", exact_plan=first_plan_text.encode("utf-8") + ) + assert first_prepared is not None + self.run_phase( + "materialize-plan", prompt=str(first_prepared["plan_prompt"]) + ) + self.run_phase("implementing") + (self.fixture.repository / "historical-only.txt").write_text( + "accepted historical bytes\n", encoding="utf-8" + ) + self.run_phase("reviewing") + self.run_phase("prepare-review") + manifest_bytes = (self.fixture.program_root / "manifest.json").read_bytes() + first_plan_path = ( + self.fixture.program_root + / "increments/ARCHIVE-INDEX/exact-file-plan.md" + ) + first_plan_bytes = first_plan_path.read_bytes() + self.rollover("immediate") + + _completed, rendered = self.run_phase("render-exact-plan") + assert rendered is not None + successor_plan_text = str(rendered["plan"]) + inherited_preserve_line = "- `historical-only.txt` — exact owned path.\n" + self.assertIn(inherited_preserve_line, successor_plan_text) + successor_plan_text = successor_plan_text.replace( + inherited_preserve_line, "", 1 + ).replace( + "### Preserve\n\n", + f"### Preserve\n\n{inherited_preserve_line}", + 1, + ) + successor_plan = successor_plan_text.encode("utf-8") + required_paths = tuple(rendered["required_future_paths"]) + self.assertTrue(required_paths) + for relative in required_paths: + with self.subTest(missing_future_path=relative): + lines = successor_plan.decode("utf-8").splitlines(keepends=True) + tampered = "".join( + line for line in lines if f"`{relative}`" not in line + ).encode("utf-8") + before = repository_snapshot(self.fixture.program_root) + rejected, _value = self.run_phase( + "prepare-plan", exact_plan=tampered, check=False + ) + self.assertEqual(rejected.returncode, 1, rejected.stderr) + self.assertEqual(repository_snapshot(self.fixture.program_root), before) + + _completed, prepared = self.run_phase( + "prepare-plan", exact_plan=successor_plan + ) + assert prepared is not None + self.run_phase( + "materialize-plan", prompt=str(prepared["plan_prompt"]) + ) + self.run_phase("implementing") + self.run_phase("block") + self.assertEqual(self.discover()["disposition"], "blocked-recovery-ready") + _completed, resolution = self.run_phase("render-block-resolution") + assert resolution is not None + self.run_phase("resolve-block", prompt=str(resolution["prompt"])) + self.assertEqual(self.load_status()["current_increment_state"], "implementing") + + self.run_phase("reviewing") + self.run_phase("prepare-review") + self.assertEqual(self.discover()["disposition"], "resume") + second_plan_path = ( + self.fixture.program_root + / "increments/ARCHIVE-VERIFY/exact-file-plan.md" + ) + second_plan_bytes = second_plan_path.read_bytes() + _completed, stop = self.run_phase("render-accept-stop") + assert stop is not None + self.run_phase("accept", prompt=str(stop["prompt"])) + self.assertEqual(self.discover()["disposition"], "accepted-stop") + _completed, choice = self.run_phase("render-later-continuation") + assert choice is not None + _completed, third = self.run_phase( + "rollover", prompt=str(choice["prompt"]) + ) + assert third is not None + self.assertEqual(third["successor_increment_id"], "ARCHIVE-PUBLISH") + self.assertEqual(self.load_status()["current_increment_state"], "preparing") + _completed, third_preflight = self.run_phase("render-exact-plan") + assert third_preflight is not None + third_required = set(third_preflight["required_future_paths"]) + self.assertIn( + "implementation-programs/ARCHIVE-PROGRAM/closure/reconciliation.json", + third_required, + ) + self.assertIn( + "implementation-programs/ARCHIVE-PROGRAM/closure/closure-packet.md", + third_required, + ) + third_plan_text = str(third_preflight["plan"]).replace( + inherited_preserve_line, "", 1 + ).replace( + "### Preserve\n\n", + f"### Preserve\n\n{inherited_preserve_line}", + 1, + ) + _completed, third_prepared = self.run_phase( + "prepare-plan", exact_plan=third_plan_text.encode("utf-8") + ) + assert third_prepared is not None + if third_prepared["plan_prompt"] is not None: + self.run_phase( + "materialize-plan", prompt=str(third_prepared["plan_prompt"]) + ) + third_baseline = json.loads( + ( + self.fixture.program_root + / "increments/ARCHIVE-PUBLISH/execution-baseline.json" + ).read_text(encoding="utf-8") + ) + self.assertIn("historical-only.txt", third_baseline["inherited_paths"]) + self.run_phase("implementing") + self.run_phase("reviewing") + self.run_phase("prepare-review") + _completed, final_stop = self.run_phase("render-accept-stop") + assert final_stop is not None + self.run_phase("accept", prompt=str(final_stop["prompt"])) + self.assertEqual(self.discover()["disposition"], "accepted-stop") + self.assertEqual(first_plan_path.read_bytes(), first_plan_bytes) + self.assertEqual(second_plan_path.read_bytes(), second_plan_bytes) + self.assertEqual( + (self.fixture.program_root / "manifest.json").read_bytes(), + manifest_bytes, + ) + + def test_frozen_v0_1_1_final_increment_closes_without_rewrite(self) -> None: + frozen_before = repository_snapshot(COMPATIBILITY_FIXTURE) + bootstrap_support._validate_compatibility_fixture_inventory() + contract = json.loads( + (COMPATIBILITY_FIXTURE / "fixture-contract.json").read_text( + encoding="utf-8" + ) + ) + self.assertEqual(contract["package_version"], "0.1.1") + self.fixture.close() + self.fixture = bootstrap_support.materialize_v0_1_1_compatibility_state( + "accepted-stop" + ) + manifest_path = self.fixture.program_root / "manifest.json" + plan_path = ( + self.fixture.program_root + / "increments/ARCHIVE-INDEX/exact-file-plan.md" + ) + manifest_bytes = manifest_path.read_bytes() + plan_bytes = plan_path.read_bytes() + + _completed, prepared = self.run_phase("prepare-closure") + assert prepared is not None + self.run_phase("close", prompt=str(prepared["prompt"])) + self.assertEqual(self.load_status()["program_state"], "closed") + self.assertEqual(manifest_path.read_bytes(), manifest_bytes) + self.assertEqual(plan_path.read_bytes(), plan_bytes) + self.assertEqual(repository_snapshot(COMPATIBILITY_FIXTURE), frozen_before) + inventory = json.loads( + (COMPATIBILITY_FIXTURE / "inventory.json").read_text(encoding="utf-8") + ) + self.assertEqual( + sorted(item["path"] for item in inventory["files"]), + [item["path"] for item in inventory["files"]], + ) + self.assertTrue( + all( + re.fullmatch(r"[0-9a-f]{64}", item["sha256"]) is not None + for item in inventory["files"] + ) + ) + + def test_rollover_and_blocked_prefixes_recover_across_fresh_processes( + self, + ) -> None: + immediate_cases = ( + ("diff-approval", "increment-acceptance-retry-ready"), + ("accepted-status", "accepted-continuation-retry-ready"), + ("action-authorization", "increment-continuation-retry-ready"), + ("successor-grant", "increment-continuation-retry-ready"), + ("handoff", "increment-rollover-retry-ready"), + ("successor-brief", "increment-rollover-retry-ready"), + ("rollover-record", "increment-rollover-retry-ready"), + ("successor-status", "resume"), + ) + later_cases = ( + ("action-authorization", "accepted-state-continuation-retry-ready"), + ("successor-grant", "accepted-state-continuation-retry-ready"), + ("handoff", "accepted-state-rollover-retry-ready"), + ("successor-brief", "accepted-state-rollover-retry-ready"), + ("rollover-record", "accepted-state-rollover-retry-ready"), + ("successor-status", "resume"), + ) + for domain, cases in ( + ("immediate", immediate_cases), + ("accepted-state", later_cases), + ): + for label, expected_disposition in cases: + with self.subTest(domain=domain, label=label): + self.reset_fixture() + self.publish_and_advance_first_to_diff() + if domain == "immediate": + _completed, choice = self.run_phase( + "render-accept-continue" + ) + phase = "dispose-diff" + else: + _completed, stop = self.run_phase("render-accept-stop") + assert stop is not None + self.run_phase("accept", prompt=str(stop["prompt"])) + _completed, choice = self.run_phase( + "render-later-continuation" + ) + phase = "rollover" + assert choice is not None + interrupted, _value = self.run_phase( + phase, + prompt=str(choice["prompt"]), + fail_label=label, + check=False, + ) + self.assertEqual(interrupted.returncode, 1, interrupted.stderr) + self.assertIn(f"injected-after:{label}", interrupted.stderr) + self.assertEqual( + self.discover()["disposition"], expected_disposition + ) + self.run_phase(phase, prompt=str(choice["prompt"])) + self.assertEqual( + self.load_status()["current_increment_id"], "ARCHIVE-VERIFY" + ) + + for label, expected_disposition in ( + ("action-authorization", "blocked-resolution-retry-ready"), + ("resolution-record", "blocked-resolution-retry-ready"), + ("resumed-status", "resume"), + ): + with self.subTest(blocked_label=label): + self.reset_fixture() + self.publish_and_advance_first_to_diff() + self.rollover("immediate") + self.materialize_current_plan() + self.run_phase("implementing") + self.run_phase("block") + _completed, resolution = self.run_phase( + "render-block-resolution" + ) + assert resolution is not None + interrupted, _value = self.run_phase( + "resolve-block", + prompt=str(resolution["prompt"]), + fail_label=label, + check=False, + ) + self.assertEqual(interrupted.returncode, 1, interrupted.stderr) + self.assertIn(f"injected-after:{label}", interrupted.stderr) + self.assertEqual( + self.discover()["disposition"], expected_disposition + ) + self.run_phase( + "resolve-block", prompt=str(resolution["prompt"]) + ) + self.assertEqual( + self.load_status()["current_increment_state"], "implementing" + ) + + def test_successor_materialization_prefixes_recover_in_each_mode(self) -> None: + cases = { + "approval:standard": ( + ("prepare-plan", "exact-plan", "plan-preparation-retry-ready"), + ( + "prepare-plan", + "awaiting-plan-status", + "plan-preparation-retry-ready", + ), + ( + "materialize-plan", + "plan-approval", + "plan-materialization-retry-ready", + ), + ( + "materialize-plan", + "execution-baseline", + "plan-materialization-retry-ready", + ), + ( + "materialize-plan", + "plan-action-authorization", + "plan-materialization-retry-ready", + ), + ("materialize-plan", "authorized-status", "resume"), + ), + "approval:pre-approve": ( + ("prepare-plan", "exact-plan", "plan-preparation-retry-ready"), + ( + "prepare-plan", + "execution-baseline", + "plan-materialization-retry-ready", + ), + ( + "prepare-plan", + "plan-action-authorization", + "plan-materialization-retry-ready", + ), + ("prepare-plan", "authorized-status", "resume"), + ), + "approval:full-increment": ( + ("prepare-plan", "exact-plan", "plan-preparation-retry-ready"), + ( + "prepare-plan", + "execution-baseline", + "plan-materialization-retry-ready", + ), + ( + "prepare-plan", + "plan-action-authorization", + "plan-materialization-retry-ready", + ), + ("prepare-plan", "authorized-status", "resume"), + ), + } + for approval_mode, mode_cases in cases.items(): + for phase, label, expected_disposition in mode_cases: + with self.subTest(approval_mode=approval_mode, label=label): + self.reset_fixture(approval_mode=approval_mode) + self.publish_and_advance_first_to_diff() + self.rollover("immediate") + prompt = None + if phase == "materialize-plan": + _completed, prepared = self.run_phase("prepare-plan") + assert prepared is not None + prompt = str(prepared["plan_prompt"]) + interrupted, _value = self.run_phase( + phase, + prompt=prompt, + fail_label=label, + check=False, + ) + self.assertEqual(interrupted.returncode, 1, interrupted.stderr) + self.assertIn(f"injected-after:{label}", interrupted.stderr) + self.assertEqual( + self.discover()["disposition"], expected_disposition + ) + self.run_phase(phase, prompt=prompt) + status = self.load_status() + if phase == "materialize-plan" or approval_mode != "approval:standard": + self.assertEqual( + status["current_increment_state"], "authorized" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_package_validation.py b/tests/test_package_validation.py index 1213700..a4991fa 100644 --- a/tests/test_package_validation.py +++ b/tests/test_package_validation.py @@ -79,11 +79,19 @@ "program_closure", ) ) +PLAN_B_SCRIPTS = tuple( + f"skills/implementing-staged-plans/scripts/{name}.py" + for name in ( + "program_continuation", + "program_rollover", + "blocked_recovery", + ) +) VALID_MANIFEST = { "name": "implementation-plugin", - "version": "0.1.1", + "version": "0.1.2", "description": "Run approved implementation programs one reviewable increment at a time.", "skills": "./skills/", } @@ -172,6 +180,8 @@ def write_valid_package(self) -> None: self.write(DISCOVERY_SCRIPT, "# deterministic read-only program discovery\n") for script in PLAN_A_SCRIPTS: self.write(script, f"# {Path(script).stem}\n") + for script in PLAN_B_SCRIPTS: + self.write(script, f"# {Path(script).stem}\n") class PackageValidationTestCase(unittest.TestCase): @@ -441,6 +451,7 @@ def test_required_authority_assets_are_regular_files(self) -> None: CHECKPOINT_SCRIPT, DISCOVERY_REFERENCE, DISCOVERY_SCRIPT, + *PLAN_B_SCRIPTS, ): with self.subTest(missing_path=missing_path): self.fixture.write_valid_package() @@ -484,9 +495,9 @@ def test_valid_minimal_package_returns_no_issues(self) -> None: self.assertEqual(VALIDATOR.validate_package(self.fixture.root), []) - def test_plan_a_scripts_and_three_manifest_identities_are_required(self) -> None: + def test_production_scripts_and_three_manifest_identities_are_required(self) -> None: self.fixture.write_valid_package() - for script in PLAN_A_SCRIPTS: + for script in (*PLAN_A_SCRIPTS, *PLAN_B_SCRIPTS): with self.subTest(script=script): path = self.fixture.root / script original = path.read_bytes() @@ -500,7 +511,7 @@ def test_plan_a_scripts_and_three_manifest_identities_are_required(self) -> None value["version"] = "0.1.0" self.fixture.write_json(".claude-plugin/plugin.json", value) self.assert_issue_contains( - VALIDATOR.validate_package(self.fixture.root), "version must equal '0.1.1'" + VALIDATOR.validate_package(self.fixture.root), "version must equal '0.1.2'" ) def test_package_digest_inventory_is_sorted_and_excludes_repository_surfaces(self) -> None: diff --git a/tests/test_program_activation.py b/tests/test_program_activation.py index 1decc71..bd03784 100644 --- a/tests/test_program_activation.py +++ b/tests/test_program_activation.py @@ -72,12 +72,18 @@ def exact_plan_bytes(program_root: Path, observation) -> bytes: required = ACTIVATION.required_future_lifecycle_writes( program_root, Path(observation.path), status["current_increment_id"] ) + inherited = set( + status.get("inherited_workspace_binding", {}).get("inherited_paths", []) + ) + product_paths = { + "archive-output.txt", + "reviews/architecture.json", + "reviews/requirements.json", + "reviews/test-evidence.json", + } create = sorted( { - "archive-output.txt", - "reviews/architecture.json", - "reviews/requirements.json", - "reviews/test-evidence.json", + *(product_paths - inherited), *( item.path for item in required @@ -86,7 +92,10 @@ def exact_plan_bytes(program_root: Path, observation) -> bytes: } ) modify = sorted( - item.path for item in required if item.disposition == "Modify" + { + *inherited, + *(item.path for item in required if item.disposition == "Modify"), + } ) preserve = ["catalog.txt"] source = status["source_binding"] @@ -393,6 +402,39 @@ def test_apply_cli_uses_fresh_repository_observation(self) -> None: class ExactPlanMaterializationTests(unittest.TestCase): + def test_successor_plan_candidate_inherits_only_canonical_rollover_products(self) -> None: + from tests.test_program_rollover import ROLLOVER, accepted_continuation_program + + fixture, program_root, observation, prompt = accepted_continuation_program( + "accepted-state" + ) + try: + ROLLOVER.persist_increment_rollover(program_root, prompt, observation) + successor_observation = ACTIVATION._without_owned_program_paths( + program_root, + ACTIVATION.inspect_repository( + fixture.repository, fixture.head + ).observation, + ) + candidate = ACTIVATION._build_plan_candidate( + program_root, + exact_plan_bytes(program_root, successor_observation), + successor_observation, + ) + rollover = json.loads( + (program_root / "state/rollovers.jsonl") + .read_text(encoding="utf-8") + .splitlines()[-1] + ) + self.assertEqual( + candidate.baseline["inherited_paths"], + sorted( + item["path"] for item in rollover["accepted_product_delta"] + ), + ) + finally: + fixture.close() + def discover(self, fixture: BootstrapFixture) -> dict[str, object]: completed = subprocess.run( [ diff --git a/tests/test_program_continuation.py b/tests/test_program_continuation.py new file mode 100644 index 0000000..eaeea85 --- /dev/null +++ b/tests/test_program_continuation.py @@ -0,0 +1,310 @@ +import importlib.util +import json +import subprocess +import sys +import unittest +from pathlib import Path +from unittest import mock + +from tests.program_bootstrap_support import repository_snapshot +from tests.test_diff_disposition import DIFF, awaiting_diff_program + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +SCRIPT_ROOT = REPOSITORY_ROOT / "skills/implementing-staged-plans/scripts" +SCRIPT_PATH = SCRIPT_ROOT / "program_continuation.py" +DISCOVERY_PATH = SCRIPT_ROOT / "program_discovery.py" + +sys.path.insert(0, str(SCRIPT_ROOT)) +try: + SPEC = importlib.util.spec_from_file_location("program_continuation", SCRIPT_PATH) + if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"Unable to load program continuation from {SCRIPT_PATH}") + CONTINUATION = importlib.util.module_from_spec(SPEC) + sys.modules[SPEC.name] = CONTINUATION + SPEC.loader.exec_module(CONTINUATION) +finally: + sys.path.remove(str(SCRIPT_ROOT)) + + +class ProgramContinuationTests(unittest.TestCase): + def discover(self, fixture) -> dict[str, object]: + completed = subprocess.run( + [sys.executable, str(DISCOVERY_PATH), "discover", str(fixture.repository)], + cwd=REPOSITORY_ROOT, + text=True, + capture_output=True, + check=False, + ) + self.assertIn(completed.returncode, {0, 1}, completed.stderr) + return json.loads(completed.stdout) + + def test_extension_binds_exact_pre_record_projection_and_live_delta(self) -> None: + fixture, program_root, observation = awaiting_diff_program( + {"ARCHIVE-VERIFY": ("ARCHIVE-INDEX",)} + ) + try: + acceptance = DIFF.build_diff_acceptance_candidate( + program_root, observation + ) + extension = CONTINUATION.build_continuation_extension( + program_root, acceptance, observation + ) + self.assertIsNotNone(extension) + projection = dict(extension.successor_projection) + self.assertEqual( + set(projection), + { + "schema_version", + "program_id", + "program_revision", + "current_increment_id", + "successor_increment_id", + "prior_status_sha256", + "prior_status_sequence", + "checkpoint_id", + "approval_event_id", + "successor_brief_sha256", + "accepted_product_delta_sha256", + "successor_approval_mode", + "selected_workspace", + "workspace_selection_sha256", + "inherited_workspace_sha256", + "allowed_conditional_action_ceiling", + "rollover_authorization_id", + "successor_grant_id", + }, + ) + for forbidden in ( + "accepted_status_sha256", + "submitted_prompt_sha256", + "action_authorization_sha256", + "successor_grant_sha256", + "rollover_sha256", + "successor_status_sha256", + ): + self.assertNotIn(forbidden, projection) + self.assertEqual( + tuple(item.path for item in extension.accepted_product_delta), + tuple(sorted(item.path for item in extension.accepted_product_delta)), + ) + finally: + fixture.close() + + def test_accept_continue_identifiers_are_topological_and_order_independent(self) -> None: + fixture, program_root, observation = awaiting_diff_program( + {"ARCHIVE-VERIFY": ("ARCHIVE-INDEX",)} + ) + try: + acceptance = DIFF.build_diff_acceptance_candidate( + program_root, observation + ) + extension = CONTINUATION.build_continuation_extension( + program_root, acceptance, observation + ) + candidate = CONTINUATION.build_accept_continue_candidate( + acceptance, extension + ) + binding = candidate.accepted_status["diff_disposition_binding"] + self.assertEqual(binding["decision"], "accept-continue") + self.assertEqual( + binding["successor_authority_projection"], + extension.successor_projection, + ) + self.assertNotEqual(candidate.checkpoint_id, acceptance.checkpoint_id) + self.assertNotEqual( + candidate.approval_event_id, acceptance.approval_event_id + ) + reordered = dict(reversed(tuple(extension.successor_projection.items()))) + self.assertEqual( + CONTINUATION.successor_projection_sha256(reordered), + CONTINUATION.successor_projection_sha256( + extension.successor_projection + ), + ) + finally: + fixture.close() + + def test_immediate_acceptance_prefixes_are_discoverable_and_retryable(self) -> None: + for failure_label, expected in ( + ("diff-approval", "increment-acceptance-retry-ready"), + ("accepted-status", "accepted-continuation-retry-ready"), + ): + with self.subTest(failure_label=failure_label): + fixture, program_root, observation = awaiting_diff_program( + {"ARCHIVE-VERIFY": ("ARCHIVE-INDEX",)} + ) + try: + prompt = CONTINUATION.render_accept_continue_prompt(program_root) + + def interrupt( + label: str, *, expected_label: str = failure_label + ) -> None: + if label == expected_label: + raise RuntimeError("injected continuation acceptance failure") + + with mock.patch.object(DIFF, "_after_persist", side_effect=interrupt): + with self.assertRaisesRegex(RuntimeError, "injected"): + DIFF._persist_diff_acceptance_prefix( + program_root, prompt, observation + ) + discovered = self.discover(fixture) + self.assertEqual(discovered["disposition"], expected, discovered) + receipt = DIFF._persist_diff_acceptance_prefix( + program_root, prompt, observation + ) + self.assertEqual(receipt.decision, "accept-continue") + self.assertEqual(receipt.increment_state, "accepted") + finally: + fixture.close() + + def test_later_continuation_uses_a_distinct_read_only_prompt_domain(self) -> None: + fixture, program_root, observation = awaiting_diff_program( + {"ARCHIVE-VERIFY": ("ARCHIVE-INDEX",)} + ) + try: + stop_prompt = "Accept and stop.\n\n" + DIFF.build_diff_acceptance_candidate( + program_root, observation + ).prompt + stopped = DIFF.persist_accept_stop(program_root, stop_prompt, observation) + before = repository_snapshot(program_root) + prompt = CONTINUATION.render_accepted_state_continuation_prompt( + program_root + ) + command = CONTINUATION.validate_submitted_continuation_prompt( + program_root, prompt + ) + self.assertEqual( + command.schema_version, + CONTINUATION.ACCEPTED_STATE_CONTINUATION_SCHEMA, + ) + self.assertNotEqual(command.checkpoint_id, stopped.approval_event_id) + self.assertNotIn(stop_prompt, prompt) + self.assertEqual(repository_snapshot(program_root), before) + finally: + fixture.close() + + def test_stop_prompt_replay_never_creates_continuation_authority(self) -> None: + fixture, program_root, observation = awaiting_diff_program( + {"ARCHIVE-VERIFY": ("ARCHIVE-INDEX",)} + ) + try: + action_authorizations = ( + program_root / "state/action-authorizations.jsonl" + ).read_bytes() + increment_grants = ( + program_root / "state/increment-grants.jsonl" + ).read_bytes() + stop_prompt = "Accept and stop.\n\n" + DIFF.build_diff_acceptance_candidate( + program_root, observation + ).prompt + DIFF.persist_accept_stop(program_root, stop_prompt, observation) + before = repository_snapshot(program_root) + replay = DIFF.persist_accept_stop(program_root, stop_prompt, observation) + self.assertTrue(replay.recovered) + self.assertEqual(repository_snapshot(program_root), before) + self.assertEqual( + (program_root / "state/action-authorizations.jsonl").read_bytes(), + action_authorizations, + ) + self.assertEqual( + (program_root / "state/increment-grants.jsonl").read_bytes(), + increment_grants, + ) + finally: + fixture.close() + + def test_later_continuation_stops_without_writes_when_no_successor_exists(self) -> None: + fixture, program_root, observation = awaiting_diff_program() + try: + stop_prompt = "Accept and stop.\n\n" + DIFF.build_diff_acceptance_candidate( + program_root, observation + ).prompt + DIFF.persist_accept_stop(program_root, stop_prompt, observation) + before = repository_snapshot(program_root) + with self.assertRaisesRegex(ValueError, "no allocated successor"): + CONTINUATION.render_accepted_state_continuation_prompt(program_root) + self.assertEqual(repository_snapshot(program_root), before) + finally: + fixture.close() + + def test_unbound_rollover_row_cannot_satisfy_successor_dependency(self) -> None: + fixture, program_root, observation = awaiting_diff_program( + {"ARCHIVE-VERIFY": ("ARCHIVE-BLOCKER",)} + ) + try: + acceptance = DIFF.build_diff_acceptance_candidate( + program_root, observation + ) + self.assertIsNone( + CONTINUATION.build_continuation_extension( + program_root, acceptance, observation + ) + ) + rollover_path = program_root / "state/rollovers.jsonl" + rollover_path.write_text( + json.dumps( + {"current_increment_id": "ARCHIVE-BLOCKER"}, + separators=(",", ":"), + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + before = repository_snapshot(program_root) + with self.assertRaisesRegex(ValueError, "unbound rollover history"): + CONTINUATION.build_continuation_extension( + program_root, acceptance, observation + ) + self.assertEqual(repository_snapshot(program_root), before) + finally: + fixture.close() + + def test_render_cli_emits_the_exact_accepted_state_prompt_without_writes(self) -> None: + fixture, program_root, observation = awaiting_diff_program( + {"ARCHIVE-VERIFY": ("ARCHIVE-INDEX",)} + ) + try: + stop_prompt = "Accept and stop.\n\n" + DIFF.build_diff_acceptance_candidate( + program_root, observation + ).prompt + DIFF.persist_accept_stop(program_root, stop_prompt, observation) + expected = CONTINUATION.render_accepted_state_continuation_prompt( + program_root + ) + before = repository_snapshot(program_root) + completed = subprocess.run( + [sys.executable, str(SCRIPT_PATH), "render", str(program_root)], + cwd=REPOSITORY_ROOT, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertEqual(completed.stdout, expected) + self.assertEqual(repository_snapshot(program_root), before) + finally: + fixture.close() + + def test_accept_stop_validation_is_not_relaxed_for_a_divergent_binding(self) -> None: + fixture, program_root, observation = awaiting_diff_program() + try: + stop_prompt = DIFF.render_diff_disposition_prompt(program_root) + DIFF.persist_accept_stop(program_root, stop_prompt, observation) + status_path = program_root / "state/status.json" + status = json.loads(status_path.read_text(encoding="utf-8")) + status["diff_disposition_binding"]["approval_event_id"] = "DIVERGENT" + status_path.write_text( + json.dumps(status, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + with self.assertRaisesRegex( + ValueError, "accepted status diff disposition binding is invalid" + ): + DIFF.build_diff_acceptance_candidate(program_root, observation) + finally: + fixture.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_program_discovery.py b/tests/test_program_discovery.py index dad6e7a..84ca7db 100644 --- a/tests/test_program_discovery.py +++ b/tests/test_program_discovery.py @@ -1079,6 +1079,34 @@ def test_multiple_new_controlling_candidates_require_selection(self) -> None: finally: fixture.close() + def test_exact_accepted_continue_status_routes_to_compound_prompt_retry(self) -> None: + from tests.test_diff_disposition import DIFF, awaiting_diff_program + from tests.test_program_continuation import CONTINUATION + + fixture, program_root, observation = awaiting_diff_program( + {"ARCHIVE-VERIFY": ("ARCHIVE-INDEX",)} + ) + try: + prompt = CONTINUATION.render_accept_continue_prompt(program_root) + + def interrupt(label: str) -> None: + if label == "accepted-status": + raise RuntimeError("injected accepted-status response loss") + + with mock.patch.object(DIFF, "_after_persist", side_effect=interrupt): + with self.assertRaisesRegex(RuntimeError, "response loss"): + DIFF._persist_diff_acceptance_prefix( + program_root, prompt, observation + ) + result = DISCOVERY.discover_programs(fixture.repository) + self.assertEqual( + result.disposition, + "accepted-continuation-retry-ready", + ) + self.assertFalse(result.stop_required) + finally: + fixture.close() + if __name__ == "__main__": unittest.main() diff --git a/tests/test_program_rollover.py b/tests/test_program_rollover.py new file mode 100644 index 0000000..3addfe1 --- /dev/null +++ b/tests/test_program_rollover.py @@ -0,0 +1,484 @@ +import importlib.util +import json +import subprocess +import sys +import unittest +from dataclasses import replace +from pathlib import Path +from unittest import mock + +from tests.program_bootstrap_support import repository_snapshot +from tests.test_diff_disposition import DIFF, awaiting_diff_program +from tests.test_program_continuation import CONTINUATION + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +SCRIPT_ROOT = REPOSITORY_ROOT / "skills/implementing-staged-plans/scripts" +SCRIPT_PATH = SCRIPT_ROOT / "program_rollover.py" +DISCOVERY_PATH = SCRIPT_ROOT / "program_discovery.py" + +sys.path.insert(0, str(SCRIPT_ROOT)) +try: + SPEC = importlib.util.spec_from_file_location("program_rollover", SCRIPT_PATH) + if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"Unable to load program rollover from {SCRIPT_PATH}") + ROLLOVER = importlib.util.module_from_spec(SPEC) + sys.modules[SPEC.name] = ROLLOVER + SPEC.loader.exec_module(ROLLOVER) +finally: + sys.path.remove(str(SCRIPT_ROOT)) + + +def accepted_continuation_program(domain: str): + fixture, program_root, observation = awaiting_diff_program( + {"ARCHIVE-VERIFY": ("ARCHIVE-INDEX",)} + ) + if domain == "immediate": + prompt = CONTINUATION.render_accept_continue_prompt(program_root) + DIFF._persist_diff_acceptance_prefix(program_root, prompt, observation) + elif domain == "accepted-state": + stop_prompt = "Accept and stop.\n\n" + DIFF.build_diff_acceptance_candidate( + program_root, observation + ).prompt + DIFF.persist_accept_stop(program_root, stop_prompt, observation) + prompt = CONTINUATION.render_accepted_state_continuation_prompt(program_root) + else: + raise ValueError(f"unsupported continuation test domain: {domain}") + return fixture, program_root, observation, prompt + + +class ProgramRolloverTests(unittest.TestCase): + def discover(self, fixture) -> dict[str, object]: + completed = subprocess.run( + [sys.executable, str(DISCOVERY_PATH), "discover", str(fixture.repository)], + cwd=REPOSITORY_ROOT, + text=True, + capture_output=True, + check=False, + ) + self.assertIn(completed.returncode, {0, 1}, completed.stderr) + return json.loads(completed.stdout) + + def test_required_rollover_writes_compose_with_plan_a_allocations(self) -> None: + fixture, program_root, _observation, _prompt = accepted_continuation_program( + "immediate" + ) + try: + required = ROLLOVER.required_increment_rollover_writes( + program_root, + fixture.repository, + "ARCHIVE-VERIFY", + ) + by_path = {item.path: item.disposition for item in required} + prefix = "implementation-programs/ARCHIVE-PROGRAM" + self.assertEqual( + by_path[f"{prefix}/state/action-authorizations.jsonl"], "Modify" + ) + self.assertEqual( + by_path[f"{prefix}/state/increment-grants.jsonl"], "Modify" + ) + self.assertEqual(by_path[f"{prefix}/state/rollovers.jsonl"], "Modify") + self.assertEqual(by_path[f"{prefix}/state/status.json"], "Modify") + self.assertEqual( + by_path[f"{prefix}/increments/ARCHIVE-INDEX/handoff.md"], "Create" + ) + self.assertEqual( + by_path[f"{prefix}/increments/ARCHIVE-VERIFY/brief.md"], "Create" + ) + self.assertGreater(len(required), 6) + finally: + fixture.close() + + def test_immediate_and_later_prefixes_are_discoverable_and_retryable(self) -> None: + labels = ( + "action-authorization", + "successor-grant", + "handoff", + "successor-brief", + "rollover-record", + "successor-status", + ) + for domain in ("immediate", "accepted-state"): + for label in labels: + with self.subTest(domain=domain, label=label): + fixture, program_root, observation, prompt = ( + accepted_continuation_program(domain) + ) + try: + manifest_before = (program_root / "manifest.json").read_bytes() + + def interrupt( + completed_label: str, *, expected_label: str = label + ) -> None: + if completed_label == expected_label: + raise RuntimeError("injected rollover interruption") + + with mock.patch.object( + ROLLOVER, "_after_persist", side_effect=interrupt + ): + with self.assertRaisesRegex(RuntimeError, "injected"): + ROLLOVER.persist_increment_rollover( + program_root, prompt, observation + ) + if label == "rollover-record": + status = json.loads( + (program_root / "state/status.json").read_text( + encoding="utf-8" + ) + ) + with self.assertRaisesRegex( + ValueError, "unbound rollover history" + ): + ROLLOVER.validated_inherited_paths( + program_root, status, observation + ) + discovered = self.discover(fixture) + if label == "successor-status": + expected = "resume" + elif label in {"action-authorization", "successor-grant"}: + expected = ( + "increment-continuation-retry-ready" + if domain == "immediate" + else "accepted-state-continuation-retry-ready" + ) + else: + expected = ( + "increment-rollover-retry-ready" + if domain == "immediate" + else "accepted-state-rollover-retry-ready" + ) + self.assertEqual( + discovered["disposition"], expected, discovered + ) + receipt = ( + DIFF.persist_diff_disposition( + program_root, prompt, observation + ) + if domain == "immediate" + else ROLLOVER.persist_increment_rollover( + program_root, prompt, observation + ) + ) + self.assertEqual( + receipt.successor_increment_id, "ARCHIVE-VERIFY" + ) + self.assertEqual(receipt.current_increment_id, "ARCHIVE-VERIFY") + self.assertFalse(receipt.requires_retry) + self.assertEqual( + (program_root / "manifest.json").read_bytes(), + manifest_before, + ) + finally: + fixture.close() + + def test_continuation_reason_preserves_rollover_retry_authority(self) -> None: + fixture, program_root, observation, prompt = accepted_continuation_program( + "accepted-state" + ) + try: + acceptance = DIFF.build_diff_acceptance_candidate( + program_root, observation + ) + + def interrupt( + completed_label: str, *, label: str = "rollover-record" + ) -> None: + if completed_label == label: + raise RuntimeError("injected rollover interruption") + + with mock.patch.object( + ROLLOVER, "_after_persist", side_effect=interrupt + ): + with self.assertRaisesRegex(RuntimeError, "injected"): + ROLLOVER.persist_increment_rollover( + program_root, prompt, observation + ) + + self.assertEqual( + CONTINUATION.continuation_unavailability_reason( + program_root, + acceptance, + allow_unbound_rollover_suffix=True, + ), + "", + ) + finally: + fixture.close() + + def test_rollover_rejects_non_object_inherited_workspace_binding(self) -> None: + for inherited_binding in (None, [], "invalid", 1): + with self.subTest(inherited_binding=inherited_binding): + fixture, program_root, observation, _prompt = ( + accepted_continuation_program("accepted-state") + ) + try: + status_path = program_root / "state/status.json" + status = json.loads(status_path.read_text(encoding="utf-8")) + status["inherited_workspace_binding"] = inherited_binding + status_path.write_bytes(ROLLOVER._canonical_json_bytes(status)) + prompt = ( + CONTINUATION.render_accepted_state_continuation_prompt( + program_root + ) + ) + + with self.assertRaisesRegex( + ValueError, + "^prior inherited workspace inventory is invalid$", + ): + ROLLOVER._build_rollover_candidate( + program_root, prompt, observation + ) + finally: + fixture.close() + + def test_completed_rollover_history_requires_activation_authority_anchor( + self, + ) -> None: + fixture, program_root, observation, prompt = accepted_continuation_program( + "accepted-state" + ) + try: + ROLLOVER.persist_increment_rollover( + program_root, prompt, observation + ) + status = json.loads( + (program_root / "state/status.json").read_text(encoding="utf-8") + ) + self.assertTrue( + ROLLOVER.validated_inherited_paths( + program_root, status, observation + ) + ) + rollover_path = program_root / "state/rollovers.jsonl" + records = [ + json.loads(line) + for line in rollover_path.read_text(encoding="utf-8").splitlines() + ] + records[0]["prior_increment_authority_binding"][ + "grant_id" + ] = "FORGED-GENESIS-GRANT" + rollover_path.write_bytes(ROLLOVER._canonical_json_line(records[0])) + status["rollover_binding"]["rollover_sha256"] = ( + ROLLOVER._sha256_bytes(ROLLOVER._canonical_json_line(records[0])) + ) + (program_root / "state/status.json").write_bytes( + ROLLOVER._canonical_json_bytes(status) + ) + with self.assertRaisesRegex( + ValueError, + "^rollover chain prior increment authority is invalid$", + ): + ROLLOVER.validated_inherited_paths( + program_root, status, observation + ) + finally: + fixture.close() + + def test_completed_rollover_history_requires_genesis_increment_identity( + self, + ) -> None: + fixture, program_root, observation, prompt = accepted_continuation_program( + "accepted-state" + ) + try: + ROLLOVER.persist_increment_rollover( + program_root, prompt, observation + ) + status_path = program_root / "state/status.json" + rollover_path = program_root / "state/rollovers.jsonl" + action_path = program_root / "state/action-authorizations.jsonl" + status = json.loads(status_path.read_text(encoding="utf-8")) + rollovers = [ + json.loads(line) + for line in rollover_path.read_text(encoding="utf-8").splitlines() + ] + actions = [ + json.loads(line) + for line in action_path.read_text(encoding="utf-8").splitlines() + ] + rollover = rollovers[0] + rollover["current_increment_id"] = "FORGED-GENESIS" + action = next( + item + for item in actions + if item.get("authorization_id") + == rollover["rollover_authorization_id"] + ) + action["current_increment_id"] = "FORGED-GENESIS" + action_path.write_bytes( + b"".join(ROLLOVER._canonical_json_line(item) for item in actions) + ) + rollover["rollover_authorization_sha256"] = ROLLOVER._sha256_bytes( + ROLLOVER._canonical_json_line(action) + ) + rollover_path.write_bytes(ROLLOVER._canonical_json_line(rollover)) + status["rollover_binding"]["rollover_sha256"] = ROLLOVER._sha256_bytes( + ROLLOVER._canonical_json_line(rollover) + ) + status_path.write_bytes(ROLLOVER._canonical_json_bytes(status)) + + with self.assertRaisesRegex( + ValueError, + "^rollover chain increment authority is invalid$", + ): + ROLLOVER.validated_inherited_paths( + program_root, status, observation + ) + finally: + fixture.close() + + def test_forged_rollover_history_fails_before_every_transaction_write( + self, + ) -> None: + for forged_delta in ("empty", "extra-path"): + with self.subTest(forged_delta=forged_delta): + fixture, program_root, observation, prompt = ( + accepted_continuation_program("accepted-state") + ) + try: + candidate = ROLLOVER._build_rollover_candidate( + program_root, prompt, observation + ) + forged = dict(candidate.rollover_record) + forged["rollover_id"] = f"FORGED-{forged_delta.upper()}" + if forged_delta == "empty": + forged["accepted_product_delta"] = [] + else: + forged["accepted_product_delta"] = [ + *forged["accepted_product_delta"], + { + "path": "forged-extra.txt", + "disposition": "Modify", + "sha256": "f" * 64, + }, + ] + candidate.rollover_path.write_bytes( + ROLLOVER._canonical_json_line(forged) + ) + before = repository_snapshot(program_root) + + with self.assertRaisesRegex(ValueError, "rollover"): + ROLLOVER.persist_increment_rollover( + program_root, prompt, observation + ) + self.assertEqual(repository_snapshot(program_root), before) + finally: + fixture.close() + + def test_coordinator_completes_immediate_continuation_without_second_prompt(self) -> None: + fixture, program_root, observation = awaiting_diff_program( + {"ARCHIVE-VERIFY": ("ARCHIVE-INDEX",)} + ) + try: + prompt = CONTINUATION.render_accept_continue_prompt(program_root) + receipt = DIFF.persist_diff_disposition(program_root, prompt, observation) + self.assertEqual(receipt.successor_increment_id, "ARCHIVE-VERIFY") + status = json.loads( + (program_root / "state/status.json").read_text(encoding="utf-8") + ) + self.assertEqual(status["current_increment_id"], "ARCHIVE-VERIFY") + self.assertEqual(status["current_increment_state"], "preparing") + finally: + fixture.close() + + def test_apply_cli_uses_fresh_observation_and_completes_later_rollover(self) -> None: + fixture, program_root, _observation, prompt = accepted_continuation_program( + "accepted-state" + ) + try: + prompt_path = fixture.root / "continuation-prompt.md" + prompt_path.write_text(prompt, encoding="utf-8") + completed = subprocess.run( + [ + sys.executable, + str(SCRIPT_PATH), + "apply", + str(program_root), + "--prompt-file", + str(prompt_path), + "--repository", + str(fixture.repository), + "--base-commit", + fixture.head, + ], + cwd=REPOSITORY_ROOT, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + payload = json.loads(completed.stdout) + self.assertEqual(payload["successor_increment_id"], "ARCHIVE-VERIFY") + self.assertFalse(payload["requires_retry"]) + finally: + fixture.close() + + def test_invalid_prompt_observation_product_or_manifest_fails_before_writes(self) -> None: + for case in ("prompt", "observation", "product", "manifest"): + with self.subTest(case=case): + fixture, program_root, observation, prompt = ( + accepted_continuation_program("accepted-state") + ) + try: + if case == "prompt": + prompt += "tampered\n" + elif case == "observation": + observation = replace(observation, head_commit="f" * 40) + elif case == "product": + (fixture.repository / "archive-output.txt").write_text( + "changed after acceptance\n", encoding="utf-8" + ) + else: + manifest_path = program_root / "manifest.json" + manifest = json.loads( + manifest_path.read_text(encoding="utf-8") + ) + manifest["schema_version"] = "implementation-program-manifest/v1" + manifest_path.write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + before = repository_snapshot(program_root) + with self.assertRaises(ValueError): + ROLLOVER.persist_increment_rollover( + program_root, prompt, observation + ) + self.assertEqual(repository_snapshot(program_root), before) + finally: + fixture.close() + + def test_divergent_partial_file_is_preserved_and_requires_recovery(self) -> None: + fixture, program_root, observation, prompt = accepted_continuation_program( + "accepted-state" + ) + try: + def interrupt(label: str) -> None: + if label == "handoff": + raise RuntimeError("injected") + + with mock.patch.object(ROLLOVER, "_after_persist", side_effect=interrupt): + with self.assertRaises(RuntimeError): + ROLLOVER.persist_increment_rollover( + program_root, prompt, observation + ) + handoff = program_root / "increments/ARCHIVE-INDEX/handoff.md" + handoff.write_bytes(handoff.read_bytes() + b"divergent\n") + before = repository_snapshot(program_root) + discovered = self.discover(fixture) + self.assertEqual( + discovered["disposition"], + "accepted-state-continuation-recovery-required", + discovered, + ) + with self.assertRaisesRegex(ValueError, "recovery-required"): + ROLLOVER.persist_increment_rollover( + program_root, prompt, observation + ) + self.assertEqual(repository_snapshot(program_root), before) + finally: + fixture.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_repository_preparation.py b/tests/test_repository_preparation.py index fd9139e..262cc4e 100644 --- a/tests/test_repository_preparation.py +++ b/tests/test_repository_preparation.py @@ -6,7 +6,7 @@ import sys import tempfile import unittest -from dataclasses import replace +from dataclasses import asdict, replace from pathlib import Path from unittest import mock @@ -750,6 +750,96 @@ def test_authorized_requires_no_product_delta(self) -> None: assessment.issues, ) + def test_inherited_paths_require_one_safe_owned_non_user_baseline(self) -> None: + valid = json.loads(json.dumps(asdict(self.baseline))) + valid["inherited_paths"] = ["catalog.txt"] + parsed = PREPARATION.execution_baseline_from_value(valid) + self.assertEqual(parsed.inherited_paths, ("catalog.txt",)) + + cases = [] + duplicate = json.loads(json.dumps(valid)) + duplicate["inherited_paths"] = ["catalog.txt", "catalog.txt"] + cases.append( + ( + "duplicate", + duplicate, + "execution baseline inherited inventory is duplicated", + ) + ) + malformed = json.loads(json.dumps(valid)) + malformed["inherited_paths"] = ["../catalog.txt"] + cases.append( + ("malformed", malformed, "execution baseline structure is invalid") + ) + create_owned = json.loads(json.dumps(valid)) + create_owned["inherited_paths"] = ["archive-output.txt"] + cases.append( + ( + "create-owned", + create_owned, + "inherited paths must be owned as Modify or Preserve", + ) + ) + missing_baseline = json.loads(json.dumps(valid)) + missing_baseline["path_baselines"] = [ + item + for item in missing_baseline["path_baselines"] + if item["path"] != "catalog.txt" + ] + cases.append( + ( + "missing-baseline", + missing_baseline, + "each inherited path requires exactly one path baseline", + ) + ) + user_overlap = json.loads(json.dumps(valid)) + user_overlap["user_work_baselines"] = [ + { + "path": "catalog.txt", + "categories": ["modified"], + "sha256": sha256_file(self.fixture.root / "catalog.txt"), + } + ] + cases.append( + ( + "user-overlap", + user_overlap, + "inherited paths must be disjoint from user-work baselines", + ) + ) + for label, value, expected_error in cases: + with self.subTest(label=label): + with self.assertRaisesRegex(ValueError, f"^{expected_error}$"): + PREPARATION.execution_baseline_from_value(value) + + def test_inherited_preserve_path_is_not_unmapped_user_dirt(self) -> None: + inherited_path = self.fixture.root / "catalog.txt" + inherited_path.write_text("accepted predecessor bytes\n", encoding="utf-8") + baseline = replace( + self.baseline, + file_map=PREPARATION.ExactFileMap( + create=(), + modify=(), + preserve=("catalog.txt",), + ), + path_baselines=( + PREPARATION.ExecutionPathBaseline( + "catalog.txt", "Preserve", sha256_file(inherited_path) + ), + ), + inherited_paths=("catalog.txt",), + ) + + assessment = PREPARATION.validate_execution_workspace( + self.program_root, + baseline, + PREPARATION.inspect_repository(self.fixture.root, self.fixture.base), + increment_state="authorized", + ) + + self.assertTrue(assessment.valid, assessment.issues) + def test_implementing_allows_a_subset_but_reviewing_requires_complete_delta(self) -> None: (self.fixture.root / "catalog.txt").write_text("changed\n", encoding="utf-8") implementing = self.assess("implementing") diff --git a/tests/test_state_authority.py b/tests/test_state_authority.py index 8aa7e77..fa2d51e 100644 --- a/tests/test_state_authority.py +++ b/tests/test_state_authority.py @@ -44,6 +44,8 @@ sys.path.remove(str(SCRIPT_ROOT)) from tests.test_diff_disposition import awaiting_diff_program +from tests.test_blocked_recovery import BLOCKED, block_request, implementing_program +from tests.test_program_rollover import ROLLOVER, accepted_continuation_program BASE_COMMIT = "b" * 40 @@ -65,6 +67,12 @@ def write_json(path: Path, value: object) -> None: class ManagedLifecycleWriteTests(unittest.TestCase): + def test_rollover_is_a_supported_distinct_lifecycle_action(self) -> None: + self.assertIn("rollover-increment", AUTHORITY.ACTION_NAMES) + + def test_blocked_resume_is_a_supported_distinct_lifecycle_action(self) -> None: + self.assertIn("resume-blocked-program", AUTHORITY.ACTION_NAMES) + def test_status_brief_must_match_the_status_current_increment_grant(self) -> None: fixture = BootstrapFixture() try: @@ -133,6 +141,41 @@ def test_final_increment_derives_modify_review_and_closure_allocations(self) -> self.assertEqual(by_disposition["Preserve"], set()) finally: fixture.close() + + def test_traceability_successor_does_not_cross_disjoint_allocations(self) -> None: + traceability = { + "atomic_requirements": [ + {"assigned_increments": ["INCREMENT-A", "INCREMENT-B"]}, + {"assigned_increments": ["INCREMENT-C", "INCREMENT-D"]}, + ] + } + + self.assertIsNone( + AUTHORITY._traceability_successor(traceability, "INCREMENT-B") + ) + + def test_traceability_successor_suppresses_multiple_direct_successors(self) -> None: + traceability = { + "atomic_requirements": [ + {"assigned_increments": ["INCREMENT-A", "INCREMENT-B"]}, + {"assigned_increments": ["INCREMENT-A", "INCREMENT-C"]}, + ] + } + + self.assertIsNone( + AUTHORITY._traceability_successor(traceability, "INCREMENT-A") + ) + + def test_traceability_successor_rejects_duplicate_allocation_entries(self) -> None: + traceability = { + "atomic_requirements": [ + {"assigned_increments": ["INCREMENT-A", "INCREMENT-A"]}, + ] + } + + with self.assertRaisesRegex(ValueError, "unique strings"): + AUTHORITY._traceability_successor(traceability, "INCREMENT-A") + def test_unique_traceability_successor_replaces_closure_with_navigation(self) -> None: fixture = BootstrapFixture() try: @@ -143,12 +186,9 @@ def test_unique_traceability_successor_replaces_closure_with_navigation(self) -> traceability_path = program_root / manifest["logical_roles"]["traceability"] traceability = json.loads(traceability_path.read_text(encoding="utf-8")) traceability["atomic_requirements"][0]["assigned_increments"] = [ - "ARCHIVE-INDEX" + "ARCHIVE-INDEX", + "ARCHIVE-SUCCESSOR", ] - successor = copy.deepcopy(traceability["atomic_requirements"][0]) - successor["id"] = "INTEGRITY-SUCCESSOR" - successor["assigned_increments"] = ["ARCHIVE-SUCCESSOR"] - traceability["atomic_requirements"].append(successor) traceability_path.write_bytes(canonical_json(traceability)) required = AUTHORITY.required_future_lifecycle_writes( @@ -203,11 +243,11 @@ def test_three_increment_allocation_selects_only_immediate_successor(self) -> No manifest = json.loads((program_root / "manifest.json").read_text(encoding="utf-8")) traceability_path = program_root / manifest["logical_roles"]["traceability"] traceability = json.loads(traceability_path.read_text(encoding="utf-8")) - for increment_id in ("ARCHIVE-NEXT-A", "ARCHIVE-NEXT-B"): - successor = copy.deepcopy(traceability["atomic_requirements"][0]) - successor["id"] = f"INTEGRITY-{increment_id}" - successor["assigned_increments"] = [increment_id] - traceability["atomic_requirements"].append(successor) + traceability["atomic_requirements"][0]["assigned_increments"] = [ + "ARCHIVE-INDEX", + "ARCHIVE-NEXT-A", + "ARCHIVE-NEXT-B", + ] traceability_path.write_bytes(canonical_json(traceability)) required = AUTHORITY.required_future_lifecycle_writes( @@ -234,6 +274,119 @@ def write_json_lines(path: Path, records: list[dict[str, object]]) -> None: ) +class RolloverHistoryAuthorityTests(unittest.TestCase): + def test_inherited_path_validation_failure_is_reported(self) -> None: + fixture, program_root, observation, prompt = accepted_continuation_program( + "accepted-state" + ) + try: + ROLLOVER.persist_increment_rollover(program_root, prompt, observation) + normalized = ROLLOVER._fresh_observation(program_root, observation) + + with mock.patch( + "program_rollover.validated_inherited_paths", + side_effect=ValueError("inherited path diagnostics are unavailable"), + ): + issues = AUTHORITY.validate_state_authority( + program_root, normalized + ) + + self.assertIn("inherited path diagnostics are unavailable", issues) + finally: + fixture.close() + + def test_blocked_path_validation_failure_is_reported(self) -> None: + fixture, program_root, observation = implementing_program() + try: + BLOCKED.block_current_program( + program_root, block_request(fixture), observation + ) + + with mock.patch( + "blocked_recovery.blocked_workspace_paths", + side_effect=ValueError("blocked path diagnostics are unavailable"), + ): + issues = AUTHORITY.validate_state_authority( + program_root, observation + ) + + self.assertIn("blocked path diagnostics are unavailable", issues) + finally: + fixture.close() + + def test_arbitrary_genesis_rollover_row_is_not_state_authority(self) -> None: + fixture, program_root, observation = awaiting_diff_program( + {"ARCHIVE-VERIFY": ("ARCHIVE-BLOCKER",)} + ) + try: + rollover_path = program_root / "state/rollovers.jsonl" + write_json_lines( + rollover_path, + [{"current_increment_id": "ARCHIVE-BLOCKER"}], + ) + before = repository_snapshot(program_root) + + issues = AUTHORITY.validate_state_authority(program_root, observation) + + self.assertIn( + "unbound rollover history is not lifecycle authority", issues + ) + self.assertEqual(repository_snapshot(program_root), before) + finally: + fixture.close() + + def test_arbitrary_bound_rollover_suffix_is_not_state_authority(self) -> None: + fixture, program_root, observation, prompt = accepted_continuation_program( + "accepted-state" + ) + try: + ROLLOVER.persist_increment_rollover(program_root, prompt, observation) + rollover_path = program_root / "state/rollovers.jsonl" + records = [ + json.loads(line) + for line in rollover_path.read_text(encoding="utf-8").splitlines() + ] + write_json_lines( + rollover_path, + [*records, {"current_increment_id": "ARCHIVE-BLOCKER"}], + ) + normalized = ROLLOVER._fresh_observation(program_root, observation) + before = repository_snapshot(program_root) + + issues = AUTHORITY.validate_state_authority(program_root, normalized) + + self.assertIn( + "unbound rollover history is not lifecycle authority", issues + ) + self.assertEqual(repository_snapshot(program_root), before) + finally: + fixture.close() + + def test_exact_rollover_record_prefix_remains_state_authority(self) -> None: + fixture, program_root, observation, prompt = accepted_continuation_program( + "accepted-state" + ) + try: + def interrupt(completed_label: str) -> None: + if completed_label == "rollover-record": + raise RuntimeError("injected rollover interruption") + + with mock.patch.object(ROLLOVER, "_after_persist", side_effect=interrupt): + with self.assertRaisesRegex(RuntimeError, "injected"): + ROLLOVER.persist_increment_rollover( + program_root, prompt, observation + ) + before = repository_snapshot(program_root) + + self.assertEqual( + AUTHORITY.validate_state_authority(program_root, observation), + [], + ) + self.assertEqual(repository_snapshot(program_root), before) + finally: + fixture.close() + + class DeferredMutationGuardTests(unittest.TestCase): def test_new_program_state_rejects_malformed_authority_bindings(self) -> None: fixture, program_root, observation = awaiting_diff_program()