diff --git a/.gitignore b/.gitignore index bb705fb..139e033 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,8 @@ __pycache__/ /candidates.jsonl /chats.jsonl /judgments.jsonl +/task-source.jsonl +/task-inventory-draft.jsonl +/task-inventory.jsonl +/read-attributions.jsonl +/effect-judgments.jsonl diff --git a/README.md b/README.md index 950c81b..bc31cc6 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,22 @@ # Context Tree Value Audit -`context-tree-value-audit` 0.4.0 is an explicit-only Skill for task-first, +`context-tree-value-audit` 0.5.0 is an explicit-only Skill for task-first, evidence-first analysis of Context Tree decision value for the current First Tree Runtime when its native historical evidence is supported. It reconstructs single-Agent-owned continuous Tasks from authorized Chats, records whether a -Tree Read is observed or unresolved, and judges one optional Effect without a -minimum sample gate. +Tree Read is observed or unresolved, and judges zero or more independent +Effects without a minimum sample gate. The 0.2 series renamed the installable Skill from `context-tree-insights` to `context-tree-value-audit`. Replace the old Skill directory during upgrade; do not install both names because they represent one explicit audit capability, not two independent workflows. -Version 0.4 replaces the 0.3 Task judgment schema v2 with continuous episode -schema v3. Do not reuse schema-v1 or schema-v2 `task-judgments.jsonl`: -reconstruct the episodes and their ownership/objective/outcome anchors, then -rerun the deterministic reporter. No migration or compatibility reader is -provided. +Version 0.5 replaces the combined schema-v3 Task judgment with three ordered +schema-v4 artifacts: frozen Task inventory, Read attributions, and Effect +judgments. Do not reuse schema-v1/v2/v3 judgment files. Reconstruct Tasks from +the message-only projection, freeze the inventory digest, then rerun Read and +Effect analysis. No migration or compatibility reader is provided. The audit core remains separate from First Tree core. Codex, Claude Code, and Claude Code TUI use their existing native local transcripts. Cursor and Kimi @@ -40,9 +40,14 @@ Tree. counting unit. - Short continuations, status prompts, context-dependent questions, repeated review/fix requests, and phases of one delivery do not become separate Tasks. -- Clear Tasks carry source-backed ownership, objective, and outcome anchors; - work owned by another Agent remains context until the audited Agent visibly - receives, accepts, or takes over an objective. +- Clear Tasks carry source-backed objective and same-Agent outcome messages; + work owned by another Agent remains context until the audited Agent receives + or visibly accepts an objective. That acceptance is an analyst judgment from + work messages; the reporter does not infer the addressee of arbitrary prose. +- Task reconstruction uses a message-only projection that excludes + collector-derived Reads, passages, Tree-mention indexes, receipts, choice + projections, and Effect judgments; original work-message content remains + unchanged. Its normalized inventory is frozen before Read attribution. - Local Runtime evidence is preflighted against authorized Chat and Agent IDs before complete recorded output is scanned. - Missing, cleaned, ambiguous, malformed, truncated, or unsupported traces are @@ -59,8 +64,8 @@ Tree. duplicate, pending, or out-of-window results stay unresolved. - Read is only `observed` or `unresolved`; unresolved is never counted as unused. -- Effect is optional and only `confirmed`, `constrained`, `redirected`, or - `conflicted`. +- Each Task has zero or more Effects; each is only `confirmed`, `constrained`, + `redirected`, or `conflicted`. - A decision receipt may support an Effect but cannot create one by itself. - There is no fixed Task quota, task-type gate, or saturation state. - The output is a sampled evidence report, not causal proof, ROI, or a @@ -184,7 +189,7 @@ tasks. Pin a reviewed commit or release when installing for another Agent. ## Pipeline -The Skill orchestrates four stages: +The Skill orchestrates six operational steps across three analysis stages: 1. `export-chats` resolves explicit authorization and exports visible records. 2. `collect` maps authorized Chats to supported native local evidence, @@ -192,15 +197,18 @@ The Skill orchestrates four stages: grammar, reconstructs exact or read-only-composite evidence plus visible choices, and distinguishes local default-branch matches from unverified sources. Unsupported Runtime history produces unresolved Reads. -3. The Agent reconstructs continuous Task episodes with explicit - ownership/objective/outcome anchors and writes one observed/unresolved Read - plus at most one Effect in schema-v3 `task-judgments.jsonl`. -4. `report` validates episode ownership and anchors, weak fragment-only - objectives, source ownership, windows, cross-Chat linkage, Read/choice - timing, deduplication, and aggregate conservation, then creates - `evidence.jsonl` and `REPORT.md`. An optional hash-anchored reviewed - baseline is shown separately, so a current collector gap cannot erase - previously reviewed positive cases or silently import them into the rerun. +3. `task-source` removes every collector-derived Tree/Read/Effect projection + and keeps only authorized work messages. It does not redact literal + Tree/Read/Effect discussion from the work itself. +4. The Agent reconstructs Tasks from that projection; `freeze-tasks` validates + and writes a digest-bound `task-inventory.jsonl`. +5. The Agent writes digest-bound Read attributions, then zero-or-more Effect + judgments for each clear Task without changing the frozen inventory. +6. `report` validates Task sources, weak fragment-only objectives, source + ownership, windows, cross-Chat linkage, Read/choice timing, inventory + digests, deduplication, multi-Effect conservation, and creates + `evidence.jsonl` and `REPORT.md`. An optional hash-anchored reviewed baseline + remains separate from the current rerun. There is no default time window. `--days` is an optional data-acquisition bound. Every available Task in the authorized bound is reported; sample size diff --git a/evals/manual-behavior-checklist.md b/evals/manual-behavior-checklist.md index 74128e7..6499a0e 100644 --- a/evals/manual-behavior-checklist.md +++ b/evals/manual-behavior-checklist.md @@ -4,7 +4,7 @@ Use this checklist before admitting a `context-tree-value-audit` revision. Run i in designated First Tree Agent workspaces against disposable or sanitized records. Never commit generated artifacts. -For a 0.4.0 upgrade, confirm the installed payload exposes only +For a 0.5.0 upgrade, confirm the installed payload exposes only `$context-tree-value-audit`; the superseded `$context-tree-insights` directory must not remain as a second callable Skill. Exercise the README's minimal move/copy/compare flow and confirm the old payload is outside both Skill @@ -170,7 +170,7 @@ Pass when: - malformed receipt is omitted with `context_decision_invalid`; - no malformed receipt blocks the message, Chat export, or audit. -## 5. Continuous Task reconstruction +## 5. Pure Task reconstruction and freeze Use one Chat containing one objective across planning, implementation, review, QA, correction, and final delivery, followed by a genuinely independent @@ -184,29 +184,32 @@ one or more leading Agent mentions; and concrete objectives that contain Pass when: +- `task-source.jsonl` contains authorized work messages but no + collector-derived Reads, passages, Tree-mention indexes, decision receipts, + choice projections, or Effect judgments; +- original work-message content remains byte-for-byte intact even when the + work itself discusses Tree, Read, or Effect, and those literal terms are not + treated as audit evidence during Task reconstruction; - all phases and continuations for the first deliverable remain one Task; - it produces two Task rows with separate source messages and windows; -- clear Tasks have audited-Agent ownership, objective, material object scope, - independent outcome, bounded source fragments, one primary deliverable, and - explicit ownership/objective/outcome anchors; +- clear Tasks have a concrete objective, independent same-Agent outcome, + bounded source fragments, and explicit objective/outcome sources; +- material scope and primary deliverable may clarify the boundary but are not + required for research, diagnosis, review, or decision Tasks; - weak prompt variants and weak normalized objectives are merged or excluded, while concrete `continue` objectives remain eligible; -- a weak assignment cannot borrow another sender's concrete objective, and one - Agent message cannot simultaneously serve as objective and terminal outcome; -- an earlier weak objective anchor cannot admit a Read that precedes the - earliest ownership-compatible concrete objective anchor; -- an earlier ownership anchor from a sender incompatible with the ownership - kind cannot admit a Read before compatible ownership is established; -- every outcome anchor is a non-empty current-Agent message, so a later human +- one Agent message cannot simultaneously serve as objective and terminal + outcome; +- every outcome source is a non-empty current-Agent message, so a later human follow-up cannot extend the episode or become the bound Effect outcome; -- work owned by another Agent remains context until a visible assignment, - transfer, or acceptance; +- work owned by another Agent remains context until the audited Agent receives + or visibly accepts an objective; - a candidate missing any clear-Task gate is excluded with the deterministic structured exclusion kind; -- excluded candidates contain no episode, Read, or Effect judgment; -- Reads and choices outside the established episode are rejected; -- ownership/objective/outcome anchors copied across Tasks are rejected; -- one Read or choice copied into both Tasks is rejected. +- excluded candidates contain no clear-Task sources, Read, or Effect judgment; +- `freeze-tasks` writes one normalized inventory digest to every row; +- changing any frozen Task row invalidates the digest; +- Read/Effect fields in a Task inventory draft are rejected. ## 6. Cross-Chat handoff merge @@ -228,31 +231,36 @@ Pass when: - the first is `observed` with valid Read IDs; - the second is `unresolved` with a reason; -- unresolved Read has no Read IDs and a null Effect; +- unresolved Read has no Read IDs and an empty Effect list; - no `not_observed`, `unused`, or negative-value state is emitted; -- Reads outside the established episode, Task window, or source Chats are +- Reads outside the frozen Task window or source Chats are rejected; +- one Read cannot be attributed to different Tasks; - unresolved Tasks appear in coverage counts, never an unused denominator. -## 8. Optional Effect +## 8. Zero-or-more Effects Prepare valid examples of `confirmed`, `constrained`, `redirected`, and -`conflicted`, plus observed-Read Tasks with no Effect. +`conflicted`, including one Task with two distinct Effects supported by the +same Read, plus observed-Read Tasks with no Effect. Pass when: -- unknown Effects, multiple Effects, confidence tiers, and numeric weights are - rejected; +- unknown Effects, confidence tiers, and numeric weights are rejected; +- one Task can carry multiple Effects and the report separately counts Effect + Tasks and total Effects; - every Effect has observed Task Reads, later same-Agent choices, an outcome - anchor bound to the episode outcome, and a summary; -- the bound Effect outcome anchor is not earlier than any cited Read completion + message inside the Task, and a summary; +- the Effect outcome message is not earlier than any cited Read completion or choice; - post-choice Reads and out-of-window choices are rejected; -- a null Effect requires one short `effect_reason`; +- an empty Effect list requires one short `effect_reason`; - a decision receipt alone does not create an Effect; - superseded task types, sampling fields, `verified` / `probable`, rubrics, and support fields are rejected; -- there is at most one Effect per Task. +- the same Read may support multiple distinct choices; +- one choice cannot be reused across Effects; +- unresolved Reads cannot have Effects. ## 9. Sample handling @@ -270,13 +278,13 @@ Pass when: Pass when the report includes: -- a complete clear Task inventory with ownership, objective, scope, primary - deliverable, outcome, Read, Effect, and evidence summary; -- every clear Task's ownership/objective/outcome anchors and boundary rationale; +- a complete frozen Task inventory with objective, optional scope/deliverable, + outcome, source IDs, Read, zero-or-more Effects, and evidence summary; +- the Task inventory digest and every clear Task's objective/outcome sources; - a complete excluded-candidate inventory with exclusion kind, observed scope, and reason; - observed and unresolved Read Tasks; -- Effect Tasks and observed Reads without an Effect; +- Effect Tasks, total Effects, and observed Reads without an Effect; - the four-Effect distribution; - authorized Chat/message/trace coverage and gaps; - the four-class in-window Tree-read attempt conservation table. @@ -284,8 +292,9 @@ Pass when the report includes: Verify: - observed + unresolved = clear Tasks; -- Effect + observed Read without Effect = observed Read Tasks; -- the four Effect counts sum to Effect Tasks; +- Effect Tasks + observed Read without Effect = observed Read Tasks; +- the four Effect counts sum to total Effects; +- total Effects is greater than or equal to Effect Tasks; - the report does not output a global effectiveness rate; - Read counts, receipts, and unresolved gaps are not represented as causal value or non-value. diff --git a/scripts/validate_skill.py b/scripts/validate_skill.py index 91b217c..074be8d 100644 --- a/scripts/validate_skill.py +++ b/scripts/validate_skill.py @@ -33,6 +33,11 @@ "chats.jsonl", "judgments.jsonl", "task-judgments.jsonl", + "task-source.jsonl", + "task-inventory-draft.jsonl", + "task-inventory.jsonl", + "read-attributions.jsonl", + "effect-judgments.jsonl", } diff --git a/skills/context-tree-value-audit/SKILL.md b/skills/context-tree-value-audit/SKILL.md index 1259191..ca41719 100644 --- a/skills/context-tree-value-audit/SKILL.md +++ b/skills/context-tree-value-audit/SKILL.md @@ -9,21 +9,22 @@ description: Audit how Context Tree reads affected complete work Tasks when a hu Run a manual and read-only retrospective that reconstructs single-Agent-owned continuous Tasks from authorized Chats, determines whether a Tree Read is -observed or unresolved, and reports whether the Read reasonably confirmed, -constrained, redirected, or conflicted with the later choice. +observed or unresolved, and reports zero or more independent Effects where a +Read reasonably confirmed, constrained, redirected, or conflicted with later +choices. -Keep three responsibilities separate: +Keep three analysis stages strictly ordered: -- `Chat UUID @ Agent UUID` controls authorization, trace mapping, and evidence - sourcing; -- Task reconstruction defines the judgment and counting unit; -- the bundled script performs deterministic collection, reference validation, - deduplication, conservation checks, and reporting. +- reconstruct Task from message-only work evidence; +- freeze the Task inventory before any Read is visible to the analyst; +- attribute Reads, then analyze zero or more Effects without changing Task + boundaries. -The collector establishes what records exist. The Agent reconstructs -single-Agent-owned continuous work episodes and performs passage-to-choice -judgment. A Read or decision receipt is evidence, not server-verified -causality. +`Chat UUID @ Agent UUID` remains the authorization and evidence-source unit. +Task is the work unit. Effect is the independently counted value unit. The +bundled script performs deterministic projection, freezing, reference +validation, deduplication, conservation checks, and reporting. A Read or +decision receipt is evidence, not server-verified causality. ## Gate the run @@ -159,19 +160,45 @@ For message metadata: - omit malformed receipts and add `context_decision_invalid`; - never fail Chat export because analysis metadata is malformed. -## Reconstruct and judge Tasks +## Reconstruct Tasks, then attribute Reads, then judge Effects Read -[references/task-analysis-schema.md](references/task-analysis-schema.md), then -write exactly one `task-judgments.jsonl` row for every reconstructed Task. +[references/task-analysis-schema.md](references/task-analysis-schema.md). + +First project a message-only source. This output deliberately excludes +collector-derived Reads, passages, Tree-mention indexes, decision receipts, +choice projections, and Effect judgments. It retains original work-message +content verbatim, including literal Tree, Read, or Effect discussion when that +discussion is part of the objective: + +```bash +python3 "$CTVA_SKILL_DIR/scripts/context_tree_value_audit.py" task-source \ + --artifact-root "$CTVA_ARTIFACT_DIR" \ + --agent-workspace "AGENT_UUID=/absolute/current/agent/workspace" \ + --candidates "$CTVA_ARTIFACT_DIR/candidates.jsonl" \ + --output "$CTVA_ARTIFACT_DIR/task-source.jsonl" +``` + +During Task reconstruction, read only `task-source.jsonl`. Do not inspect +`candidates.jsonl`, trace evidence, Reads, receipts, choices, or any anticipated +Effect. Write one schema-v4 `task-inventory-draft.jsonl` row for every clear +Task or excluded candidate, then freeze it: + +```bash +python3 "$CTVA_SKILL_DIR/scripts/context_tree_value_audit.py" freeze-tasks \ + --artifact-root "$CTVA_ARTIFACT_DIR" \ + --agent-workspace "AGENT_UUID=/absolute/current/agent/workspace" \ + --task-source "$CTVA_ARTIFACT_DIR/task-source.jsonl" \ + --task-inventory-draft "$CTVA_ARTIFACT_DIR/task-inventory-draft.jsonl" \ + --task-inventory-output "$CTVA_ARTIFACT_DIR/task-inventory.jsonl" +``` A clear Task is one independently judgeable continuous work episode owned by -the audited Agent. It needs a concrete objective, material object scope, -independently judgeable outcome or terminal state, bounded source fragments -from objective through outcome, explicit ownership/objective/outcome anchors, -and one primary terminal deliverable. Otherwise mark the candidate excluded -with a structured exclusion kind. Excluded candidates carry no episode, Read, -or Effect judgment. +the audited Agent. It needs a concrete objective, independently judgeable +outcome, bounded source fragments, and explicit objective/outcome sources. +Material scope and primary deliverable help clarify boundaries but are +optional. Otherwise mark the candidate excluded with a structured exclusion +kind. Short continuations, status prompts, context-dependent questions, merge approval, repeated review/fix requests, and ordinary phase transitions are not @@ -180,15 +207,21 @@ plus corrections to the same deliverable, into one episode. Split only when there is a new objective, material scope or deliverable change, independent outcome, and unambiguous source boundary. -For a single-Agent audit, another Agent's work is context until this Agent is -visibly assigned, transferred, or accepts an objective. A later independent -review, takeover, verification gate, or orchestration objective may form a new -owned episode only when it passes every clear-Task gate. +For a single-Agent audit, another Agent's work is context until this Agent +receives or visibly accepts an objective. A later independent review, takeover, +verification gate, or orchestration objective may form a new episode only when +it passes every clear-Task gate. +Judge that acceptance from the work messages themselves. The reporter checks +source identity, ordering, and a same-Agent outcome, but does not infer who +arbitrary prose addresses. One Chat may contain multiple Tasks. Merge across Chats only for one PR/MR/ Issue, a visible handoff, or the same objective and primary delivery, and -record the explicit shared linkage. Do not copy one read or choice into -different Tasks. +record the explicit shared linkage. + +After freeze, do not edit Task rows or boundaries. Write exactly one +schema-v4 `read-attributions.jsonl` row for every clear Task. Each row carries +the frozen `inventory_sha256`. Read is only: @@ -199,8 +232,10 @@ Read is only: Do not invent `not_observed`. Missing telemetry and receipt absence are unknown, not proof of non-use. -Effect is optional and has exactly one type: `confirmed`, `constrained`, -`redirected`, or `conflicted`. Record it only when all four conditions hold: +Then write exactly one schema-v4 `effect-judgments.jsonl` row for every clear +Task, carrying the same inventory digest. Each Task has zero or more Effects; +each Effect has exactly one type: `confirmed`, `constrained`, `redirected`, or +`conflicted`. Record one only when all four conditions hold: 1. a real Read contains a relevant normal Tree decision or constraint; 2. the Read completes before the cited choice; @@ -209,11 +244,13 @@ Effect is optional and has exactly one type: `confirmed`, `constrained`, 4. no more direct user instruction or other evidence fully explains the result. -Every Effect needs Task-window Read IDs, later same-Agent choice message IDs, -an outcome anchor, and a concise summary. If the evidence is insufficient, set -Effect to null and record one short reason. Do not add confidence tiers, -support levels, numeric weights, `verified`, or `probable`. A decision receipt -may support the judgment but cannot create an Effect by itself. +Every Effect needs attributed Read IDs, later same-Agent choice message IDs, +a same-Agent outcome message, and a concise summary. The same Read may support +multiple distinct choices, but one choice cannot be reused across Effects. If +the evidence is insufficient, use an empty Effect list and record one short +reason. Do not add confidence tiers, support levels, numeric weights, +`verified`, or `probable`. A decision receipt may support the judgment but +cannot create an Effect by itself. Report every available Task in the authorized acquisition bound. There is no minimum Task quota, task-type coverage gate, batch-expansion rule, or saturation @@ -227,31 +264,33 @@ python3 "$CTVA_SKILL_DIR/scripts/context_tree_value_audit.py" report \ --artifact-root "$CTVA_ARTIFACT_DIR" \ --agent-workspace "AGENT_UUID=/absolute/current/agent/workspace" \ --candidates "$CTVA_ARTIFACT_DIR/candidates.jsonl" \ - --task-judgments "$CTVA_ARTIFACT_DIR/task-judgments.jsonl" \ + --task-inventory "$CTVA_ARTIFACT_DIR/task-inventory.jsonl" \ + --read-attributions "$CTVA_ARTIFACT_DIR/read-attributions.jsonl" \ + --effect-judgments "$CTVA_ARTIFACT_DIR/effect-judgments.jsonl" \ --evidence-output "$CTVA_ARTIFACT_DIR/evidence.jsonl" \ --report-output "$CTVA_ARTIFACT_DIR/REPORT.md" ``` -Optionally supply a v3 `--reviewed-baseline` when an independently reviewed +Optionally supply a v4 `--reviewed-baseline` when an independently reviewed earlier case set exists. The reporter keeps its hash-anchored Task and Effect counts separate from the current rerun. -The deterministic reporter rejects Task judgment schemas v1 and v2, weak -fragment-only -objectives, missing or invalid episode ownership and anchors, reused episode -identity anchors, Reads or choices outside the established episode, -unauthorized source messages, Task-window violations, unlinked cross-Chat -merges, duplicated Reads/choices, invalid Effects, unbound outcome anchors, -v0.2 judgment fields, and non-conserving aggregates. +The deterministic pipeline rejects old combined judgment schemas, Task drafts +that contain Read/Effect fields, weak fragment-only objectives, missing or +invalid objective/outcome sources, reused identity sources, changed inventory +digests, Reads or choices outside the frozen Task, unauthorized source +messages, Task-window violations, unlinked cross-Chat merges, duplicated Reads +across Tasks, reused choices across Effects, invalid Effects, invalid outcome +messages, superseded judgment fields, and non-conserving aggregates. The report must include: -- a complete inventory of clear Tasks with boundary rationale and excluded - candidates with structured exclusion reasons; +- a complete frozen inventory of clear Tasks and excluded candidates with + structured exclusion reasons; - observed and unresolved Read Tasks; -- Effect Tasks and observed Reads without an Effect; -- the four-effect distribution; -- every clear Task's Read and Effect result; +- Effect Tasks, total Effects, and observed Reads without an Effect; +- the four-Effect distribution over total Effects; +- every clear Task's Read and zero-or-more Effect results; - every excluded Task's reason; - authorized Chat, message, trace, and coverage-gap counts; - the four-class in-window Tree-read attempt conservation table; diff --git a/skills/context-tree-value-audit/VERSION b/skills/context-tree-value-audit/VERSION index 1d0ba9e..8f0916f 100644 --- a/skills/context-tree-value-audit/VERSION +++ b/skills/context-tree-value-audit/VERSION @@ -1 +1 @@ -0.4.0 +0.5.0 diff --git a/skills/context-tree-value-audit/agents/openai.yaml b/skills/context-tree-value-audit/agents/openai.yaml index aa4b0cc..c43ae9f 100644 --- a/skills/context-tree-value-audit/agents/openai.yaml +++ b/skills/context-tree-value-audit/agents/openai.yaml @@ -1,6 +1,6 @@ interface: display_name: "Context Tree Value Audit" - short_description: "Audit continuous Task, Read, and Effect evidence" - default_prompt: "Use $context-tree-value-audit to audit how Context Tree reads affected single-Agent-owned continuous Tasks, using the available authorized sample." + short_description: "Audit frozen Task, Read, and Effect evidence" + default_prompt: "Use $context-tree-value-audit to reconstruct and freeze Tasks before attributing Context Tree Reads and zero-or-more Effects, using the available authorized sample." policy: allow_implicit_invocation: false diff --git a/skills/context-tree-value-audit/references/evidence-schema.md b/skills/context-tree-value-audit/references/evidence-schema.md index 6ab864d..3fa38dc 100644 --- a/skills/context-tree-value-audit/references/evidence-schema.md +++ b/skills/context-tree-value-audit/references/evidence-schema.md @@ -231,9 +231,15 @@ contaminate the current run's gaps. A call that starts in-window but whose result completes after the acquisition end remains one `unresolved_opaque` attempt; it does not disappear from the denominator. -`visible_messages` supports Task reconstruction. `visible_choice_candidates` -contains only later visible messages authored by the audited Agent; human or -other-Agent messages cannot establish that Agent's effect. +`visible_messages` is the source for the later message-only `task-source` +projection. That projection keeps only message identity, time, sender, and +unchanged content; it removes decision receipts and every collector-derived +Tree-mention, Read, choice, and Effect projection before Task reconstruction. +Literal Tree/Read/Effect discussion inside a work message remains part of that +message and cannot by itself establish that a Read or Effect occurred. +`visible_choice_candidates` is used only after the Task inventory is frozen and +contains visible messages authored by the audited Agent; human or other-Agent +messages cannot establish that Agent's Effect. `outside_candidate_set` means collection found neither a successful qualifying Tree read nor a visible Tree-influence signal. It is not evidence of no diff --git a/skills/context-tree-value-audit/references/task-analysis-schema.md b/skills/context-tree-value-audit/references/task-analysis-schema.md index 2bb1238..5ae8b2f 100644 --- a/skills/context-tree-value-audit/references/task-analysis-schema.md +++ b/skills/context-tree-value-audit/references/task-analysis-schema.md @@ -1,21 +1,38 @@ # Task, Read, and Effect Schema -Use this reference after `collect` produces `candidates.jsonl`. Task is the -judgment and counting unit. `Chat UUID @ Agent UUID` remains only the -authorization, trace-mapping, and evidence-source unit. +The audit uses three ordered stages. Task reconstruction is completed and +frozen before Tree Reads are visible to the analyst. Read attribution then +uses that frozen inventory. Effect analysis runs last and may not change either +earlier artifact. -Write exactly one schema-v3 `task-judgments.jsonl` row for every reconstructed -Task or excluded candidate. +`Chat UUID @ Agent UUID` remains the authorization, trace-mapping, and source +unit. Task is the work unit. Effect is the independently counted value unit. -## Clear Task +## Stage 1: Task reconstruction + +Run `task-source` after collection. It projects only authorized work messages: +no collector-derived Reads, passages, Tree-mention indexes, decision receipts, +choice projections, or Effect judgments are present. Original message content +is unchanged and may literally discuss Tree, Read, or Effect when that +discussion is part of the work. Reconstruct Tasks only from this message-only +artifact and do not use those literal terms as evidence that a Read or Effect +occurred. + +A Task is one continuous work episode in which the audited Agent accepted a +concrete objective and produced an independently judgeable outcome. Scope and +primary deliverable may clarify the boundary but are optional. + +Write one schema-v4 `task-inventory-draft.jsonl` row for every clear Task or +excluded candidate: ```json { - "schema_version": 3, + "schema_version": 4, "task_id": "stable-local-task-id", "status": "clear", "objective": "Choose the state authority", "object_scope": "state persistence", + "primary_deliverable": "A decision selecting one state source", "outcome": "Kept the existing state source", "started_at": "RFC3339", "ended_at": "RFC3339", @@ -23,103 +40,39 @@ Task or excluded candidate. { "audit_id": "CHAT_UUID@AGENT_UUID", "message_ids": [ - "assignment-message-id", - "continue-message-id", + "objective-message-id", + "continuation-message-id", "outcome-message-id" ] } ], - "episode": { - "ownership": { - "kind": "assigned", - "anchor_message_ids": ["assignment-message-id"], - "reason": "A human assigned this objective to the audited Agent." - }, - "objective_anchor_message_ids": ["assignment-message-id"], - "outcome_anchor_message_ids": ["outcome-message-id"], - "continuation_message_ids": ["continue-message-id"], - "primary_deliverable": "A decision selecting the authoritative state source.", - "boundary_reason": "The assignment and final decision bound one continuous objective." - }, - "read": { - "status": "observed", - "read_ids": ["read-id"], - "reason": null - }, - "effect": { - "type": "constrained", - "read_ids": ["read-id"], - "choice_message_ids": ["outcome-message-id"], - "outcome_anchor": "outcome-message-id", - "summary": "The constraint ruled out a second state source." - }, - "effect_reason": null + "objective_source_message_ids": ["objective-message-id"], + "outcome_source_message_ids": ["outcome-message-id"] } ``` -A clear Task is one independently judgeable, continuous work episode owned by -the audited Agent. It requires all six gates: - -1. ownership established by an assignment, transfer, or visible acceptance; -2. a concrete normalized objective; -3. a material object scope; -4. an independently judgeable outcome or terminal state; -5. bounded source fragments from objective through outcome; -6. one primary terminal deliverable. - -The `episode` object makes those gates auditable. Ownership must be -`assigned`, `transferred`, or `accepted`. `assigned` and `transferred` need a -non-current-Agent ownership and objective anchor; `accepted` needs a -current-Agent ownership and objective anchor. At least one concrete objective -anchor must come from that ownership-compatible sender: non-current for -`assigned`/`transferred`, current for `accepted`. A weak assignment plus another -sender's later concrete message cannot be combined into a clear objective. -Synthesized judgment prose cannot turn a weak prompt into one. Every Task needs -one or more distinct, strictly later outcome anchors, and every outcome anchor -must be a non-empty current-Agent message. Every episode anchor must be one of -the Task's authorized source messages. Ownership and objective may use the same -handoff message; outcome and continuation anchors must remain separate from the -ownership/objective identity anchors. The Read/choice evidence window begins -only after the earliest ownership-kind-compatible ownership anchor and the -earliest ownership-compatible, non-weak objective anchor are both established; -earlier incompatible-sender ownership or weak objective anchors cannot move -that window backward. - -A short continuation, status prompt, or context-dependent question is not a -clear Task by itself. Examples include `continue`, `status`, `why`, `继续`, -`做了吗`, `你在干啥`, `进展呢`, `地址呢`, `为什么`, `什么意思`, -`你这个修复什么`, `那这个呢`, `再检查`, `修一下`, and `重新看`. -Polite or modal wrappers do not make those fragments concrete: -`please continue`, `status please`, `请继续`, and `修一下吧` remain weak. -The anchored context-dependent command set also keeps deictic variants such as -`please continue fixing it`, `please fix it`, `continue the work`, -`继续修一下`, `修复一下`, `修这个`, and `帮忙修下` weak. -Edge-only Unicode punctuation/symbol decoration and closed high-frequency -modifiers do not change that result, so `“please continue”`, -`please just continue`, `请继续吧~`, and `请继续(谢谢)` remain weak. -One or more leading First Tree Agent mentions use the exact slug grammar and -stop before punctuation or adjacent prose: `@agent-one @agent-two,请继续` -remains weak, while -`@agent-one @agent-two,请继续完成状态源方案并交付独立决定` remains -concrete. Strip only those closed decorations and wrappers; a message such as -`Please continue the state-source design and deliver the authority decision` -remains concrete because the residual text names an objective and deliverable. -Merge a weak fragment into its parent episode when that parent is visible; -otherwise exclude it. Never invent the missing objective from work performed by -another Agent. - -Treat one complete objective-to-outcome work item as one Task. Keep planning, -implementation, review, QA, corrections, merge approval, status questions, -and short continuations for the same deliverable in that Task. Split only when -there is a new objective, materially different scope or deliverable, an -independent outcome, and an unambiguous source boundary. - -## Excluded Task +`objective`, `outcome`, source fragments, and objective/outcome message IDs are +required. `object_scope` and `primary_deliverable` may be null. `started_at` +must equal the earliest objective source; `ended_at` must equal the latest +outcome source. Outcome sources must be non-empty messages authored by the +audited Agent and must strictly follow all objective sources. + +The Task inventory must not contain `episode`, ownership categories, +continuation IDs, boundary rationale, Read, Effect, confidence, support, +sampling, or task-type fields. Assignment, transfer, and visible acceptance are +all expressed by the objective source messages rather than a separate +ownership taxonomy. +The analyst is responsible for judging from those work messages that the +audited Agent received or accepted the objective. The reporter validates source +identity, ordering, and the audited Agent's outcome; it does not infer the +semantic addressee of arbitrary message prose. + +Use an excluded row when a defensible Task cannot be reconstructed: ```json { - "schema_version": 3, - "task_id": "stable-local-task-id", + "schema_version": 4, + "task_id": "stable-local-candidate-id", "status": "excluded", "objective": null, "object_scope": "unclear scope", @@ -137,61 +90,49 @@ independent outcome, and an unambiguous source boundary. } ``` -Excluded candidates do not contain `episode`, `read`, `effect`, or -`effect_reason`. They must contain one structured `exclusion_kind`: +Allowed exclusion kinds: - `greeting_or_acknowledgement`; - `status_ping_or_continuation`; - `context_dependent_clarification`; - `missing_objective`; -- `missing_scope`; - `missing_outcome`; - `ownership_not_established`; - `automatic_or_provider_only`; - `ambiguous_boundary`; - `non_independent_subphase`. -When more than one label could apply, use this precedence so reruns converge: +Apply this precedence when more than one fits: -1. form-only exclusions: `automatic_or_provider_only`, - `greeting_or_acknowledgement`, `status_ping_or_continuation`, - `context_dependent_clarification`, `non_independent_subphase`; +1. form-only exclusions; 2. `ownership_not_established`; 3. `missing_objective`; -4. `missing_scope`; -5. `missing_outcome`; -6. `ambiguous_boundary`. - -An excluded row is an observed candidate, not a Task. Keep any partially known -objective/scope/outcome fields honest, and use `exclusion_reason` to identify -other passed or failed gates; do not add a partial `episode` object. - -## Task reconstruction +4. `missing_outcome`; +5. `ambiguous_boundary`. -One Chat may contain multiple Tasks, but a new message or phase does not create -a new Task. Merge: +An excluded row is an observed candidate, not a Task. It contains no clear-Task +sources, primary deliverable, Read, or Effect analysis. -- short continuation, status, clarification, review-again, fix-again, or merge - messages into the active parent episode; -- plan → implementation → review → QA → final delivery for one objective and - primary deliverable; -- corrections and revisions to that same deliverable. +### Task boundaries -Split only when all four are present: a new objective, a material scope or -deliverable change, an independently judgeable outcome, and an unambiguous -source boundary. Keep the resulting source messages, windows, Reads, and -choices separate. +Keep planning, implementation, review, QA, corrections, merge approval, status +questions, and short continuations inside one Task when they serve the same +objective and outcome. Split only when a new objective, material scope or +deliverable change, independent outcome, and unambiguous source boundary are +all present. -For a single-Agent audit, work assigned to another Agent is Chat context, not -this Agent's Task. Start this Agent's Task only at a visible assignment, -transfer, or acceptance. A later independent review, takeover, verification -gate, or orchestration objective may form a new owned episode when it passes -all six gates. An ordinary status check always stays in its parent episode. +Weak fragments such as `continue`, `status`, `why`, `please continue`, +`please fix it`, `继续`, `做了吗`, `修一下`, and mention-decorated equivalents +cannot establish a Task objective by themselves. Merge them into a visible +parent episode or exclude them. A concrete message that contains `continue` +but names its objective and outcome remains eligible. -## Cross-Chat Task +For a single-Agent audit, another Agent's work is context until the audited +Agent receives or visibly accepts an objective. Another Agent's later message +cannot serve as this Agent's outcome. -Merge fragments from more than one Chat only when every fragment carries the -same explicit linkage: +Merge fragments across Chats only when every fragment carries the same +explicit linkage: ```json { @@ -202,81 +143,111 @@ same explicit linkage: } ``` -Allowed linkage kinds are: +Allowed kinds are `work_item`, `explicit_handoff`, and +`same_objective_delivery`. Topical similarity alone is insufficient. -- `work_item` for one PR, MR, or Issue; -- `explicit_handoff` for a visible handoff; -- `same_objective_delivery` for the same objective and primary delivery. +### Freeze -The reporter rejects an unlinked cross-Chat Task. A single-Chat Task must not -claim cross-Chat linkage. Every source message must belong to the referenced -authorized Chat-Agent evidence row and fall inside the Task and acquisition -windows. +`freeze-tasks` validates the draft against the message-only Task source, +normalizes row order, computes one SHA-256 digest, and writes +`task-inventory.jsonl`. Every frozen row carries the same +`inventory_sha256`. -## Read +The digest is an internal integrity fence, not a user-facing evidence concept. +If Task reconstruction changes, create a new frozen inventory and rerun Read +and Effect analysis. Never edit the frozen inventory during later stages. -Read has only two states: +## Stage 2: Read attribution -- `observed` — one or more attributable Task-window `read_ids` exist; -- `unresolved` — historical evidence cannot resolve the Read; include a short - `reason`, keep `read_ids` empty, and keep Effect null. +Write exactly one schema-v4 `read-attributions.jsonl` row for every clear Task: + +```json +{ + "schema_version": 4, + "inventory_sha256": "64-lowercase-hex", + "task_id": "stable-local-task-id", + "status": "observed", + "read_ids": ["read-id"], + "reason": null +} +``` -Do not use `confirmed`, `not_observed`, `unused`, or a negative-value state. -Missing telemetry and an absent decision receipt are unknown. An unresolved -Read is never evidence that the Tree was not read or had no value. +Read has two states: -Every observed Read must belong to a source Chat, start and complete inside the -Task window, occur no earlier than established episode ownership/objective and -no later than the episode outcome, and be assigned to only one Task. +- `observed` — one or more attributable Task-window Read IDs exist; +- `unresolved` — historical evidence cannot resolve the Read; `read_ids` is + empty and `reason` explains the gap. -Collector command classification is not a Read by itself. For -`read_only_composite` or `output_attribution: aggregate`, inspect the recorded -passage and component paths. If actual Tree content is not attributable, keep -the Read unresolved. +Zero attributed Reads are represented as unresolved unless the available +historical evidence can support a stronger interpretation. Do not emit +`not_observed`, `unused`, or another negative-value state. Missing telemetry +and receipt absence remain unknown. -## Effect +Every observed Read must belong to one source Chat, start and complete inside +the frozen Task window, and be attributed to only one Task. Read attribution +cannot create, delete, merge, split, or resize a Task. -Effect is either null or one object whose `type` is: +Collector command classification is not a Read by itself. For aggregate or +read-only-composite evidence, inspect the retained passage and component paths. +If actual Tree content cannot be attributed, keep the Task unresolved. -- `confirmed` — removed material uncertainty and justified keeping the choice; +## Stage 3: Effect analysis + +Write exactly one schema-v4 `effect-judgments.jsonl` row for every clear Task: + +```json +{ + "schema_version": 4, + "inventory_sha256": "64-lowercase-hex", + "task_id": "stable-local-task-id", + "effects": [ + { + "type": "constrained", + "read_ids": ["read-id"], + "choice_message_ids": ["choice-message-id"], + "outcome_message_id": "outcome-message-id", + "summary": "The constraint ruled out a second state source." + } + ], + "effect_reason": null +} +``` + +A Task has zero or more Effects. Each Effect independently binds supporting +Reads, one or more later same-Agent choices, a same-Agent outcome message, and +one summary. Valid types are: + +- `confirmed` — removed material uncertainty and justified keeping a choice; - `constrained` — ruled out an option or narrowed the acceptable boundary; - `redirected` — changed the intended approach; - `conflicted` — exposed a conflict that still required resolution. -Record an Effect only when all four conditions hold: +Record an Effect only when: -1. a real Read contains a relevant normal Tree decision, constraint, rationale, - or cross-domain relationship; +1. a real Read contains relevant normal Tree content; 2. every cited Read completes before the earliest cited choice; -3. the later same-Agent choice or outcome reasonably shows one of the four - effects; -4. no more direct user instruction or other evidence fully explains the - result. - -Every Effect requires observed Read IDs, later same-Agent choice message IDs, a -non-empty outcome anchor equal to one of the episode outcome message IDs, and -one concrete summary sentence. Effect choices must be authorized Task source -messages inside the established episode. The selected Effect outcome anchor -must be no earlier than every cited Read completion and cited choice. - -Ownership, objective, and outcome identity anchors cannot be copied across -different clear Tasks. One message that appears to bundle multiple objectives -does not provide an unambiguous split boundary; merge or exclude unless -separate source anchors establish the episodes. The same Read or choice cannot -be copied across Tasks. - -If those conditions are not met, set `"effect": null` and include one short -`effect_reason`. Do not add `verified`, `probable`, confidence tiers, support -levels, numeric weights, or multiple Effects. A `contextDecision` receipt may -support the judgment but cannot create an Effect by itself. +3. the later same-Agent choice or outcome reasonably shows the Effect; +4. no more direct user instruction or other evidence fully explains it. + +The same Read may support multiple distinct choices and therefore multiple +Effects. One choice message cannot be reused across Effects. Outcome messages +may be shared when distinct choices converge on one later result. Effects may +bind intermediate same-Agent outcomes inside the Task; they do not have to use +the Task's terminal outcome source. + +When no Effect is defensible, set `"effects": []` and include one short +`effect_reason`. An unresolved Read must have no Effects. Do not add +`verified`, `probable`, confidence tiers, support levels, rubrics, or numeric +weights. A `contextDecision` receipt may support judgment but cannot create an +Effect by itself. ## Separately reviewed historical baseline -An optional `reviewed-baseline.jsonl` contains one schema-v3 aggregate: +An optional schema-v4 baseline remains separate from the current rerun: ```json { - "schema_version": 3, + "schema_version": 4, "basis": "separately_reviewed_task_cases", "reviewed_at": "RFC3339", "evidence_anchor": { @@ -285,37 +256,30 @@ An optional `reviewed-baseline.jsonl` contains one schema-v3 aggregate: }, "clear_tasks": 44, "effect_tasks": 16, + "effects": 19, "effect_counts": { - "confirmed": 2, - "constrained": 8, - "redirected": 5, + "confirmed": 3, + "constrained": 9, + "redirected": 6, "conflicted": 1 } } ``` -The Effect counts must conserve `effect_tasks`. The reporter keeps the -baseline separate from the current rerun. +Effect counts conserve `effects`; `effect_tasks` cannot exceed clear Tasks or +total Effects. ## Reporting -Report every available clear Task and excluded candidate in the authorized -acquisition bound. There is no minimum Task quota, task-type coverage gate, -batch expansion, or saturation status. - -Include: - -- one complete clear Task inventory with ownership, objective, scope, primary - deliverable, outcome, Read, Effect, and evidence summary; -- each clear Task's ownership/objective/outcome anchors and boundary rationale; -- one complete excluded-candidate inventory with structured exclusion kind, - observed scope, and reason. +Report every available Task in the authorized acquisition bound. There is no +minimum Task quota, task-type gate, batch expansion, or saturation status. -The report must conserve: +The report conserves: - observed Read Tasks + unresolved Read Tasks = clear Tasks; -- Effect Tasks + observed Reads without an Effect = observed Read Tasks; -- the four Effect counts = Effect Tasks. +- Effect Tasks + observed Read without Effect = observed Read Tasks; +- the four Effect counts = total Effects; +- total Effects is greater than or equal to Effect Tasks. -Always state the sample size and evidence gaps. Do not output a global -effectiveness rate, causal claim, or ROI. +Always show both Effect Tasks and total Effects. State sample size and evidence +gaps. Never output a global effectiveness rate, causal claim, or ROI. diff --git a/skills/context-tree-value-audit/scripts/context_tree_value_audit.py b/skills/context-tree-value-audit/scripts/context_tree_value_audit.py index 2417db4..577933d 100755 --- a/skills/context-tree-value-audit/scripts/context_tree_value_audit.py +++ b/skills/context-tree-value-audit/scripts/context_tree_value_audit.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 -"""Build task-first Context Tree audits from First Tree Chats and local runtime evidence. +"""Build three-stage Context Tree audits from First Tree Chats and runtime evidence. Collection remains deliberately conservative and read-only. Semantic value is -judged at Task level after authorized Chat evidence has been collected. +judged only after a message-only Task inventory has been frozen. """ from __future__ import annotations @@ -29,18 +29,17 @@ from urllib.parse import urlsplit SCHEMA_VERSION = 1 -TASK_JUDGMENT_SCHEMA_VERSION = 3 +TASK_INVENTORY_SCHEMA_VERSION = 4 +TASK_ANALYSIS_SCHEMA_VERSION = 4 AUTHORIZATION_VALUES = {"explicit_agent", "explicit_chat"} EFFECT_VALUES = {"confirmed", "constrained", "redirected", "conflicted"} READ_STATUS_VALUES = {"observed", "unresolved"} TASK_STATUS_VALUES = {"clear", "excluded"} -TASK_OWNERSHIP_VALUES = {"assigned", "transferred", "accepted"} TASK_EXCLUSION_VALUES = { "greeting_or_acknowledgement", "status_ping_or_continuation", "context_dependent_clarification", "missing_objective", - "missing_scope", "missing_outcome", "ownership_not_established", "automatic_or_provider_only", @@ -5189,6 +5188,19 @@ def optional_text(value: Any, *, field: str) -> str | None: return value.strip() or None +def require_exact_keys( + value: Mapping[str, Any], + allowed: set[str], + *, + field: str, +) -> None: + unexpected = sorted(set(value) - allowed) + if unexpected: + raise AuditError( + f"{field} contains unsupported field(s): {', '.join(unexpected)}." + ) + + def strip_edge_decorations(value: str, *, preserve_at: bool) -> str: result = value.strip() while result: @@ -5296,78 +5308,29 @@ def is_weak_task_fragment(value: str) -> bool: ) -def validate_task_episode(value: Any, *, task_id: str) -> dict[str, Any]: - field = f"task[{task_id}].episode" - if not isinstance(value, dict): - raise AuditError(f"{field} must be an object.") - ownership = value.get("ownership") - if not isinstance(ownership, dict): - raise AuditError(f"{field}.ownership must be an object.") - ownership_kind = require_string( - ownership.get("kind"), f"{field}.ownership.kind" +def validate_task_sources(value: Mapping[str, Any], *, task_id: str) -> dict[str, Any]: + objective_source_ids = string_id_list( + value.get("objective_source_message_ids"), + field=f"task[{task_id}].objective_source_message_ids", ) - if ownership_kind not in TASK_OWNERSHIP_VALUES: - raise AuditError( - f"{field}.ownership.kind must be one of: " - f"{', '.join(sorted(TASK_OWNERSHIP_VALUES))}." - ) - ownership_anchor_ids = string_id_list( - ownership.get("anchor_message_ids"), - field=f"{field}.ownership.anchor_message_ids", - ) - objective_anchor_ids = string_id_list( - value.get("objective_anchor_message_ids"), - field=f"{field}.objective_anchor_message_ids", - ) - outcome_anchor_ids = string_id_list( - value.get("outcome_anchor_message_ids"), - field=f"{field}.outcome_anchor_message_ids", - ) - continuation_ids = string_id_list( - value.get("continuation_message_ids", []), - field=f"{field}.continuation_message_ids", + outcome_source_ids = string_id_list( + value.get("outcome_source_message_ids"), + field=f"task[{task_id}].outcome_source_message_ids", ) - if ( - not ownership_anchor_ids - or not objective_anchor_ids - or not outcome_anchor_ids - ): - raise AuditError( - f"{field} requires ownership, objective, and outcome anchor IDs." - ) - ownership_anchor_set = set(ownership_anchor_ids) - objective_anchor_set = set(objective_anchor_ids) - outcome_anchor_set = set(outcome_anchor_ids) - if outcome_anchor_set & ( - ownership_anchor_set | objective_anchor_set - ): + if not objective_source_ids or not outcome_source_ids: raise AuditError( - f"{field}.outcome_anchor_message_ids must be separate from ownership " - "and objective anchors." + f"task[{task_id}] requires objective and outcome source message IDs." ) - if set(continuation_ids) & ( - ownership_anchor_set | objective_anchor_set | outcome_anchor_set - ): + if set(objective_source_ids) & set(outcome_source_ids): raise AuditError( - f"{field}.continuation_message_ids must be separate from ownership, " - "objective, and outcome anchors." + f"task[{task_id}] objective and outcome source messages must be separate." ) return { - "ownership": { - "kind": ownership_kind, - "anchor_message_ids": ownership_anchor_ids, - "reason": require_string( - ownership.get("reason"), f"{field}.ownership.reason" - ), - }, - "objective_anchor_message_ids": objective_anchor_ids, - "outcome_anchor_message_ids": outcome_anchor_ids, - "continuation_message_ids": continuation_ids, - "primary_deliverable": require_string( - value.get("primary_deliverable"), f"{field}.primary_deliverable" - ), - "boundary_reason": require_string( - value.get("boundary_reason"), f"{field}.boundary_reason" + "objective_source_message_ids": objective_source_ids, + "outcome_source_message_ids": outcome_source_ids, + "primary_deliverable": optional_text( + value.get("primary_deliverable"), + field=f"task[{task_id}].primary_deliverable", ), } @@ -5378,16 +5341,36 @@ def positive_int(value: Any, *, field: str) -> int: return value +def non_negative_int(value: Any, *, field: str) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise AuditError(f"{field} must be a non-negative integer.") + return value + + def load_reviewed_baseline(path: Path) -> dict[str, Any]: rows = list(iter_jsonl(path)) if len(rows) != 1: raise AuditError("--reviewed-baseline must contain exactly one JSONL row.") row = rows[0] - if row.get("schema_version") != TASK_JUDGMENT_SCHEMA_VERSION: + if row.get("schema_version") != TASK_ANALYSIS_SCHEMA_VERSION: raise AuditError( "Reviewed baseline must use schema_version " - f"{TASK_JUDGMENT_SCHEMA_VERSION}." + f"{TASK_ANALYSIS_SCHEMA_VERSION}." ) + require_exact_keys( + row, + { + "schema_version", + "basis", + "reviewed_at", + "evidence_anchor", + "clear_tasks", + "effect_tasks", + "effects", + "effect_counts", + }, + field="reviewed_baseline", + ) if row.get("basis") != "separately_reviewed_task_cases": raise AuditError( "Reviewed baseline basis must be separately_reviewed_task_cases." @@ -5395,6 +5378,11 @@ def load_reviewed_baseline(path: Path) -> dict[str, Any]: anchor = row.get("evidence_anchor") if not isinstance(anchor, dict): raise AuditError("Reviewed baseline requires evidence_anchor.") + require_exact_keys( + anchor, + {"artifact_id", "sha256"}, + field="reviewed_baseline.evidence_anchor", + ) artifact_id = require_string( anchor.get("artifact_id"), "reviewed_baseline.evidence_anchor.artifact_id", @@ -5420,7 +5408,7 @@ def load_reviewed_baseline(path: Path) -> dict[str, Any]: clear_tasks = positive_int( row.get("clear_tasks"), field="reviewed_baseline.clear_tasks" ) - effect_tasks = positive_int( + effect_tasks = non_negative_int( row.get("effect_tasks"), field="reviewed_baseline.effect_tasks" ) if effect_tasks > clear_tasks: @@ -5428,6 +5416,13 @@ def load_reviewed_baseline(path: Path) -> dict[str, Any]: "Reviewed baseline effect_tasks must not exceed clear_tasks." ) + effects = non_negative_int( + row.get("effects"), field="reviewed_baseline.effects" + ) + if effects < effect_tasks: + raise AuditError( + "Reviewed baseline effects must not be less than effect_tasks." + ) effect_counts = row.get("effect_counts") if not isinstance(effect_counts, dict) or set(effect_counts) != EFFECT_VALUES: raise AuditError( @@ -5441,9 +5436,9 @@ def load_reviewed_baseline(path: Path) -> dict[str, Any]: f"reviewed_baseline.effect_counts.{effect} must be a non-negative integer." ) normalized_effect_counts[effect] = value - if sum(normalized_effect_counts.values()) != effect_tasks: + if sum(normalized_effect_counts.values()) != effects: raise AuditError( - "Reviewed baseline effect_counts must conserve effect_tasks." + "Reviewed baseline effect_counts must conserve effects." ) return { "basis": "separately_reviewed_task_cases", @@ -5454,18 +5449,40 @@ def load_reviewed_baseline(path: Path) -> dict[str, Any]: }, "clear_tasks": clear_tasks, "effect_tasks": effect_tasks, + "effects": effects, "effect_counts": normalized_effect_counts, } -def load_task_judgments(path: Path) -> list[dict[str, Any]]: +def load_task_inventory( + path: Path, + *, + require_frozen: bool, +) -> tuple[list[dict[str, Any]], str | None]: tasks: list[dict[str, Any]] = [] task_ids: set[str] = set() + declared_digests: set[str] = set() for row in iter_jsonl(path): - if row.get("schema_version") != TASK_JUDGMENT_SCHEMA_VERSION: + if row.get("schema_version") != TASK_INVENTORY_SCHEMA_VERSION: raise AuditError( - "Every Task judgment must use schema_version " - f"{TASK_JUDGMENT_SCHEMA_VERSION}." + "Every Task inventory row must use schema_version " + f"{TASK_INVENTORY_SCHEMA_VERSION}." + ) + declared_digest = row.get("inventory_sha256") + if require_frozen: + if ( + not isinstance(declared_digest, str) + or re.fullmatch(r"[0-9a-f]{64}", declared_digest) is None + ): + raise AuditError( + "Every frozen Task inventory row requires one lowercase " + "inventory_sha256." + ) + declared_digests.add(declared_digest) + elif declared_digest is not None: + raise AuditError( + "Task inventory drafts must not contain inventory_sha256; " + "freeze-tasks creates it." ) task_id = require_string(row.get("task_id"), "task.task_id") if task_id in task_ids: @@ -5476,7 +5493,33 @@ def load_task_judgments(path: Path) -> list[dict[str, Any]]: raise AuditError( f"task[{task_id}].status must be one of: {', '.join(sorted(TASK_STATUS_VALUES))}." ) - normalized = dict(row) + common_fields = { + "schema_version", + "task_id", + "status", + "objective", + "object_scope", + "outcome", + "started_at", + "ended_at", + "source_fragments", + } + allowed_fields = ( + common_fields + | {"exclusion_kind", "exclusion_reason"} + if status == "excluded" + else common_fields + | { + "objective_source_message_ids", + "outcome_source_message_ids", + "primary_deliverable", + } + ) + if require_frozen: + allowed_fields.add("inventory_sha256") + normalized: dict[str, Any] = { + "schema_version": TASK_INVENTORY_SCHEMA_VERSION, + } normalized["task_id"] = task_id normalized["status"] = status normalized["objective"] = optional_text( @@ -5511,6 +5554,11 @@ def load_task_judgments(path: Path) -> list[dict[str, Any]]: field = f"task[{task_id}].source_fragments[{index}]" if not isinstance(fragment, dict): raise AuditError(f"{field} must be an object.") + require_exact_keys( + fragment, + {"audit_id", "message_ids", "linkage"}, + field=field, + ) current_audit_id = require_string(fragment.get("audit_id"), f"{field}.audit_id") message_ids = string_id_list( fragment.get("message_ids"), field=f"{field}.message_ids" @@ -5525,6 +5573,11 @@ def load_task_judgments(path: Path) -> list[dict[str, Any]]: if linkage is not None: if not isinstance(linkage, dict): raise AuditError(f"{field}.linkage must be an object.") + require_exact_keys( + linkage, + {"kind", "key"}, + field=f"{field}.linkage", + ) kind = require_string(linkage.get("kind"), f"{field}.linkage.kind") if kind not in LINKAGE_VALUES: raise AuditError( @@ -5542,23 +5595,42 @@ def load_task_judgments(path: Path) -> list[dict[str, Any]]: "sampling_order", "saturation_signals", "exposure", - "effects", "support", + "episode", + "read", + "reads", + "read_ids", + "read_attribution", + "effect", + "effects", + "effect_type", + "effect_judgment", + "effect_reason", + "decision_receipt", ): if removed_field in row: raise AuditError( - f"task[{task_id}].{removed_field} belongs to the " - "superseded v0.2 judgment model." + f"task[{task_id}].{removed_field} does not belong in the " + "pure Task inventory." ) + require_exact_keys( + row, + allowed_fields, + field=f"task[{task_id}] pure Task inventory", + ) if status == "excluded": if any( field in row - for field in ("episode", "read", "effect", "effect_reason") + for field in ( + "objective_source_message_ids", + "outcome_source_message_ids", + "primary_deliverable", + ) ): raise AuditError( - f"Excluded task[{task_id}] must not contain episode, read, " - "or effect judgment." + f"Excluded task[{task_id}] must not contain clear-Task sources " + "or a primary deliverable." ) exclusion_kind = require_string( row.get("exclusion_kind"), f"task[{task_id}].exclusion_kind" @@ -5575,12 +5647,9 @@ def load_task_judgments(path: Path) -> list[dict[str, Any]]: tasks.append(normalized) continue - if not all( - normalized[field] is not None - for field in ("objective", "object_scope", "outcome") - ): + if normalized["objective"] is None or normalized["outcome"] is None: raise AuditError( - f"Clear task[{task_id}] requires objective, object_scope, and outcome." + f"Clear task[{task_id}] requires objective and outcome." ) if "exclusion_kind" in row or "exclusion_reason" in row: raise AuditError( @@ -5593,71 +5662,223 @@ def load_task_judgments(path: Path) -> list[dict[str, Any]]: "prompt, or context-dependent fragment; merge it into its parent " "episode or exclude it." ) - normalized["episode"] = validate_task_episode( - row.get("episode"), task_id=task_id + normalized.update(validate_task_sources(row, task_id=task_id)) + tasks.append(normalized) + if not tasks: + raise AuditError("No Task inventory rows were provided.") + digest = task_inventory_digest(tasks) + if require_frozen: + if len(declared_digests) != 1: + raise AuditError( + "Frozen Task inventory rows must share exactly one inventory_sha256." + ) + declared_digest = next(iter(declared_digests)) + if declared_digest != digest: + raise AuditError( + "Frozen Task inventory digest does not match its Task rows." + ) + return tasks, digest + return tasks, None + + +def sorted_task_inventory( + tasks: Sequence[Mapping[str, Any]], +) -> list[dict[str, Any]]: + return [ + dict(task) + for task in sorted( + tasks, + key=lambda item: ( + item["status"] != "clear", + item["started_at"], + item["task_id"], + ), ) + ] - read = row.get("read") - if not isinstance(read, dict): - raise AuditError(f"Clear task[{task_id}] requires a read object.") - read_status = require_string( - read.get("status"), f"task[{task_id}].read.status" + +def task_inventory_digest(tasks: Sequence[Mapping[str, Any]]) -> str: + payload = "\n".join( + json.dumps( + task, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), ) - if read_status not in READ_STATUS_VALUES: + for task in sorted_task_inventory(tasks) + ) + return hashlib.sha256(f"{payload}\n".encode("utf-8")).hexdigest() + + +def frozen_task_inventory( + tasks: Sequence[Mapping[str, Any]], + digest: str, +) -> list[dict[str, Any]]: + return [ + {**task, "inventory_sha256": digest} + for task in sorted_task_inventory(tasks) + ] + + +def load_read_attributions( + path: Path, + *, + tasks: Sequence[Mapping[str, Any]], + inventory_sha256: str, +) -> dict[str, dict[str, Any]]: + clear_task_ids = { + task["task_id"] for task in tasks if task["status"] == "clear" + } + rows: dict[str, dict[str, Any]] = {} + for row in iter_jsonl(path): + if row.get("schema_version") != TASK_ANALYSIS_SCHEMA_VERSION: raise AuditError( - f"task[{task_id}].read.status must be observed or unresolved." + "Every Read attribution must use schema_version " + f"{TASK_ANALYSIS_SCHEMA_VERSION}." ) - observed_reads = string_id_list( - read.get("read_ids", []), - field=f"task[{task_id}].read.read_ids", + if row.get("inventory_sha256") != inventory_sha256: + raise AuditError( + "Every Read attribution must reference the frozen Task inventory." + ) + require_exact_keys( + row, + { + "schema_version", + "inventory_sha256", + "task_id", + "status", + "read_ids", + "reason", + }, + field="read_attribution", + ) + task_id = require_string(row.get("task_id"), "read_attribution.task_id") + if task_id not in clear_task_ids: + raise AuditError( + f"Read attribution references unknown clear Task {task_id}." + ) + if task_id in rows: + raise AuditError(f"Duplicate Read attribution for Task {task_id}.") + status = require_string( + row.get("status"), f"read_attribution[{task_id}].status" + ) + if status not in READ_STATUS_VALUES: + raise AuditError( + f"read_attribution[{task_id}].status must be observed or unresolved." + ) + read_ids = string_id_list( + row.get("read_ids", []), + field=f"read_attribution[{task_id}].read_ids", ) reason = optional_text( - read.get("reason"), field=f"task[{task_id}].read.reason" - ) - if read_status == "observed" and not observed_reads: - raise AuditError(f"Observed read for task[{task_id}] requires read_ids.") - if read_status == "observed" and reason is not None: - raise AuditError(f"Observed read for task[{task_id}] must not include a reason.") - if read_status == "unresolved" and reason is None: - raise AuditError(f"Unresolved read for task[{task_id}] requires a reason.") - if read_status == "unresolved" and observed_reads: + row.get("reason"), field=f"read_attribution[{task_id}].reason" + ) + if status == "observed" and not read_ids: raise AuditError( - f"Unresolved read for task[{task_id}] must not contain read_ids." + f"Observed Read attribution for Task {task_id} requires read_ids." ) - normalized["read"] = { - "status": read_status, - "read_ids": observed_reads, + if status == "observed" and reason is not None: + raise AuditError( + f"Observed Read attribution for Task {task_id} must not include a reason." + ) + if status == "unresolved" and reason is None: + raise AuditError( + f"Unresolved Read attribution for Task {task_id} requires a reason." + ) + if status == "unresolved" and read_ids: + raise AuditError( + f"Unresolved Read attribution for Task {task_id} must not contain read_ids." + ) + rows[task_id] = { + "status": status, + "read_ids": read_ids, "reason": reason, } + missing = clear_task_ids - set(rows) + if missing: + raise AuditError( + "Missing Read attribution for clear Task(s): " + + ", ".join(sorted(missing)) + + "." + ) + return rows - effect = row.get("effect") - effect_reason = optional_text( - row.get("effect_reason"), field=f"task[{task_id}].effect_reason" + +def load_effect_judgments( + path: Path, + *, + tasks: Sequence[Mapping[str, Any]], + reads_by_task: Mapping[str, Mapping[str, Any]], + inventory_sha256: str, +) -> dict[str, dict[str, Any]]: + clear_task_ids = { + task["task_id"] for task in tasks if task["status"] == "clear" + } + rows: dict[str, dict[str, Any]] = {} + for row in iter_jsonl(path): + if row.get("schema_version") != TASK_ANALYSIS_SCHEMA_VERSION: + raise AuditError( + "Every Effect judgment must use schema_version " + f"{TASK_ANALYSIS_SCHEMA_VERSION}." + ) + if row.get("inventory_sha256") != inventory_sha256: + raise AuditError( + "Every Effect judgment must reference the frozen Task inventory." + ) + require_exact_keys( + row, + { + "schema_version", + "inventory_sha256", + "task_id", + "effects", + "effect_reason", + }, + field="effect_judgment", ) - if effect is None: - if effect_reason is None: - raise AuditError( - f"task[{task_id}] without an effect requires effect_reason." - ) - normalized["effect"] = None - normalized["effect_reason"] = effect_reason - else: - field = f"task[{task_id}].effect" + task_id = require_string(row.get("task_id"), "effect_judgment.task_id") + if task_id not in clear_task_ids: + raise AuditError( + f"Effect judgment references unknown clear Task {task_id}." + ) + if task_id in rows: + raise AuditError(f"Duplicate Effect judgment for Task {task_id}.") + raw_effects = row.get("effects") + if not isinstance(raw_effects, list): + raise AuditError( + f"effect_judgment[{task_id}].effects must be an array." + ) + effects: list[dict[str, Any]] = [] + for index, effect in enumerate(raw_effects): + field = f"effect_judgment[{task_id}].effects[{index}]" if not isinstance(effect, dict): raise AuditError(f"{field} must be an object.") - if effect_reason is not None: - raise AuditError( - f"task[{task_id}] with an effect must not include effect_reason." - ) - for removed_field in ("original_judgment", "rubric", "support"): + for removed_field in ( + "original_judgment", + "rubric", + "support", + "confidence", + ): if removed_field in effect: raise AuditError( - f"{field}.{removed_field} belongs to the superseded v0.2 judgment model." + f"{field}.{removed_field} belongs to a superseded model." ) - effect_value = require_string(effect.get("type"), f"{field}.type") - if effect_value not in EFFECT_VALUES: + require_exact_keys( + effect, + { + "type", + "read_ids", + "choice_message_ids", + "outcome_message_id", + "summary", + }, + field=field, + ) + effect_type = require_string(effect.get("type"), f"{field}.type") + if effect_type not in EFFECT_VALUES: raise AuditError( - f"{field}.type must be one of: {', '.join(sorted(EFFECT_VALUES))}." + f"{field}.type must be one of: " + f"{', '.join(sorted(EFFECT_VALUES))}." ) effect_reads = string_id_list( effect.get("read_ids"), field=f"{field}.read_ids" @@ -5667,23 +5888,51 @@ def load_task_judgments(path: Path) -> list[dict[str, Any]]: field=f"{field}.choice_message_ids", ) if not effect_reads or not choice_ids: - raise AuditError(f"{field} requires read_ids and choice_message_ids.") - normalized["effect"] = { - "type": effect_value, - "read_ids": effect_reads, - "choice_message_ids": choice_ids, - "outcome_anchor": require_string( - effect.get("outcome_anchor"), f"{field}.outcome_anchor" - ), - "summary": require_string(effect.get("summary"), f"{field}.summary"), - } - normalized["effect_reason"] = None - if read_status == "unresolved" and normalized["effect"] is not None: + raise AuditError( + f"{field} requires read_ids and choice_message_ids." + ) + effects.append( + { + "type": effect_type, + "read_ids": effect_reads, + "choice_message_ids": choice_ids, + "outcome_message_id": require_string( + effect.get("outcome_message_id"), + f"{field}.outcome_message_id", + ), + "summary": require_string( + effect.get("summary"), f"{field}.summary" + ), + } + ) + effect_reason = optional_text( + row.get("effect_reason"), + field=f"effect_judgment[{task_id}].effect_reason", + ) + if effects and effect_reason is not None: raise AuditError( - f"Unresolved read for task[{task_id}] must not contain an effect." + f"Task {task_id} with Effects must not include effect_reason." ) - tasks.append(normalized) - return tasks + if not effects and effect_reason is None: + raise AuditError( + f"Task {task_id} without an Effect requires effect_reason." + ) + if reads_by_task[task_id]["status"] == "unresolved" and effects: + raise AuditError( + f"Unresolved Read attribution for Task {task_id} cannot have Effects." + ) + rows[task_id] = { + "effects": effects, + "effect_reason": effect_reason, + } + missing = clear_task_ids - set(rows) + if missing: + raise AuditError( + "Missing Effect judgment for clear Task(s): " + + ", ".join(sorted(missing)) + + "." + ) + return rows def timestamp_in_task(value: Any, *, start: datetime, end: datetime, field: str) -> datetime: @@ -5693,44 +5942,28 @@ def timestamp_in_task(value: Any, *, start: datetime, end: datetime, field: str) return timestamp -def validate_task_refs( +def validate_task_inventory_refs( candidates: Sequence[Mapping[str, Any]], tasks: Sequence[dict[str, Any]], -) -> None: +) -> dict[str, dict[str, Any]]: candidates_by_id = {candidate["audit_id"]: candidate for candidate in candidates} messages_by_audit: dict[str, dict[str, Mapping[str, Any]]] = {} - choices: dict[str, tuple[str, Mapping[str, Any]]] = {} - reads: dict[str, tuple[str, Mapping[str, Any]]] = {} for candidate in candidates: current_audit_id = candidate["audit_id"] visible_messages = candidate.get("visible_messages") if not isinstance(visible_messages, list): - visible_messages = [ - *candidate.get("visible_choice_candidates", []), - *candidate.get("visible_tree_mentions", []), - ] + raise AuditError( + f"Task source {current_audit_id} requires visible_messages." + ) message_index = { message.get("message_id"): message for message in visible_messages if isinstance(message, dict) and isinstance(message.get("message_id"), str) } messages_by_audit[current_audit_id] = message_index - for choice in candidate["visible_choice_candidates"]: - message_id = choice.get("message_id") - if isinstance(message_id, str): - if message_id in choices: - raise AuditError(f"Choice message ID {message_id} is not globally unique.") - choices[message_id] = (current_audit_id, choice) - for read in candidate["reads"]: - read_id = read.get("read_id") - if isinstance(read_id, str): - if read_id in reads: - raise AuditError(f"Read ID {read_id} is not globally unique.") - reads[read_id] = (current_audit_id, read) - read_owners: dict[str, str] = {} - choice_owners: dict[str, str] = {} - episode_anchor_owners: dict[str, str] = {} + identity_source_owners: dict[str, str] = {} + contexts: dict[str, dict[str, Any]] = {} for task in tasks: task_id = task["task_id"] start = parse_datetime(task["started_at"], field=f"task {task_id} started_at") @@ -5795,33 +6028,25 @@ def validate_task_refs( if task["status"] == "excluded": continue - episode = task["episode"] - identity_anchor_ids = { - *episode["ownership"]["anchor_message_ids"], - *episode["objective_anchor_message_ids"], - *episode["outcome_anchor_message_ids"], + objective_source_ids = task["objective_source_message_ids"] + outcome_source_ids = task["outcome_source_message_ids"] + identity_source_ids = { + *objective_source_ids, + *outcome_source_ids, } - for message_id in identity_anchor_ids: - previous = episode_anchor_owners.setdefault(message_id, task_id) + for message_id in identity_source_ids: + previous = identity_source_owners.setdefault(message_id, task_id) if previous != task_id: raise AuditError( - f"Episode anchor {message_id} is copied across incompatible " + f"Task identity source {message_id} is copied across incompatible " f"Tasks {previous} and {task_id}." ) - episode_message_ids = { - *identity_anchor_ids, - *episode["continuation_message_ids"], - } - if not episode_message_ids.issubset(source_message_ids): - unknown = ", ".join(sorted(episode_message_ids - source_message_ids)) + if not identity_source_ids.issubset(source_message_ids): + unknown = ", ".join(sorted(identity_source_ids - source_message_ids)) raise AuditError( - f"task[{task_id}].episode references messages outside its source " + f"task[{task_id}] identity sources reference messages outside its source " f"fragments: {unknown}." ) - episode_messages = { - message_id: source_messages[message_id] - for message_id in episode_message_ids - } source_agent_ids = { candidates_by_id[audit]["chat"]["source_agent_id"] for audit in source_audits @@ -5831,149 +6056,62 @@ def validate_task_refs( f"task[{task_id}] source fragments must belong to one audited Agent." ) source_agent_id = next(iter(source_agent_ids)) - objective_anchor_messages = { - message_id: episode_messages[message_id] - for message_id in episode["objective_anchor_message_ids"] - } - objective_anchor_senders = { - message.get("sender_id") - for message in objective_anchor_messages.values() + objective_source_messages = { + message_id: source_messages[message_id] + for message_id in objective_source_ids } - if episode["ownership"]["kind"] == "accepted": - compatible_ownership_anchor_ids = [ - message_id - for message_id in episode["ownership"]["anchor_message_ids"] - if episode_messages[message_id].get("sender_id") - == source_agent_id - ] - if not compatible_ownership_anchor_ids: - raise AuditError( - f"Accepted ownership for task[{task_id}] requires a " - "current-Agent anchor." - ) - if source_agent_id not in objective_anchor_senders: - raise AuditError( - f"Accepted ownership for task[{task_id}] requires a " - "current-Agent objective anchor." - ) - concrete_compatible_objective_ids = [ - message_id - for message_id, message in objective_anchor_messages.items() - if message.get("sender_id") == source_agent_id - and isinstance(message.get("content"), str) - and not is_weak_task_fragment(message["content"]) - ] - else: - compatible_ownership_anchor_ids = [ - message_id - for message_id in episode["ownership"]["anchor_message_ids"] - if isinstance( - episode_messages[message_id].get("sender_id"), str - ) - and bool( - episode_messages[message_id]["sender_id"].strip() - ) - and episode_messages[message_id]["sender_id"] - != source_agent_id - ] - if not compatible_ownership_anchor_ids: - raise AuditError( - f"{episode['ownership']['kind'].title()} ownership for " - f"task[{task_id}] requires a non-current-Agent anchor." - ) - if not any( - isinstance(sender_id, str) - and bool(sender_id.strip()) - and sender_id != source_agent_id - for sender_id in objective_anchor_senders - ): - raise AuditError( - f"{episode['ownership']['kind'].title()} ownership for " - f"task[{task_id}] requires a non-current-Agent objective anchor." - ) - concrete_compatible_objective_ids = [ - message_id - for message_id, message in objective_anchor_messages.items() - if isinstance(message.get("sender_id"), str) - and bool(message["sender_id"].strip()) - and message["sender_id"] != source_agent_id - and isinstance(message.get("content"), str) - and not is_weak_task_fragment(message["content"]) - ] - if not concrete_compatible_objective_ids: + concrete_objective_ids = [ + message_id + for message_id, message in objective_source_messages.items() + if isinstance(message.get("sender_id"), str) + and bool(message["sender_id"].strip()) + and isinstance(message.get("content"), str) + and not is_weak_task_fragment(message["content"]) + ] + if not concrete_objective_ids: raise AuditError( - f"task[{task_id}] requires at least one ownership-compatible " - "concrete objective-anchor source message; weak continuations, " - "context-only prompts, and another sender's objective cannot be " + f"task[{task_id}] requires at least one concrete objective-source " + "message; weak continuations and context-only prompts cannot be " "normalized into a clear objective." ) - invalid_outcome_anchor_ids = [ + invalid_outcome_source_ids = [ message_id - for message_id in episode["outcome_anchor_message_ids"] - if episode_messages[message_id].get("sender_id") != source_agent_id + for message_id in outcome_source_ids + if source_messages[message_id].get("sender_id") != source_agent_id or not isinstance( - episode_messages[message_id].get("content"), str + source_messages[message_id].get("content"), str ) - or not episode_messages[message_id]["content"].strip() + or not source_messages[message_id]["content"].strip() ] - if invalid_outcome_anchor_ids: + if invalid_outcome_source_ids: raise AuditError( - f"task[{task_id}] requires every outcome anchor to be a " + f"task[{task_id}] requires every outcome source to be a " "non-empty current-Agent message." ) - ownership_times = [ + objective_source_times = [ parse_datetime( - episode_messages[message_id]["created_at"], - field=f"task {task_id} ownership anchor {message_id}", - ) - for message_id in episode["ownership"]["anchor_message_ids"] - ] - compatible_ownership_times = [ - parse_datetime( - episode_messages[message_id]["created_at"], - field=( - f"task {task_id} compatible ownership anchor " - f"{message_id}" - ), + source_messages[message_id]["created_at"], + field=f"task {task_id} objective source {message_id}", ) - for message_id in compatible_ownership_anchor_ids + for message_id in objective_source_ids ] - objective_anchor_times = [ + outcome_source_times = [ parse_datetime( - episode_messages[message_id]["created_at"], - field=f"task {task_id} objective anchor {message_id}", + source_messages[message_id]["created_at"], + field=f"task {task_id} outcome source {message_id}", ) - for message_id in episode["objective_anchor_message_ids"] + for message_id in outcome_source_ids ] - concrete_objective_times = [ - parse_datetime( - episode_messages[message_id]["created_at"], - field=( - f"task {task_id} concrete objective anchor " - f"{message_id}" - ), - ) - for message_id in concrete_compatible_objective_ids - ] - evidence_started_at = max( - min(compatible_ownership_times), min(concrete_objective_times) - ) - episode_started_at = min( - [*ownership_times, *objective_anchor_times] - ) - objective_times = [*ownership_times, *objective_anchor_times] - outcome_times = [ - parse_datetime( - episode_messages[message_id]["created_at"], - field=f"task {task_id} outcome anchor {message_id}", + episode_started_at = min(objective_source_times) + episode_ended_at = max(outcome_source_times) + if min(outcome_source_times) <= max(objective_source_times): + raise AuditError( + f"task[{task_id}] outcome sources must strictly follow objective sources." ) - for message_id in episode["outcome_anchor_message_ids"] - ] - episode_ended_at = max(outcome_times) - if min(outcome_times) <= max(objective_times): + if start != episode_started_at or end != episode_ended_at: raise AuditError( - f"task[{task_id}] outcome anchors must strictly follow ownership " - "and objective anchors." + f"task[{task_id}] started_at and ended_at must equal its first " + "objective source and final outcome source." ) for message_id, message in source_messages.items(): message_time = parse_datetime( @@ -5985,63 +6123,121 @@ def validate_task_refs( f"task[{task_id}] source message {message_id} is outside its " "established episode." ) + contexts[task_id] = { + "start": start, + "end": end, + "source_audits": source_audits, + "source_message_ids": source_message_ids, + "source_messages": source_messages, + "source_agent_id": source_agent_id, + } + return contexts - observed_reads = set(task["read"]["read_ids"]) - for read_id in observed_reads: + +def candidate_reads( + candidates: Sequence[Mapping[str, Any]], +) -> dict[str, tuple[str, Mapping[str, Any]]]: + reads: dict[str, tuple[str, Mapping[str, Any]]] = {} + for candidate in candidates: + for read in candidate["reads"]: + read_id = read.get("read_id") + if isinstance(read_id, str): + if read_id in reads: + raise AuditError( + f"Read ID {read_id} is not globally unique." + ) + reads[read_id] = (candidate["audit_id"], read) + return reads + + +def validate_read_refs( + candidates: Sequence[Mapping[str, Any]], + tasks: Sequence[Mapping[str, Any]], + reads_by_task: Mapping[str, Mapping[str, Any]], + contexts: Mapping[str, Mapping[str, Any]], +) -> dict[str, tuple[str, Mapping[str, Any]]]: + reads = candidate_reads(candidates) + read_owners: dict[str, str] = {} + for task in tasks: + if task["status"] != "clear": + continue + task_id = task["task_id"] + context = contexts[task_id] + for read_id in reads_by_task[task_id]["read_ids"]: item = reads.get(read_id) if item is None: - raise AuditError(f"task[{task_id}] references unknown read {read_id}.") + raise AuditError( + f"Read attribution for Task {task_id} references unknown Read {read_id}." + ) current_audit_id, read = item - if current_audit_id not in source_audits: + if current_audit_id not in context["source_audits"]: raise AuditError( - f"task[{task_id}] read {read_id} is outside its source Chat fragments." + f"Task {task_id} Read {read_id} is outside its source Chat fragments." ) read_started_at = timestamp_in_task( read.get("timestamp"), - start=start, - end=end, + start=context["start"], + end=context["end"], field=f"task {task_id} read {read_id} timestamp", ) read_completed_at = timestamp_in_task( read.get("completed_at"), - start=start, - end=end, + start=context["start"], + end=context["end"], field=f"task {task_id} read {read_id} completion", ) - if ( - read_started_at < evidence_started_at - or read_completed_at < evidence_started_at - ): - raise AuditError( - f"task[{task_id}] read {read_id} precedes established " - "episode ownership/objective." - ) - if ( - read_started_at > episode_ended_at - or read_completed_at > episode_ended_at - ): + if read_started_at >= read_completed_at: raise AuditError( - f"task[{task_id}] read {read_id} occurs after the episode " - "outcome." + f"Task {task_id} Read {read_id} must complete after it starts." ) previous = read_owners.setdefault(read_id, task_id) if previous != task_id: raise AuditError( - f"Read {read_id} is copied across incompatible Tasks {previous} and {task_id}." + f"Read {read_id} is copied across incompatible Tasks " + f"{previous} and {task_id}." ) + return reads + - effect = task["effect"] - if effect is not None: +def candidate_choices( + candidates: Sequence[Mapping[str, Any]], +) -> dict[str, tuple[str, Mapping[str, Any]]]: + choices: dict[str, tuple[str, Mapping[str, Any]]] = {} + for candidate in candidates: + for choice in candidate["visible_choice_candidates"]: + message_id = choice.get("message_id") + if isinstance(message_id, str): + if message_id in choices: + raise AuditError( + f"Choice message ID {message_id} is not globally unique." + ) + choices[message_id] = (candidate["audit_id"], choice) + return choices + + +def validate_effect_refs( + candidates: Sequence[Mapping[str, Any]], + tasks: Sequence[Mapping[str, Any]], + reads_by_task: Mapping[str, Mapping[str, Any]], + effects_by_task: Mapping[str, Mapping[str, Any]], + contexts: Mapping[str, Mapping[str, Any]], + reads: Mapping[str, tuple[str, Mapping[str, Any]]], +) -> None: + choices = candidate_choices(candidates) + choice_owners: dict[str, tuple[str, int]] = {} + for task in tasks: + if task["status"] != "clear": + continue + task_id = task["task_id"] + context = contexts[task_id] + observed_reads = set(reads_by_task[task_id]["read_ids"]) + for effect_index, effect in enumerate( + effects_by_task[task_id]["effects"] + ): + effect_name = f"Effect {effect_index + 1} in Task {task_id}" if not set(effect["read_ids"]).issubset(observed_reads): raise AuditError( - f"Effect in task[{task_id}] references reads outside its observed reads." - ) - if effect["outcome_anchor"] not in set( - episode["outcome_anchor_message_ids"] - ): - raise AuditError( - f"Effect in task[{task_id}] must bind outcome_anchor to one " - "of the episode outcome anchors." + f"{effect_name} references Reads outside its attributed Reads." ) read_times = [ parse_datetime( @@ -6050,55 +6246,65 @@ def validate_task_refs( ) for read_id in effect["read_ids"] ] - outcome_anchor_time = parse_datetime( - episode_messages[effect["outcome_anchor"]]["created_at"], - field=( - f"task {task_id} effect outcome anchor " - f"{effect['outcome_anchor']}" - ), - ) choice_times: list[datetime] = [] for message_id in effect["choice_message_ids"]: - if message_id not in source_message_ids: + if message_id not in context["source_message_ids"]: raise AuditError( - f"Effect in task[{task_id}] choice {message_id} is outside " - "its source fragments." + f"{effect_name} choice {message_id} is outside its " + "Task source fragments." ) item = choices.get(message_id) if item is None: raise AuditError( - f"Effect in task[{task_id}] references unknown choice {message_id}." + f"{effect_name} references unknown same-Agent choice " + f"{message_id}." ) current_audit_id, choice = item - if current_audit_id not in source_audits: + if current_audit_id not in context["source_audits"]: raise AuditError( - f"task[{task_id}] choice {message_id} is outside its source Chat fragments." + f"{effect_name} choice {message_id} is outside its " + "source Chats." ) choice_time = timestamp_in_task( choice.get("created_at"), - start=start, - end=end, + start=context["start"], + end=context["end"], field=f"task {task_id} choice {message_id}", ) - if not evidence_started_at <= choice_time <= episode_ended_at: - raise AuditError( - f"task[{task_id}] choice {message_id} is outside its " - "established episode." - ) choice_times.append(choice_time) - previous = choice_owners.setdefault(message_id, task_id) - if previous != task_id: + owner = (task_id, effect_index) + previous = choice_owners.setdefault(message_id, owner) + if previous != owner: raise AuditError( - f"Choice {message_id} is copied across incompatible Tasks {previous} and {task_id}." + f"Choice {message_id} is reused across independent Effects." ) - if max(read_times) > min(choice_times): + outcome_message_id = effect["outcome_message_id"] + outcome_message = context["source_messages"].get(outcome_message_id) + if ( + outcome_message is None + or outcome_message.get("sender_id") + != context["source_agent_id"] + or not isinstance(outcome_message.get("content"), str) + or not outcome_message["content"].strip() + ): raise AuditError( - f"Effect in task[{task_id}] cites a read completed after its earliest choice." + f"{effect_name} requires a non-empty current-Agent outcome " + "message inside its Task." ) - if outcome_anchor_time < max([*read_times, *choice_times]): + outcome_time = timestamp_in_task( + outcome_message.get("created_at"), + start=context["start"], + end=context["end"], + field=f"task {task_id} Effect outcome {outcome_message_id}", + ) + if max(read_times) >= min(choice_times): + raise AuditError( + f"{effect_name} requires every Read to complete before its " + "earliest choice." + ) + if outcome_time < max([*read_times, *choice_times]): raise AuditError( - f"Effect in task[{task_id}] binds an outcome anchor that " - "precedes a cited Read completion or choice." + f"{effect_name} outcome precedes a cited Read completion or choice." ) @@ -6117,7 +6323,7 @@ def independent_effect_id(effect: Mapping[str, Any]) -> str: "effect": effect["type"], "read_ids": sorted(effect["read_ids"]), "choice_message_ids": sorted(effect["choice_message_ids"]), - "outcome_anchor": effect["outcome_anchor"], + "outcome_message_id": effect["outcome_message_id"], } digest = hashlib.sha256( json.dumps(identity, ensure_ascii=False, sort_keys=True).encode("utf-8") @@ -6125,21 +6331,27 @@ def independent_effect_id(effect: Mapping[str, Any]) -> str: return f"effect-{digest}" -def build_task_evidence(tasks: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: +def build_task_evidence( + tasks: Sequence[Mapping[str, Any]], + *, + inventory_sha256: str, + reads_by_task: Mapping[str, Mapping[str, Any]], + effects_by_task: Mapping[str, Mapping[str, Any]], +) -> list[dict[str, Any]]: evidence: list[dict[str, Any]] = [] - for task in sorted( - tasks, - key=lambda item: ( - item["status"] != "clear", - item["started_at"], - item["task_id"], - ), - ): - row = dict(task) - if task["status"] == "clear" and task["effect"] is not None: - projected = dict(task["effect"]) - projected["effect_id"] = independent_effect_id(task["effect"]) - row["effect"] = projected + for task in sorted_task_inventory(tasks): + row = {**task, "inventory_sha256": inventory_sha256} + if task["status"] == "clear": + task_id = task["task_id"] + row["read"] = dict(reads_by_task[task_id]) + row["effects"] = [ + { + **effect, + "effect_id": independent_effect_id(effect), + } + for effect in effects_by_task[task_id]["effects"] + ] + row["effect_reason"] = effects_by_task[task_id]["effect_reason"] evidence.append(row) return evidence @@ -6168,13 +6380,18 @@ def render_report( unresolved_read_tasks = [ task for task in clear_tasks if task["read"]["status"] == "unresolved" ] - effect_tasks = [task for task in clear_tasks if task["effect"] is not None] + effect_tasks = [task for task in clear_tasks if task["effects"]] observed_without_effect_tasks = [ task for task in observed_read_tasks - if task["effect"] is None + if not task["effects"] + ] + effects = [ + effect + for task in clear_tasks + for effect in task["effects"] ] - effect_counts = Counter(task["effect"]["type"] for task in effect_tasks) + effect_counts = Counter(effect["type"] for effect in effects) exclusion_counts = Counter( task["exclusion_kind"] for task in excluded_tasks ) @@ -6189,8 +6406,10 @@ def render_report( raise AuditError( "Effect and no-Effect counts do not conserve observed Read Tasks." ) - if sum(effect_counts.values()) != len(effect_tasks): - raise AuditError("Effect distribution does not conserve Effect Tasks.") + if len(effects) < len(effect_tasks): + raise AuditError("Effect count cannot be less than Effect Task count.") + if sum(effect_counts.values()) != len(effects): + raise AuditError("Effect distribution does not conserve Effects.") mapped_audits = sum(1 for row in candidates if row["mapped_trace_files"]) chat_message_counts: dict[str, int] = {} @@ -6250,6 +6469,7 @@ def render_report( table_row(["Read observed Tasks", len(observed_read_tasks)]), table_row(["Read unresolved Tasks", len(unresolved_read_tasks)]), table_row(["Effect Tasks", len(effect_tasks)]), + table_row(["Effects", len(effects)]), table_row( [ "Observed Read without Effect", @@ -6265,7 +6485,7 @@ def render_report( "", "## Effect Distribution", "", - table_row(["Effect", "Tasks"]), + table_row(["Effect", "Effects"]), table_row(["---", "---:"]), ] for effect_type in ("confirmed", "constrained", "redirected", "conflicted"): @@ -6297,7 +6517,6 @@ def render_report( table_row( [ "Task ID", - "Ownership", "Objective", "Object scope", "Primary deliverable", @@ -6317,7 +6536,6 @@ def render_report( "---", "---", "---", - "---", ] ), ] @@ -6325,72 +6543,117 @@ def render_report( for task in sorted( clear_tasks, key=lambda item: (item["started_at"], item["task_id"]) ): - effect = task["effect"] + task_effects = task["effects"] effect_label = ( - effect["type"] - if effect is not None + ", ".join(effect["type"] for effect in task_effects) + if task_effects else "not judged" if task["read"]["status"] == "unresolved" else "none" ) + evidence_summary = ( + "; ".join(effect["summary"] for effect in task_effects) + if task_effects + else task["effect_reason"] + ) lines.append( table_row( [ f"`{task['task_id']}`", - f"`{task['episode']['ownership']['kind']}`", report_cell(task["objective"]), report_cell(task["object_scope"]), - report_cell(task["episode"]["primary_deliverable"]), + report_cell(task["primary_deliverable"]), report_cell(task["outcome"]), task["read"]["status"], effect_label, - effect["summary"] if effect is not None else task["effect_reason"], + evidence_summary, ] ) ) if not clear_tasks: lines.append( - table_row(["None", "—", "—", "—", "—", "—", "—", "—", "—"]) + table_row(["None", "—", "—", "—", "—", "—", "—", "—"]) ) lines.append("") - if clear_tasks: - lines.extend(["## Clear Task Boundary Rationale", ""]) - for index, task in enumerate( - sorted( - clear_tasks, - key=lambda item: (item["started_at"], item["task_id"]), - ), - start=1, - ): - episode = task["episode"] - ownership = episode["ownership"] - lines.extend( - [ - f"### {index}. `{task['task_id']}`", - "", - f"- Ownership: `{ownership['kind']}` — " - f"{report_cell(ownership['reason'])}", - "- Ownership anchors: " - + ", ".join( - f"`{message_id}`" - for message_id in ownership["anchor_message_ids"] - ), - "- Objective anchors: " - + ", ".join( - f"`{message_id}`" - for message_id in episode["objective_anchor_message_ids"] - ), - "- Outcome anchors: " - + ", ".join( - f"`{message_id}`" - for message_id in episode["outcome_anchor_message_ids"] - ), - f"- Boundary: {report_cell(episode['boundary_reason'])}", + if tasks: + inventory_sha256 = tasks[0]["inventory_sha256"] + lines.extend( + [ + "## Frozen Task Inventory", + "", + f"Inventory digest: `sha256:{inventory_sha256}`.", + "", + ] + ) + if clear_tasks: + for index, task in enumerate( + sorted( + clear_tasks, + key=lambda item: (item["started_at"], item["task_id"]), + ), + start=1, + ): + lines.extend( + [ + f"### {index}. `{task['task_id']}`", + "", + "- Objective sources: " + + ", ".join( + f"`{message_id}`" + for message_id in task[ + "objective_source_message_ids" + ] + ), + "- Outcome sources: " + + ", ".join( + f"`{message_id}`" + for message_id in task[ + "outcome_source_message_ids" + ] + ), + "", + ] + ) + else: + lines.extend( + [ + "No clear Tasks were reconstructed; the digest still binds " + "the excluded-candidate inventory.", "", ] ) + if effects: + lines.extend(["## Effect Details", ""]) + effect_number = 0 + for task in sorted( + clear_tasks, + key=lambda item: (item["started_at"], item["task_id"]), + ): + for effect in task["effects"]: + effect_number += 1 + lines.extend( + [ + f"### {effect_number}. `{effect['effect_id']}`", + "", + f"- Task: `{task['task_id']}`", + f"- Type: `{effect['type']}`", + "- Reads: " + + ", ".join( + f"`{read_id}`" for read_id in effect["read_ids"] + ), + "- Choices: " + + ", ".join( + f"`{message_id}`" + for message_id in effect["choice_message_ids"] + ), + f"- Outcome message: `{effect['outcome_message_id']}`", + f"- Summary: {report_cell(effect['summary'])}", + "", + ] + ) + if excluded_tasks: lines.extend( [ @@ -6428,8 +6691,9 @@ def render_report( table_row(["---", "---:"]), table_row(["Reviewed clear Tasks", reviewed_baseline["clear_tasks"]]), table_row(["Reviewed effect Tasks", reviewed_baseline["effect_tasks"]]), + table_row(["Reviewed Effects", reviewed_baseline["effects"]]), "", - table_row(["Effect", "Reviewed Tasks"]), + table_row(["Effect", "Reviewed Effects"]), table_row(["---", "---:"]), ] ) @@ -6463,7 +6727,7 @@ def render_report( table_row(["Chats mapped to local runtime evidence", len(mapped_chat_ids)]), table_row(["Audit units mapped to local runtime evidence", mapped_audits]), table_row(["In-window Tree-read attempts", attempt_total]), - table_row(["Task judgments", len(tasks)]), + table_row(["Frozen Task inventory rows", len(tasks)]), "", "### Tree-read grammar conservation", "", @@ -6506,11 +6770,11 @@ def render_report( "", "## Rule and Boundaries", "", - "A clear Task is one single-Agent-owned continuous work episode. It requires a concrete objective, material object scope, independently judgeable outcome or terminal state, bounded source fragments, explicit ownership/objective/outcome anchors, and one primary terminal deliverable. Short continuations, status prompts, context-dependent questions, and phases or corrections of one delivery stay inside their parent episode. Excluded candidates carry a structured exclusion kind and no episode, Read, or Effect.", + "A clear Task is one continuous work episode in which the audited Agent accepted a concrete objective and produced an independently judgeable outcome. Scope and primary deliverable may clarify the boundary but are not mandatory. Short continuations, status prompts, context-dependent questions, and phases or corrections of one delivery stay inside their parent episode. Task reconstruction is frozen before Read attribution and Effect analysis.", "", "Read is `observed` only when the recovered Task-window evidence contains attributable Tree content; otherwise it is `unresolved` with a reason.", "", - "An Effect exists only when a real relevant normal-content read precedes the choice, the later choice or outcome reasonably shows `confirmed`, `constrained`, `redirected`, or `conflicted` influence, and no more direct evidence fully explains the result. Otherwise the Effect is blank with a short reason.", + "Each Task may have zero or more Effects. Every Effect exists only when a real relevant normal-content Read precedes one distinct choice, the later same-Agent choice or outcome reasonably shows `confirmed`, `constrained`, `redirected`, or `conflicted` influence, and no more direct evidence fully explains the result. Otherwise the Effect list is empty with a short reason.", "", "A decision receipt may support this judgment but cannot create an Effect by itself.", "", @@ -6589,6 +6853,149 @@ def validate_read_tree_source( } +def validate_task_source_candidate( + value: Mapping[str, Any], + workspace_identity: WorkspaceIdentity, +) -> dict[str, Any]: + """Validate only authority and work messages needed for Stage 1.""" + if value.get("schema_version") != SCHEMA_VERSION: + raise AuditError( + f"Every candidate must use schema_version {SCHEMA_VERSION}." + ) + chat = value.get("chat") + window = value.get("window") + visible_messages = value.get("visible_messages") + if ( + not isinstance(chat, dict) + or not isinstance(window, dict) + or not isinstance(visible_messages, list) + ): + raise AuditError( + "Every Task-source candidate requires chat, window, and visible_messages." + ) + chat_id = require_uuid(chat.get("chat_id"), "candidate.chat.chat_id") + source_agent_id = require_uuid( + chat.get("source_agent_id"), + f"candidate[{chat_id}].chat.source_agent_id", + ) + source_agent = require_string( + chat.get("source_agent"), + f"candidate[{chat_id}].chat.source_agent", + ) + if ( + source_agent_id != workspace_identity.agent_id + or source_agent != workspace_identity.agent_name + ): + raise AuditError( + f"Candidate {chat_id} does not match the authorized Agent workspace identity." + ) + expected_audit_id = audit_id(chat_id, source_agent_id) + if value.get("audit_id") != expected_audit_id: + raise AuditError( + f"Candidate {chat_id} audit_id does not match its Chat and Agent UUIDs." + ) + authorization = validate_authorization( + chat.get("authorization"), + f"candidate[{expected_audit_id}].chat.authorization", + ) + if "authorization_context" in chat: + raise AuditError( + f"Candidate {expected_audit_id} must not contain authorization_context." + ) + message_count = chat.get("message_count") + if not isinstance(message_count, int) or isinstance(message_count, bool): + raise AuditError( + f"Candidate {expected_audit_id} must contain integer message_count." + ) + start_value = window.get("start") + parsed_window_start = None + if start_value is not None: + parsed_window_start = parse_datetime( + require_string( + start_value, + f"candidate[{expected_audit_id}].window.start", + ), + field=f"candidate {expected_audit_id} window start", + ) + end_value = require_string( + window.get("end"), + f"candidate[{expected_audit_id}].window.end", + ) + parsed_window_end = parse_datetime( + end_value, + field=f"candidate {expected_audit_id} window end", + ) + if ( + parsed_window_start is not None + and parsed_window_start > parsed_window_end + ): + raise AuditError( + f"Candidate {expected_audit_id} window starts after it ends." + ) + if message_count != len(visible_messages): + raise AuditError( + f"Candidate {expected_audit_id} message_count does not match visible_messages." + ) + seen_message_ids: set[str] = set() + projected_messages: list[dict[str, Any]] = [] + for message in visible_messages: + if ( + not isinstance(message, dict) + or not isinstance(message.get("message_id"), str) + or not isinstance(message.get("created_at"), str) + or not isinstance(message.get("sender_id"), str) + or not isinstance(message.get("content"), str) + ): + raise AuditError( + f"Candidate {expected_audit_id} contains an invalid visible message." + ) + message_id = message["message_id"] + if message_id in seen_message_ids: + raise AuditError( + f"Candidate {expected_audit_id} contains duplicate visible message " + f"{message_id}." + ) + seen_message_ids.add(message_id) + message_time = parse_datetime( + message["created_at"], + field=f"candidate {expected_audit_id} message {message_id}", + ) + if ( + parsed_window_start is not None + and message_time < parsed_window_start + ) or message_time > parsed_window_end: + raise AuditError( + f"Candidate {expected_audit_id} message {message_id} is outside " + "its acquisition window." + ) + projected_messages.append( + { + field: message.get(field) + for field in ( + "message_id", + "created_at", + "sender_id", + "sender_kind", + "content", + ) + } + ) + return { + "schema_version": SCHEMA_VERSION, + "audit_id": expected_audit_id, + "chat": { + "chat_id": chat_id, + "authorization": authorization, + "source_agent_id": source_agent_id, + }, + "window": { + "start": start_value, + "end": end_value, + }, + "visible_messages": projected_messages, + } + + def validate_report_candidate( value: Mapping[str, Any], workspace_identity: WorkspaceIdentity, @@ -6867,16 +7274,268 @@ def validate_report_candidate( return dict(value) +def build_task_source( + candidates: Sequence[Mapping[str, Any]], +) -> list[dict[str, Any]]: + return [ + { + "schema_version": SCHEMA_VERSION, + "audit_id": candidate["audit_id"], + "chat": { + "chat_id": candidate["chat"]["chat_id"], + "authorization": candidate["chat"]["authorization"], + "source_agent_id": candidate["chat"]["source_agent_id"], + }, + "window": dict(candidate["window"]), + "visible_messages": [ + { + field: message.get(field) + for field in ( + "message_id", + "created_at", + "sender_id", + "sender_kind", + "content", + ) + } + for message in candidate["visible_messages"] + ], + } + for candidate in sorted( + candidates, + key=lambda item: item["audit_id"], + ) + ] + + +def validate_task_source( + value: Mapping[str, Any], + workspace_identity: WorkspaceIdentity, +) -> dict[str, Any]: + if value.get("schema_version") != SCHEMA_VERSION: + raise AuditError( + f"Every Task source must use schema_version {SCHEMA_VERSION}." + ) + require_exact_keys( + value, + { + "schema_version", + "audit_id", + "chat", + "window", + "visible_messages", + }, + field="task_source", + ) + chat = value.get("chat") + window = value.get("window") + visible_messages = value.get("visible_messages") + if ( + not isinstance(chat, dict) + or not isinstance(window, dict) + or not isinstance(visible_messages, list) + ): + raise AuditError( + "Every Task source requires chat, window, and visible_messages." + ) + require_exact_keys( + chat, + {"chat_id", "authorization", "source_agent_id"}, + field="task_source.chat", + ) + require_exact_keys( + window, + {"start", "end"}, + field="task_source.window", + ) + chat_id = require_uuid(chat.get("chat_id"), "task_source.chat.chat_id") + source_agent_id = require_uuid( + chat.get("source_agent_id"), + f"task_source[{chat_id}].chat.source_agent_id", + ) + if source_agent_id != workspace_identity.agent_id: + raise AuditError( + f"Task source {chat_id} does not match the authorized Agent." + ) + expected_audit_id = audit_id(chat_id, source_agent_id) + if value.get("audit_id") != expected_audit_id: + raise AuditError( + f"Task source {chat_id} audit_id does not match its Chat and Agent." + ) + authorization = validate_authorization( + chat.get("authorization"), + f"task_source[{expected_audit_id}].chat.authorization", + ) + start_value = window.get("start") + if start_value is not None: + parse_datetime( + require_string( + start_value, + f"task_source[{expected_audit_id}].window.start", + ), + field=f"task source {expected_audit_id} window start", + ) + parse_datetime( + require_string( + window.get("end"), + f"task_source[{expected_audit_id}].window.end", + ), + field=f"task source {expected_audit_id} window end", + ) + seen_message_ids: set[str] = set() + projected_messages: list[dict[str, Any]] = [] + for message in visible_messages: + if ( + not isinstance(message, dict) + or not isinstance(message.get("message_id"), str) + or not isinstance(message.get("created_at"), str) + or not isinstance(message.get("sender_id"), str) + or not isinstance(message.get("content"), str) + ): + raise AuditError( + f"Task source {expected_audit_id} contains an invalid visible message." + ) + require_exact_keys( + message, + { + "message_id", + "created_at", + "sender_id", + "sender_kind", + "content", + }, + field=f"task_source[{expected_audit_id}].visible_message", + ) + message_id = message["message_id"] + if message_id in seen_message_ids: + raise AuditError( + f"Task source {expected_audit_id} contains duplicate message " + f"{message_id}." + ) + seen_message_ids.add(message_id) + parse_datetime( + message["created_at"], + field=f"task source {expected_audit_id} message {message_id}", + ) + projected_messages.append( + { + field: message.get(field) + for field in ( + "message_id", + "created_at", + "sender_id", + "sender_kind", + "content", + ) + } + ) + return { + "schema_version": SCHEMA_VERSION, + "audit_id": expected_audit_id, + "chat": { + "chat_id": chat_id, + "authorization": authorization, + "source_agent_id": source_agent_id, + }, + "window": { + "start": start_value, + "end": window["end"], + }, + "visible_messages": projected_messages, + } + + +def project_task_source(args: argparse.Namespace) -> None: + workspace_identity = parse_agent_workspace(args.agent_workspace) + artifact_root = resolve_artifact_root(args.artifact_root, workspace_identity) + candidates_path = artifact_path( + artifact_root, args.candidates, field="--candidates", must_exist=True + ) + output_path = artifact_path( + artifact_root, args.output, field="--output", must_exist=False + ) + require_distinct_paths( + { + "--candidates": candidates_path, + "--output": output_path, + } + ) + candidates = [ + validate_task_source_candidate(candidate, workspace_identity) + for candidate in iter_jsonl(candidates_path) + ] + if not candidates: + raise AuditError("No candidate records were provided.") + if len({candidate["audit_id"] for candidate in candidates}) != len(candidates): + raise AuditError("Candidate evidence contains duplicate Chat-Agent audit rows.") + write_jsonl(output_path, build_task_source(candidates)) + + +def freeze_task_inventory(args: argparse.Namespace) -> None: + workspace_identity = parse_agent_workspace(args.agent_workspace) + artifact_root = resolve_artifact_root(args.artifact_root, workspace_identity) + task_source_path = artifact_path( + artifact_root, + args.task_source, + field="--task-source", + must_exist=True, + ) + draft_path = artifact_path( + artifact_root, + args.task_inventory_draft, + field="--task-inventory-draft", + must_exist=True, + ) + output_path = artifact_path( + artifact_root, + args.task_inventory_output, + field="--task-inventory-output", + must_exist=False, + ) + require_distinct_paths( + { + "--task-source": task_source_path, + "--task-inventory-draft": draft_path, + "--task-inventory-output": output_path, + } + ) + task_sources = [ + validate_task_source(row, workspace_identity) + for row in iter_jsonl(task_source_path) + ] + if not task_sources: + raise AuditError("No Task source records were provided.") + if len({row["audit_id"] for row in task_sources}) != len(task_sources): + raise AuditError("Task source contains duplicate Chat-Agent audit rows.") + tasks, _ = load_task_inventory(draft_path, require_frozen=False) + validate_task_inventory_refs(task_sources, tasks) + digest = task_inventory_digest(tasks) + write_jsonl(output_path, frozen_task_inventory(tasks, digest)) + print(f"task_inventory_sha256={digest}") + + def finalize_report(args: argparse.Namespace) -> None: workspace_identity = parse_agent_workspace(args.agent_workspace) artifact_root = resolve_artifact_root(args.artifact_root, workspace_identity) candidates_path = artifact_path( artifact_root, args.candidates, field="--candidates", must_exist=True ) - task_judgments_path = artifact_path( + task_inventory_path = artifact_path( artifact_root, - args.task_judgments, - field="--task-judgments", + args.task_inventory, + field="--task-inventory", + must_exist=True, + ) + read_attributions_path = artifact_path( + artifact_root, + args.read_attributions, + field="--read-attributions", + must_exist=True, + ) + effect_judgments_path = artifact_path( + artifact_root, + args.effect_judgments, + field="--effect-judgments", must_exist=True, ) evidence_path = artifact_path( @@ -6897,7 +7556,9 @@ def finalize_report(args: argparse.Namespace) -> None: ) paths = { "--candidates": candidates_path, - "--task-judgments": task_judgments_path, + "--task-inventory": task_inventory_path, + "--read-attributions": read_attributions_path, + "--effect-judgments": effect_judgments_path, "--evidence-output": evidence_path, "--report-output": report_path, } @@ -6912,11 +7573,43 @@ def finalize_report(args: argparse.Namespace) -> None: raise AuditError("No candidate records were provided.") if len({candidate["audit_id"] for candidate in candidates}) != len(candidates): raise AuditError("Candidate evidence contains duplicate Chat-Agent audit rows.") - tasks = load_task_judgments(task_judgments_path) - if not tasks: - raise AuditError("No Task judgments were provided.") - validate_task_refs(candidates, tasks) - evidence = build_task_evidence(tasks) + tasks, inventory_sha256 = load_task_inventory( + task_inventory_path, + require_frozen=True, + ) + assert inventory_sha256 is not None + contexts = validate_task_inventory_refs(candidates, tasks) + reads_by_task = load_read_attributions( + read_attributions_path, + tasks=tasks, + inventory_sha256=inventory_sha256, + ) + reads = validate_read_refs( + candidates, + tasks, + reads_by_task, + contexts, + ) + effects_by_task = load_effect_judgments( + effect_judgments_path, + tasks=tasks, + reads_by_task=reads_by_task, + inventory_sha256=inventory_sha256, + ) + validate_effect_refs( + candidates, + tasks, + reads_by_task, + effects_by_task, + contexts, + reads, + ) + evidence = build_task_evidence( + tasks, + inventory_sha256=inventory_sha256, + reads_by_task=reads_by_task, + effects_by_task=effects_by_task, + ) reviewed_baseline = ( load_reviewed_baseline(reviewed_baseline_path) if reviewed_baseline_path is not None @@ -6935,7 +7628,7 @@ def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=( "Read-only Context Tree Value Audit collector with deterministic " - "Task-first judgment validation and reporting." + "Task freeze, Read attribution, Effect validation, and reporting." ) ) subparsers = parser.add_subparsers(dest="command", required=True) @@ -7019,9 +7712,66 @@ def build_parser() -> argparse.ArgumentParser: ) collect_parser.set_defaults(handler=collect_evidence) + task_source_parser = subparsers.add_parser( + "task-source", + help="Project message-only evidence for pure Task reconstruction.", + ) + task_source_parser.add_argument( + "--artifact-root", + required=True, + help="Exact triggering-agent artifact directory.", + ) + task_source_parser.add_argument( + "--agent-workspace", + required=True, + help="Exact AGENT_UUID=/absolute/workspace identity.", + ) + task_source_parser.add_argument( + "--candidates", + required=True, + help="Candidate JSONL from collect.", + ) + task_source_parser.add_argument( + "--output", + required=True, + help="Destination message-only Task source JSONL.", + ) + task_source_parser.set_defaults(handler=project_task_source) + + freeze_parser = subparsers.add_parser( + "freeze-tasks", + help="Validate and freeze a pure Task inventory before Read analysis.", + ) + freeze_parser.add_argument( + "--artifact-root", + required=True, + help="Exact triggering-agent artifact directory.", + ) + freeze_parser.add_argument( + "--agent-workspace", + required=True, + help="Exact AGENT_UUID=/absolute/workspace identity.", + ) + freeze_parser.add_argument( + "--task-source", + required=True, + help="Message-only Task source JSONL.", + ) + freeze_parser.add_argument( + "--task-inventory-draft", + required=True, + help="Pure Task inventory draft JSONL.", + ) + freeze_parser.add_argument( + "--task-inventory-output", + required=True, + help="Destination frozen Task inventory JSONL.", + ) + freeze_parser.set_defaults(handler=freeze_task_inventory) + report_parser = subparsers.add_parser( "report", - help="Validate Task judgments and render final evidence/report artifacts.", + help="Validate frozen Task, Read, and Effect artifacts and render the report.", ) report_parser.add_argument( "--artifact-root", @@ -7035,9 +7785,19 @@ def build_parser() -> argparse.ArgumentParser: ) report_parser.add_argument("--candidates", required=True, help="Candidate JSONL from collect.") report_parser.add_argument( - "--task-judgments", + "--task-inventory", + required=True, + help="Frozen Task inventory JSONL from freeze-tasks.", + ) + report_parser.add_argument( + "--read-attributions", + required=True, + help="Read attribution JSONL tied to the frozen Task inventory.", + ) + report_parser.add_argument( + "--effect-judgments", required=True, - help="Task-level Agent judgment JSONL.", + help="Effect judgment JSONL tied to the frozen Task inventory.", ) report_parser.add_argument( "--reviewed-baseline", diff --git a/tests/test_context_tree_value_audit.py b/tests/test_context_tree_value_audit.py index 9a13ce4..f8e00ec 100644 --- a/tests/test_context_tree_value_audit.py +++ b/tests/test_context_tree_value_audit.py @@ -183,9 +183,9 @@ def test_skill_is_an_explicit_only_umbrella(self) -> None: self.assertIn("explicit_agent", reference) self.assertIn("explicit_chat", reference) self.assertIn("Task, Read, and Effect Schema", task_reference) - self.assertIn('"schema_version": 3', task_reference) + self.assertIn('"schema_version": 4', task_reference) self.assertIn('"status": "observed"', task_reference) - self.assertIn('"effect": null', task_reference) + self.assertIn('"effects": []', task_reference) self.assertIn("no more direct user instruction", task_reference) self.assertIn("in_window_tree_read_attempts", reference) self.assertIn("unresolved_opaque", reference) @@ -193,7 +193,7 @@ def test_skill_is_an_explicit_only_umbrella(self) -> None: self.assertNotIn("original_judgment", task_reference) self.assertNotIn("sampling_order", task_reference) self.assertIn("sampled evidence report", skill) - self.assertEqual("0.4.0", version) + self.assertEqual("0.5.0", version) self.assertIn(".skill-quarantine/", readme) self.assertIn("diff -qr", readme) self.assertIn("rollback", readme) @@ -201,6 +201,22 @@ def test_skill_is_an_explicit_only_umbrella(self) -> None: "/Users/", "\n".join((skill, openai, reference, task_reference)) ) + def test_private_schema_v4_artifacts_are_ignored_and_rejected(self) -> None: + private_names = { + "task-source.jsonl", + "task-inventory-draft.jsonl", + "task-inventory.jsonl", + "read-attributions.jsonl", + "effect-judgments.jsonl", + } + gitignore = (ROOT / ".gitignore").read_text(encoding="utf-8") + validator = (ROOT / "scripts" / "validate_skill.py").read_text( + encoding="utf-8" + ) + for name in private_names: + self.assertIn(f"/{name}", gitignore) + self.assertIn(f'"{name}"', validator) + def test_install_layout_supports_codex_and_claude_upgrade_and_rollback( self, ) -> None: @@ -1271,15 +1287,26 @@ def task_judgment( source_message_ids = list( dict.fromkeys([objective_anchor_id, message_id]) ) + message_times = { + message["message_id"]: message["created_at"] + for message in candidate.get("visible_messages", []) + if isinstance(message, dict) + and isinstance(message.get("message_id"), str) + and isinstance(message.get("created_at"), str) + } return { - "schema_version": 3, + "schema_version": 4, "task_id": task_id, "status": "clear", "objective": "Choose one state source", "object_scope": "state persistence", "outcome": "Kept the existing authoritative state source.", - "started_at": "2026-07-22T10:00:00Z", - "ended_at": "2026-07-22T10:06:00Z", + "started_at": message_times.get( + objective_anchor_id, "2026-07-22T10:01:00Z" + ), + "ended_at": message_times.get( + message_id, "2026-07-22T10:05:00Z" + ), "source_fragments": [ { "audit_id": candidate["audit_id"], @@ -1326,8 +1353,107 @@ def report( report_name: str = "REPORT.md", reviewed_baseline_name: str | None = None, ) -> subprocess.CompletedProcess[str]: - task_path = self.artifacts / "task-judgments.jsonl" - write_jsonl(task_path, tasks) + candidates_path = self.artifacts / candidates_name + task_source_path = self.artifacts / "task-source.jsonl" + task_source_result = run_cli( + "task-source", + "--artifact-root", + str(self.artifacts), + "--agent-workspace", + f"{AGENT_ID}={self.workspace}", + "--candidates", + str(candidates_path), + "--output", + str(task_source_path), + ) + if task_source_result.returncode != 0: + return task_source_result + + inventory_rows: list[dict[str, Any]] = [] + read_rows: list[dict[str, Any]] = [] + effect_rows: list[dict[str, Any]] = [] + for task in tasks: + inventory = json.loads(json.dumps(task)) + read = inventory.pop("read", None) + effect = inventory.pop("effect", None) + effects = inventory.pop("effects", None) + effect_reason = inventory.pop("effect_reason", None) + episode = inventory.pop("episode", None) + if isinstance(episode, dict): + inventory["objective_source_message_ids"] = episode.get( + "objective_anchor_message_ids" + ) + inventory["outcome_source_message_ids"] = episode.get( + "outcome_anchor_message_ids" + ) + inventory["primary_deliverable"] = episode.get( + "primary_deliverable" + ) + inventory_rows.append(inventory) + + if inventory.get("status") != "clear": + continue + task_id = inventory.get("task_id") + if isinstance(read, dict): + read_rows.append( + { + "schema_version": 4, + "task_id": task_id, + "status": read.get("status"), + "read_ids": read.get("read_ids"), + "reason": read.get("reason"), + } + ) + selected_effects = ( + effects + if effects is not None + else [effect] + if isinstance(effect, dict) + else [] + ) + projected_effects = [] + for selected_effect in selected_effects: + projected = dict(selected_effect) + if "outcome_anchor" in projected: + projected["outcome_message_id"] = projected.pop( + "outcome_anchor" + ) + projected_effects.append(projected) + effect_rows.append( + { + "schema_version": 4, + "task_id": task_id, + "effects": projected_effects, + "effect_reason": effect_reason, + } + ) + + inventory_draft_path = self.artifacts / "task-inventory-draft.jsonl" + inventory_path = self.artifacts / "task-inventory.jsonl" + write_jsonl(inventory_draft_path, inventory_rows) + freeze_result = run_cli( + "freeze-tasks", + "--artifact-root", + str(self.artifacts), + "--agent-workspace", + f"{AGENT_ID}={self.workspace}", + "--task-source", + str(task_source_path), + "--task-inventory-draft", + str(inventory_draft_path), + "--task-inventory-output", + str(inventory_path), + ) + if freeze_result.returncode != 0: + return freeze_result + frozen_rows = read_jsonl(inventory_path) + inventory_sha256 = frozen_rows[0]["inventory_sha256"] + for row in [*read_rows, *effect_rows]: + row["inventory_sha256"] = inventory_sha256 + read_path = self.artifacts / "read-attributions.jsonl" + effect_path = self.artifacts / "effect-judgments.jsonl" + write_jsonl(read_path, read_rows) + write_jsonl(effect_path, effect_rows) arguments = [ "report", "--artifact-root", @@ -1335,9 +1461,13 @@ def report( "--agent-workspace", f"{AGENT_ID}={self.workspace}", "--candidates", - str(self.artifacts / candidates_name), - "--task-judgments", - str(task_path), + str(candidates_path), + "--task-inventory", + str(inventory_path), + "--read-attributions", + str(read_path), + "--effect-judgments", + str(effect_path), "--evidence-output", str(self.artifacts / evidence_name), "--report-output", @@ -3964,9 +4094,9 @@ def test_report_is_task_first_deterministic_and_rejects_invalid_effects(self) -> self.assertIn("There is no minimum Task quota", markdown) self.assertIn("Tree-read grammar conservation", markdown) evidence = read_jsonl(self.artifacts / "minimal-evidence-one.jsonl") - self.assertEqual("constrained", evidence[0]["effect"]["type"]) - self.assertIn("effect_id", evidence[0]["effect"]) - self.assertNotIn("derived_support", evidence[0]["effect"]) + self.assertEqual("constrained", evidence[0]["effects"][0]["type"]) + self.assertIn("effect_id", evidence[0]["effects"][0]) + self.assertNotIn("derived_support", evidence[0]["effects"][0]) invalid = json.loads(json.dumps(task)) invalid["effect"]["type"] = "informed" @@ -3986,7 +4116,7 @@ def test_report_is_task_first_deterministic_and_rejects_invalid_effects(self) -> report_name="minimal-missing-anchor-REPORT.md", ) self.assertEqual(2, missing_anchor_result.returncode) - self.assertIn("outcome_anchor", missing_anchor_result.stderr) + self.assertIn("outcome_message_id", missing_anchor_result.stderr) def test_report_survives_tree_advance_and_rejects_v02_confidence_fields( self, @@ -4054,7 +4184,415 @@ def test_report_survives_tree_advance_and_rejects_v02_confidence_fields( report_name=f"legacy-{field}-REPORT.md", ) self.assertEqual(2, result.returncode) - self.assertIn("superseded v0.2", result.stderr) + self.assertIn("superseded model", result.stderr) + + def test_report_supports_multiple_effects_and_enforces_inventory_digest( + self, + ) -> None: + self.write_chat_export() + self.write_trace_fixtures() + collected = self.collect("candidates.jsonl") + self.assertEqual(0, collected.returncode, collected.stderr) + candidate = read_jsonl(self.artifacts / "candidates.jsonl")[0] + second_choice_id = "second-effect-choice" + second_choice = { + "message_id": second_choice_id, + "created_at": "2026-07-22T10:04:00Z", + "sender_id": AGENT_ID, + "sender_kind": "agent", + "content": "I will retain the current source and document its boundary.", + } + candidate["visible_messages"].append(second_choice) + candidate["visible_choice_candidates"].append( + { + field: second_choice[field] + for field in ("message_id", "created_at", "sender_id", "content") + } + ) + candidate["chat"]["message_count"] += 1 + write_jsonl(self.artifacts / "candidates.jsonl", [candidate]) + + task = self.task_judgment(candidate) + task["source_fragments"][0]["message_ids"].insert(-1, second_choice_id) + read_id = candidate["reads"][0]["read_id"] + task["effects"] = [ + { + "type": "confirmed", + "read_ids": [read_id], + "choice_message_ids": [second_choice_id], + "outcome_message_id": MESSAGE_ID, + "summary": "The Tree removed uncertainty about retaining the source.", + }, + { + "type": "constrained", + "read_ids": [read_id], + "choice_message_ids": [MESSAGE_ID], + "outcome_message_id": MESSAGE_ID, + "summary": "The Tree ruled out creating a second state table.", + }, + ] + task.pop("effect") + task["effect_reason"] = None + accepted = self.report( + [task], + evidence_name="multi-effect-evidence.jsonl", + report_name="multi-effect-REPORT.md", + ) + self.assertEqual(0, accepted.returncode, accepted.stderr) + evidence = read_jsonl(self.artifacts / "multi-effect-evidence.jsonl") + self.assertEqual(2, len(evidence[0]["effects"])) + self.assertEqual( + 2, len({effect["effect_id"] for effect in evidence[0]["effects"]}) + ) + report = (self.artifacts / "multi-effect-REPORT.md").read_text( + encoding="utf-8" + ) + self.assertIn("| Effect Tasks | 1 |", report) + self.assertIn("| Effects | 2 |", report) + self.assertIn("| confirmed | 1 |", report) + self.assertIn("| constrained | 1 |", report) + valid_read_rows = read_jsonl( + self.artifacts / "read-attributions.jsonl" + ) + valid_effect_rows = read_jsonl( + self.artifacts / "effect-judgments.jsonl" + ) + + same_time_candidate = json.loads(json.dumps(candidate)) + same_time_candidate["reads"][0]["completed_at"] = second_choice[ + "created_at" + ] + write_jsonl( + self.artifacts / "same-time-candidates.jsonl", + [same_time_candidate], + ) + same_time_result = run_cli( + "report", + "--artifact-root", + str(self.artifacts), + "--agent-workspace", + f"{AGENT_ID}={self.workspace}", + "--candidates", + str(self.artifacts / "same-time-candidates.jsonl"), + "--task-inventory", + str(self.artifacts / "task-inventory.jsonl"), + "--read-attributions", + str(self.artifacts / "read-attributions.jsonl"), + "--effect-judgments", + str(self.artifacts / "effect-judgments.jsonl"), + "--evidence-output", + str(self.artifacts / "same-time-evidence.jsonl"), + "--report-output", + str(self.artifacts / "same-time-REPORT.md"), + "--generated-at", + NOW, + ) + self.assertEqual(2, same_time_result.returncode) + self.assertIn( + "complete before its earliest choice", + same_time_result.stderr, + ) + + reversed_read_candidate = json.loads(json.dumps(candidate)) + reversed_read_candidate["reads"][0]["timestamp"] = ( + "2026-07-22T10:03:00Z" + ) + reversed_read_candidate["reads"][0]["completed_at"] = ( + "2026-07-22T10:02:00Z" + ) + write_jsonl( + self.artifacts / "reversed-read-candidates.jsonl", + [reversed_read_candidate], + ) + reversed_read_result = run_cli( + "report", + "--artifact-root", + str(self.artifacts), + "--agent-workspace", + f"{AGENT_ID}={self.workspace}", + "--candidates", + str(self.artifacts / "reversed-read-candidates.jsonl"), + "--task-inventory", + str(self.artifacts / "task-inventory.jsonl"), + "--read-attributions", + str(self.artifacts / "read-attributions.jsonl"), + "--effect-judgments", + str(self.artifacts / "effect-judgments.jsonl"), + "--evidence-output", + str(self.artifacts / "reversed-read-evidence.jsonl"), + "--report-output", + str(self.artifacts / "reversed-read-REPORT.md"), + "--generated-at", + NOW, + ) + self.assertEqual(2, reversed_read_result.returncode) + self.assertIn( + "must complete after it starts", + reversed_read_result.stderr, + ) + + zero_duration_read_candidate = json.loads(json.dumps(candidate)) + zero_duration_read_candidate["reads"][0]["completed_at"] = ( + zero_duration_read_candidate["reads"][0]["timestamp"] + ) + write_jsonl( + self.artifacts / "zero-duration-read-candidates.jsonl", + [zero_duration_read_candidate], + ) + zero_duration_read_result = run_cli( + "report", + "--artifact-root", + str(self.artifacts), + "--agent-workspace", + f"{AGENT_ID}={self.workspace}", + "--candidates", + str(self.artifacts / "zero-duration-read-candidates.jsonl"), + "--task-inventory", + str(self.artifacts / "task-inventory.jsonl"), + "--read-attributions", + str(self.artifacts / "read-attributions.jsonl"), + "--effect-judgments", + str(self.artifacts / "effect-judgments.jsonl"), + "--evidence-output", + str(self.artifacts / "zero-duration-read-evidence.jsonl"), + "--report-output", + str(self.artifacts / "zero-duration-read-REPORT.md"), + "--generated-at", + NOW, + ) + self.assertEqual(2, zero_duration_read_result.returncode) + self.assertIn( + "must complete after it starts", + zero_duration_read_result.stderr, + ) + + frozen_rows = read_jsonl(self.artifacts / "task-inventory.jsonl") + tampered_rows = json.loads(json.dumps(frozen_rows)) + tampered_rows[0]["objective"] = "A changed objective after freeze" + write_jsonl(self.artifacts / "task-inventory.jsonl", tampered_rows) + tampered_result = run_cli( + "report", + "--artifact-root", + str(self.artifacts), + "--agent-workspace", + f"{AGENT_ID}={self.workspace}", + "--candidates", + str(self.artifacts / "candidates.jsonl"), + "--task-inventory", + str(self.artifacts / "task-inventory.jsonl"), + "--read-attributions", + str(self.artifacts / "read-attributions.jsonl"), + "--effect-judgments", + str(self.artifacts / "effect-judgments.jsonl"), + "--evidence-output", + str(self.artifacts / "tampered-evidence.jsonl"), + "--report-output", + str(self.artifacts / "tampered-REPORT.md"), + "--generated-at", + NOW, + ) + self.assertEqual(2, tampered_result.returncode) + self.assertIn("digest does not match", tampered_result.stderr) + write_jsonl(self.artifacts / "task-inventory.jsonl", frozen_rows) + + leaked_draft = json.loads(json.dumps(frozen_rows)) + for row in leaked_draft: + row.pop("inventory_sha256") + leaked_draft[0]["read"] = { + "status": "observed", + "read_ids": [read_id], + } + write_jsonl( + self.artifacts / "leaked-task-inventory-draft.jsonl", + leaked_draft, + ) + leaked_result = run_cli( + "freeze-tasks", + "--artifact-root", + str(self.artifacts), + "--agent-workspace", + f"{AGENT_ID}={self.workspace}", + "--task-source", + str(self.artifacts / "task-source.jsonl"), + "--task-inventory-draft", + str(self.artifacts / "leaked-task-inventory-draft.jsonl"), + "--task-inventory-output", + str(self.artifacts / "leaked-task-inventory.jsonl"), + ) + self.assertEqual(2, leaked_result.returncode) + self.assertIn("pure Task inventory", leaked_result.stderr) + + unknown_draft = json.loads(json.dumps(leaked_draft)) + unknown_draft[0].pop("read") + unknown_draft[0]["tree_passage"] = "derived content" + write_jsonl( + self.artifacts / "unknown-task-inventory-draft.jsonl", + unknown_draft, + ) + unknown_task_result = run_cli( + "freeze-tasks", + "--artifact-root", + str(self.artifacts), + "--agent-workspace", + f"{AGENT_ID}={self.workspace}", + "--task-source", + str(self.artifacts / "task-source.jsonl"), + "--task-inventory-draft", + str(self.artifacts / "unknown-task-inventory-draft.jsonl"), + "--task-inventory-output", + str(self.artifacts / "unknown-task-inventory.jsonl"), + ) + self.assertEqual(2, unknown_task_result.returncode) + self.assertIn("unsupported field", unknown_task_result.stderr) + + duplicate_choice = json.loads(json.dumps(task)) + duplicate_choice["effects"][1]["choice_message_ids"] = [ + second_choice_id + ] + rejected = self.report( + [duplicate_choice], + evidence_name="duplicate-effect-evidence.jsonl", + report_name="duplicate-effect-REPORT.md", + ) + self.assertEqual(2, rejected.returncode) + self.assertIn("reused across independent Effects", rejected.stderr) + write_jsonl( + self.artifacts / "read-attributions.jsonl", + valid_read_rows, + ) + write_jsonl( + self.artifacts / "effect-judgments.jsonl", + valid_effect_rows, + ) + + read_rows = json.loads(json.dumps(valid_read_rows)) + read_rows[0]["inventory_sha256"] = "0" * 64 + write_jsonl(self.artifacts / "read-attributions.jsonl", read_rows) + digest_rejected = run_cli( + "report", + "--artifact-root", + str(self.artifacts), + "--agent-workspace", + f"{AGENT_ID}={self.workspace}", + "--candidates", + str(self.artifacts / "candidates.jsonl"), + "--task-inventory", + str(self.artifacts / "task-inventory.jsonl"), + "--read-attributions", + str(self.artifacts / "read-attributions.jsonl"), + "--effect-judgments", + str(self.artifacts / "effect-judgments.jsonl"), + "--evidence-output", + str(self.artifacts / "digest-evidence.jsonl"), + "--report-output", + str(self.artifacts / "digest-REPORT.md"), + "--generated-at", + NOW, + ) + self.assertEqual(2, digest_rejected.returncode) + self.assertIn("frozen Task inventory", digest_rejected.stderr) + write_jsonl( + self.artifacts / "read-attributions.jsonl", + valid_read_rows, + ) + + effect_rows = json.loads(json.dumps(valid_effect_rows)) + effect_rows[0]["inventory_sha256"] = "0" * 64 + write_jsonl(self.artifacts / "effect-judgments.jsonl", effect_rows) + effect_digest_rejected = run_cli( + "report", + "--artifact-root", + str(self.artifacts), + "--agent-workspace", + f"{AGENT_ID}={self.workspace}", + "--candidates", + str(self.artifacts / "candidates.jsonl"), + "--task-inventory", + str(self.artifacts / "task-inventory.jsonl"), + "--read-attributions", + str(self.artifacts / "read-attributions.jsonl"), + "--effect-judgments", + str(self.artifacts / "effect-judgments.jsonl"), + "--evidence-output", + str(self.artifacts / "effect-digest-evidence.jsonl"), + "--report-output", + str(self.artifacts / "effect-digest-REPORT.md"), + "--generated-at", + NOW, + ) + self.assertEqual(2, effect_digest_rejected.returncode) + self.assertIn( + "frozen Task inventory", + effect_digest_rejected.stderr, + ) + write_jsonl( + self.artifacts / "effect-judgments.jsonl", + valid_effect_rows, + ) + + unknown_read_rows = json.loads(json.dumps(valid_read_rows)) + unknown_read_rows[0]["tree_passage"] = "derived content" + write_jsonl( + self.artifacts / "read-attributions.jsonl", + unknown_read_rows, + ) + unknown_read_result = run_cli( + "report", + "--artifact-root", + str(self.artifacts), + "--agent-workspace", + f"{AGENT_ID}={self.workspace}", + "--candidates", + str(self.artifacts / "candidates.jsonl"), + "--task-inventory", + str(self.artifacts / "task-inventory.jsonl"), + "--read-attributions", + str(self.artifacts / "read-attributions.jsonl"), + "--effect-judgments", + str(self.artifacts / "effect-judgments.jsonl"), + "--evidence-output", + str(self.artifacts / "unknown-read-evidence.jsonl"), + "--report-output", + str(self.artifacts / "unknown-read-REPORT.md"), + "--generated-at", + NOW, + ) + self.assertEqual(2, unknown_read_result.returncode) + self.assertIn("unsupported field", unknown_read_result.stderr) + write_jsonl( + self.artifacts / "read-attributions.jsonl", + valid_read_rows, + ) + + unknown_effect_rows = json.loads(json.dumps(valid_effect_rows)) + unknown_effect_rows[0]["effect_claim"] = "derived content" + write_jsonl( + self.artifacts / "effect-judgments.jsonl", + unknown_effect_rows, + ) + unknown_effect_result = run_cli( + "report", + "--artifact-root", + str(self.artifacts), + "--agent-workspace", + f"{AGENT_ID}={self.workspace}", + "--candidates", + str(self.artifacts / "candidates.jsonl"), + "--task-inventory", + str(self.artifacts / "task-inventory.jsonl"), + "--read-attributions", + str(self.artifacts / "read-attributions.jsonl"), + "--effect-judgments", + str(self.artifacts / "effect-judgments.jsonl"), + "--evidence-output", + str(self.artifacts / "unknown-effect-evidence.jsonl"), + "--report-output", + str(self.artifacts / "unknown-effect-REPORT.md"), + "--generated-at", + NOW, + ) + self.assertEqual(2, unknown_effect_result.returncode) + self.assertIn("unsupported field", unknown_effect_result.stderr) def test_report_handles_excluded_task( self, @@ -4065,7 +4603,7 @@ def test_report_handles_excluded_task( self.assertEqual(0, collected.returncode, collected.stderr) candidate = read_jsonl(self.artifacts / "candidates.jsonl")[0] excluded = { - "schema_version": 3, + "schema_version": 4, "task_id": "excluded-1", "status": "excluded", "objective": None, @@ -4094,6 +4632,12 @@ def test_report_handles_excluded_task( self.assertIn("| Excluded Tasks | 1 |", report) self.assertIn("## Excluded Tasks", report) self.assertIn("No clear objective and outcome boundary.", report) + self.assertIn("## Frozen Task Inventory", report) + self.assertIn("Inventory digest:", report) + self.assertIn( + "digest still binds the excluded-candidate inventory", + report, + ) for legacy_field, legacy_value in ( ("task_type", "solution_design"), @@ -4108,7 +4652,7 @@ def test_report_handles_excluded_task( report_name=f"excluded-legacy-{legacy_field}-REPORT.md", ) self.assertEqual(2, rejected.returncode) - self.assertIn("superseded v0.2", rejected.stderr) + self.assertIn("pure Task inventory", rejected.stderr) def test_one_chat_splits_into_two_tasks_and_duplicate_read_is_rejected( self, @@ -4192,9 +4736,9 @@ def test_one_chat_splits_into_two_tasks_and_duplicate_read_is_rejected( report_name="minimal-duplicate-REPORT.md", ) self.assertEqual(2, duplicate_result.returncode) - self.assertIn("precedes established episode", duplicate_result.stderr) + self.assertIn("outside the Task window", duplicate_result.stderr) - def test_task_episode_ownership_anchors_and_weak_fragments_are_enforced( + def test_task_inventory_sources_and_weak_fragments_are_enforced( self, ) -> None: self.write_chat_export() @@ -4203,7 +4747,6 @@ def test_task_episode_ownership_anchors_and_weak_fragments_are_enforced( self.assertEqual(0, collected.returncode, collected.stderr) candidate = read_jsonl(self.artifacts / "candidates.jsonl")[0] assignment_id = "assignment-message" - objective_id = "objective-message" continuation_id = "continuation-message" candidate["visible_messages"].extend( [ @@ -4217,16 +4760,6 @@ def test_task_episode_ownership_anchors_and_weak_fragments_are_enforced( "decision." ), }, - { - "message_id": objective_id, - "created_at": "2026-07-22T10:01:00Z", - "sender_id": OTHER_AGENT_ID, - "sender_kind": "human", - "content": ( - "Use state persistence as the scope and deliver the " - "authoritative state-source decision." - ), - }, { "message_id": continuation_id, "created_at": "2026-07-22T10:04:00Z", @@ -4236,40 +4769,36 @@ def test_task_episode_ownership_anchors_and_weak_fragments_are_enforced( }, ] ) - candidate["chat"]["message_count"] += 3 + candidate["chat"]["message_count"] += 2 write_jsonl(self.artifacts / "candidates.jsonl", [candidate]) valid = self.task_judgment(candidate) + valid["started_at"] = "2026-07-22T10:00:00Z" valid["source_fragments"][0]["message_ids"] = [ assignment_id, - objective_id, + ACCEPTANCE_MESSAGE_ID, continuation_id, MESSAGE_ID, ] - valid["episode"] = { - "ownership": { - "kind": "assigned", - "anchor_message_ids": [assignment_id], - "reason": "A human assigned the objective to the audited Agent.", - }, - "objective_anchor_message_ids": [objective_id], - "outcome_anchor_message_ids": [MESSAGE_ID], - "continuation_message_ids": [continuation_id], - "primary_deliverable": "A decision selecting one state source.", - "boundary_reason": "One assignment led to one terminal decision.", - } + valid["episode"]["objective_anchor_message_ids"] = [assignment_id] + valid["episode"]["outcome_anchor_message_ids"] = [MESSAGE_ID] accepted = self.report( [valid], - evidence_name="episode-valid-evidence.jsonl", - report_name="episode-valid-REPORT.md", + evidence_name="inventory-valid-evidence.jsonl", + report_name="inventory-valid-REPORT.md", ) self.assertEqual(0, accepted.returncode, accepted.stderr) - report = (self.artifacts / "episode-valid-REPORT.md").read_text( + report = (self.artifacts / "inventory-valid-REPORT.md").read_text( encoding="utf-8" ) - self.assertIn("## Clear Task Boundary Rationale", report) - self.assertIn("Ownership anchors", report) + self.assertIn("## Frozen Task Inventory", report) + self.assertIn("Objective sources", report) self.assertIn("Primary deliverable", report) + task_source = ( + self.artifacts / "task-source.jsonl" + ).read_text(encoding="utf-8") + self.assertNotIn("decision_receipt", task_source) + self.assertNotIn('"reads"', task_source) weak_variants = ( "please continue", @@ -4280,25 +4809,14 @@ def test_task_episode_ownership_anchors_and_weak_fragments_are_enforced( "please fix it", "continue the work", "proceed", - "please proceed", "go ahead", "keep going", "carry on", "fix that", - "continue with that", - "try again", "继续修一下", - "继续做", - "接着做", - "修复一下", "修这个", - "重试一下", - "处理一下吧", - "帮忙修下", "“please continue”", - "please continue 🙏", "请继续(谢谢)", - "@fixture-agent,请继续", "@agent-one @agent-two,请继续", ) for index, source_content in enumerate(weak_variants): @@ -4306,30 +4824,29 @@ def test_task_episode_ownership_anchors_and_weak_fragments_are_enforced( next( message for message in weak_candidate["visible_messages"] - if message["message_id"] == objective_id + if message["message_id"] == assignment_id )["content"] = source_content write_jsonl(self.artifacts / "candidates.jsonl", [weak_candidate]) result = self.report( [valid], - evidence_name=f"episode-weak-{index}-evidence.jsonl", - report_name=f"episode-weak-{index}-REPORT.md", + evidence_name=f"inventory-weak-{index}-evidence.jsonl", + report_name=f"inventory-weak-{index}-REPORT.md", ) self.assertEqual(2, result.returncode) - self.assertIn("concrete objective-anchor source", result.stderr) + self.assertIn("concrete objective-source", result.stderr) concrete_variants = ( "Please continue the state-source design and deliver the decision.", - "Proceed with the schema-v3 validator and deliver PR #7.", + "Proceed with the schema-v4 validator and deliver the PR.", "请继续完成状态源方案并交付独立决定", - "继续做 schema v3 validator 并提交 PR #7", - "@agent-one @agent-two,请继续完成状态源方案并交付独立决定", + "@agent-one,请继续完成状态源方案并交付独立决定", ) for index, source_content in enumerate(concrete_variants): concrete_candidate = json.loads(json.dumps(candidate)) next( message for message in concrete_candidate["visible_messages"] - if message["message_id"] == objective_id + if message["message_id"] == assignment_id )["content"] = source_content write_jsonl( self.artifacts / "candidates.jsonl", @@ -4337,123 +4854,21 @@ def test_task_episode_ownership_anchors_and_weak_fragments_are_enforced( ) result = self.report( [valid], - evidence_name=f"episode-concrete-{index}-evidence.jsonl", - report_name=f"episode-concrete-{index}-REPORT.md", + evidence_name=f"inventory-concrete-{index}-evidence.jsonl", + report_name=f"inventory-concrete-{index}-REPORT.md", ) self.assertEqual(0, result.returncode, result.stderr) - mixed_candidate = json.loads(json.dumps(candidate)) - next( - message - for message in mixed_candidate["visible_messages"] - if message["message_id"] == objective_id - )["content"] = "Please continue." - write_jsonl(self.artifacts / "candidates.jsonl", [mixed_candidate]) - mixed_sender_objective = json.loads(json.dumps(valid)) - mixed_sender_objective["source_fragments"][0]["message_ids"].insert( - 2, ACCEPTANCE_MESSAGE_ID - ) - mixed_sender_objective["episode"][ - "objective_anchor_message_ids" - ].append(ACCEPTANCE_MESSAGE_ID) - mixed_sender_result = self.report( - [mixed_sender_objective], - evidence_name="episode-mixed-sender-evidence.jsonl", - report_name="episode-mixed-sender-REPORT.md", - ) - self.assertEqual(2, mixed_sender_result.returncode) - self.assertIn( - "ownership-compatible concrete objective-anchor", - mixed_sender_result.stderr, - ) - - late_objective_id = "late-concrete-objective-message" - late_objective_candidate = json.loads(json.dumps(candidate)) - next( - message - for message in late_objective_candidate["visible_messages"] - if message["message_id"] == objective_id - )["content"] = "Please continue." - late_objective_candidate["visible_messages"].append( - { - "message_id": late_objective_id, - "created_at": "2026-07-22T10:03:00Z", - "sender_id": OTHER_AGENT_ID, - "sender_kind": "human", - "content": ( - "Deliver the independent authoritative state-source " - "decision." - ), - } - ) - late_objective_candidate["chat"]["message_count"] += 1 - write_jsonl( - self.artifacts / "candidates.jsonl", - [late_objective_candidate], - ) - read_before_concrete_objective = json.loads(json.dumps(valid)) - read_before_concrete_objective["source_fragments"][0][ - "message_ids" - ].insert(2, late_objective_id) - read_before_concrete_objective["episode"][ - "objective_anchor_message_ids" - ].append(late_objective_id) - read_before_concrete_result = self.report( - [read_before_concrete_objective], - evidence_name="episode-read-before-concrete-evidence.jsonl", - report_name="episode-read-before-concrete-REPORT.md", - ) - self.assertEqual(2, read_before_concrete_result.returncode) - self.assertIn( - "precedes established episode ownership/objective", - read_before_concrete_result.stderr, - ) - - late_ownership_id = "late-compatible-ownership-message" - late_ownership_candidate = json.loads(json.dumps(candidate)) - next( - message - for message in late_ownership_candidate["visible_messages"] - if message["message_id"] == objective_id - )["sender_id"] = AGENT_ID - late_ownership_candidate["visible_messages"].append( - { - "message_id": late_ownership_id, - "created_at": "2026-07-22T10:03:00Z", - "sender_id": AGENT_ID, - "sender_kind": "agent", - "content": ( - "I accept ownership of the authoritative state-source " - "decision." - ), - } - ) - late_ownership_candidate["chat"]["message_count"] += 1 - write_jsonl( - self.artifacts / "candidates.jsonl", - [late_ownership_candidate], - ) - read_before_compatible_ownership = json.loads(json.dumps(valid)) - read_before_compatible_ownership["source_fragments"][0][ - "message_ids" - ].insert(2, late_ownership_id) - read_before_compatible_ownership["episode"]["ownership"] = { - "kind": "accepted", - "anchor_message_ids": [assignment_id, late_ownership_id], - "reason": ( - "The audited Agent visibly accepted the assigned objective." - ), - } - read_before_ownership_result = self.report( - [read_before_compatible_ownership], - evidence_name="episode-read-before-ownership-evidence.jsonl", - report_name="episode-read-before-ownership-REPORT.md", - ) - self.assertEqual(2, read_before_ownership_result.returncode) - self.assertIn( - "precedes established episode ownership/objective", - read_before_ownership_result.stderr, + write_jsonl(self.artifacts / "candidates.jsonl", [candidate]) + weak_objective = json.loads(json.dumps(valid)) + weak_objective["objective"] = "修一下吧" + weak_result = self.report( + [weak_objective], + evidence_name="inventory-weak-objective-evidence.jsonl", + report_name="inventory-weak-objective-REPORT.md", ) + self.assertEqual(2, weak_result.returncode) + self.assertIn("only a continuation", weak_result.stderr) human_outcome_id = "human-outcome-message" mixed_outcome_candidate = json.loads(json.dumps(candidate)) @@ -4472,54 +4887,40 @@ def test_task_episode_ownership_anchors_and_weak_fragments_are_enforced( [mixed_outcome_candidate], ) mixed_outcome = json.loads(json.dumps(valid)) + mixed_outcome["ended_at"] = "2026-07-22T10:05:30Z" mixed_outcome["source_fragments"][0]["message_ids"].append( human_outcome_id ) - mixed_outcome["episode"]["outcome_anchor_message_ids"].append( + mixed_outcome["episode"]["outcome_anchor_message_ids"] = [ human_outcome_id - ) - mixed_outcome["effect"]["outcome_anchor"] = human_outcome_id - mixed_outcome_result = self.report( + ] + mixed_result = self.report( [mixed_outcome], - evidence_name="episode-mixed-outcome-evidence.jsonl", - report_name="episode-mixed-outcome-REPORT.md", - ) - self.assertEqual(2, mixed_outcome_result.returncode) - self.assertIn( - "every outcome anchor to be a non-empty current-Agent message", - mixed_outcome_result.stderr, + evidence_name="inventory-human-outcome-evidence.jsonl", + report_name="inventory-human-outcome-REPORT.md", ) + self.assertEqual(2, mixed_result.returncode) + self.assertIn("every outcome source", mixed_result.stderr) - write_jsonl(self.artifacts / "candidates.jsonl", [candidate]) - weak_objective = json.loads(json.dumps(valid)) - weak_objective["objective"] = "修一下吧" - weak_objective_result = self.report( - [weak_objective], - evidence_name="episode-weak-objective-evidence.jsonl", - report_name="episode-weak-objective-REPORT.md", + missing_sources = json.loads(json.dumps(valid)) + missing_sources.pop("episode") + missing_result = self.report( + [missing_sources], + evidence_name="inventory-missing-sources-evidence.jsonl", + report_name="inventory-missing-sources-REPORT.md", ) - self.assertEqual(2, weak_objective_result.returncode) - self.assertIn("only a continuation", weak_objective_result.stderr) + self.assertEqual(2, missing_result.returncode) + self.assertIn("objective_source_message_ids", missing_result.stderr) - missing_episode = json.loads(json.dumps(valid)) - missing_episode.pop("episode") - missing_episode_result = self.report( - [missing_episode], - evidence_name="episode-missing-evidence.jsonl", - report_name="episode-missing-REPORT.md", + schema_v3 = json.loads(json.dumps(valid)) + schema_v3["schema_version"] = 3 + schema_result = self.report( + [schema_v3], + evidence_name="inventory-schema-v3-evidence.jsonl", + report_name="inventory-schema-v3-REPORT.md", ) - self.assertEqual(2, missing_episode_result.returncode) - self.assertIn(".episode must be an object", missing_episode_result.stderr) - - schema_v2 = json.loads(json.dumps(valid)) - schema_v2["schema_version"] = 2 - schema_v2_result = self.report( - [schema_v2], - evidence_name="episode-schema-v2-evidence.jsonl", - report_name="episode-schema-v2-REPORT.md", - ) - self.assertEqual(2, schema_v2_result.returncode) - self.assertIn("schema_version 3", schema_v2_result.stderr) + self.assertEqual(2, schema_result.returncode) + self.assertIn("schema_version 4", schema_result.stderr) collapsed = self.task_judgment( candidate, @@ -4531,28 +4932,15 @@ def test_task_episode_ownership_anchors_and_weak_fragments_are_enforced( ) collapsed_result = self.report( [collapsed], - evidence_name="episode-collapsed-evidence.jsonl", - report_name="episode-collapsed-REPORT.md", + evidence_name="inventory-collapsed-evidence.jsonl", + report_name="inventory-collapsed-REPORT.md", ) self.assertEqual(2, collapsed_result.returncode) - self.assertIn( - "must be separate from ownership and objective anchors", - collapsed_result.stderr, - ) - - unbound_effect = json.loads(json.dumps(valid)) - unbound_effect["effect"]["outcome_anchor"] = "arbitrary-anchor" - unbound_effect_result = self.report( - [unbound_effect], - evidence_name="episode-unbound-effect-evidence.jsonl", - report_name="episode-unbound-effect-REPORT.md", - ) - self.assertEqual(2, unbound_effect_result.returncode) - self.assertIn("bind outcome_anchor", unbound_effect_result.stderr) + self.assertIn("must be separate", collapsed_result.stderr) early_outcome_id = "early-outcome-message" - early_outcome_candidate = json.loads(json.dumps(candidate)) - early_outcome_candidate["visible_messages"].append( + early_candidate = json.loads(json.dumps(candidate)) + early_candidate["visible_messages"].append( { "message_id": early_outcome_id, "created_at": "2026-07-22T10:01:30Z", @@ -4561,29 +4949,112 @@ def test_task_episode_ownership_anchors_and_weak_fragments_are_enforced( "content": "An early intermediate state was recorded.", } ) - early_outcome_candidate["chat"]["message_count"] += 1 + early_candidate["chat"]["message_count"] += 1 + write_jsonl(self.artifacts / "candidates.jsonl", [early_candidate]) + early_effect = json.loads(json.dumps(valid)) + early_effect["source_fragments"][0]["message_ids"].insert( + -1, early_outcome_id + ) + early_effect["effect"]["outcome_anchor"] = early_outcome_id + early_result = self.report( + [early_effect], + evidence_name="effect-early-outcome-evidence.jsonl", + report_name="effect-early-outcome-REPORT.md", + ) + self.assertEqual(2, early_result.returncode) + self.assertIn("outcome precedes", early_result.stderr) + + def test_task_source_ignores_malformed_derived_evidence(self) -> None: + self.write_chat_export() + self.write_trace_fixtures() + collected = self.collect("candidates.jsonl") + self.assertEqual(0, collected.returncode, collected.stderr) + candidate = read_jsonl(self.artifacts / "candidates.jsonl")[0] + + baseline_result = run_cli( + "task-source", + "--artifact-root", + str(self.artifacts), + "--agent-workspace", + f"{AGENT_ID}={self.workspace}", + "--candidates", + str(self.artifacts / "candidates.jsonl"), + "--output", + str(self.artifacts / "task-source-baseline.jsonl"), + ) + self.assertEqual(0, baseline_result.returncode, baseline_result.stderr) + baseline = ( + self.artifacts / "task-source-baseline.jsonl" + ).read_bytes() + inventory = self.task_judgment(candidate) + + candidate["collector_diagnostics"] = "malformed" + candidate["tree_source_snapshot"] = {"status": "future-invalid"} + candidate["reads"] = [{"malformed": True}] + candidate["visible_choice_candidates"] = [{"malformed": True}] + candidate["visible_tree_mentions"] = "malformed" + candidate["visible_messages"][0]["decision_receipt"] = { + "future": "invalid" + } write_jsonl( - self.artifacts / "candidates.jsonl", - [early_outcome_candidate], + self.artifacts / "derived-damage-candidates.jsonl", + [candidate], ) - early_outcome = json.loads(json.dumps(valid)) - early_outcome["source_fragments"][0]["message_ids"].insert( - -1, early_outcome_id + damaged_result = run_cli( + "task-source", + "--artifact-root", + str(self.artifacts), + "--agent-workspace", + f"{AGENT_ID}={self.workspace}", + "--candidates", + str(self.artifacts / "derived-damage-candidates.jsonl"), + "--output", + str(self.artifacts / "task-source-derived-damage.jsonl"), ) - early_outcome["episode"]["outcome_anchor_message_ids"].insert( - 0, early_outcome_id + self.assertEqual(0, damaged_result.returncode, damaged_result.stderr) + self.assertEqual( + baseline, + (self.artifacts / "task-source-derived-damage.jsonl").read_bytes(), ) - early_outcome["effect"]["outcome_anchor"] = early_outcome_id - early_outcome_result = self.report( - [early_outcome], - evidence_name="episode-early-outcome-evidence.jsonl", - report_name="episode-early-outcome-REPORT.md", + + task_source = read_jsonl( + self.artifacts / "task-source-derived-damage.jsonl" ) - self.assertEqual(2, early_outcome_result.returncode) - self.assertIn( - "precedes a cited Read completion or choice", - early_outcome_result.stderr, + task_source[0]["reads"] = [] + write_jsonl( + self.artifacts / "task-source-with-derived-field.jsonl", + task_source, + ) + inventory.pop("read") + inventory.pop("effect") + inventory.pop("effect_reason") + episode = inventory.pop("episode") + inventory["objective_source_message_ids"] = episode[ + "objective_anchor_message_ids" + ] + inventory["outcome_source_message_ids"] = episode[ + "outcome_anchor_message_ids" + ] + inventory["primary_deliverable"] = episode["primary_deliverable"] + write_jsonl( + self.artifacts / "stage-one-inventory-draft.jsonl", + [inventory], + ) + rejected = run_cli( + "freeze-tasks", + "--artifact-root", + str(self.artifacts), + "--agent-workspace", + f"{AGENT_ID}={self.workspace}", + "--task-source", + str(self.artifacts / "task-source-with-derived-field.jsonl"), + "--task-inventory-draft", + str(self.artifacts / "stage-one-inventory-draft.jsonl"), + "--task-inventory-output", + str(self.artifacts / "stage-one-inventory.jsonl"), ) + self.assertEqual(2, rejected.returncode) + self.assertIn("unsupported field", rejected.stderr) def test_cross_chat_task_requires_explicit_linkage(self) -> None: self.write_chat_export() @@ -4621,6 +5092,7 @@ def test_cross_chat_task_requires_explicit_linkage(self) -> None: task["episode"]["outcome_anchor_message_ids"].append( SECOND_MESSAGE_ID ) + task["ended_at"] = "2026-07-22T10:05:30Z" no_linkage = self.report( [task], evidence_name="no-linkage-evidence.jsonl", @@ -4710,7 +5182,7 @@ def test_every_available_task_reports_without_a_sampling_gate(self) -> None: self.artifacts / "reviewed-baseline.jsonl", [ { - "schema_version": 3, + "schema_version": 4, "basis": "separately_reviewed_task_cases", "reviewed_at": "2026-07-22T12:00:00Z", "evidence_anchor": { @@ -4719,6 +5191,7 @@ def test_every_available_task_reports_without_a_sampling_gate(self) -> None: }, "clear_tasks": 44, "effect_tasks": 16, + "effects": 16, "effect_counts": { "confirmed": 2, "constrained": 8, @@ -4743,6 +5216,42 @@ def test_every_available_task_reports_without_a_sampling_gate(self) -> None: self.assertNotIn("Derived support", baseline_report) self.assertNotIn("support_counts", baseline_report) + write_jsonl( + self.artifacts / "zero-effect-baseline.jsonl", + [ + { + "schema_version": 4, + "basis": "separately_reviewed_task_cases", + "reviewed_at": "2026-07-22T12:00:00Z", + "evidence_anchor": { + "artifact_id": "reviewed-zero-effect-cases", + "sha256": "b" * 64, + }, + "clear_tasks": 5, + "effect_tasks": 0, + "effects": 0, + "effect_counts": { + "confirmed": 0, + "constrained": 0, + "redirected": 0, + "conflicted": 0, + }, + } + ], + ) + zero_baseline = self.report( + tasks[:1], + evidence_name="zero-baseline-evidence.jsonl", + report_name="zero-baseline-REPORT.md", + reviewed_baseline_name="zero-effect-baseline.jsonl", + ) + self.assertEqual(0, zero_baseline.returncode, zero_baseline.stderr) + zero_report = ( + self.artifacts / "zero-baseline-REPORT.md" + ).read_text(encoding="utf-8") + self.assertIn("| Reviewed effect Tasks | 0 |", zero_report) + self.assertIn("| Reviewed Effects | 0 |", zero_report) + for legacy_field, legacy_value in ( ("task_type", "solution_design"), ("sampling_order", 1), @@ -4756,7 +5265,7 @@ def test_every_available_task_reports_without_a_sampling_gate(self) -> None: report_name=f"legacy-{legacy_field}-REPORT.md", ) self.assertEqual(2, rejected.returncode) - self.assertIn("superseded v0.2", rejected.stderr) + self.assertIn("pure Task inventory", rejected.stderr) def test_symlinked_artifact_output_is_rejected(self) -> None: self.write_chat_export()