diff --git a/docs/reference/required-work-context.md b/docs/reference/required-work-context.md index ea8cc48f93..ed3990e43d 100644 --- a/docs/reference/required-work-context.md +++ b/docs/reference/required-work-context.md @@ -210,6 +210,24 @@ retains its complete body and declaration. Todo-less reads and the explicit reduces repeated binding observations without treating a shorter context as acceptance, authority or a measured model benefit. +For enabled native quota with selected work, the same acceptance snapshot may +include `outcome_context` navigation. `suggested_outcome_ids` prioritizes work +connected through registered Todo relations, shared acceptance/source/evidence +references, or failed/stale observations. An Agent owning two outcomes does not +by itself connect their work. `coverage_complete` describes coverage of the +registered relationship graph, not completeness of model understanding or proof +that other outcomes are irrelevant. Missing relationships, unbound work or global +acceptance widen suggestions and expose `limitations`. + +This is advisory reading order. It neither filters outcomes nor adds a full-read +obligation, admission rule, approval or completion proof. The complete contract +stays in the existing capture; use generic `quota context-read --select` to open +needed sections, with `--list` where the installed reader supports discovery. +No outcome-specific reader or pagination protocol is introduced. The suggestion +is bound to the exact acceptance digest and provider revision; host oracle +readback that changes the source drops that stale advice. Disabled delivery, +explicit inventory inspection and signed TurnEnvelope retain their prior shapes. + For an exact Todo, the existing command now returns one complete record: ```sh diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index 95149b628b..65a3f1be4f 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -654,6 +654,10 @@ def handle_quota_command( turn_instance_id=heartbeat_turn_id, interaction_projection_hooks=interaction_projection_hooks, turn_start_hook_dispatch=turn_start_hook_dispatch, + context_navigation=( + native_context_activation(args)["enabled"] + and not bool(getattr(args, "turn_envelope", False)) + ), goal_ref=goal_ref, ) _attach_turn_start_hook_dispatch(payload, turn_start_hook_dispatch) diff --git a/loopx/control_plane/goals/acceptance.py b/loopx/control_plane/goals/acceptance.py index cbc43b3ec7..7137059b44 100644 --- a/loopx/control_plane/goals/acceptance.py +++ b/loopx/control_plane/goals/acceptance.py @@ -195,6 +195,7 @@ def inspect_goal_acceptance( agent_id: str | None = None, todo_id: str | None = None, include_work_details: bool = True, + context_agent_id: str | None = None, delivery_workspace: Mapping[str, Any] | None = None, validation_workspace_path: Path | None = None, ) -> dict[str, Any]: @@ -205,13 +206,20 @@ def inspect_goal_acceptance( {**route, "registered_agents": registered_agent_ids_for_goal(goal), **({"agent_id": agent_id} if agent_id is not None else {}), **({"todo_id": todo_id} if todo_id is not None else {}), + **({"context_agent_id": context_agent_id} if context_agent_id is not None else {}), **({"include_work_details": False} if not include_work_details else {})}, ) + from copy import deepcopy + captured_contract = deepcopy(basis["goal_acceptance_contract"]) if "outcome_context" in basis else None basis["goal_acceptance_contract"] = observe_goal_acceptance_oracles( registry_path=registry_path, goal_id=goal_id, contract=basis["goal_acceptance_contract"], criteria=(basis.get("contract") or {}).get("criteria", []), task_repository=(basis.get("todo") or {}).get("task_repository"), delivery_workspace=delivery_workspace, validation_workspace_path=validation_workspace_path) + if captured_contract is not None and basis["goal_acceptance_contract"] != captured_contract: + # The optional navigation binds the exact owner's observation. Do not + # reuse it after host-side oracle readback changes those facts. + basis.pop("outcome_context", None) return basis diff --git a/loopx/control_plane/goals/acceptance_authority.ts b/loopx/control_plane/goals/acceptance_authority.ts index 9372dfc139..c5d1b6ef81 100644 --- a/loopx/control_plane/goals/acceptance_authority.ts +++ b/loopx/control_plane/goals/acceptance_authority.ts @@ -1,4 +1,5 @@ -import {bindGoalOutcomesStateOwner, GoalOutcomeValidationError, goalOutcomeCheckBasis, refineGoalOutcome} from "./outcome_requirements.ts"; +import {bindGoalOutcomesStateOwner, GoalOutcomeValidationError, goalOutcomeCheckBasis, refineGoalOutcome, + outcomeContextNavigation} from "./outcome_requirements.ts"; /** Acceptance effects use the existing canonical head, CAS and operation journal. * Only trusted local owner/host adapters may invoke these mutation exports. */ import {isAbsolute} from "node:path"; @@ -561,6 +562,7 @@ export async function inspectGoalAcceptance( includeWorkDetails = true, agentId?: string, registeredAgents?: unknown, + contextAgentId?: string, ): Promise { const head = await store.loadAuthority(); if (head.status !== "loaded") return source(store, {...head}); @@ -582,6 +584,14 @@ export async function inspectGoalAcceptance( ...(state ? acceptanceTask(key, todo, state) : {todo_id: key, state: "unbound", criterion_ids: [], applicable: false}), })); + const contract = projectGoalAcceptance(head.head, goalId, registeredAgents); + const context = !includeWorkDetails && contextAgentId !== undefined && todoId !== undefined + ? outcomeContextNavigation(head.head, goalId, contextAgentId, todoId, contract) : null; + if (context && (!Array.isArray(registeredAgents) || !registeredAgents.includes(contextAgentId))) { + context.coverage_complete = false; + context.limitations = [...context.limitations as string[], "context_agent_unavailable"]; + context.suggested_outcome_ids = ((contract.outcome_requirements as JsonObject).outcomes as JsonObject[]).map(o => o.id); + } return source(store, {status: "loaded", provider_revision: head.provider_revision, revision: state?.revision ?? null, contract_digest: state?.digest ?? null, contract: state ? state.document : null, tasks, @@ -589,7 +599,8 @@ export async function inspectGoalAcceptance( completion_requirements: acceptanceCompletionRequirements(head.head, goalId, todoId)}), ...(agentId === undefined || currentGoalObjective(head.head) === null ? {} : {objective_work_digest: goalObjectiveWorkDigest(head.head, agentId), goal_objective_guard: goalObjectiveWorkGuard(head.head, agentId)}), - goal_acceptance_contract: projectGoalAcceptance(head.head, goalId, registeredAgents)}); + ...(context === null ? {} : {outcome_context: {...context, provider_revision: head.provider_revision}}), + goal_acceptance_contract: contract}); } async function local(value: unknown, kind: "inspect" | "configure" | "verify" | "lifecycle" | "refine" | "objective"): Promise { @@ -609,7 +620,8 @@ async function local(value: unknown, kind: "inspect" | "configure" | "verify" | request.goal_ref === undefined ? undefined : parseWireExactGoalRef(request.goal_ref, "acceptance request goal_ref"), request.include_work_details !== false, request.agent_id === undefined ? undefined : requireAuthorityStoreId(request.agent_id, "agent_id"), - request.registered_agents); + request.registered_agents, + request.context_agent_id === undefined ? undefined : requireAuthorityStoreId(request.context_agent_id, "context_agent_id")); } if (kind === "configure") return configureGoalAcceptance(store, request); if (kind === "refine") return refineGoalAcceptanceOutcome(store, request); diff --git a/loopx/control_plane/goals/outcome_requirements.ts b/loopx/control_plane/goals/outcome_requirements.ts index 47db417fd6..1d9ba9857b 100644 --- a/loopx/control_plane/goals/outcome_requirements.ts +++ b/loopx/control_plane/goals/outcome_requirements.ts @@ -7,8 +7,11 @@ import {readGoalAcceptanceLifecycle, sameExactGoalRef, parseWireExactGoalRef, ty import {BARE_SHA256_PATTERN} from "../content_digest.ts"; import {goalAcceptanceTodoDigest} from "./acceptance_work_basis.ts"; +import {projectRelations, todoRef} from "../work_items/planning_relations.ts"; +import {normalizeTodoResumeWhen, TODO_RESUME_NORMALIZE_REQUEST_SCHEMA_VERSION} from "../todos/resume_condition.ts"; export const GOAL_OUTCOMES_SCHEMA = "loopx_goal_outcomes_v0"; +export const OUTCOME_CONTEXT_NAVIGATION_SCHEMA = "outcome_context_navigation_v1"; interface Requirement extends JsonObject {id: string; description: string} interface ReviewItem extends JsonObject {id: string; conclusion: "met" | "unmet" | "uncertain"; evidence_refs: string[]; note: string} interface OutcomeReview extends JsonObject {basis_digest: string; items: ReviewItem[]} @@ -230,6 +233,112 @@ export function projectGoalOutcomes(head: JsonObject, goalId: string, acceptance execution_authority_granted: false, goal_completion_assessed: false}, acceptance); } +/** Suggest reading order from registered relationships, never reading obligations. + * Use the complete canonical work snapshot and the existing relation owner; + * the bounded planning horizon cannot establish this registered-relation closure. + * Unknown relationships widen suggestions, never force a full-file model read. + * Ownership alone neither establishes relevance nor proves independence. + */ +export function outcomeContextNavigation(head: JsonObject, goalId: string, agentId: string, + todoId: string, acceptance: JsonObject): JsonObject | null { + const declaration = acceptance.outcome_requirements as JsonObject | undefined; + if (!declaration || !Array.isArray(declaration.outcomes)) return null; + const outcomes = declaration.outcomes as JsonObject[]; + const work = indexCoordinationProjectionTodos(head, goalId).todos; + const suggested = new Set(); + const related = new Set([todoId]); + const reasons = new Set(); + if (declaration.current_lifetime !== true) reasons.add("outcome_lifetime_unavailable"); + if (!work.has(todoId) || !outcomes.some(o => (o.todo_ids as string[]).includes(todoId))) + reasons.add("selected_work_unbound"); + if (Array.isArray(acceptance.criteria) && acceptance.criteria.length && + (acceptance.scope as JsonObject | undefined)?.kind !== "selected_work") + reasons.add("goal_wide_acceptance"); + const adjacency = new Map>(); + const connect = (left: string, right: string) => { + for (const [from, to] of [[left, right], [right, left]]) { + const neighbors = adjacency.get(from) ?? new Set(); + neighbors.add(to); adjacency.set(from, neighbors); + } + }; + const rows = [...work.values()].map(todo => ({todo_id: String(todo.todo_id), + successor_todo_ids: (todo.successor_todo_ids ?? []) as string[], + ...Object.fromEntries(["unblocks_todo_id", "superseded_by", "resume_when"] + .filter(key => typeof todo[key] === "string" && todo[key] !== "") + .map(key => [key, todo[key]]))})); + const sharedRefs = new Map(); + for (const relation of projectRelations(rows)) { + const target = todoRef(relation.to_ref); + if (target !== null) { + if (!work.has(target)) reasons.add("work_relation_unavailable"); + else connect(relation.from_todo_id, target); + } else if (relation.relation === "resumes_when") { + // Exact external conditions may be shared without naming a Todo. + // No substring/prose matching or invented cross-Agent dependency. + const normalized = normalizeTodoResumeWhen({ + schema_version: TODO_RESUME_NORMALIZE_REQUEST_SCHEMA_VERSION, resume_when: relation.to_ref, + }); + if (normalized === null) { reasons.add("work_relation_unavailable"); continue; } + const key = `${relation.relation}:${normalized}`; + const previous = sharedRefs.get(key); + if (previous) connect(previous, relation.from_todo_id); + else sharedRefs.set(key, relation.from_todo_id); + } else reasons.add("work_relation_unavailable"); + } + const checkScope = acceptance.scope as JsonObject | undefined; + if (checkScope?.kind === "selected_work" && Array.isArray(checkScope.todo_ids)) { + const ids = checkScope.todo_ids as string[]; + for (const key of ids) { + if (!work.has(key)) reasons.add("acceptance_work_unavailable"); + else if (ids[0]) connect(ids[0], key); + } + } + const materialWork = new Map(); + for (const outcome of outcomes) { + const ids = outcome.todo_ids as string[]; + if (!Array.isArray(ids) || !ids.length || ids.some(key => !work.has(key))) { + reasons.add("outcome_work_unavailable"); continue; + } + // One outcome may legitimately span several Agents' work. Ownership is + // not a substitute for its actual linked tasks or their shared acceptance. + for (const key of ids) connect(ids[0], key); + // A common declared request or evidence item may carry shared obligations + // or opposing interpretations. Expand its actual work without deciding + // relevance from descriptions or promoting either review to acceptance. + const refs = [...(outcome.sources as JsonObject[]).map(source => String(source.ref)), + ...(outcome.requirements as JsonObject[]).flatMap(r => + ((r.agent_review as JsonObject | undefined)?.evidence_refs ?? []) as string[])]; + for (const ref of refs) { + const previous = materialWork.get(ref); + if (previous) connect(previous, ids[0]); + else materialWork.set(ref, ids[0]); + } + // A failed/stale current observation cannot be hidden solely by ownership; + // its related work and complete evidence also need assessment. + if ((outcome.requirements as JsonObject[]).some(r => r.status === "failed" || r.status === "stale")) + suggested.add(String(outcome.id)); + if (suggested.has(String(outcome.id))) for (const key of ids) related.add(key); + } + const queue = [...related]; + for (let cursor = 0; cursor < queue.length; cursor++) for (const next of adjacency.get(queue[cursor]) ?? []) { + if (!related.has(next)) { related.add(next); queue.push(next); } + } + for (const outcome of outcomes) { + if ((outcome.todo_ids as string[]).some(key => related.has(key))) suggested.add(String(outcome.id)); + } + if (reasons.size) for (const outcome of outcomes) suggested.add(String(outcome.id)); + return {schema_version: OUTCOME_CONTEXT_NAVIGATION_SCHEMA, goal_id: goalId, agent_id: agentId, todo_id: todoId, + outcome_revision: declaration.revision, + acceptance_digest: canonicalAuthoritySha256(acceptance), + coverage_complete: reasons.size === 0, limitations: [...reasons].sort(), + suggested_outcome_ids: outcomes.filter(o => suggested.has(String(o.id))).map(o => o.id), + instruction: "Navigation from registered work relationships, not a reading gate or a proof of independence. " + + "Start with the suggested outcomes; inspect other criteria, shared constraints and contrary evidence when the decision needs them. " + + "All outcomes remain in goal_acceptance_contract. Use the same capture's generic field index and selectors for detail. " + + "Incomplete coverage calls for judgment and relevant expansion, not an automatic full-file read. " + + "Goal intent, required criteria, permissions, stops and verification obligations still apply."}; +} + /** Re-use after the host observes oracle freshness; raw stored evidence cannot * become a current passing result without those observations. No state writes. */ export function observeOutcomeRequirements(declaration: JsonObject, acceptance: JsonObject, diff --git a/loopx/control_plane/quota/live_decision.py b/loopx/control_plane/quota/live_decision.py index 982c5695d1..c8af6e877a 100644 --- a/loopx/control_plane/quota/live_decision.py +++ b/loopx/control_plane/quota/live_decision.py @@ -545,6 +545,7 @@ def build_live_quota_should_run_decision( interaction_projection_hooks: Sequence[InteractionProjectionHookRegistration] | None = None, turn_start_hook_dispatch: Mapping[str, Any] | None = None, + context_navigation: bool = False, workspace_path: Path | None = None, goal_ref: Mapping[str, object] | None = None, inline_goal_source: bool = False, @@ -825,5 +826,6 @@ def build_live_quota_should_run_decision( from ..work_items.context_readback import attach_work_context attach_work_context(payload, registry_path=registry_path, runtime_root=runtime_root, - hook_dispatch=turn_start_hook_dispatch, inline_goal_source=inline_goal_source) + hook_dispatch=turn_start_hook_dispatch, context_navigation=context_navigation, + inline_goal_source=inline_goal_source) return payload diff --git a/loopx/control_plane/work_items/context_readback.py b/loopx/control_plane/work_items/context_readback.py index e110d6e627..35faa71fd8 100644 --- a/loopx/control_plane/work_items/context_readback.py +++ b/loopx/control_plane/work_items/context_readback.py @@ -20,7 +20,7 @@ from ..todos.summary_item import todo_text_content_revision -def _source_content(read, *, registry_path, runtime_root, goal_id, todo_id, +def _source_content(read, *, registry_path, runtime_root, goal_id, todo_id, agent_id=None, inline_goal_source=False): source = read.get("source") if source == "goal_state": @@ -73,7 +73,7 @@ def _source_content(read, *, registry_path, runtime_root, goal_id, todo_id, # selected-Todo read owns its body; do not transport it a second time # through the bounded acceptance RPC before discarding it here. return inspect_goal_acceptance(registry_path=registry_path, goal_id=goal_id, - runtime_root=str(runtime_root), todo_id=todo_id, include_work_details=False) + runtime_root=str(runtime_root), todo_id=todo_id, context_agent_id=agent_id, include_work_details=False) if source == "selected_todo": if not todo_id: raise ValueError("selected Todo identity is unavailable") @@ -131,6 +131,7 @@ def _user_context(*, registry_path, runtime_root, goal_id, agent_id): def attach_work_context(payload: dict[str, Any], *, registry_path: Path, runtime_root: Path, hook_dispatch: dict[str, Any] | None, + context_navigation: bool = False, inline_goal_source: bool = False) -> None: packet_selected = payload.get("selected_todo") selected_text_snapshot = None @@ -157,7 +158,7 @@ def attach_work_context(payload: dict[str, Any], *, registry_path: Path, try: result["content"] = _source_content(read, registry_path=registry_path, runtime_root=runtime_root, goal_id=goal_id, todo_id=plan.get("todo_id"), - inline_goal_source=inline_goal_source) + agent_id=agent_id if context_navigation else None, inline_goal_source=inline_goal_source) except (OSError, ValueError, RuntimeError): result["error_code"] = "context_source_unavailable" results.append(result) diff --git a/tests/control_plane/test_outcome_context_navigation_cli.py b/tests/control_plane/test_outcome_context_navigation_cli.py new file mode 100644 index 0000000000..5b6df27a55 --- /dev/null +++ b/tests/control_plane/test_outcome_context_navigation_cli.py @@ -0,0 +1,119 @@ +"""Native quota carries optional navigation without replacing the acceptance body.""" +from __future__ import annotations + +import argparse +import json + +import pytest + +from canonical_authority_fixture import initialize_canonical_authority +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.testing.canary_harness import run_json_cli_result, write_fixture_registry +from loopx.control_plane.todos.machine_section_projection import render_canonical_todo_sections +from loopx.control_plane.turn_driver.codex_context import codex_context_hook +from loopx.cli_commands.quota_capture import read_context_capture + + +@pytest.mark.parametrize("inherited", [False, True]) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("marked_goal", [True, False]) +def test_quota_navigation_is_opt_in_and_original_capture_remains_complete(tmp_path, monkeypatch, marked_goal, provider, inherited): + goal, agent = "scope-cli", "author" + runtime, registry, state = tmp_path / "runtime", tmp_path / "registry.json", tmp_path / "state.md" + todos = [{"schema_version": "todo_item_v0", "todo_id": f"todo_{owner}", "role": "agent", + "text": f"Complete {owner} export. Preserve cancellation at the tail.", "status": "open", "done": False, + "task_class": "advancement_task", "action_kind": "implement", "index": i + 1, + "source_section": "Agent Todo", "archive_state": "active", "claimed_by": owner} + for i, owner in enumerate((agent, "peer"))] + body = "---\nstatus: active\n---\n# Goal\n## Objective\nDeliver compatible exports. Never publish.\n## Agent Todo\n\n## User Todo\n\n## Completed Work Archive\n" + state.write_text(render_canonical_todo_sections(body, todos, provider_revision="fixture-source").markdown + if marked_goal else body) + write_fixture_registry(project=tmp_path, runtime_root=runtime, registry_path=registry, goal_id=goal, + domain="software", adapter_kind="generic_project_goal_v0", state_file=str(state), + registered_agents=[agent, "peer"], quota_allowed_slots=None) + initialize_canonical_authority(runtime, goal, + build_todo_runtime_shadow_projection(goal_id=goal, todos=todos, handoff_mode="soft_claim"), state_path=state, provider=provider) + + def cli(*args): + code, payload = run_json_cli_result(*args, registry_path=registry, runtime_root=runtime) + assert code == 0, json.dumps(payload) + return payload + + for owner in (agent, "peer"): + document = tmp_path / f"{owner}.json" + document.write_text(json.dumps({"outcome_id": owner, "owner_agent_id": owner, + "objective": f"Deliver the {owner} export.", "todo_ids": [f"todo_{owner}"], + "requirements": [{"id": f"r{i}", "description": + (f"{owner} exact requirement {i}. " + "Keep every compatibility and recovery condition. " * 7).strip()} + for i in range(12)], + "source": {"ref": f"message:{owner}", "summary": "Original independent export request."}})) + before = cli("goal-acceptance", "inspect", "--goal-id", goal) + cli("goal-acceptance", "refine", "--goal-id", goal, "--agent-id", owner, "--document", str(document), + "--expected-provider-revision", before["provider_revision"], "--operation-id", f"outcome-{owner}", "--execute") + if inherited: + from tests.control_plane.test_context_machine_configuration import configure + code, applied = configure(registry, runtime, tmp_path, "on") + assert code == 0 and applied["readback_verified"], applied + off = cli("quota", "should-run", "--goal-id", goal, "--agent-id", agent, "--context-delivery", "off") + source = next(row["content"] for row in off["interaction_contract"]["agent_channel"]["work_context"]["sources"] + if row.get("source") == "goal_acceptance") + assert "outcome_context" not in source + assert len(source["goal_acceptance_contract"]["outcome_requirements"]["outcomes"]) == 2 + native = tmp_path / "native" + native.mkdir(mode=0o700) + (tmp_path / "captures").mkdir(mode=0o700) + monkeypatch.setenv("CODEX_THREAD_ID", "scope-session") + monkeypatch.setenv("LOOPX_CONTEXT_DELIVERY", "on") + monkeypatch.setenv("LOOPX_EXECUTION_MODE", "heartbeat") + codex_context_hook(str(native), {"hook_event_name": "SessionStart", "session_id": "scope-session", + "cwd": str(tmp_path), "source": "startup"}) + first = cli("quota", "should-run", "--goal-id", goal, "--agent-id", agent, + *([] if inherited else ["--context-delivery", "on"]), + "--runtime-profile", "generic_cli", "--codex-context-dir", str(native), + "--decision-output-root", str(tmp_path / "captures"), "--turn-instance-id", "scope-turn") + page = read_context_capture(argparse.Namespace(capture_file=first["recovery"]["path"], + capture_sha256=first["recovery"]["sha256"], offset=0, page_bytes=64000, max_page_output_bytes=64000, format="json")) + parts = [] + while True: + assert page["ok"], page + parts.append(page["content"]) + if page["eof"]: + break + page = read_context_capture(argparse.Namespace(capture_file=first["recovery"]["path"], capture_sha256=first["recovery"]["sha256"], + offset=page["next_offset"], page_bytes=64000, max_page_output_bytes=64000, format="json")) + presentation = json.loads("".join(parts)) + assert presentation.get("schema_version") != "outcome_context_projection_v0" + if not marked_goal: + goal_source = next(row["content"] for row in + presentation["interaction_contract"]["agent_channel"]["work_context"]["sources"] + if row.get("source") == "goal_state") + assert goal_source["full_source"] is True and goal_source["text"] == body + observed = next(row["content"] for row in presentation["interaction_contract"]["agent_channel"]["work_context"]["sources"] + if row.get("source") == "goal_acceptance") + assert observed["outcome_context"]["suggested_outcome_ids"] == [agent] + assert observed["outcome_context"]["coverage_complete"] is True + assert "required_outcome_ids" not in observed["outcome_context"] + assert [row["id"] for row in observed["goal_acceptance_contract"]["outcome_requirements"]["outcomes"]] == [agent, "peer"] + assert observed["goal_acceptance_contract"] == source["goal_acceptance_contract"] + # Use the shared exact selector, without a specialized outcome reader. + source_index = next(i for i, row in enumerate(presentation["interaction_contract"]["agent_channel"]["work_context"]["sources"]) + if row.get("source") == "goal_acceptance") + pointer = f"/interaction_contract/agent_channel/work_context/sources/{source_index}/content/goal_acceptance_contract/outcome_requirements/outcomes/1" + detail = cli("quota", "context-read", "--capture-file", first["recovery"]["path"], + "--capture-sha256", first["recovery"]["sha256"], "--select", pointer, + "--page-bytes", "64000", "--max-page-output-bytes", "64000") + assert detail["eof"] and "peer exact requirement 11" in detail["content"] + + # Exercise the real acceptance adapter against this same provider snapshot, + # injecting only the host oracle observation after the canonical read. + from loopx.control_plane.goals import acceptance as acceptance_io + observe = acceptance_io.observe_goal_acceptance_oracles + def changed(**kwargs): + result = observe(**kwargs) + result["outcome_requirements"]["outcomes"][1]["requirements"][0]["status"] = "stale" + return result + monkeypatch.setattr(acceptance_io, "observe_goal_acceptance_oracles", changed) + fresh = acceptance_io.inspect_goal_acceptance(registry_path=registry, goal_id=goal, + runtime_root=str(runtime), todo_id=f"todo_{agent}", context_agent_id=agent, include_work_details=False) + assert "outcome_context" not in fresh + assert fresh["goal_acceptance_contract"]["outcome_requirements"]["outcomes"][1]["requirements"][0]["status"] == "stale" diff --git a/tests/control_plane_ts/outcome_context_navigation.test.ts b/tests/control_plane_ts/outcome_context_navigation.test.ts new file mode 100644 index 0000000000..07ba50deb2 --- /dev/null +++ b/tests/control_plane_ts/outcome_context_navigation.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {randomUUID} from "node:crypto"; +import {Pool} from "pg"; +import {PostgreSqlAuthorityStore, installPostgreSqlAuthorityStoreSchema} from "../../loopx/control_plane/coordination/postgresql_authority_store.ts"; +import {mkdtemp, rm} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; +import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; +import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; +import {projectGoalAcceptance} from "../../loopx/control_plane/goals/acceptance_contract.ts"; +import {inspectGoalAcceptance} from "../../loopx/control_plane/goals/acceptance_authority.ts"; +import {refineGoalOutcome, outcomeContextNavigation} from "../../loopx/control_plane/goals/outcome_requirements.ts"; +import {authorityProjectionFixture} from "./authority_projection_fixture.ts"; + +const goal = "navigation-goal", agent = "author", todo = "todo_selected"; + +function fixture(extra: JsonObject = {}, sharedSource = false) { + let head = authorityProjectionFixture(goal, [ + {todo_id: todo, role: "agent", status: "open", done: false, text: "Selected task tail: preserve cancellation.", + task_class: "advancement_task", claimed_by: agent, archive_state: "active", ...extra}, + {todo_id: "todo_peer", role: "agent", status: "open", done: false, text: "Independent peer task.", + task_class: "advancement_task", claimed_by: "peer", archive_state: "active"}, + ]); + for (const [owner, work] of [[agent, todo], ["peer", "todo_peer"]]) { + head = {...head, goal_outcomes: refineGoalOutcome(head, goal, null, owner, { + outcome_id: owner, owner_agent_id: owner, objective: `Preserve ${owner} requirements.`, todo_ids: [work], + requirements: Array.from({length: 12}, (_, i) => ({id: `r${i}`, + description: `${owner} requirement ${i}: ` + "Preserve exact behavior and evidence. ".repeat(8).trim()})), + source: {ref: sharedSource ? "message:shared" : `message:${owner}`, summary: "Original explicit requirement."}, + })}; + } + return head; +} + +function navigation(head = fixture(), change?: (acceptance: JsonObject) => void) { + const acceptance = projectGoalAcceptance(head, goal, [agent, "peer"]); + change?.(acceptance); + const before = structuredClone({head, acceptance}); + const result = outcomeContextNavigation(head, goal, agent, todo, acceptance)!; + assert.deepEqual({head, acceptance}, before); + assert.equal(result.acceptance_digest, canonicalAuthoritySha256(acceptance)); + return result; +} + +test("independent work remains available; suggestions do not impose a read gate", () => { + const result = navigation(); + assert.equal(result.coverage_complete, true); + assert.deepEqual(result.suggested_outcome_ids, [agent]); + assert.equal(result.required_outcome_ids, undefined); + assert.match(String(result.instruction), /not a reading gate/); +}); + +for (const [name, extra, shared] of [ + ["successor dependency", {successor_todo_ids: ["todo_peer"]}, false], + ["unblocks dependency", {unblocks_todo_id: "todo_peer"}, false], + ["shared source", {}, true], +] as const) test(`${name} prioritizes the related peer`, () => { + assert.deepEqual(navigation(fixture(extra, shared)).suggested_outcome_ids, [agent, "peer"]); +}); + +for (const status of ["failed", "stale"]) test(`${status} peer evidence draws attention across ownership`, () => { + const result = navigation(fixture(), acceptance => { + acceptance.outcome_requirements.outcomes[1].requirements[0].status = status; + }); + assert.deepEqual(result.suggested_outcome_ids, [agent, "peer"]); +}); + +test("ownership alone does not make every past outcome relevant", () => { + const head = fixture(); + head.goal_outcomes.outcomes[1].owner_agent_id = agent; + assert.deepEqual(navigation(head).suggested_outcome_ids, [agent]); +}); + +test("shared review evidence joins tasks even with independent original sources", () => { + const result = navigation(fixture(), acceptance => { + for (const row of acceptance.outcome_requirements.outcomes) + row.requirements[0].agent_review = {evidence_refs: ["evidence:shared"]}; + }); + assert.deepEqual(result.suggested_outcome_ids, [agent, "peer"]); +}); + +for (const kind of ["missing_relation", "unbound_work", "goal_wide", "lifetime"]) + test(`${kind} exposes incomplete coverage without deleting or mandating bodies`, () => { + const head = fixture(kind === "missing_relation" ? {successor_todo_ids: ["todo_missing"]} : {}); + const result = navigation(head, acceptance => { + if (kind === "unbound_work") acceptance.outcome_requirements.outcomes[0].todo_ids = []; + if (kind === "goal_wide") { acceptance.criteria = [{id: "global"}]; acceptance.scope = {kind: "all_advancement"}; } + if (kind === "lifetime") acceptance.outcome_requirements.current_lifetime = false; + }); + assert.equal(result.coverage_complete, false); + assert.ok(result.limitations.length); + assert.deepEqual(result.suggested_outcome_ids, [agent, "peer"]); + assert.match(String(result.instruction), /not an automatic full-file read/); + }); + +for (const provider of ["file", "sqlite", "postgresql"]) test(`${provider}: inspect derives navigation from one actual canonical snapshot without mutations`, + {skip: provider === "postgresql" && !process.env.LOOPX_TEST_POSTGRES_URL}, async t => { + const root = await mkdtemp(join(tmpdir(), "scope-provider-")); t.after(() => rm(root, {recursive: true, force: true})); + let store; + if (provider === "postgresql") { + const pool = new Pool({connectionString: process.env.LOOPX_TEST_POSTGRES_URL, max: 4}); + const db = {connect: async () => { + const client = await pool.connect(); + return {query: async (sql: string, values?: readonly unknown[]) => client.query(sql, values ? [...values] : undefined), + release: () => client.release()}; + }}; + const tenant = `outcome-navigation-${randomUUID()}`; + t.after(async () => { + try { + for (const table of ["authority_commits", "authority_heads"]) + await pool.query(`DELETE FROM loopx_control_plane.${table} WHERE tenant_id=$1 AND goal_id=$2`, [tenant, goal]); + } finally { await pool.end(); } + }); + await installPostgreSqlAuthorityStoreSchema(db, `postgresql:${"b".repeat(32)}`); + store = new PostgreSqlAuthorityStore(db, {tenant_id: tenant, goal_id: goal}); + } else store = provider === "file" ? new FileAuthorityStore(root, goal) : new SqliteAuthorityStore(root, goal); + const seeded = await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, + next_projection: fixture(), events: [], receipts: []}); + assert.equal(seeded.status, "applied", JSON.stringify(seeded)); + const before = await store.loadAuthority(); let reads = 0; const load = store.loadAuthority.bind(store); + store.loadAuthority = async () => {reads++; return load();}; + const scoped = await inspectGoalAcceptance(store, goal, todo, undefined, false, undefined, [agent, "peer"], agent); + assert.equal(reads, 1); + assert.equal(scoped.outcome_context.coverage_complete, true); + assert.deepEqual(scoped.outcome_context.suggested_outcome_ids, [agent]); + assert.equal(scoped.outcome_context.acceptance_digest, canonicalAuthoritySha256(scoped.goal_acceptance_contract)); + const off = await inspectGoalAcceptance(store, goal, todo, undefined, false, undefined, [agent, "peer"]); + assert.equal(off.outcome_context, undefined); + assert.deepEqual(off.goal_acceptance_contract, scoped.goal_acceptance_contract); + assert.deepEqual(await store.loadAuthority(), before); +});