diff --git a/devtools/e2e_live/scenarios.py b/devtools/e2e_live/scenarios.py index 97f6f0839..5379c1169 100644 --- a/devtools/e2e_live/scenarios.py +++ b/devtools/e2e_live/scenarios.py @@ -784,7 +784,9 @@ def sw1_stub_script(_clone: pathlib.Path) -> dict: "goal": "Survey the repository with two parallel scouts.", "plan": "Schedule two scouts, wait for both, summarize.", "spec": {"deliverables": ["Two scout results summarized."], - "acceptance_claims": ["Both scouts completed and were absorbed."]}}}, + "acceptance_claims": ["Both scouts completed and were absorbed."], + # Required on every submitted spec (owner 9=A); a survey changes no file. + "affected_paths": []}}}, scout("A", "list the top-level directories"), scout("B", "list the test modules under tests/system_e2e"), wait_step, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f40841cb7..e92e1975f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1750,9 +1750,9 @@ Plan review, task acceptance, commit review, and deep self-review answer differe #### Plan construction and review -`plan_task` reviews an INTENTION before the work starts — the same organ for code, research, deliverables, and actions (BIBLE P3). The obligation is constitutional and the finalization gate is structural (`owner_hurry.force_plan_decision`); the order in which a task asks, explores and plans is the mind's judgment, and no prompt choreographs it. The submitted envelope carries the goal, the plan prose, and a typed domain-neutral SPEC (`in_scope`, `non_goals`, `acceptance_claims`, `invariants`, `decisions` with rejected alternatives, `deferred`, `affected_resources`, `evidence`); `ouroboros/tools/plan_spec.py` validates and normalizes its full operative content without shortening strings or dropping excess items, then mints the ids that are the only valid `breaks` targets (`goal`, `claim_N`, `invariant_N`, `decision_N`, `deferred_N`) — host-minted positionally, because a caller-chosen id could shadow another target and corrupt what a blocking finding `breaks` — and hashes it. Governance documents always come from the system repository; declared targets and evidence resolve against `active_repo_dir_for(ctx)`, and a path escaping the active subject or an unreadable root is a named omission, never a silent gap. +`plan_task` reviews an INTENTION before the work starts — the same organ for code, research, deliverables, and actions (BIBLE P3). The obligation is constitutional and the finalization gate is structural (`owner_hurry.force_plan_decision`); the order in which a task asks, explores and plans is the mind's judgment, and no prompt choreographs it. The submitted envelope carries the goal, the plan prose, and a typed domain-neutral SPEC (`in_scope`, `non_goals`, `acceptance_claims`, `invariants`, `decisions` with rejected alternatives, `deferred`, `affected_paths` — REQUIRED, the files the work will CHANGE, `[]` when it changes none — `affected_resources` (the same question in words: systems, services, projects, people, never resolved as paths), `evidence`); a submitted spec without `affected_paths` is refused before any dispatch with the typed `PLAN_RESOURCE_FORM_REQUIRED`, which records nothing and leaves an open wave collectable; `ouroboros/tools/plan_spec.py` validates and normalizes its full operative content without shortening strings or dropping excess items, then mints the ids that are the only valid `breaks` targets (`goal`, `claim_N`, `invariant_N`, `decision_N`, `deferred_N`) — host-minted positionally, because a caller-chosen id could shadow another target and corrupt what a blocking finding `breaks` — and hashes it. Governance documents always come from the system repository; declared targets and evidence resolve against `active_repo_dir_for(ctx)`, and a path escaping the active subject or an unreadable root is a named omission, never a silent gap. -ONE structural fact tiers the governance pack: `constitutional` is true iff a declared `affected_resources`/`evidence` PATH locator resolves under the Ouroboros system repository (an `evidence` path only if it exists). A constitutional plan carries BIBLE.md and ARCHITECTURE.md in full (an `api_chat` row inline; a retrieving `agent_session` row as mandatory full reads) — the constitutional packet is not tiered, so assembling it without either is a typed failure, never a disclosure — and every other plan carries the runtime heading-derived navigation maps (`context_layout.generate_doc_nav_map`, never a copy) plus resolvable pointers. There is no plan-kind taxonomy, no agent-declared `plan_class`, no planning scouts, and no plan Atlas. The ONE caller-facing strength axis is the envelope's optional `reviewer_effort`: the panel's effort for THIS order, placed on the default rung of each reviewer row's ladder (`reviewer_slot_config.row_effort`: an explicit per-row effort, then a compound Cursor/Agy route slug, then the declaration, then the owner's `OUROBOROS_EFFORT_REVIEW` setting), passed as an ARGUMENT of `plan_review_slots` only, so the commit gate, scope, acceptance and skill review keep reading the untouched rows. Effort is roster identity: a different declaration re-dispatches a paid panel within `OUROBOROS_REVIEW_MAX_CYCLES`, the same one replays free, and a wave records the declaration (`reviewer_effort`) so a collection rebuilds the roster it was dispatched with. Scoping against BIBLE P1 ("Cognitive horizon is part of continuity"): this is the review panel's strength for one order, ordered by the mind under the owner's setting as the default (the owner chose this scoping: the setting is the default and Ouroboros may order stronger or weaker per envelope), never the core's own model, effort or horizon; the disclosed residual is that a cheap panel's closed GREEN is earned authority for that envelope and a later stronger order does not reopen it. The last-execution projection keeps a declared effort apart from the row's saved effort (`declared_effort`). +ONE structural fact tiers the governance pack: `constitutional` is true iff a declared `affected_paths` locator resolves under the Ouroboros system repository, whether or not that file exists yet (creating `ouroboros/new_module.py` IS self-modification). Nothing else resolves as a path: `affected_resources` is prose, and an `evidence` locator is something to LOOK AT — reading a repository file is not changing it, so it never buys the pack on its own, and the system-repo reads are named in the disclosure note instead. A constitutional plan carries BIBLE.md and ARCHITECTURE.md in full (an `api_chat` row inline; a retrieving `agent_session` row as mandatory full reads) — the constitutional packet is not tiered, so assembling it without either is a typed failure, never a disclosure — and every other plan carries the runtime heading-derived navigation maps (`context_layout.generate_doc_nav_map`, never a copy) plus resolvable pointers. There is no plan-kind taxonomy, no agent-declared `plan_class`, no planning scouts, and no plan Atlas. The ONE caller-facing strength axis is the envelope's optional `reviewer_effort`: the panel's effort for THIS order, placed on the default rung of each reviewer row's ladder (`reviewer_slot_config.row_effort`: an explicit per-row effort, then a compound Cursor/Agy route slug, then the declaration, then the owner's `OUROBOROS_EFFORT_REVIEW` setting), passed as an ARGUMENT of `plan_review_slots` only, so the commit gate, scope, acceptance and skill review keep reading the untouched rows. Effort is roster identity: a different declaration re-dispatches a paid panel within `OUROBOROS_REVIEW_MAX_CYCLES`, the same one replays free, and a wave records the declaration (`reviewer_effort`) so a collection rebuilds the roster it was dispatched with. Scoping against BIBLE P1 ("Cognitive horizon is part of continuity"): this is the review panel's strength for one order, ordered by the mind under the owner's setting as the default (the owner chose this scoping: the setting is the default and Ouroboros may order stronger or weaker per envelope), never the core's own model, effort or horizon; the disclosed residual is that a cheap panel's closed GREEN is earned authority for that envelope and a later stronger order does not reopen it. The last-execution projection keeps a declared effort apart from the row's saved effort (`declared_effort`). Declared evidence is resolved by `ouroboros/tools/plan_evidence.py` against exactly two allowed roots — the active workspace and the system repository — with the shared sensitive-name policy applied to locator and target; every refused, missing, truncated, oversized, binary, or URL locator becomes a typed omission row in the manifest (the host never fetches a URL), and a `need_evidence` locator a reviewer names is attached by the host on the next cycle through the same policy. What that policy cannot attach is a named `[reviewer-requested]` omission row the panel is dispatched with and judges with, never a reason to run no reviewer; the evidence continuation uses a fresh full-packet dispatch only when no exact artifact reference exists, disclosed per slot as a `capability_delta`. An unreadable referenced artifact instead fails closed with `plan_review_exact_artifact_unavailable` and never mints replacement authority. A locator may carry an exact range — `::lines=A-B`, `::bytes=A-B`, `::tail=N`, or `::symbol=Name` for `.py` sources — and a source above the per-item byte bound is attached head-first with the cut named. The manifest hash joins the spec hash and `constitutional` in the wave fingerprint, so changing what the reviewers can see changes the identity of the review. The root exploration log remains outside that identity. Automatic own-room evidence uses the recorded source selected by the author-request identity described below. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 7284b279c..8d5d2063e 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -813,8 +813,9 @@ MUST include these artifacts as **first-class context sections** — not as optional or opportunistic inclusions via touched-file packs. Plan review is the one flow whose governance pack is tiered, and by ONE -structural fact — whether a declared `affected_resources` target, or an -EXISTING `evidence` path, resolves under the Ouroboros system repository — +structural fact — whether a declared `affected_paths` target (a file the work +will CHANGE) resolves under the Ouroboros system repository; `affected_resources` +is prose the host never resolves, and an `evidence` path is a read, not a change — never by prose and never by a plan-kind taxonomy, which is what keeps classification un-gameable. This is a tiering, not an omission: before any work exists the reviewer's subject is the @@ -859,7 +860,7 @@ The context-delivery registry: | Advisory pre-review (`tools/claude_advisory_review.py`) | Two delivery classes: an `api_chat` row runs the bounded NATIVE inspection episode (governance docs reached through its read-only tools); an `agent_session` row receives a resolvable pointer marked MANDATORY FULL READ and the session reads the full doc itself — retrieval is disclosed (native reads are host-observed; vendor-session reads are not) | same two delivery classes | same two delivery classes | | Scope review (`tools/scope_review.py`) | full canonical doc + Atlas accounting; a size terminal or a degradation rung under a cold density cap takes the shared cold-start density rung above (one probe, one rebuild, `density_probe` ladder step) | full canonical doc + Atlas accounting | full canonical doc + Atlas accounting | | Skill review (`skill_review.py`) | full inline (`api_chat`) / mandatory full source-root read (`agent_session`) | full inline (`api_chat`) / mandatory full source-root read (`agent_session`) | full inline (`api_chat`) / mandatory full source-root read (`agent_session`) | -| Plan review (`tools/plan_review.py`) | full for a SELF-MODIFICATION plan (structural path fact: a declared `affected_resources` target, or an existing `evidence` path, resolves under the system repo); otherwise a heading-derived navigation map of BIBLE.md generated at runtime (never a copy) | inline, in full, for a self-modification plan; otherwise the lossless navigation map + a resolvable pointer (W3) | named on-demand pointer; a reviewer that needs it returns `need_evidence` (an exact `::lines=A-B` range for one section) and the host attaches what the evidence policy allows on the next cycle, naming every absence | +| Plan review (`tools/plan_review.py`) | full for a SELF-MODIFICATION plan (structural path fact: a declared `affected_paths` target resolves under the system repo, whether or not the file exists yet; an `evidence` read of a repo file does not); otherwise a heading-derived navigation map of BIBLE.md generated at runtime (never a copy) | inline, in full, for a self-modification plan; otherwise the lossless navigation map + a resolvable pointer (W3) | named on-demand pointer; a reviewer that needs it returns `need_evidence` (an exact `::lines=A-B` range for one section) and the host attaches what the evidence policy allows on the next cycle, naming every absence | | Deep self-review (`deep_self_review.py`) | Three deliveries on the `deep_review` row. Packed api row: full canonical doc + Atlas accounting; a required set unfit under a cold density cap gets one bounded probe send and one rebuild, and a pack still unfit is the typed `deep_self_review_pack_unfit` refusal asking the owner to switch the row (no automatic fallback). Native inspection episode / agent session: MANDATORY full read at the repository root, named with its size in the task; the native episode's `read_file` receipts carry the delivered line extent and the host merges the repository-root intervals for BIBLE.md afterwards — `read` only on full coverage, else `partial`/`missing`/`unobserved` — disclosed in the report header and as a typed `capability_delta` (host-observed reads); a session's reads are `unobserved`. Memory (up to seven whitelisted files) is INLINED byte-exact on every delivery, each entry's disposition disclosed (task text, `deep_review_memory`, header `memory=n/7`) — never receipt-checked | Packed: full (max) / navigation map (low) + Atlas accounting. Retrieving rows: navigation map (`generate_doc_nav_map`), sections read on demand | Packed: full canonical doc + Atlas accounting. Retrieving rows: navigation map, sections read on demand (CHECKLISTS.md likewise) | Skill Review keeps the full stable governance/host prefix for cache-friendly diff --git a/ouroboros/owner_hurry.py b/ouroboros/owner_hurry.py index ecdb4497c..a525701d2 100644 --- a/ouroboros/owner_hurry.py +++ b/ouroboros/owner_hurry.py @@ -537,7 +537,8 @@ def plan_review_reminder(decision: Dict[str, Any]) -> str: if status == "legacy_open_requires_resubmission": return ( f"{tag} An open plan review from a previous schema cannot be honored. Re-call " - "plan_task with your goal, plan and spec to start a fresh review before finalizing." + "plan_task with your goal, plan and spec — including affected_paths, the files the " + "work will change ([] when none) — to start a fresh review before finalizing." ) from ouroboros.review_cycles import review_max_cycles @@ -578,13 +579,15 @@ def plan_review_reminder(decision: Dict[str, Any]) -> str: ) if outcome == "REVISE_PLAN": return ( - f"{tag} Blocking plan review requires a revised spec. Change the spec and call " + f"{tag} Blocking plan review requires a revised spec. Change the spec — it carries " + "affected_paths, the files the work will change ([] when none) — and call " "plan_task again (or reject the blocking findings with a rationale via " "review_disposition). Continue analysis and non-mutating preparation, but do not " "begin the work before the review closes or a real task-wide rail fires." ) return ( - f"{tag} Call plan_task with a concrete goal, plan and spec. If review infrastructure " + f"{tag} Call plan_task with a concrete goal, plan and spec, whose affected_paths lists " + "the files the work will change ([] when none). If review infrastructure " "is unavailable, continue analysis and non-mutating preparation, but do not begin the " "work before the review closes or a real task-wide rail fires." ) diff --git a/ouroboros/size_ratchet_manifest.py b/ouroboros/size_ratchet_manifest.py index 14052b60c..8e434f3f2 100644 --- a/ouroboros/size_ratchet_manifest.py +++ b/ouroboros/size_ratchet_manifest.py @@ -156,6 +156,7 @@ BAND_PATHS = { "ouroboros/tools/commit_gate.py": "Grew INTO the band by the review-wave fix binding the actor reference (delivery class) into the commit review contract fingerprint \u2014 same-module contract identity, splitting it would separate the fingerprint from its gate.", "ouroboros/tools/core.py": "D05 ledger split (rows 311-349): read/list and owner-chat delivery spans moved to core_file_tools/core_artifacts; facade re-enters the band from above (2283 -> 1373) and shrinks further when the residual catalog split lands", "ouroboros/tools/delegate.py": "D07 finisher DEL1 split brought the nanny-verb monolith DOWN from the 1600 hard cap into the band (1600->1263); terminal-evidence family extracted to tools/delegate_terminal_evidence.py, shrink-only direction", + "ouroboros/tools/plan_review.py": "Entered the band from 999 lines: the required-affected_paths form (owner 9=A) added the schema field and the PLAN_RESOURCE_FORM_REQUIRED refusal, which must name the task's open wave and the $0 disposition exit \u2014 it belongs beside the one preamble both the paid and dry-run paths share, not in the pure plan_spec companion that owns no task state.", "ouroboros/tools/plan_review_runtime.py": "Entered the band from 986 lines: timeout custody synthesis joined the existing plan-review runtime owner while preserving profile-continuity disclosures and typed health facts during target integration.", "ouroboros/tools/registry_core.py": "F3.1 typed-organ re-split (D04 rows 156/167/170/171/174/175): the tip ToolRegistry class body re-homed whole from the protected registry facade; the guard/dispatch surface already left for its sibling leaves, and the class shrinks further only with the ABI-8 post-release handler conversion.", "ouroboros/tools/review.py": "D06 F2.3a re-entry by extraction: the multi-model fan-out moved to review_multi_model.py (1550->1269); the remaining single-owner review cycle machinery lands in the 1001-1500 band with headroom", diff --git a/ouroboros/tools/plan_review.py b/ouroboros/tools/plan_review.py index 6b67f7361..4dead7ec6 100644 --- a/ouroboros/tools/plan_review.py +++ b/ouroboros/tools/plan_review.py @@ -2,7 +2,7 @@ Owner-approved redesign (2026-08-15, plan §6/§7): the agent submits a SPEC (goal, in_scope, non_goals, acceptance_claims, invariants, decisions, deferred, -affected_resources, evidence) plus prose; the host normalizes it (``plan_spec``), +affected_paths, affected_resources, evidence) plus prose; the host normalizes it (``plan_spec``), resolves ONE structural fact (``constitutional``), attaches the declared evidence bounded with every omission named, builds the lean packet (``plan_packet``), fans it across the configured reviewer rows through the shared review substrate (api_chat @@ -181,11 +181,21 @@ _SPEC_SCHEMA = { }, "description": "Deliberately deferred until the work is underway; ids deferred_1..N — a blocking finding may name any spec id it breaks: goal, claim_N, invariant_N, decision_N or deferred_N itself.", }, + "affected_paths": { + "type": "array", "items": {"type": "string"}, + "description": ( + "REQUIRED. The FILES the work will CHANGE — paths relative to the subject " + "workspace root, absolute, or file://; send [] when this work changes no files. " + "A path under the Ouroboros system repository makes the plan constitutional " + "(BIBLE + ARCHITECTURE go to reviewers), whether or not the file exists yet. " + "This is the ONLY field read as file paths." + ), + }, "affected_resources": { "type": "array", "items": {"type": "string"}, "description": ( - "What the work will CHANGE (paths, systems, services). Paths resolving under " - "the Ouroboros system repository make the plan constitutional (BIBLE goes to reviewers)." + "What else the work will CHANGE, described in words: systems, services, projects, " + "people. Descriptions only — never file paths; nothing here is resolved as a path." ), }, "evidence": { @@ -193,12 +203,12 @@ _SPEC_SCHEMA = { "description": ( "What reviewers should LOOK AT: file paths, task: of a prior task, URLs. " "The host attaches files bounded (never fetching URLs) and names every omission. " - "An EXISTING path here that resolves under the Ouroboros system repository also " - "makes the plan constitutional (BIBLE + ARCHITECTURE go to reviewers); a path that " - "does not exist does not, and the skip is disclosed." + "Reading a repository file here is not changing it and does not make the plan " + "constitutional; only listing it under affected_paths does." ), }, }, + "required": ["affected_paths"], } _DISPOSITION_SCHEMA = { @@ -252,7 +262,8 @@ def get_tools(): "new; a different reviewer_effort re-dispatches a paid panel). Under blocking enforcement an " "open review holds finalization; under advisory you may proceed with the " "review open and the host discloses it. Declare evidence reviewers need; " - "declare affected_resources so a self-modification gets the constitutional pack." + "affected_paths is required — the files the work will change ([] when none) — " + "and is what gives a self-modification the constitutional pack." ), "parameters": { "type": "object", @@ -407,6 +418,34 @@ def _reviewer_requested_locators(ctx: ToolContext, state_root: pathlib.Path) -> seen.append(loc) return seen, dropped +def _resource_form_refusal(ctx: ToolContext, state_root: pathlib.Path) -> str: + """The old-form refusal text: name the missing field, show it, and protect an open wave. + + Nothing durable is written, so a task whose previous wave is still open keeps it — and is + told how to close it at $0 instead of re-buying a panel it cannot afford.""" + detail = "" + try: + _root, task_id = _planning_state_location(ctx) + wave = current_plan_review_wave(load_plan_review_state(state_root, task_id)) or {} + except (OSError, TimeoutError, ValueError): + wave = {} + if wave and not wave.get("closed"): + detail = ( + "\nThe open plan-review wave " + f"{wave.get('request_fingerprint') or '(fingerprint not recorded)'} is untouched: answer " + "it first with review_disposition naming that fingerprint (free, no reviewer call), or " + "re-send this spec in the form above." + ) + return ( + "ERROR: PLAN_RESOURCE_FORM_REQUIRED: spec.affected_paths is required — the FILES this work " + "will change, as their own list; send [] when it changes no files. affected_resources is " + "now prose (systems, services, projects, people) and is never resolved as a path. " + "For example:\n" + ' "affected_paths": ["ouroboros/tools/plan_review.py"],\n' + ' "affected_resources": ["the plan-review organ", "the owner\'s review budget"]\n' + "No reviewer was called and nothing was recorded." + detail + ) + def _prepare_plan_inputs(ctx: ToolContext, request: "_PlanRequest", state_root: pathlib.Path, *, persist: bool = False) -> dict: """The ONE preamble the paid path and the dry-run seam share: normalize the spec (with the envelope's goal injected), resolve the subject roots, derive `constitutional`, attach the @@ -423,21 +462,29 @@ def _prepare_plan_inputs(ctx: ToolContext, request: "_PlanRequest", state_root: if errors: return {"error": "ERROR: PLAN_SPEC_INVALID: " + "; ".join(errors) + ". No reviewer was called.", "code": "TOOL_ARG_ERROR"} + if isinstance(raw_spec, dict) and "affected_paths" not in raw_spec: + # Owner 9=A: a spec in the old mixed form is refused BEFORE any paid dispatch, because + # `affected_resources` used to be read as a path list — a prose item became "a file under + # the Ouroboros repo" and bought every reviewer the whole constitution (~470k tokens/cycle) + # for a deck. The refusal carries its OWN code: a message containing `PLAN_SPEC_INVALID` + # takes the durable superseding-attempt path below and would orphan an open wave. + return {"error": _resource_form_refusal(ctx, state_root), "code": "TOOL_ARG_ERROR"} from ouroboros.review_substrate import review_repo_dirs_for try: system_root, active_root = review_repo_dirs_for(ctx) except ValueError as exc: return {"error": f"ERROR: PLAN_SUBJECT_ROOT_INVALID: {exc}", "code": "TOOL_ERROR"} - locators = list(spec["affected_resources"]) + list(spec["evidence"]) + affected_paths = list(spec.get("affected_paths") or []) + locators = affected_paths + list(spec["evidence"]) constitutional, constitutional_note = plan_spec.resolve_constitutional( active_root=active_root, system_repo_root=system_root, - affected_resources=spec["affected_resources"], evidence=spec["evidence"], + affected_paths=affected_paths, evidence=spec["evidence"], payload_roots=_plan_payload_roots(ctx, locators), ) reminder = ( - "REMINDER: affected_resources is empty; if this work will change Ouroboros's own body, " - "declare those paths so reviewers receive the constitutional pack (BIBLE)." - if active_root == system_root and not spec["affected_resources"] else "" + "REMINDER: affected_paths is empty; if this work will change Ouroboros's own body, " + "list those files so reviewers receive the constitutional pack (BIBLE)." + if active_root == system_root and not affected_paths else "" ) declared_evidence = list(spec["evidence"]) # W3: earlier-cycle need_evidence is HOST-attached try: diff --git a/ouroboros/tools/plan_spec.py b/ouroboros/tools/plan_spec.py index f7dedbe4b..2ebd314e4 100644 --- a/ouroboros/tools/plan_spec.py +++ b/ouroboros/tools/plan_spec.py @@ -22,7 +22,7 @@ from hashlib import sha256 import json import pathlib import re -from typing import Any, Callable, Iterable, Mapping, Optional +from typing import Any, Iterable, Mapping, Optional from ouroboros.config import adaptive_quorum from ouroboros.contracts.task_contract import normalize_acceptance_claims @@ -57,10 +57,12 @@ AUTHOR_DISPOSITION_SCHEMA = { } DISPOSITION_DECISIONS = ("accept", "reject", "defer") -_SPEC_STRING_LISTS = ("in_scope", "non_goals", "invariants", "affected_resources", "evidence") +_SPEC_STRING_LISTS = ( + "in_scope", "non_goals", "invariants", "affected_paths", "affected_resources", "evidence", +) _SPEC_KEYS = frozenset({ "goal", "in_scope", "non_goals", "acceptance_claims", "invariants", - "decisions", "deferred", "affected_resources", "evidence", + "decisions", "deferred", "affected_paths", "affected_resources", "evidence", }) _URL_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://") _FILE_SCHEME = "file://" @@ -224,6 +226,9 @@ def normalize_spec(raw: Mapping[str, Any] | None) -> tuple[dict, list[str]]: Invariant ids are positional (``invariant_1..N``) because the schema keeps ``invariants: [str]``; ``spec_ids``/``spec_with_ids`` derive them; the id ``goal`` is reserved for the intention as a whole (D32). + ``affected_paths`` (the files the work will CHANGE) appears in the normalized + spec only when the input declared it, so a spec stored before the field existed + keeps its recorded identity byte for byte. """ errors: list[str] = [] omissions: list[str] = [] @@ -240,6 +245,13 @@ def normalize_spec(raw: Mapping[str, Any] | None) -> tuple[dict, list[str]]: ) spec: dict[str, Any] = {"goal": goal} for key in _SPEC_STRING_LISTS: + # `affected_paths` is the ONE key that is never defaulted: a spec RECORDED before the + # field existed must normalize to the same bytes as it did then, or its `spec_hash` / + # `plan_fingerprint` move and the open wave it belongs to becomes uncollectable. New + # submissions always carry the key — `plan_review` refuses the old mixed form before + # any paid dispatch, so the absence here only ever means "stored under the old shape". + if key == "affected_paths" and key not in raw: + continue spec[key] = _string_list(raw.get(key), key, errors, omissions) seen = {GOAL_ID, *(f"invariant_{i}" for i in range(1, len(spec["invariants"]) + 1))} spec["acceptance_claims"] = _normalize_claims(raw.get("acceptance_claims"), errors, omissions, seen) @@ -248,8 +260,8 @@ def normalize_spec(raw: Mapping[str, Any] | None) -> tuple[dict, list[str]]: spec["normalization_omissions"] = omissions ordered = {key: spec[key] for key in ( "goal", "in_scope", "non_goals", "acceptance_claims", "invariants", "decisions", - "deferred", "affected_resources", "evidence", "normalization_omissions", - )} + "deferred", "affected_paths", "affected_resources", "evidence", "normalization_omissions", + ) if key in spec} return ordered, errors @@ -333,7 +345,9 @@ def spec_delta(prev_spec: Mapping[str, Any] | None, spec: Mapping[str, Any]) -> (added / removed / changed content); plain string lists diff by text; the goal is a changed flag; ``renumbered`` lists elements whose text moved to a different id (positional ids shift when an earlier element is dropped or - reordered). ``prev_spec=None`` means everything is added. + reordered); ``declared_keys_changed`` reports a spec whose set of declared keys + itself moved (a spec stored before ``affected_paths`` existed declares no such + key at all). ``prev_spec=None`` means everything is added. """ prev = dict(prev_spec or {}) ids: dict[str, list[str]] = {"added": [], "removed": [], "changed": []} @@ -350,12 +364,21 @@ def spec_delta(prev_spec: Mapping[str, Any] | None, spec: Mapping[str, Any]) -> "removed": [t for t in before_l if t not in after_l], } goal_changed = str(prev.get("goal") or "") != str(spec.get("goal") or "") - changed = goal_changed or any(ids.values()) or any( + # A key that appears or disappears between two normalized specs is a change even when both + # sides read as empty: `affected_paths` ABSENT (stored before the field existed) and + # `affected_paths: []` ("this work changes no files") are different claims, and the spec + # hash already says so — the delta the reviewer reads must not say they are the same. + declared_keys_changed = prev_spec is not None and ( + {key for key in prev if key != "normalization_omissions"} + != {key for key in spec if key != "normalization_omissions"} + ) + changed = goal_changed or declared_keys_changed or any(ids.values()) or any( entry["added"] or entry["removed"] for entry in lists.values() ) return { "changed": changed, "goal_changed": goal_changed, + "declared_keys_changed": declared_keys_changed, "ids": ids, "lists": lists, "renumbered": _renumbered(prev, spec), @@ -454,35 +477,31 @@ def _under(path: pathlib.Path, root: pathlib.Path) -> bool: return False -def _default_exists(path: pathlib.Path) -> bool: - """Fail CLOSED: only a definite "this path is not there" lets an evidence locator skip - the constitutional escalation. An unreadable path (permissions, a transient I/O error) - counts as present, because the safe error is carrying the constitution needlessly, never - dropping it on a filesystem hiccup.""" - try: - return path.exists() - except (OSError, ValueError, RuntimeError): - return True - - def resolve_constitutional( *, active_root: str | pathlib.Path, system_repo_root: str | pathlib.Path, - affected_resources: Iterable[str], + affected_paths: Iterable[str], evidence: Iterable[str], payload_roots: Iterable[str | pathlib.Path] = (), - evidence_exists: Optional[Callable[[pathlib.Path], bool]] = None, ) -> tuple[bool, str]: - """ONE structural fact: does this plan touch Ouroboros's own body? + """ONE structural fact: will this plan CHANGE Ouroboros's own body? - Port of ``plan_review_runtime.resolve_plan_class`` without the enum. True iff - any ``affected_resources`` / ``evidence`` PATH locator (relative resolved - against ``active_root``, absolute or ``file://`` as-is) resolves to or under - the system repository. The active binding alone does NOT decide (owner - decision D29: a plan bound to the system repo that declares no system path - is not constitutional). Skill-payload paths under ``payload_roots`` are - exempt (data plane, as today). URLs and ``task:``/``chat:`` locators never make it + True iff a declared ``affected_paths`` locator (relative resolved against + ``active_root``, absolute or ``file://`` as-is) resolves to or under the + system repository — whether or not the file exists yet, because creating + ``ouroboros/new_module.py`` IS self-modification. ``affected_resources`` is + never read here: treating every non-URL string in it as a path is what turned + a Russian sentence about a project into "a file under the repo" and billed the + whole constitution to a deck plan (owner decisions 8=A/9=A/16=A). + ``evidence`` is what to LOOK AT — reading a repository file is not changing it, + so an evidence locator never decides on its own (16=A); when the work will also + change that file it is in ``affected_paths`` and decides there. System-repo + evidence reads are named in the note so the author can see why the constitutional + pack was not bought (P1). The active binding alone does NOT decide (owner + decision D29: a plan bound to the system repo that declares no system path is + not constitutional). Skill-payload paths under ``payload_roots`` are exempt + (data plane, as today). URLs and ``task:``/``chat:`` locators never make it true. Returns ``(constitutional, note)`` — the note names the deciding locator for disclosure. """ @@ -498,9 +517,10 @@ def resolve_constitutional( # proposal to drop system-repo-nested payload roots (S-B05) was REJECTED for that # reason; the residual it names requires control of the payload predicate itself. payloads = [pathlib.Path(p).resolve(strict=False) for p in payload_roots] - exists = evidence_exists if evidence_exists is not None else _default_exists - skipped: list[str] = [] - for label, locators in (("affected_resources", affected_resources), ("evidence", evidence)): + + def _system_locators(locators: Iterable[str]): + """Lazily yield the declared locators that are PATHS resolving under the system repo, + verbatim — the first one is enough to decide, so nothing further is resolved.""" for raw in locators or []: locator = str(raw or "").strip() if not _is_path_locator(locator): @@ -509,28 +529,31 @@ def resolve_constitutional( if resolved is None or any(_under(resolved, payload) for payload in payloads): continue if resolved == system or _under(resolved, system): - # An `affected_resources` target counts whether or not it exists yet — creating - # `ouroboros/new_module.py` IS self-modification. An `evidence` locator is only - # something to LOOK AT: a path that does not exist (a typo, a file belonging to - # another workspace) must not drag the constitutional pack in behind it — but the - # skip is DISCLOSED, never reported as "no locator resolved" (P1). - if label == "evidence" and not exists(resolved): - skipped.append(locator) - continue - # The locator is quoted VERBATIM (never ``repr``): the note is disclosure a - # reviewer copies back, and ``repr`` doubles every backslash of a Windows path. - return True, ( - f"constitutional: {label} locator '{locator}' resolves under the " - "Ouroboros system repository (structural fact)" - ) - if skipped: - listed = ", ".join(f"'{item}'" for item in skipped[:5]) - return False, ( - "not constitutional: the only system-repo locators declared are EVIDENCE paths that do " - f"not exist ({listed}) — declare them under " - "affected_resources if the work will change them" + yield locator + + for target in _system_locators(affected_paths): + # The locator is quoted VERBATIM (never ``repr``): the note is disclosure a + # reviewer copies back, and ``repr`` doubles every backslash of a Windows path. + return True, ( + f"constitutional: affected_paths locator '{target}' resolves under the " + "Ouroboros system repository (structural fact)" ) - return False, "not constitutional: no declared locator resolves under the Ouroboros system repository" + reads: list[str] = [] + for read in _system_locators(evidence): + reads.append(read) + if len(reads) >= 5: # the note names them; a sixth adds nothing a reviewer acts on + break + if reads: + listed = ", ".join(f"'{item}'" for item in reads) + return False, ( + "not constitutional: the system-repo locators declared are EVIDENCE reads " + f"({listed}), and reading a repository file is not changing it — list a file " + "under affected_paths if the work will change it" + ) + return False, ( + "not constitutional: no declared affected_paths locator resolves under the " + "Ouroboros system repository" + ) # ----------------------------------------------------------------------- findings diff --git a/scripts/run_plan_review.py b/scripts/run_plan_review.py index 671b8e2c8..eece4f2a4 100644 --- a/scripts/run_plan_review.py +++ b/scripts/run_plan_review.py @@ -14,7 +14,10 @@ Usage (from anywhere): ``spec.json`` follows the plan_task spec schema: in_scope, non_goals, acceptance_claims, invariants, decisions[{choice, rejected, why}], deferred[{what, why_safe_to_defer}], -affected_resources, evidence (``--evidence`` values are appended to it). +affected_paths (REQUIRED — the files the work will CHANGE, ``[]`` when it changes none; this +is the only list resolved as paths, and a path under the system repo makes the plan +constitutional), affected_resources (the same question in words: systems, services, projects, +people), evidence (``--evidence`` values are appended to it). """ from __future__ import annotations diff --git a/tests/system_e2e/test_system_scenarios_w3a.py b/tests/system_e2e/test_system_scenarios_w3a.py index 9f4f51af6..4e3a05075 100644 --- a/tests/system_e2e/test_system_scenarios_w3a.py +++ b/tests/system_e2e/test_system_scenarios_w3a.py @@ -253,6 +253,8 @@ S11_GOAL = "Write the w3a plan-review smoke note." S11_SPEC_V1 = { "in_scope": ["w3a plan-review smoke"], "acceptance_claims": ["The plan-review smoke completes with a recorded chronicle."], + # Required on every submitted spec (owner 9=A); the smoke note changes no repository file. + "affected_paths": [], } S11_SPEC_V2 = { **S11_SPEC_V1, diff --git a/tests/test_complete_chosen_inputs.py b/tests/test_complete_chosen_inputs.py index 290dc958f..59441d9f0 100644 --- a/tests/test_complete_chosen_inputs.py +++ b/tests/test_complete_chosen_inputs.py @@ -9,8 +9,8 @@ from ouroboros.tools import plan_packet, plan_spec @pytest.mark.parametrize("field", ["goal", "in_scope", "non_goals", "invariants", - "affected_resources", "evidence", "acceptance_claims", - "decisions", "deferred"]) + "affected_paths", "affected_resources", "evidence", + "acceptance_claims", "decisions", "deferred"]) def test_operative_tail_changes_normalized_identity_and_current_packet(field): prefix = "яё𐍈🚀\n" * 500 raw = {"goal": "Full plan", field: prefix + "TAIL_A"} diff --git a/tests/test_complete_plan_state.py b/tests/test_complete_plan_state.py index a6e123b3f..62ed25f17 100644 --- a/tests/test_complete_plan_state.py +++ b/tests/test_complete_plan_state.py @@ -83,7 +83,8 @@ def test_full_plan_review_disposition_repeat_and_tail_delta(_harness): ctx = _harness.make_ctx() goal = "full chosen goal\n" * 22_000 + "GOAL_TAIL" spec = {"in_scope": ["full requirement\n" * 22_000 + "SCOPE_TAIL_A"], - "acceptance_claims": ["full criterion\n" * 22_000 + "CLAIM_TAIL"]} + "acceptance_claims": ["full criterion\n" * 22_000 + "CLAIM_TAIL"], + "affected_paths": []} # required on every submitted spec (owner 9=A) assert _control(_call(ctx, spec, goal=goal)) == {"outcome": "REVIEW_REQUIRED", "closed": True} state = _state(_harness) fingerprint = state["waves"][-1]["request_fingerprint"] diff --git a/tests/test_plan_resource_form.py b/tests/test_plan_resource_form.py new file mode 100644 index 000000000..ae46a18fb --- /dev/null +++ b/tests/test_plan_resource_form.py @@ -0,0 +1,262 @@ +"""The plan spec's resource form: `affected_paths` is the ONE list the host resolves. + +Owner decisions 8=A / 9=A / 16=A, taken after a live wave: the old `affected_resources` list +was read as "everything that is not a URL is a file path", so the prose item «Отдельный проект +«TSMC — 10-летний инвестиционный анализ»» resolved to a path under the Ouroboros repository, the +plan was marked constitutional, and every reviewer of a deck-shaped plan received BIBLE.md plus +ARCHITECTURE.md in full (~470k tokens per cycle). Across the live history not one of the 14 +constitutional verdicts came from a real file path in that list. + +What is pinned here: a declared CHANGE target decides (existing or not); prose never resolves +and never reaches the filesystem; an evidence READ of a repository file is named but buys +nothing; a spec submitted in the old mixed form is refused before any dispatch without writing +anything; and a wave recorded under the old shape still closes for free. +""" + +from __future__ import annotations + +import copy +import json + +from ouroboros.tools import plan_spec +from tests.test_plan_review_engine import ( # noqa: F401 — `harness` is the fixture + CLEAN, + DECK_SPEC, + _call, + _control, + _finding, + _state, + harness, +) + +# Verbatim from the live wave of task 12102247e791ff1e (structural-health sprint evidence, +# plan-resource-live-examples.json): the list that made a deck plan constitutional. +TSMC_RESOURCES = [ + "Отдельный проект «TSMC — 10-летний инвестиционный анализ»", + "Файлы исследования, моделей и PDF внутри его project workspace", + "Финальные пользовательские deliverables", +] + + +# The three plan-review waves recorded on the live install while `affected_resources` was still +# resolved as a path list (structural-health sprint evidence, plan-resource-live-examples.json: +# tasks 12102247e791ff1e, 0be6fb0690f44bbd, 6596589bb3674aaa). Their resource lists are verbatim; +# the plan bodies stay on the owner's machine. The digests were taken from this module BEFORE +# `affected_paths` existed — a stored wave must keep its identity or the task it belongs to can +# never collect or dispose of it again. +STORED_LEGACY_WAVES = ( + ("12102247e791ff1e", + ["Отдельный проект «TSMC — 10-летний инвестиционный анализ»", + "Файлы исследования, моделей и PDF внутри его project workspace", + "Финальные пользовательские deliverables"], + [], + "8832d9db81cbd61dea584c193130e25b3c63fea25b007b7978456f07f92e1f82", + "40bc4f76e9053c02066ee0900a6ebde424af70450adfe2e2fdf6ccff1b87d337"), + ("0be6fb0690f44bbd", + ["/Users/anton/Ouroboros/projects/TSMC___10-____________________________"], + [], + "a8aea6bb3adbb7dcb635dbebb7161d238d34168502420b7be4fd4f4c1692f06f", + "8e652cd2dbbce9ebfa1ecafe147cea4adf41672551995e83659b2e712d869bcb"), + ("6596589bb3674aaa", + ["/Users/anton/Ouroboros/data/skills/external/context-lens", + "/Users/anton/Ouroboros/projects/Context_Lens___________________________________"], + ["data/skills/external/context-lens/SKILL.md"], + "2e2764e09bc74383c082bae1eabc44c9acb24c5c8cd4048c4e77187375bbc240", + "9acfa5071662c07c1f716c5e15a0fd1648c56b64df96a3e5aae1b9289e91fbff"), +) + + +def test_stored_specs_from_before_affected_paths_keep_their_recorded_identity(): + """`affected_paths` is never defaulted onto input that did not declare it: normalizing a + spec recorded under the old shape yields the same key list, the same spec hash and the same + plan fingerprint it was recorded with.""" + for task_id, resources, evidence, spec_digest, fingerprint in STORED_LEGACY_WAVES: + raw = {"goal": f"stored plan wave {task_id}", "affected_resources": list(resources), + "evidence": list(evidence)} + spec, errors = plan_spec.normalize_spec(raw) + assert errors == [] + assert "affected_paths" not in spec + assert list(spec) == [ + "goal", "in_scope", "non_goals", "acceptance_claims", "invariants", "decisions", + "deferred", "affected_resources", "evidence", "normalization_omissions", + ] + assert plan_spec.spec_hash(spec) == spec_digest + assert plan_spec.plan_fingerprint( + spec["goal"], "stored prose", spec, "manifest-hash", False) == fingerprint + + +def test_spec_delta_sees_the_affected_paths_claim_appear_and_then_move(): + """Absent ("stored before the field existed") and `[]` ("this work changes no files") are + different claims, so the delta the next reviewer reads must not call them the same.""" + base = {"goal": "g", "affected_resources": ["the uploader"]} + legacy, _ = plan_spec.normalize_spec(base) + declares_none, _ = plan_spec.normalize_spec({**base, "affected_paths": []}) + declares_file, _ = plan_spec.normalize_spec({**base, "affected_paths": ["ouroboros/uploader.py"]}) + + appeared = plan_spec.spec_delta(legacy, declares_none) + assert appeared["changed"] and appeared["declared_keys_changed"] + assert appeared["prev_hash"] != appeared["hash"] + + moved = plan_spec.spec_delta(declares_none, declares_file) + assert moved["changed"] and moved["declared_keys_changed"] is False + assert moved["lists"]["affected_paths"] == {"added": ["ouroboros/uploader.py"], "removed": []} + assert plan_spec.spec_delta(declares_file, declares_file)["changed"] is False + + +def _system_prompt(substrate, index=0): + return substrate.calls[index]["request"].messages[0]["content"][0]["text"] + + +def test_a_declared_new_source_file_is_constitutional_before_it_exists(harness): # noqa: F811 + """A file the work will CREATE has no bytes and no parent directory on disk yet; writing + `ouroboros/new/deep/module.py` IS self-modification, so the full pack must ride.""" + target = harness.system / "ouroboros" / "new" / "deep" / "module.py" + assert not target.exists() and not target.parent.exists() + substrate = harness.install({"s1": CLEAN, "s2": CLEAN, "s3": CLEAN}) + + out = _call(harness.make_ctx(), spec={**DECK_SPEC, "affected_paths": [str(target)]}) + + wave = _state(harness)["waves"][-1] + assert wave["constitutional"] is True + assert "affected_paths" in wave["constitutional_note"] and str(target) in wave["constitutional_note"] + system_prompt = _system_prompt(substrate) + assert "## BIBLE.md (constitution" in system_prompt and "6. Governance" in system_prompt + assert "## ARCHITECTURE.md (architecture and data flow" in system_prompt + assert "on-demand pointer" not in system_prompt + assert "REMINDER" not in out + + +def test_prose_resources_never_reach_the_filesystem_and_never_buy_the_pack(harness, monkeypatch): # noqa: F811 + """The TSMC list, exactly as the live wave sent it: descriptions of a project, its files and + its deliverables. With no change target declared the plan is ordinary, the constitutional + resolver resolves nothing at all, and the host reminds the agent that the list it CAN use + for that is `affected_paths`.""" + resolved: list[str] = [] + real = plan_spec._resolve_locator_path + monkeypatch.setattr( + plan_spec, "_resolve_locator_path", + lambda locator, root: (resolved.append(locator), real(locator, root))[1], + ) + substrate = harness.install({"s1": CLEAN, "s2": CLEAN, "s3": CLEAN}) + spec = {**DECK_SPEC, "affected_paths": [], "affected_resources": list(TSMC_RESOURCES)} + + # `active_workspace=False` binds the task to the system repo itself: the one situation where + # an empty change-target list is worth a reminder (D29 keeps the binding from deciding). + out = _call(harness.make_ctx(active_workspace=False), spec=spec) + + assert resolved == [] + wave = _state(harness)["waves"][-1] + assert wave["constitutional"] is False + assert wave["constitutional_note"] == ( + "not constitutional: no declared affected_paths locator resolves under the " + "Ouroboros system repository" + ) + assert "REMINDER: affected_paths is empty" in out + system_prompt = _system_prompt(substrate) + assert "## BIBLE.md (constitution" not in system_prompt and "on-demand pointer" in system_prompt + + +def test_reading_a_repository_file_is_not_changing_it(harness): # noqa: F811 + """Owner 16=A. The same locator, twice: as evidence alone it is named in the note and the + pack stays a pointer; listed as a change target too, it escalates.""" + bible = str(harness.system / "BIBLE.md") + substrate = harness.install({"s1": CLEAN, "s2": CLEAN, "s3": CLEAN}) + + _call(harness.make_ctx(), spec={**DECK_SPEC, "affected_paths": [], "evidence": [bible]}) + + read_only = _state(harness)["waves"][-1] + assert read_only["constitutional"] is False + assert "EVIDENCE reads" in read_only["constitutional_note"] and bible in read_only["constitutional_note"] + assert "## BIBLE.md (constitution" not in _system_prompt(substrate) + + substrate = harness.install({"s1": CLEAN, "s2": CLEAN, "s3": CLEAN}) + _call(harness.make_ctx(task_id="task-2"), + spec={**DECK_SPEC, "affected_paths": [bible], "evidence": [bible]}) + + changing = _state(harness, "task-2")["waves"][-1] + assert changing["constitutional"] is True + assert "affected_paths" in changing["constitutional_note"] + assert "## BIBLE.md (constitution" in _system_prompt(substrate) + + +def test_a_legacy_form_submission_is_refused_and_records_nothing(harness): # noqa: F811 + """Owner 9=A: the old mixed form is refused BEFORE any dispatch. The refusal quotes the + field with an example, and — because its code is not the `PLAN_SPEC_INVALID` family that + supersedes the current attempt — the task's recorded plan state does not move a byte.""" + substrate = harness.install({"s1": CLEAN, "s2": CLEAN, "s3": CLEAN}) + ctx = harness.make_ctx() + assert _control(_call(ctx))["closed"] is True + before = json.dumps(_state(harness), sort_keys=True, ensure_ascii=False) + + legacy = {key: value for key, value in DECK_SPEC.items() if key != "affected_paths"} + legacy["affected_resources"] = list(TSMC_RESOURCES) + out = _call(ctx, spec=legacy) + + assert "PLAN_RESOURCE_FORM_REQUIRED" in out and "spec.affected_paths is required" in out + assert '"affected_paths": ["ouroboros/tools/plan_review.py"]' in out + assert '"affected_resources": ["the plan-review organ"' in out + assert len(substrate.calls) == 1 # the refused submission called no reviewer + assert json.dumps(_state(harness), sort_keys=True, ensure_ascii=False) == before + + +def test_a_legacy_form_submission_over_an_open_wave_points_at_the_free_exit(harness): # noqa: F811 + """The expensive mistake this prevents: re-submitting into a refusal while an OPEN wave is + still the live obligation. The refusal names that wave and the $0 way to answer it.""" + ask = json.dumps([_finding("f1", "need_evidence", breaks="goal", summary="who signs off?")]) + harness.install({"s1": ask, "s2": CLEAN, "s3": CLEAN}) + ctx = harness.make_ctx() + assert _control(_call(ctx)) == {"outcome": "REVIEW_REQUIRED", "closed": False} + open_fingerprint = _state(harness)["waves"][-1]["request_fingerprint"] + before = json.dumps(_state(harness), sort_keys=True, ensure_ascii=False) + + legacy = {key: value for key, value in DECK_SPEC.items() if key != "affected_paths"} + out = _call(ctx, spec=legacy) + + assert "PLAN_RESOURCE_FORM_REQUIRED" in out + assert open_fingerprint in out and "review_disposition" in out + assert json.dumps(_state(harness), sort_keys=True, ensure_ascii=False) == before + + +def test_a_wave_stored_before_affected_paths_still_closes_at_zero_cost(harness): # noqa: F811 + """No grace mechanism was built for the waves already open (owner, disclosed) — they must + simply stay answerable. A wave recorded under the old spec shape closes through + `review_disposition` with no reviewer call, and its recorded identity does not move.""" + from ouroboros import task_results + from ouroboros.tools import plan_review_artifacts as artifacts + from ouroboros.tools.plan_review import _apply_disposition + + legacy_spec, errors = plan_spec.normalize_spec({ + "goal": "Deliver the TSMC analysis", + "affected_resources": list(TSMC_RESOURCES), + }) + assert errors == [] and "affected_paths" not in legacy_spec + stored_hash = plan_spec.spec_hash(legacy_spec) + fingerprint = "c" * 64 + wave = { + "schema_version": 2, "cycle_index": 1, "request_fingerprint": fingerprint, + "goal": legacy_spec["goal"], "spec": legacy_spec, "spec_hash": stored_hash, + "aggregate": "REVIEW_REQUIRED", "closed": False, "paid": True, "dispositions": [], + "findings": [{"finding_id": "s1:f1", "id": "f1", "class": "need_evidence", "breaks": "goal", + "locator": "", "summary": "who signs this off?", "recommendation": ""}], + } + artifacts.record_exact_wave( + harness.drive, "task-1", wave, copy.deepcopy(wave), need_evidence_seen=[], page_size=32, + ) + task_results.record_plan_review_attempt( + harness.drive, "task-1", fingerprint=fingerprint, status="open", + ) + paid_before = int(_state(harness).get("cycles_paid") or 0) + substrate = harness.install({"s1": CLEAN, "s2": CLEAN, "s3": CLEAN}) + + out = _apply_disposition(harness.make_ctx(), {"review_fingerprint": fingerprint, "items": [ + {"finding_id": "s1:f1", "decision": "accept", "rationale": "the owner signs it off"}, + ]}) + + assert _control(out) == {"outcome": "REVIEW_REQUIRED", "closed": True} + assert substrate.calls == [] + state = _state(harness) + assert int(state.get("cycles_paid") or 0) == paid_before + closed = state["waves"][-1] + assert closed["request_fingerprint"] == fingerprint and closed["closed"] is True + assert closed["spec_hash"] == stored_hash and closed["aggregate"] == "REVIEW_REQUIRED" + assert "affected_paths" not in closed["spec"] diff --git a/tests/test_plan_review.py b/tests/test_plan_review.py index 261e5d6e2..069a0809a 100644 --- a/tests/test_plan_review.py +++ b/tests/test_plan_review.py @@ -355,7 +355,7 @@ def test_malformed_reviewer_slots_block_plan_review_before_any_dispatch(tmp_path patch.object(pr, "_run_plan_review_slots", side_effect=AssertionError("no reviewer dispatch")), ): - result = pr._handle_plan_task(ctx, plan="P", goal="G", spec={"in_scope": ["x"]}) + result = pr._handle_plan_task(ctx, plan="P", goal="G", spec={"in_scope": ["x"], "affected_paths": []}) assert "Invalid reviewer-slot configuration blocks plan review" in result assert "not valid JSON" in result @@ -410,7 +410,7 @@ def test_expired_explicit_deadline_skips_before_any_reviewer(monkeypatch, tmp_pa monkeypatch.setattr(pr, "_plan_review_slots", lambda: (_ for _ in ()).throw(AssertionError("expired deadline must skip"))) out = asyncio.run(pr._run_plan_review_async( - ctx, pr._PlanRequest(goal="G", plan="P", spec={"in_scope": ["x"]}), + ctx, pr._PlanRequest(goal="G", plan="P", spec={"in_scope": ["x"], "affected_paths": []}), )) assert out.startswith("PLAN_TASK_SKIPPED_DEADLINE: the task deadline has expired") @@ -437,7 +437,7 @@ def test_expired_deadline_replays_a_recorded_wave_but_never_pays(monkeypatch, tm monkeypatch.setattr(pr, "_plan_review_slots", lambda: (_ for _ in ()).throw(AssertionError("no panel under a dead deadline"))) out = asyncio.run(pr._run_plan_review_async( - ctx, pr._PlanRequest(goal="G", plan="P", spec={"in_scope": ["x"]}), + ctx, pr._PlanRequest(goal="G", plan="P", spec={"in_scope": ["x"], "affected_paths": []}), )) assert out.startswith("PLAN_TASK_SKIPPED_DEADLINE:") attempt = load_plan_review_state(tmp_path, ctx.task_id)["current_attempt"] @@ -512,8 +512,10 @@ class TestPlanReviewToolRegistration(unittest.TestCase): spec = params["spec"]["properties"] self.assertEqual(set(spec), { "in_scope", "non_goals", "acceptance_claims", "invariants", "decisions", - "deferred", "affected_resources", "evidence", + "deferred", "affected_paths", "affected_resources", "evidence", }) + # The ONE list the host resolves as file paths, and the one a submitted spec must carry. + self.assertEqual(params["spec"]["required"], ["affected_paths"]) disposition = params["review_disposition"] self.assertEqual(disposition["required"], ["review_fingerprint", "items"]) decision = disposition["properties"]["items"]["items"]["properties"]["decision"] diff --git a/tests/test_plan_review_engine.py b/tests/test_plan_review_engine.py index 078b98df4..9a2b249d4 100644 --- a/tests/test_plan_review_engine.py +++ b/tests/test_plan_review_engine.py @@ -134,7 +134,10 @@ DECK_SPEC = { "invariants": ["deliver by Friday", "no confidential numbers"], "decisions": [{"choice": "one chart per slide", "rejected": ["tables"], "why": "audience"}], "deferred": [{"what": "color palette", "why_safe_to_defer": "cosmetic"}], - "affected_resources": [], + # `affected_paths` is REQUIRED on every submitted spec (owner 9=A): a deck changes no + # repository file, and `[]` is how a plan says exactly that. + "affected_paths": [], + "affected_resources": ["the Q3 board deck"], "evidence": [], } @@ -669,13 +672,14 @@ def test_evidence_omissions_reach_the_packet_and_the_wave(harness): assert manifest["attached"][0]["sha256"] and "text" not in manifest["attached"][0] -def test_constitutional_from_affected_resources_and_reminder_on_system_binding(harness): +def test_constitutional_from_affected_paths_and_reminder_on_system_binding(harness): sub = harness.install({"s1": CLEAN, "s2": CLEAN, "s3": CLEAN}) - # (a) workspace binding, an absolute path into the system repo declared as affected - spec = {**DECK_SPEC, "affected_resources": [str(harness.system / "ouroboros" / "loop.py")]} + # (a) workspace binding, an absolute path into the system repo declared as a CHANGE target + # (owner 8=A: `affected_resources` is prose now and buys nothing, so the trigger is here) + spec = {**DECK_SPEC, "affected_paths": [str(harness.system / "ouroboros" / "loop.py")]} out = _call(harness.make_ctx(), spec=spec) wave = _state(harness)["waves"][-1] - assert wave["constitutional"] is True and "affected_resources" in wave["constitutional_note"] + assert wave["constitutional"] is True and "affected_paths" in wave["constitutional_note"] system_prompt = sub.calls[0]["request"].messages[0]["content"][0]["text"] assert "## BIBLE.md" in system_prompt and "Principle 3: Immune Integrity" in system_prompt # W3: a self-modification plan carries ARCHITECTURE.md inline, in full — not a map, not a pointer @@ -688,7 +692,7 @@ def test_constitutional_from_affected_resources_and_reminder_on_system_binding(h out2 = _call(ctx) wave2 = _state(harness, "task-2")["waves"][-1] assert wave2["constitutional"] is False - assert "REMINDER: affected_resources is empty" in out2 + assert "REMINDER: affected_paths is empty" in out2 system_prompt2 = sub.calls[1]["request"].messages[0]["content"][0]["text"] assert "Principle 3: Immune Integrity\n\nreview." not in system_prompt2 assert "on-demand pointer" in system_prompt2 @@ -861,8 +865,16 @@ def test_retired_swarm_keys_are_dropped_on_settings_load(tmp_path, monkeypatch): def test_ratchet_module_sizes(): + """The engine entered the 1001-1500 band with the required-`affected_paths` form gate; the + exact debt is owned by ouroboros/size_ratchet_manifest.py, so the pin here is the band plus + the rule that a banded module must carry a rationale there.""" + from ouroboros.size_ratchet_manifest import BAND_PATHS + repo = pathlib.Path(pr.__file__).resolve().parents[2] - assert len((repo / "ouroboros" / "tools" / "plan_review.py").read_text(encoding="utf-8").splitlines()) < 1000 + lines = len((repo / "ouroboros" / "tools" / "plan_review.py").read_text(encoding="utf-8").splitlines()) + assert lines <= 1500 + if lines > 1000: + assert (BAND_PATHS.get("ouroboros/tools/plan_review.py") or "").strip() assert len((repo / "ouroboros" / "config.py").read_text(encoding="utf-8").splitlines()) <= 1600 diff --git a/tests/test_plan_review_script.py b/tests/test_plan_review_script.py index 0a1d50315..c89bba83e 100644 --- a/tests/test_plan_review_script.py +++ b/tests/test_plan_review_script.py @@ -62,6 +62,8 @@ def test_run_plan_review_script_runs_the_engine_on_the_new_envelope(monkeypatch, spec_path.write_text(json.dumps({ "in_scope": ["the accepted phase"], "acceptance_claims": ["tests green"], "invariants": ["no new settings"], + # An operator envelope carries the same required form as the agent's (owner 9=A). + "affected_paths": [], }), encoding="utf-8") evidence_file = workspace / "notes.txt" evidence_file.write_text("inspect the existing SSOT", encoding="utf-8") diff --git a/tests/test_plan_review_w3.py b/tests/test_plan_review_w3.py index 52559fc30..1e5c0cd6a 100644 --- a/tests/test_plan_review_w3.py +++ b/tests/test_plan_review_w3.py @@ -128,7 +128,7 @@ def test_constitutional_packet_without_architecture_is_a_typed_failure(harness): agent sees, never a reviewer wave that silently lacks the document.""" sub = harness.install({"s1": CLEAN, "s2": CLEAN, "s3": CLEAN}) (harness.system / "docs" / "ARCHITECTURE.md").unlink() - spec = {**DECK_SPEC, "affected_resources": [str(harness.system / "ouroboros" / "loop.py")]} + spec = {**DECK_SPEC, "affected_paths": [str(harness.system / "ouroboros" / "loop.py")]} out = _call(harness.make_ctx(), spec=spec) assert "ARCHITECTURE.md" in out and "W3" in out assert sub.calls == [] # no reviewer was called @@ -372,7 +372,7 @@ def test_session_task_is_the_compact_form_with_governance_by_mandatory_retrieval resolvable locators, never ~500k chars inline; api rows still get them inline.""" harness.state["slots"] = _slots(("api1", "m/a"), ("sess1", "cursor=grok", "session"), ("api2", "m/b")) sub = harness.install({"api1": CLEAN, "sess1": CLEAN, "api2": CLEAN}) - spec = {**DECK_SPEC, "affected_resources": [str(harness.system / "ouroboros" / "loop.py")]} + spec = {**DECK_SPEC, "affected_paths": [str(harness.system / "ouroboros" / "loop.py")]} _call(harness.make_ctx(), spec=spec) request = sub.calls[0]["request"] api_system = request.messages[0]["content"][0]["text"] @@ -383,9 +383,12 @@ def test_session_task_is_the_compact_form_with_governance_by_mandatory_retrieval assert "slots and quorum." not in task and "Principle 3: Immune Integrity\n\nreview." not in task assert "Plan Review Checklist" in task and "REDACTED snapshot" in task # the governance documents are the ONE raw-read exception, even when the agent ALSO declared - # BIBLE.md as evidence (declaring it makes the plan constitutional): no contradictory orders + # BIBLE.md as evidence beside changing it (the change target is what makes the plan + # constitutional — owner 16=A): no contradictory orders sub = harness.install({"api1": CLEAN, "sess1": CLEAN, "api2": CLEAN}) - _call(harness.make_ctx(task_id="task-2"), spec={**DECK_SPEC, "evidence": [str(harness.system / "BIBLE.md")]}) + _call(harness.make_ctx(task_id="task-2"), spec={**DECK_SPEC, + "affected_paths": [str(harness.system / "BIBLE.md")], + "evidence": [str(harness.system / "BIBLE.md")]}) task2 = sub.calls[0]["request"].session_task assert "MANDATORY FULL READS" in task2 and "even if the agent also declared them as evidence" in task2 assert f"### {harness.system / 'BIBLE.md'}" in task2 # the redacted snapshot is still there too @@ -416,7 +419,8 @@ def test_state_stays_persistable_at_the_worst_case_request_bounds(tmp_path): "decisions": [{"choice": wide, "why": wide, "rejected": [wide] * 8} for _ in range(n_items)], "deferred": [{"what": wide, "why_safe_to_defer": wide} for _ in range(n_items)], - "affected_resources": [f"{wide[:-4]}a{i:03d}" for i in range(n_items)], + "affected_paths": [f"{wide[:-4]}a{i:03d}" for i in range(n_items)], + "affected_resources": [f"{wide[:-4]}r{i:03d}" for i in range(n_items)], "evidence": [f"{wide[:-4]}e{i:03d}" for i in range(n_items)], } normalized, errors = plan_spec.normalize_spec(spec) @@ -689,13 +693,17 @@ def test_both_reviewer_routes_learn_the_range_selectors(harness): assert "::lines=A-B" in request.session_task -def test_the_plan_spec_schema_discloses_both_halves_of_the_constitutional_trigger(): - """The trigger reads `affected_resources` AND an existing `evidence` path; the schema the - agent sees says so, including that a non-existent path does not count.""" +def test_the_plan_spec_schema_names_affected_paths_as_the_only_resolved_list(): + """The trigger reads ONE list — `affected_paths` — and the schema the agent sees says so: + required, files only, `[]` when none, and the other two lists explicitly not resolved.""" + assert pr._SPEC_SCHEMA["required"] == ["affected_paths"] props = pr._SPEC_SCHEMA["properties"] - assert "system repository" in props["affected_resources"]["description"] + paths = props["affected_paths"]["description"] + assert "REQUIRED" in paths and "system repository" in paths and "[]" in paths + resources = props["affected_resources"]["description"] + assert "never file paths" in resources and "system repository" not in resources evidence = props["evidence"]["description"] - assert "system repository" in evidence and "EXISTING" in evidence + assert "does not make the plan" in evidence and "affected_paths" in evidence # ------------------------------------------------------------- in-flight honesty (P1-4) diff --git a/tests/test_plan_spec.py b/tests/test_plan_spec.py index cc2c53594..4a2df9f74 100644 --- a/tests/test_plan_spec.py +++ b/tests/test_plan_spec.py @@ -28,7 +28,8 @@ CODE_SPEC = { "invariants": ["no new dependencies", "public API unchanged"], "decisions": [{"choice": "exponential backoff", "rejected": ["fixed delay"], "why": "bursty upstream"}], "deferred": [{"what": "metrics emission", "why_safe_to_defer": "observability only"}, "log wording"], - "affected_resources": ["ouroboros/uploader.py"], + "affected_paths": ["ouroboros/uploader.py"], + "affected_resources": ["the upload pipeline", "the storage provider"], "evidence": ["ouroboros/uploader.py", "https://example.com/spec", "task:abc123"], } @@ -40,7 +41,8 @@ DECK_SPEC = { "invariants": ["deadline Friday 17:00", "no confidential customer names"], "decisions": [{"choice": "one message per slide", "rejected": ["dense slides"], "why": "board attention"}], "deferred": ["colour palette"], - "affected_resources": [], + "affected_paths": [], + "affected_resources": ["the board deck"], "evidence": [], } @@ -62,7 +64,7 @@ def test_normalize_spec_mints_ids_and_keeps_schema(): assert spec["normalization_omissions"] == [] assert list(spec) == [ "goal", "in_scope", "non_goals", "acceptance_claims", "invariants", "decisions", - "deferred", "affected_resources", "evidence", "normalization_omissions", + "deferred", "affected_paths", "affected_resources", "evidence", "normalization_omissions", ] @@ -184,11 +186,11 @@ def test_constitutional_active_binding_alone_does_not_decide(tmp_path): system = tmp_path / "repo" system.mkdir() ok, note = plan_spec.resolve_constitutional( - active_root=system, system_repo_root=system, affected_resources=[], evidence=[], + active_root=system, system_repo_root=system, affected_paths=[], evidence=[], ) assert ok is False and note.startswith("not constitutional") ok, _ = plan_spec.resolve_constitutional( - active_root=system, system_repo_root=system, affected_resources=["ouroboros/loop.py"], evidence=[], + active_root=system, system_repo_root=system, affected_paths=["ouroboros/loop.py"], evidence=[], ) assert ok is True # a relative path under the (system) active root still decides @@ -200,56 +202,63 @@ def test_constitutional_declared_paths_relative_absolute_and_file_scheme(tmp_pat workspace.mkdir() ok, note = plan_spec.resolve_constitutional( active_root=workspace, system_repo_root=system, - affected_resources=["../repo/ouroboros/loop.py"], evidence=[], + affected_paths=["../repo/ouroboros/loop.py"], evidence=[], ) - assert ok and "affected_resources" in note - # An EVIDENCE locator counts only when it actually exists: a plan that merely wants to - # LOOK at a repo file is constitutional, a typo pointing at nothing is not (E2E finding). + assert ok and "affected_paths" in note + # Owner 16=A: an EVIDENCE locator is something to LOOK AT, and reading a repo file is not + # changing it — existing or not, it never buys the constitutional pack on its own. Existence + # therefore stopped deciding anything, and the check went with it. (system / "BIBLE.md").write_text("# constitution\n", encoding="utf-8") ok, note = plan_spec.resolve_constitutional( active_root=workspace, system_repo_root=system, - affected_resources=["src/app.py"], evidence=[str(system / "BIBLE.md")], + affected_paths=["src/app.py"], evidence=[str(system / "BIBLE.md")], ) - assert ok and "evidence" in note + assert ok is False and "EVIDENCE reads" in note and str(system / "BIBLE.md") in note ok, _ = plan_spec.resolve_constitutional( active_root=workspace, system_repo_root=system, - affected_resources=["src/app.py"], evidence=[str(system / "NO_SUCH_FILE.md")], + affected_paths=["src/app.py"], evidence=[str(system / "NO_SUCH_FILE.md")], ) assert ok is False # ...while a TARGET counts whether or not it exists yet — creating a module is self-modification. ok, _ = plan_spec.resolve_constitutional( active_root=workspace, system_repo_root=system, - affected_resources=[str(system / "ouroboros" / "brand_new_module.py")], evidence=[], + affected_paths=[str(system / "ouroboros" / "brand_new_module.py")], evidence=[], ) assert ok is True ok, _ = plan_spec.resolve_constitutional( active_root=workspace, system_repo_root=system, - affected_resources=["src/app.py"], evidence=["docs/x.md"], + affected_paths=["src/app.py"], evidence=["docs/x.md"], ) assert ok is False # B-06: a system-repo path dressed as file:// is still a path. ok, note = plan_spec.resolve_constitutional( active_root=workspace, system_repo_root=system, - affected_resources=[f"file://{system}/ouroboros/loop.py"], evidence=[], + affected_paths=[f"file://{system}/ouroboros/loop.py"], evidence=[], ) assert ok is True and "file://" in note -def test_constitutional_evidence_selector_classifies_the_source_path(tmp_path): +def test_constitutional_selector_classifies_the_source_path(tmp_path): + """A `::lines=A-B` suffix names a RANGE of one file: the path in front of it decides, on + either list — as a change target it escalates, as an evidence read it is only named.""" system = tmp_path / "repo" workspace = tmp_path / "workspace" workspace.mkdir() - evidence = _write(system / "docs" / "ARCHITECTURE.md", "# Architecture\n") + source = _write(system / "docs" / "ARCHITECTURE.md", "# Architecture\n") ok, note = plan_spec.resolve_constitutional( - active_root=workspace, - system_repo_root=system, - affected_resources=[], - evidence=[f"{evidence}::lines=1-1"], + active_root=workspace, system_repo_root=system, + affected_paths=[f"{source}::lines=1-1"], evidence=[], ) - assert ok is True - assert f"{evidence}::lines=1-1" in note + assert f"{source}::lines=1-1" in note + + ok, note = plan_spec.resolve_constitutional( + active_root=workspace, system_repo_root=system, + affected_paths=[], evidence=[f"{source}::lines=1-1"], + ) + assert ok is False + assert f"{source}::lines=1-1" in note and "EVIDENCE reads" in note def test_constitutional_payload_exempt_url_task_never_and_empty_false(tmp_path): @@ -261,7 +270,7 @@ def test_constitutional_payload_exempt_url_task_never_and_empty_false(tmp_path): (system / "x").mkdir(parents=True) ok, _ = plan_spec.resolve_constitutional( active_root=workspace, system_repo_root=system, - affected_resources=[str(payload / "SKILL.md")], evidence=[], payload_roots=[payload], + affected_paths=[str(payload / "SKILL.md")], evidence=[], payload_roots=[payload], ) assert ok is False # A payload root that resolves INSIDE the system repo still exempts: the roots come @@ -271,22 +280,22 @@ def test_constitutional_payload_exempt_url_task_never_and_empty_false(tmp_path): nested = system / "data" / "skills" / "native" / "demo" ok, _ = plan_spec.resolve_constitutional( active_root=workspace, system_repo_root=system, - affected_resources=[str(nested / "SKILL.md")], evidence=[], payload_roots=[nested], + affected_paths=[str(nested / "SKILL.md")], evidence=[], payload_roots=[nested], ) assert ok is False # A system path OUTSIDE every payload root is still constitutional. ok, _ = plan_spec.resolve_constitutional( active_root=workspace, system_repo_root=system, - affected_resources=[str(system / "ouroboros" / "loop.py")], evidence=[], payload_roots=[nested], + affected_paths=[str(system / "ouroboros" / "loop.py")], evidence=[], payload_roots=[nested], ) assert ok is True ok, _ = plan_spec.resolve_constitutional( - active_root=workspace, system_repo_root=system, affected_resources=[], + active_root=workspace, system_repo_root=system, affected_paths=[], evidence=["https://github.com/razzant/ouroboros/blob/main/ouroboros/loop.py", "task:xyz"], ) assert ok is False ok, note = plan_spec.resolve_constitutional( - active_root=workspace, system_repo_root=system, affected_resources=[], evidence=[], + active_root=workspace, system_repo_root=system, affected_paths=[], evidence=[], ) assert ok is False and note.startswith("not constitutional") @@ -399,7 +408,7 @@ def test_resolve_evidence_and_constitutional_never_raise_on_hostile_locators(tmp assert manifest["attached"] == [] for locator in (long, "loop1", "\x00bad"): ok, _ = plan_spec.resolve_constitutional( - active_root=root, system_repo_root=tmp_path / "repo", affected_resources=[locator], evidence=[], + active_root=root, system_repo_root=tmp_path / "repo", affected_paths=[locator], evidence=[], ) assert ok is False # A fifo / device is a non-regular file: refused, never opened (would block forever). @@ -869,11 +878,14 @@ def test_capped_reviewer_requests_are_rendered_even_when_the_agent_declared_noth def test_domain_independence_deck_spec_without_paths(tmp_path): spec, errors = plan_spec.normalize_spec(DECK_SPEC) - assert errors == [] and spec["affected_resources"] == [] and spec["evidence"] == [] + # A deck changes no file: `affected_paths` is the empty claim, while the prose list still + # says what the work touches — and neither one is resolved into a file read. + assert errors == [] and spec["affected_paths"] == [] and spec["evidence"] == [] + assert spec["affected_resources"] == ["the board deck"] assert plan_spec.spec_ids(spec) == frozenset({"goal", "claim_1", "claim_2", "invariant_1", "invariant_2", "decision_1", "deferred_1"}) assert len(plan_spec.spec_hash(spec)) == 64 ok, _ = plan_spec.resolve_constitutional( - active_root=tmp_path / "ws", system_repo_root=tmp_path / "repo", affected_resources=spec["affected_resources"], evidence=spec["evidence"], + active_root=tmp_path / "ws", system_repo_root=tmp_path / "repo", affected_paths=spec["affected_paths"], evidence=spec["evidence"], ) assert ok is False manifest = plan_evidence.resolve_evidence(spec["evidence"], active_root=tmp_path, allowed_roots=[tmp_path]) diff --git a/tests/test_v6610_adaptive_planning.py b/tests/test_v6610_adaptive_planning.py index c1ccf9ee2..38e7c3555 100644 --- a/tests/test_v6610_adaptive_planning.py +++ b/tests/test_v6610_adaptive_planning.py @@ -1,6 +1,6 @@ """Structural constitutional escalation for ``plan_task`` (formerly v6.61.0 plan_class -escalation): the ONE path fact ``constitutional`` — declared ``affected_resources`` / -``evidence`` locators resolving under the Ouroboros system repository (owner D29: +escalation): the ONE path fact ``constitutional`` — declared ``affected_paths`` locators +(the files the work will CHANGE) resolving under the Ouroboros system repository (owner D29: the active binding alone never decides) — with the skill-payload exemption the retired ``resolve_plan_class`` applied. Kept from the original file: the path-escalation and payload-exemption cases, ported to ``plan_spec.resolve_constitutional`` @@ -34,7 +34,7 @@ def _resolve(ctx, affected, evidence=()): active = ctx.active_repo_dir() return resolve_constitutional( active_root=active, system_repo_root=system, - affected_resources=list(affected), evidence=list(evidence), + affected_paths=list(affected), evidence=list(evidence), payload_roots=plan_payload_roots(ctx, list(affected) + list(evidence)), ) @@ -44,7 +44,7 @@ def _resolve(ctx, affected, evidence=()): def test_system_repo_paths_make_the_plan_constitutional(tmp_path): ctx = _ctx(tmp_path) # active workspace IS the system repo ok, note = _resolve(ctx, ["ouroboros/loop.py"]) - assert ok and "affected_resources" in note + assert ok and "affected_paths" in note # D29: the binding alone never decides — nothing declared, not constitutional. ok, note = _resolve(ctx, []) assert ok is False and note.startswith("not constitutional") @@ -56,15 +56,15 @@ def test_external_workspace_paths_stay_non_constitutional_unless_absolute_into_s # An ABSOLUTE path back into the system repo escalates even from a workspace. abs_sys = str(tmp_path / "sys" / "ouroboros" / "config.py") ok, note = _resolve(ctx, ["src/app.py", abs_sys]) - assert ok and "affected_resources" in note - # A declared EVIDENCE locator under the system repo escalates too — when it exists. + assert ok and "affected_paths" in note + # Owner 16=A: a declared EVIDENCE locator under the system repo is a READ, not a change — + # it never escalates on its own, existing or not, and the host says which reads it saw + # instead of reporting "no locator resolved". (tmp_path / "sys" / "BIBLE.md").write_text("# constitution\n", encoding="utf-8") ok, note = _resolve(ctx, ["src/app.py"], evidence=[str(tmp_path / "sys" / "BIBLE.md")]) - assert ok and "evidence" in note - # A system-repo evidence path that does NOT exist (a typo) must not drag the constitutional - # pack in, and the host says exactly that instead of "no locator resolved". + assert ok is False and "EVIDENCE reads" in note ok, note = _resolve(ctx, ["src/app.py"], evidence=[str(tmp_path / "sys" / "NOPE.md")]) - assert ok is False and "do not exist" in note + assert ok is False and "EVIDENCE reads" in note # --- skill-payload exemption (data plane, never self-modification by itself) --------- @@ -100,7 +100,7 @@ def test_native_bucket_is_not_exempt(tmp_path): # Native skills are repo-seeded territory — the payload predicate does not admit them. ctx = _ctx(tmp_path) ok, note = _resolve(ctx, ["data/skills/native/x/plugin.py"]) - assert ok and "affected_resources" in note + assert ok and "affected_paths" in note def test_drive_resolution_failure_skips_the_exemption(tmp_path):