diff --git a/.env.example b/.env.example index 663f31b3f..d7ff43803 100644 --- a/.env.example +++ b/.env.example @@ -18,9 +18,8 @@ POWERCONTEXT_SERVER_AUTH_ENABLED=false # POWERCONTEXT_SERVER_AUTH_TOKEN=replace-me # Dashboard ------------------------------------------------------------------- -# Every Coding Agent below uses this same Scope ID. +# The Dashboard discovers Scopes from the Server. The Server creates a default Scope on first startup. POWERCONTEXT_SERVER_DASHBOARD_ENABLED=true -POWERCONTEXT_SERVER_DASHBOARD_SCOPES='[{"scope_id":"project:quickstart","display_name":"Quick Start"}]' # Logging, metrics, and tracing ----------------------------------------------- POWERCONTEXT_SERVER_LOGGING_LEVEL=INFO @@ -108,13 +107,14 @@ POWERCONTEXT_CLIENT_TIMEOUT=10 # POWERCONTEXT_CLIENT_API_TOKEN=replace-me # Coding Agent integrations --------------------------------------------------- -# Keep these values equal to the Dashboard scope above. Load .env before starting the selected Agent. -POWERCONTEXT_CODEX_SCOPE_ID=project:quickstart -POWERCONTEXT_CLAUDE_SCOPE_ID=project:quickstart -POWERCONTEXT_DSH_SCOPE_ID=project:quickstart -POWERCONTEXT_OPENCODE_SCOPE_ID=project:quickstart -POWERCONTEXT_PI_SCOPE_ID=project:quickstart -POWERCONTEXT_LANGGRAPH_SCOPE_ID=project:quickstart +# Load .env before starting the selected Agent. Codex binds each Session through the Scope service. +# Set an integration's SCOPE_ID only to select an existing Scope explicitly. +# POWERCONTEXT_CODEX_SCOPE_ID=scp_existing +# POWERCONTEXT_CLAUDE_SCOPE_ID=scp_existing +# POWERCONTEXT_DSH_SCOPE_ID=scp_existing +# POWERCONTEXT_OPENCODE_SCOPE_ID=scp_existing +# POWERCONTEXT_PI_SCOPE_ID=scp_existing +# POWERCONTEXT_LANGGRAPH_SCOPE_ID=scp_existing # Local Server endpoints used by integrations. POWERCONTEXT_CLAUDE_SERVER_URL=http://127.0.0.1:8000 diff --git a/docs/en/development/server-web-ui.md b/docs/en/development/server-web-ui.md index d30083dbe..2dcdfb60c 100644 --- a/docs/en/development/server-web-ui.md +++ b/docs/en/development/server-web-ui.md @@ -72,20 +72,22 @@ versioned prefixes. ## Understand Dashboard data -The browser authenticates against `/dashboard/scopes`, then requests `/v1/stats` with the selected `scope_id` and a -`30d` period. The Server reads one scoped snapshot and returns inventory, model usage, and recall statistics. +The browser authenticates against `/dashboard/scopes`, builds the shared Scope selector, then posts the selected +`ScopeSelection` and period to `/v1/stats`. The selector exposes three observation views: `all`, one root's `subtree`, +or one `exact` Scope. A Parent relation organizes the selector; it does not make parent data visible to a child. | Dashboard value | Source | | --- | --- | -| Sources | Current scoped Source journal position | -| Memory entries | Entries in the current Memory Artifact | -| Artifacts | Current Artifact heads grouped by family | -| Pending review | Current Candidate heads grouped by family and status | +| Sources | Selected Scopes' Source journal positions | +| Memory entries | Entries in the selected Scopes' Memory Artifacts | +| Artifacts | Selected Scopes' Artifact heads grouped by family | +| Pending review | Selected Scopes' Candidate heads grouped by family and status | | Model usage | Persisted daily generation and embedding usage | | Recall hits, token reduction, and savings trend | Persisted daily recall measurements for the configured estimator | -The Runtime performs these reads in one database transaction and calculates totals, pending Sources, family counts, -daily buckets, and token reduction on the Server. The browser presents `ready_preparations` as recall hits and plots the +The Runtime resolves the selection to exact Scope IDs, aggregates totals, pending Sources, family counts, daily buckets, +and token reduction on the Server, and returns both the selection and resolved IDs. The browser presents +`ready_preparations` as recall hits and plots the signed daily `token_reduction` as the savings trend. Each heatmap cell combines those two fields for its date. Its fixed bands are no hit, hit without a positive reduction, 1–255, 256–1023, and 1024 or more estimated tokens reduced. The fixed thresholds keep sparse activity and outliers from changing the meaning of every other cell. @@ -101,17 +103,17 @@ or rendering contract only after a second page needs the same behavior. ## Add the Handoff Report page -When Handoff Report is enabled, the Server hosts the scope Handoff page at `/handoff-reports` without requiring the scoped-statistics Dashboard or its configured scope list. The optional Dashboard remains at `/` when separately enabled. The pages share only `base.html`, the header and footer, `auth.js`, theme state, and locale state; their statistics and report calculations remain independent. +When Handoff Report is enabled, the Server hosts a read-only report page at `/handoff-reports`; the Dashboard remains +optional. Both pages load Scopes from `/dashboard/scopes` and use `scope-selection.js` to expose the same `all`, +`subtree`, and `exact` views. -The Handoff Report page obtains exact `scope_id` values with committed Handoffs from `POST /v1/handoff-reports/scopes/list-known` and uses them in a searchable scope combobox. Selecting a scope sends its required `scope_id` to `POST /v1/handoff-reports/get`; neither the Project catalog nor `project_id` participates in report selection. The page presents the exact current Handoff snapshot at full width. +The page posts the selected `ScopeSelection` to `/v1/handoff-reports/get`. The Server resolves it to exact Scope IDs +and projects each Scope's descriptor and latest exact Handoff. A Scope without a committed Handoff remains visible as +`no_handoff`. Parent does not infer Context sharing, and the report does not edit Handoff state. -The current snapshot displays objective, current state, disposition, next action, and known omissions as one Handoff document. One Edit action opens all five fields, and one Save Revision action prepares and commits the complete document as a new immutable Handoff Revision. Scope switching and background refresh pause while the editor is open. Receiver-side decisions are not part of this page; existing continuity records remain available in the read-only Continuity timeline. Apart from the explicit revision write, the browser formats returned `summary`, `coverage`, Workstream state, and digests without recalculating report semantics. - -When known-scope discovery succeeds but no scope has a committed Handoff, the page replaces report controls with a clearly labeled, data-free template preview. Retry enumerates Handoff heads again; the first committed scope replaces the preview. The preview neither creates a Handoff nor requests fabricated report data. - -The page requests the current day in UTC by default and provides current-day, ISO-week, calendar-month, and custom date-range inputs. The custom end date is inclusive in the UI and is converted to the exclusive start of the next day for the API. The current scope application normalizes this input but supplies no Activity events, reports `activity_coverage=not_configured`, and returns no period comparison. Handoff status comes from the current exact selection and must not be presented as a historical period-end state. - -The overview request may disable evidence checks for lower latency. A Markdown download makes a separate request with `format=markdown`, `download=true`, and evidence checks enabled by default. The browser never reconstructs Markdown from rendered DOM or canonical JSON. Both background refresh and browser download currently require a stored bearer token even when Server authentication is disabled; initial and manual report loads still work without one. Disabling Handoff Report removes the `/handoff-reports` page and its API while leaving the original Dashboard route, scope selection, and statistics request unchanged. +JSON is the browser projection. Markdown download repeats the same selection with `format=markdown` and +`download=true`; the browser does not reconstruct Markdown from the rendered DOM. Disabling Handoff Report removes +the page and report API without changing Dashboard selection or statistics behavior. ## Preserve the security boundary diff --git a/docs/en/docs/explanation/core-concepts.md b/docs/en/docs/explanation/core-concepts.md index 4ed93065d..786e6083f 100644 --- a/docs/en/docs/explanation/core-concepts.md +++ b/docs/en/docs/explanation/core-concepts.md @@ -89,9 +89,8 @@ inspected boundary and returns a temporary Prepared Handoff. Committing a Handof the user wants a milestone. The receiver resolves the Handoff and records an Acknowledgement; a Task Outcome preserves the final status and checks as Source evidence. -The [Handoff Report](../how-to/use-handoff-report.md) projects current Handoff Revisions for inspection and export. The -current scope report does not yet include Activity events or period comparison, and it does not rewrite Memory or the -underlying Handoff history. +The [Handoff Report](../how-to/use-handoff-report.md) projects the latest Handoff Revision in each selected Scope for +inspection and export. It is read-only and does not rewrite Memory or the underlying Handoff history. ## Interfaces expose different parts of the same Server diff --git a/docs/en/docs/how-to/configure-claude-code.md b/docs/en/docs/how-to/configure-claude-code.md index cded5130e..66ab6af33 100644 --- a/docs/en/docs/how-to/configure-claude-code.md +++ b/docs/en/docs/how-to/configure-claude-code.md @@ -73,16 +73,15 @@ Skill. Scope resolution uses this order: 1. `POWERCONTEXT_CLAUDE_SCOPE_ID`, when explicitly set; -2. the Git-private Workstream binding shared with Codex; +2. a Git-private Scope binding; 3. the normalized `remote.origin.url` of the Git top-level directory; 4. a `local:sha256:` identifier derived from the resolved project directory. -Claude Code and Codex therefore resolve the exact same scope in a checkout that has a Workstream binding. Without a -binding they still share the normalized remote scope. Bind a known Workstream with the bundled resolver: +Bind a known Scope to the checkout with the bundled resolver: ```bash -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/project_scope.py" \ - --cwd "$PWD" --bind-workstream "WORKSTREAM_SCOPE_ID" +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workspace_scope.py" \ + --cwd "$PWD" --bind-scope "SCOPE_ID" ``` The local fallback is stable for one resolved directory, but it is not intended to join unrelated checkouts. Set an diff --git a/docs/en/docs/how-to/configure-codex.md b/docs/en/docs/how-to/configure-codex.md index cb473c8b9..73a311412 100644 --- a/docs/en/docs/how-to/configure-codex.md +++ b/docs/en/docs/how-to/configure-codex.md @@ -36,27 +36,20 @@ handoff this work ``` The `project-context` Skill treats that imperative as explicit authorization to create one durable Handoff milestone. -If the catalog contains multiple Workstreams, Codex first opens a native picker; one Workstream is selected -automatically when it is the only candidate. Codex binds the selected Workstream to the checkout, inspects the current -conversation and repository, assembles the objective, branch and worktree state, changed files, observed checks, -blockers, omissions, and next action, then calls `handoff_current_work` followed by `commit_handoff`. After a successful -commit, Codex reports the selected Workstream and exact Handoff Revision; the user does not need to fill in the Handoff -content or confirm the commit again. +Codex inspects the current conversation and repository, assembles the objective, branch and worktree state, changed +files, observed checks, blockers, omissions, and next action, then calls `handoff_current_work` followed by +`commit_handoff` in the current Session Scope. After a successful commit, Codex reports the exact Handoff Revision; the +user does not need to fill in the Handoff content or confirm the commit again. `交接`, `交接当前工作`, and `commit a handoff` use the same behavior. To inspect the proposed content without writing, ask to `preview the handoff without committing`; the Skill renders the proposed fields in chat and calls no write tool. Discussing Handoff design or asking how it works does not authorize a write. -Codex resolves scope in this order: an explicit `POWERCONTEXT_CODEX_SCOPE_ID`, a Workstream scope persistently bound -to the current Git workspace, the normalized Git remote, and finally the project path. Later Codex sessions in the -same workspace reuse that scope. - -The picker returns the Workstream's human-facing `work_id` and authoritative `scope_id`. The `project-context` Skill -passes that exact scope to the resolver's `--bind-workstream` operation and verifies the result. The binding lives in -`powercontext/codex-workspace.json` below the Git-private directory, outside the worktree and commits. A one-line -Handoff then continues the selected Workstream's Artifact lifecycle and creates the next Revision. If the MCP client -does not support native elicitation, the tool returns structured choices instead; the integration must still obtain an -explicit selection and must not choose silently. +At Session start, Codex resolves Scope in this order: an explicit `POWERCONTEXT_CODEX_SCOPE_ID`, an existing Session +binding, a host-managed workspace binding, and the Server's default Scope. The selected Scope is fixed to the Session. +Repository and directory identities are lookup inputs only; they never generate a Scope ID. The prompt hook uses the +binding for recall and capture, while `PreToolUse` injects it into data-plane tools so Agent input cannot redirect a +read or write. The host must create or bind a different Scope when the Session changes work boundaries. The Hook calls `POST /v1/context/prepare` once before Codex analyzes the prompt. It requests an 8000-byte total budget, strictly validates `powercontext.prepared-context.v1`, and injects the returned content unchanged. The Runtime labels diff --git a/docs/en/docs/how-to/configure-hermes.md b/docs/en/docs/how-to/configure-hermes.md index aaaa7afbf..64aa48a40 100644 --- a/docs/en/docs/how-to/configure-hermes.md +++ b/docs/en/docs/how-to/configure-hermes.md @@ -52,12 +52,12 @@ hermes powercontext search "Python package manager" ``` Inside an interactive Hermes session, `/pc status` should reach the same active provider. Use `/pc ` followed by -Tab/Down to inspect the available Memory, Handoff, Experience, Skill, review, statistics, trace, and Workstream +Tab/Down to inspect the available Memory, Handoff, Experience, Skill, review, statistics, trace, and Scope commands. Hermes 0.20.4 does not provide enough invocation context to route gateway slash commands safely, so the companion rejects gateway invocations; use the provider's Hermes tools in gateway sessions. -The provider uses `http://127.0.0.1:8000` by default. In a Git workspace, Workstream persistence first reads the -shared `.git/powercontext/codex-workspace.json` scope binding. An explicit scope configuration takes precedence. +The provider uses `http://127.0.0.1:8000` by default. In a Git workspace, Scope binding first reads +`.git/powercontext/scope-binding.json`. An explicit Scope configuration takes precedence. Without either value, the provider derives a scope from the active Hermes profile and gateway user identifier; for a local CLI session without a user identifier, it derives a stable value from `HERMES_HOME`. @@ -80,7 +80,7 @@ the file: | `POWERCONTEXT_HERMES_CAPTURE_PRE_COMPRESS` | Capture filtered new turns before compression; disabled by default | | `POWERCONTEXT_HERMES_EVALUATION_TRACE` | Record recalled context in sensitive local JSONL traces; disabled by default | | `POWERCONTEXT_HERMES_EVALUATION_TRACE_PATH` | Override the evaluation trace directory | -| `POWERCONTEXT_HERMES_WORKSTREAM` | Read the shared Git-private Workstream binding; enabled by default | +| `POWERCONTEXT_HERMES_SCOPE_BINDING` | Read the Git-private Scope binding; enabled by default | Let the Hermes wizard store authorization in its protected `.env` secret store; do not put the token in `config.json`. Use plain HTTP only for a loopback Server. See [Deploy the Server](deploy-server.md) before connecting diff --git a/docs/en/docs/how-to/configure-workbuddy.md b/docs/en/docs/how-to/configure-workbuddy.md index 1f531bbd5..1e663b3ea 100644 --- a/docs/en/docs/how-to/configure-workbuddy.md +++ b/docs/en/docs/how-to/configure-workbuddy.md @@ -69,8 +69,8 @@ cp "$PLUGIN"/hooks/workbuddy_powercontext_hook.py \ "$PLUGIN"/hooks/workbuddy_settings.py \ "$PLUGIN"/hooks/prepared_context.py \ "$WORKBUDDY_HOOKS_DIR"/ -cp "$PLUGIN/scripts/project_scope.py" \ - "$WORKBUDDY_HOOKS_DIR/powercontext_project_scope.py" +cp "$PLUGIN/scripts/workspace_scope.py" \ + "$WORKBUDDY_HOOKS_DIR/powercontext_scope_binding.py" ``` ### 2. Register the hook @@ -131,9 +131,9 @@ EOF Then replace `${POWERCONTEXT_PYTHON}` in `~/.workbuddy/skills/project-context/SKILL.md` with a shell-safe Python -executable argument. Replace `${POWERCONTEXT_PROJECT_SCOPE_SCRIPT}` with a +executable argument. Replace `${POWERCONTEXT_SCOPE_BINDING_SCRIPT}` with a shell-safe complete path to -`/powercontext_project_scope.py`. +`/powercontext_scope_binding.py`. ### 5. Start the Server, restart WorkBuddy, and verify @@ -230,16 +230,14 @@ query strings, or fragments; plain HTTP is accepted only for loopback hosts. WorkBuddy resolves scope in this order: 1. an explicit `POWERCONTEXT_WORKBUDDY_SCOPE_ID`; -2. a Workstream scope persistently bound to the current Git workspace (stored - in `powercontext/codex-workspace.json` below the Git-private directory, - shared with the Codex and Claude Code plugins); +2. a Scope persistently bound to the current Git workspace (stored in + `powercontext/scope-binding.json` below the Git-private directory); 3. the normalized Git remote; 4. a hash of the resolved local project directory. Later WorkBuddy sessions in the same workspace reuse that scope. The -`project-context` Skill's `--bind-workstream` operation persists a Workstream -binding for a selected Handoff; the binding never enters the worktree or -commits. +`project-context` Skill's `--bind-scope` operation persists a Scope binding; +the binding never enters the worktree or commits. ## Connect to an authenticated local Server diff --git a/docs/en/docs/how-to/full-capability-runtime.md b/docs/en/docs/how-to/full-capability-runtime.md index bfb51e0be..b4adb2ee9 100644 --- a/docs/en/docs/how-to/full-capability-runtime.md +++ b/docs/en/docs/how-to/full-capability-runtime.md @@ -1,90 +1,52 @@ --- title: Full-capability Quick Start -description: Start all PowerContext capabilities in five minutes. +description: Configure models, start the Server, and verify the complete Memory loop. --- # Full-capability Quick Start -## Minimal versus full capability +`powercontext server run` works without model configuration, but model-backed extraction and vector search stay off. +The guided configuration enables generation, embeddings, scheduled Source processing, metrics, and tracing settings. -`powercontext server run` starts a minimal Server without any configuration. It stays up and accepts Sources, but the -capabilities that need models stay off. The guided `config init` flow writes one `.env` that turns everything on: - -| Capability | Default minimal server | Full-capability runtime | +| Capability | Minimal Server | Full-capability runtime | | --- | --- | --- | | Source capture | Enabled | Enabled | -| Memory extraction | Disabled; Sources stay pending | Enabled; Scheduler processes Sources every 60 s | -| Search modes | `auto, fts` only | `auto, fts, vector, hybrid` | -| Dashboard Scopes | None configured | `project:quickstart` visible | -| MCP endpoint | `/mcp` enabled | `/mcp` enabled | - -Both modes use SQLite by default. Vector search additionally uses the bundled `sqlite-vec` extension; when the -Embedding model or its profile is not configured, the Server falls back to SQLite FTS and reports -`Search modes: auto, fts`. Recall still works through FTS, but semantic and hybrid search need the Embedding model. - -## Choose the Scope ID first - -The Scope ID is PowerContext's data namespace. Think of it as the project ID. Sources, Memories, and Handoffs belong to -a Scope; the Dashboard and Coding Agent must use the same Scope ID for Agent-written data to appear in the web UI. - -A Server can store multiple Scopes. The Server configuration determines which Scopes the Dashboard can display, while -the Coding Agent configuration determines which Scope the current session reads and writes: - -```text -Coding Agent ── read/write ──> project:quickstart <── display ── Dashboard -``` - -Use any short, stable, non-empty string. Do not include keys or other secrets. For example: +| Memory extraction | Disabled | Enabled | +| Search modes | `auto, fts` | `auto, fts, vector, hybrid` | +| Dashboard | Default Scope | Default Scope and every created Scope | +| MCP endpoint | `/mcp` | `/mcp` | -```text -project:quickstart -git:github.com/oceanbase/powercontext -team:payment-service -``` - -This Quick Start uses: - -```text -project:quickstart -``` +The Server creates one opaque default Scope on first startup. The Dashboard discovers Scope descriptors from the +Server; it does not use a configured list. Integrations may bind a Session or workspace to that default or to another +existing Scope. -## Quick Start - -### Part 1: Start the Server - -#### 1. Install +## 1. Install and configure ```bash uv tool install --force "powercontext[cli,server] @ git+https://github.com/oceanbase/powercontext.git@master" +powercontext config init --output .env ``` -#### 2. Generate the configuration +Enter the provider connection and credential when prompted. For a local provider that ignores authentication, use a +non-secret placeholder accepted by that provider. + +Inspect and validate the generated file without printing credentials: ```bash -powercontext config init --output .env +powercontext config show --env-file .env +powercontext config validate --env-file .env ``` -Enter a provider credential when prompted. Pydantic AI providers require a credential during construction; for a -local service that ignores authentication, use a non-secret placeholder accepted by that service. - -When finished, the command prints setup and launch commands for Codex, Claude Code, DeepSeek Harness, OpenCode, and Pi. -The generated `.env` groups every setting you would otherwise assemble by hand: Server HTTP, Dashboard, Scope, -Generation model, Embedding model with profile ID and dimension, database kind and location, scheduler interval, and -per-host integration URLs. Inspect it any time with `powercontext config show --env-file .env`; credentials print as -``, and `powercontext config validate --env-file .env` checks the syntax and model settings. +The generated file contains Server, model, database, scheduler, and integration transport settings. Scope identity is +owned by the running Server and is not invented by the Config Generator. -#### 3. Start the Server +## 2. Start and verify the Server ```bash powercontext server run --env-file .env ``` -With `--env-file`, assignments in the file override same-named shell values, and stale `POWERCONTEXT_SERVER_*` -variables missing from the file are ignored. This makes `config validate` and `server run` use the same Server config. - -#### 4. Verify the Server - -Run this in a second terminal: +In another terminal: ```bash set -a @@ -95,126 +57,89 @@ powercontext ready powercontext capabilities ``` -Confirm these results: +The full runtime is ready when readiness is `ready`, Memory extraction is enabled, and search modes include `vector` +and `hybrid`. If only `auto, fts` appear, check the Embedding model, profile ID, dimension, credential, and Base URL. -```text -package: ok - powercontext -server liveness: ok - http://127.0.0.1:8000 status=ok -server readiness: ok - http://127.0.0.1:8000 status=ready -Status: ready -Memory extraction: enabled -Search modes: auto, fts, vector, hybrid -``` - -The full-capability runtime is ready when `doctor` reports all checks as `ok`, `Status: ready`, -`Memory extraction: enabled`, all four search modes are listed, and the Dashboard at - contains `Quick Start`. +Open and confirm that the default Scope is available. Retrieve its opaque ID for the following +API checks: -If `powercontext capabilities` lists only `auto, fts`, the Server is running in FTS-only fallback mode. Vector and -hybrid search are unavailable, so the runtime does not meet the full-capability check above. +```bash +SCOPE_ID="$(curl -fsS http://127.0.0.1:8000/v1/scopes/default \ + | python -c 'import json, sys; print(json.load(sys.stdin)["scope_id"])')" +export SCOPE_ID +``` -### Part 2: Verify the Memory loop +## 3. Verify the Memory loop -Extraction runs when Sources are flushed, so verify one full round trip before starting a Coding Agent. Use a unique -Source ID so this check remains valid when the guide is run again. With the same environment loaded: +Capture a Source with a unique ID: ```bash SOURCE_ID="quickstart-$(date +%s)-$$" curl -fsS -X POST http://127.0.0.1:8000/v1/sources/content \ -H 'content-type: application/json' \ - -d "{\"scope_id\":\"project:quickstart\",\"source_id\":\"${SOURCE_ID}\",\"content\":\"PowerContext quick start check: prefer small, verifiable steps.\"}" + -d "{\"scope_id\":\"${SCOPE_ID}\",\"source_id\":\"${SOURCE_ID}\",\"content\":\"PowerContext quick start check: prefer small, verifiable steps.\"}" ``` -The Server replies `202` with `"status":"accepted"` and a numeric `position`; keep both the Source ID and position. -Then flush the Scope, which runs Memory extraction: +Keep the returned `position`, then flush the same Scope: ```bash -curl -X POST http://127.0.0.1:8000/v1/memory/flush \ +curl -fsS -X POST http://127.0.0.1:8000/v1/memory/flush \ -H 'content-type: application/json' \ - -d '{"scope_id":"project:quickstart"}' + -d "{\"scope_id\":\"${SCOPE_ID}\"}" ``` -The flush response contains `current_cursor`. It must be greater than or equal to the capture response's `position`. -The Scheduler may process the Source before this request, so `status:"idle"` is valid when the cursor has already -reached that position. If it has not, flush again. +The returned `current_cursor` must be at least the capture `position`. `status: "idle"` is valid when the Scheduler +already processed the Source. -Now list the current Memory entries: +List Memory entries: ```bash curl -fsS -X POST http://127.0.0.1:8000/v1/memory/entries/list \ -H 'content-type: application/json' \ - -d '{"scope_id":"project:quickstart"}' + -d "{\"scope_id\":\"${SCOPE_ID}\"}" ``` -Find an entry whose `source_refs` contains the capture response's `source_id`, and record that entry's -`citation.entry_id`. This proves the captured Source produced Memory. If no entry cites this Source, extraction ran but -produced no candidate; use a new Source ID with a clearer durable fact or preference and repeat the check. - -Confirm the inventory and verify Embedding by searching with vector mode: +Find an entry whose `source_refs` contains the captured Source and record its `citation.entry_id`. Then verify vector +retrieval: ```bash -curl -X POST http://127.0.0.1:8000/v1/memory/search \ +curl -fsS -X POST http://127.0.0.1:8000/v1/memory/search \ -H 'content-type: application/json' \ - -d '{"scope_id":"project:quickstart","query":"verifiable steps","mode":"vector","limit":50}' + -d "{\"scope_id\":\"${SCOPE_ID}\",\"query\":\"verifiable steps\",\"mode\":\"vector\",\"limit\":50}" ``` -Embedding is verified only when the response contains `"mode":"vector"` and a hit whose `citation.entry_id` equals -the source-linked entry recorded above and whose `matched_by` contains `"vector"`. A hit for another entry, an empty -`hits` list, or `"mode":null` does not verify this round trip. Check -`powercontext capabilities`: if `vector` is absent from `Search modes`, the Server is in FTS-only fallback mode; if it -is present, the source-linked entry has not been confirmed through vector search yet. An explicit vector request against -existing Memory returns HTTP 422 when vector capability is unavailable. - -Finally, check the stats for model usage: +The round trip is verified when the response has `mode: "vector"`, the recorded `entry_id`, and `vector` in +`matched_by`. Confirm model usage with: ```bash -powercontext stats --scope-id project:quickstart -``` - -```text -Embedding: 1 requests, ... +powercontext stats --scope-id "$SCOPE_ID" ``` -The Embedding request count is cumulative. A non-zero value corroborates model use, but only the source-linked vector -hit above proves this round trip. - -### Part 3: Start a Coding Agent - -The Config Generator prints setup and launch commands for every supported Coding Agent. Open a new terminal, choose an -Agent, and copy the two commands under it. The first installs the PowerContext integration; the second loads the -generated `.env` and starts the Agent, so you do not need to enter the Scope ID again. - -After the Coding Agent starts, send an ordinary prompt in the project. The integration first recalls relevant Memory -from `project:quickstart`, then saves the prompt as a Source. The Scheduler extracts Memory from new Sources within -about 60 seconds, so the flush from Part 2 is only needed once to prove the loop. - -## Where data lives +## 4. Start Codex -The generated configuration leaves the database unset, so the Server stores data in the user data directory instead of -a project-local file. With `POWERCONTEXT_HOME` unset, SQLite keeps `powercontext.db` and the scheduler state in -`scheduler.db` under: +Install the plugin using the command printed by Config Generator, load `.env`, and start Codex. Do not set +`POWERCONTEXT_CODEX_SCOPE_ID` for the normal Session flow: the plugin resolves the Session binding, then the workspace +binding, then the Server default Scope. Set it only when the host must select a known existing Scope explicitly. -- macOS: `~/Library/Application Support/powercontext/` -- Linux: `~/.local/share/powercontext/` +After Codex starts, send an ordinary prompt. The plugin recalls from the bound Scope and captures the prompt as Source +evidence. Scheduled processing handles new Sources within the configured interval. -Set `POWERCONTEXT_HOME` before starting the Server to relocate all of this. Changing the database URL later points the -Server at a different (possibly empty) database; keep the previous value if you need the old data. +## Data and restart behavior -## Stop and restart +With no database override, SQLite stores `powercontext.db` and `scheduler.db` under the user data directory: -Press `Ctrl+C` in the Server terminal to stop it. Data persists in SQLite across restarts. To resume, load the same -`.env` and run `powercontext server run --env-file .env` again; pending Sources are processed on the next Scheduler run -or flush. +- Linux: `$XDG_DATA_HOME/powercontext`, or `~/.local/share/powercontext`; +- macOS: `~/Library/Application Support/powercontext`. -## Quick troubleshooting +Press `Ctrl+C` to stop the Server. Restart it with the same `.env` and data directory. The default Scope and its opaque +ID remain stable because they are persisted in the database. | Symptom | Action | | --- | --- | -| Dashboard is empty | Compare the complete Dashboard and Agent Scope strings | -| `ready` is `degraded` | Check the Generation and Embedding models, keys, and Base URLs | -| No `vector` or `hybrid` search | Configure the Embedding model, profile ID, and dimension together; without them recall stays on FTS (`auto, fts`) | +| A Scope is missing from Dashboard | Confirm it was created through the Scope API and refresh the page | +| Readiness is `degraded` | Check model identifiers, credentials, and Base URLs | +| No `vector` or `hybrid` mode | Configure Embedding model, profile ID, and dimension together | | Sources remain pending | Enable the Scheduler or call `/v1/memory/flush` | | Existing data is missing | Restore the previous database URL or `POWERCONTEXT_HOME` | -See [Troubleshooting](troubleshoot.md) for error states and [Configuration](../reference/configuration.md) for all -variables. +See [Troubleshooting](troubleshoot.md) and [Configuration](../reference/configuration.md) for details. diff --git a/docs/en/docs/how-to/handoff-with-codex.md b/docs/en/docs/how-to/handoff-with-codex.md index d03257622..b80644fc2 100644 --- a/docs/en/docs/how-to/handoff-with-codex.md +++ b/docs/en/docs/how-to/handoff-with-codex.md @@ -17,8 +17,8 @@ state, the receiver records whether it can continue, and a Task Outcome preserve ## Before you start Complete [Install and run](install-and-run.md), keep the Server running, and start a Codex session with the -PowerContext plugin configured in the current project. Work records belong to the selected scope. If the Report catalog -contains more than one Workstream, let Codex show the picker and choose the intended one. +PowerContext plugin configured in the current project. The integration binds the Session to a Scope before reading or +writing work records. Start independent work in a new Scope only when it needs its own isolation and continuation. Do not put secrets, access tokens, or other sensitive information in Work Contracts, Handoffs, acknowledgements, or outcomes. @@ -42,9 +42,9 @@ For a durable milestone, use a direct imperative: > Hand off this work with PowerContext. Inspect the current objective, branch, worktree, changed files, checks, > blockers, omissions, and next action. Commit the completed Handoff and give me its exact Revision. -The Codex Skill selects the Workstream, inspects live state, calls `handoff_current_work`, and commits the returned -Prepared Handoff in the same turn. Success includes the scope and exact Handoff Revision. If preparation succeeds but -commit fails, the boundary Source exists but no durable milestone was created. +The Codex Skill uses the current Session Scope, inspects live state, calls `handoff_current_work`, and commits the +returned Prepared Handoff in the same turn. Success includes the Scope and exact Handoff Revision. If preparation +succeeds but commit fails, the boundary Source exists but no durable milestone was created. For a read-only preview, say `Preview a PowerContext Handoff and make no writes.` For temporary transfer without a milestone, ask Codex to prepare a Handoff without committing it. That operation records the boundary Source and returns diff --git a/docs/en/docs/how-to/install-and-run.md b/docs/en/docs/how-to/install-and-run.md index c6ef2ba3e..e9237d24f 100644 --- a/docs/en/docs/how-to/install-and-run.md +++ b/docs/en/docs/how-to/install-and-run.md @@ -51,7 +51,7 @@ With no environment variables, the Server: - binds to `127.0.0.1:8000`; - enables Streamable HTTP MCP at `/mcp`; -- enables the Dashboard at `/`; when no scopes are configured, the page shows an explicit empty state; +- creates a default Scope and enables the Dashboard at `/`; - creates a persistent SQLite database in the operating system's user data directory; - supports explicit Memory operations without an inference provider. diff --git a/docs/en/docs/how-to/use-handoff-report.md b/docs/en/docs/how-to/use-handoff-report.md index 6a2724d0b..68cb5f991 100644 --- a/docs/en/docs/how-to/use-handoff-report.md +++ b/docs/en/docs/how-to/use-handoff-report.md @@ -1,12 +1,12 @@ --- title: Use Handoff Report -description: Open the Server report, select a scope, inspect Handoff history, and save a Revision. +description: Select a Scope view, inspect current Handoffs, and download a Markdown report. --- # Use Handoff Report -Handoff Report presents committed Handoff Revisions in a Server-owned web page. Use it to inspect current work by -scope, save a complete Handoff snapshot, or request a Markdown projection. +Handoff Report is a read-only view of the latest committed Handoff in each selected Scope. It does not create or edit +Scopes or Handoffs. ## Before you start @@ -17,62 +17,40 @@ powercontext server run ``` Handoff Report is enabled by default at `http://127.0.0.1:8000/handoff-reports`. It uses the Server listener and -authentication settings, but does not require the statistics Dashboard or its scope list. If bearer authentication is +authentication settings, but does not require the statistics Dashboard to be enabled. If bearer authentication is enabled, enter the configured token in the page sign-in form. -In the default unauthenticated mode, initial and manual report loads work without a token. The current browser code -requires a stored bearer token for background refresh and Markdown download, so those two controls do not issue report -requests until authentication is enabled and the page has a token. +The Server creates a default Scope during startup. Additional Scopes appear after they are created through an +integration or the Scope API. A Scope does not need a committed Handoff to appear in the report. -The page discovers scopes that contain at least one committed Handoff. Without one, it shows a data-free template -preview and disables search, period filters, editing, and download. +## 1. Commit a Handoff -## 1. Commit a Handoff in the target scope +Create a durable Handoff milestone in the Scope you want to report. In Codex, follow +[Hand off work in Codex](handoff-with-codex.md). The Codex integration writes to the Scope bound to the current Session. -Create a durable Handoff milestone in the scope you want to report. In Codex, follow -[Hand off work in Codex](handoff-with-codex.md) and keep the exact scope and Handoff Revision from the result. +The report reads committed Handoff Revisions only. A temporary Prepared Handoff is not included. -Reload Handoff Report after the commit succeeds. The page calls `list_handoff_report_known_scopes` and should include -the scope. Committing a Handoff makes the scope discoverable; no Report Project or Workstream registration is required. +## 2. Select a Scope view -## 2. Select a scope +Open Handoff Report and choose one of the shared Scope selections: -Search by `scope_id`, then select the scope. The page requests its report with `scope_id` and shows the current -objective, state, disposition, next action, and known omissions. +- **All** includes every Scope visible to this Server. +- **Scope and descendants** includes one root Scope and all of its descendants. +- **Focus** includes exactly one Scope. -The report also shows the latest Handoff history, newest first. The JSON projection contains at most the latest 20 -Revision summaries and marks when earlier history was truncated. The HTTP request schema retains an optional -`project_id` field for wire compatibility; it is deprecated and ignored when the Server generates a scope report. +Parent only expresses organization. It does not make a child's Context or Handoff visible to the parent, so each row +shows only that Scope's own latest Handoff. A selected Scope without a committed Handoff is shown as **No Handoff**. -The page starts a five-second refresh timer, but background requests currently run only when a bearer token is stored. -With authentication disabled, use **Refresh** to load changes manually. Background refresh also pauses while edits are -unsaved or a Handoff action is running. +Use **Refresh** after a Handoff or Scope changes. -## 3. Save a new Handoff Revision +## 3. Read or download the report -Select **Edit**, update the five current-snapshot fields as one document, then select **Save Revision**. The Server -prepares and commits the complete document as a new immutable Handoff Revision. +The page shows the selected Scopes, their parent relationship, Handoff status, objective, next action, and exact +Revision address. Summary counts use the same frozen selection as the rows. -Saving is a write operation. Scope switching stays paused while the editor is open. The page does not record receiver -acceptance; acknowledgements and Task Outcomes remain read-only entries in the **Continuity timeline**. - -## 4. Understand the current period controls - -The current scope report accepts and normalizes day, week, month, or custom period input, but Activity integration is -not configured. It returns no Activity events, reports `activity_coverage: not_configured`, and does not produce a -previous-period comparison. - -Do not use the period controls to infer historical work or compare Activity yet. The Handoff snapshot is the current -exact selection, not a reconstructed state at the end of the chosen period. - -## 5. Download Markdown - -With bearer authentication enabled and a token stored in the page, select **Download Markdown** to export the same -scope, locale, and normalized period. The browser requests Markdown from the Server rather than rebuilding it from the -rendered page. Downloads enable evidence checks by default and use the filename `handoff-report.md`. - -In the default unauthenticated mode, the current browser guard does not send this download request. The underlying HTTP -operation and Python Client remain available without a token while Server authentication is disabled. +Select **Download Markdown** to request the Markdown projection from the Server. The browser does not rebuild the +report from the rendered page. The JSON and Markdown projections carry selection and report digests so a consumer can +identify the exact generated result. ## Disable Handoff Report @@ -83,8 +61,8 @@ export POWERCONTEXT_SERVER_HANDOFF_REPORT_ENABLED=false powercontext server run ``` -Disabling the feature removes `/handoff-reports` and the Report API routes. The Dashboard, HTTP API, MCP, Memory, and +Disabling the feature removes `/handoff-reports` and the Report API route. The Dashboard, HTTP API, MCP, Memory, and Handoff operations remain independently configured. -For scope discovery and Report operations, see [Interfaces](../reference/interfaces.md). For exact Server settings, -see [Configuration](../reference/configuration.md). +For the Scope and Report operations, see [Interfaces](../reference/interfaces.md). For exact Server settings, see +[Configuration](../reference/configuration.md). diff --git a/docs/en/docs/reference/configuration.md b/docs/en/docs/reference/configuration.md index 726c18a41..6a010563c 100644 --- a/docs/en/docs/reference/configuration.md +++ b/docs/en/docs/reference/configuration.md @@ -57,7 +57,6 @@ Server settings use the `POWERCONTEXT_SERVER_` prefix. | `POWERCONTEXT_SERVER_AUTH_TOKEN` | unset | Static bearer token; required when authentication is enabled | | `POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK` | `false` | Opt in to a non-loopback bind while authentication is disabled | | `POWERCONTEXT_SERVER_DASHBOARD_ENABLED` | `true` | Enable the Dashboard at the Server root path `/` | -| `POWERCONTEXT_SERVER_DASHBOARD_SCOPES` | `[]` | JSON array of selectable Dashboard scopes | | `POWERCONTEXT_SERVER_HANDOFF_REPORT_ENABLED` | `true` | Enable Handoff Report and its API routes | | `POWERCONTEXT_SERVER_LOGGING_LEVEL` | `INFO` | Operational log level | | `POWERCONTEXT_SERVER_LOGGING_FORMAT` | `console` | `console` or structured `json` output | @@ -100,9 +99,9 @@ practice, such as an in-process ASGI app, Unix-domain socket, or TLS-terminating `http_client` and pass `trust_transport_security=True` explicitly. See [Deploy the Server](../how-to/deploy-server.md) for a safe Docker and remote-access setup. -The Dashboard is enabled by default and shares the Server listener and port with the HTTP API and MCP. With no scopes -configured, the page shows an empty state. Dashboard initialization failures are logged with their direct cause and do -not prevent the Server HTTP API, MCP, or health checks from starting. +The Dashboard is enabled by default and shares the Server listener and port with the HTTP API and MCP. It discovers +the default Scope and every created Scope from the Server. Dashboard initialization failures are logged with their +direct cause and do not prevent the Server HTTP API, MCP, or health checks from starting. When bearer authentication is enabled, the HTML shells at `/`, `/skills`, `/reviews`, and `/handoff-reports`, plus their static assets, remain public so the browser can render the sign-in form. Data requests stay protected. Enter the @@ -281,7 +280,7 @@ only through the environment so it does not appear in command-line arguments. | Variable | Default | Meaning | | --- | --- | --- | -| `POWERCONTEXT_CODEX_SCOPE_ID` | derived from Git remote or project path | Override project scope | +| `POWERCONTEXT_CODEX_SCOPE_ID` | unset | Explicitly select an existing Scope instead of resolving bindings and the Server default | | `POWERCONTEXT_CODEX_AUTHORIZATION` | unset | Complete `Bearer ` header for Hook and MCP requests | | `POWERCONTEXT_CODEX_CAPTURE_PROMPTS` | `true` | Capture user prompts as Source evidence | | `POWERCONTEXT_CODEX_FLUSH_ON_CAPTURE` | `false` | Wait for Source processing after capture | @@ -290,8 +289,9 @@ only through the environment so it does not appear in command-line arguments. | `POWERCONTEXT_CODEX_FLUSH_MAX_CALLS` | `4` | Maximum flush calls per prompt | The outer Codex hook timeout is ten seconds. Recall, capture, and flush fail independently and never block Codex when -the Server is unavailable or rejects authentication. The variable must be present in the environment that starts -Codex; restart Codex after changing it. +the Server is unavailable or rejects authentication. Without an explicit Scope, the plugin resolves the Session +binding, workspace binding, then Server default. Configuration variables must be present in the environment that +starts Codex; restart Codex after changing them. ## Claude Code plugin diff --git a/docs/en/docs/reference/http-api.md b/docs/en/docs/reference/http-api.md index 3d723dfc1..2e905e8c7 100644 --- a/docs/en/docs/reference/http-api.md +++ b/docs/en/docs/reference/http-api.md @@ -102,7 +102,7 @@ curl --fail \ | Experience and Skill | `/v1/experience/*`, `/v1/skill/*` | Propose, generate, and read Artifact revisions | | Review | `/v1/artifact-candidates/*` | List, inspect, revise, approve, or reject pending Candidates | | External Skills | `/v1/external-skills/*` | Scan configured targets and resolve or import packages | -| Handoff Reports | `/v1/handoff-reports/*` | Manage Projects, Workstreams, activities, reports, and workspace bindings | +| Handoff Reports | `/v1/handoff-reports/*` | Generate a read-only report for a Scope selection | | Statistics | `/v1/stats` | Read scoped usage statistics | The OpenAPI contract defines the complete path list, schemas, limits, and status codes. The higher-level workflow and diff --git a/docs/en/docs/reference/interfaces.md b/docs/en/docs/reference/interfaces.md index 661268c4a..608681d84 100644 --- a/docs/en/docs/reference/interfaces.md +++ b/docs/en/docs/reference/interfaces.md @@ -68,19 +68,10 @@ and authorization still take precedence over all Work and Handoff records. For the complete Codex transfer and acknowledgement workflow, see [Hand off work in Codex](../how-to/handoff-with-codex.md). -Handoff Report lists scopes that contain a committed Handoff, and `get_handoff_report` requires `scope_id`. -`project_id` remains deprecated wire-compatibility input and is ignored during report generation. Each returned -Workstream projection includes `handoff_revision_count`, `handoff_history_truncated`, and `handoff_history`, with at -most the latest 20 Revision summaries through the frozen selection. For the web workflow, see -[Use Handoff Report](../how-to/use-handoff-report.md). - -The current scope report returns no Activity events, reports `activity_coverage=not_configured`, and has no period -comparison. Period input is normalized but does not filter Activity. The HTTP and Python Client Markdown operations -remain available without a token when Server authentication is disabled; the current browser download and background -refresh controls require a stored bearer token. - -The Codex scope resolver can bind the current Git workspace once to a fixed Workstream scope. That binding takes -precedence over Git remote and path derivation, but remains below explicit scope configuration. +Handoff Report is a read-only projection over a Scope selection. `all` includes every Scope, `exact` includes only the +listed Scope IDs, and `subtree` includes an organization root and all descendants. Each included Scope contributes its +latest exact Handoff address or an explicit `no_handoff` result; Parent does not imply Context visibility. Codex fixes +ordinary Agent report reads to the current Session Scope. Broader selections belong to host and Dashboard views. ## DeepSeek Harness plugin diff --git a/docs/zh/development/server-web-ui.md b/docs/zh/development/server-web-ui.md index fd86e2462..755a5f349 100644 --- a/docs/zh/development/server-web-ui.md +++ b/docs/zh/development/server-web-ui.md @@ -70,20 +70,21 @@ router.add_api_route( ## 理解 Dashboard 数据来源 -浏览器先通过 `/dashboard/scopes` 完成认证并读取可选 scope,再使用所选 `scope_id` 和 `30d` period 请求 -`/v1/stats`。Server 读取同一个 scope 的 snapshot,返回 inventory、model usage 和 recall statistics。 +浏览器先通过 `/dashboard/scopes` 完成认证并读取 Scope,再用公共 Scope selector 构造 selection,将其和 period +一起提交到 `/v1/stats`。Selector 提供三种观察视图:`all`、某个组织根的 `subtree`、单个 `exact` Scope。Parent +只用于组织展示,不会让子 Scope 自动看到父 Scope 数据。 | Dashboard 数据 | 来源 | | --- | --- | -| Sources | 当前 scope 的 Source journal position | -| Memory entries | 当前 Memory Artifact 中的 entry | -| Artifacts | 按 family 分组的当前 Artifact head | -| Pending review | 按 family 和 status 分组的当前 Candidate head | +| Sources | 所选 Scope 的 Source journal position | +| Memory entries | 所选 Scope 的 Memory Artifact entry | +| Artifacts | 所选 Scope 中按 family 分组的 Artifact head | +| Pending review | 所选 Scope 中按 family 和 status 分组的 Candidate head | | Model usage | 持久化的每日 generation 和 embedding usage | | Recall 命中、Token 减少量与节约趋势 | 当前 estimator 对应的持久化每日 recall measurement | -Runtime 在一个 database transaction 中读取这些数据,并在 Server 端计算 total、pending Source、family count、daily -bucket 和 token reduction。浏览器将 `ready_preparations` 展示为 Recall 命中,并将每日有符号的 +Runtime 先将 selection 解析为 exact Scope ID,再在 Server 端汇总 total、pending Source、family count、daily bucket +和 token reduction,并同时返回 selection 与解析后的 ID。浏览器将 `ready_preparations` 展示为 Recall 命中,并将每日有符号的 `token_reduction` 绘制为节约趋势。Heatmap 的每个日期格同时使用这两个字段,固定分档为:无命中、命中但没有正向 减少、减少 1–255、256–1023,以及 1024 个以上预估 Token。固定阈值避免稀疏活动和异常大值改变其他日期的颜色含义。 @@ -98,17 +99,15 @@ document-level 结构放在 `base.html`。一个片段已经被复用,或者 ## 添加 Handoff Report 页面 -启用 Handoff Report 后,Server 会在 `/handoff-reports` 托管 scope 交接页面,不再要求同时启用 scoped statistics Dashboard 或配置其 scope 列表。单独启用 Dashboard 时,根路径 `/` 仍提供原有统计页面。两个页面只复用 `base.html`、header、footer、`auth.js`、主题和 locale storage,不共享统计或报告计算逻辑。 +启用 Handoff Report 后,Server 会在 `/handoff-reports` 托管只读报告页面,Dashboard 仍是可选功能。两个页面都从 +`/dashboard/scopes` 读取 Scope,并通过 `scope-selection.js` 提供一致的 `all`、`subtree` 和 `exact` 视图。 -Handoff Report 页面通过 `POST /v1/handoff-reports/scopes/list-known` 获取当前 token 可访问且存在 committed Handoff 的 exact `scope_id`,并将它作为可搜索范围选择器的值。选择范围后,浏览器通过 `POST /v1/handoff-reports/get` 发送必填 `scope_id` 请求 canonical JSON;Project catalog 和 `project_id` 不参与报告选择。页面继续以完整宽度显示当前精确交接快照。 +页面将所选 `ScopeSelection` 提交到 `/v1/handoff-reports/get`。Server 把它解析为 exact Scope ID,并投影每个 Scope +的 descriptor 和 latest exact Handoff。没有 committed Handoff 的 Scope 仍以 `no_handoff` 展示。报告不会创建 +Parent 不会推断 Context 共享,报告也不会编辑 Handoff 状态。 -当前快照把目标、当前状态、处置状态、下一步和已知缺失作为一份完整交接内容展示。一个“编辑”操作会同时打开五个字段,一个“保存新版本”操作会把完整内容 prepare 并 commit 为新的不可变 Handoff Revision。编辑器打开期间暂停 scope 切换和后台刷新。页面不提供接手方决策操作,已有连续性记录继续通过只读连续性时间线展示。除显式保存交接版本外,浏览器只格式化 `summary`、`coverage`、Workstream 状态和 digest,不重新计算报告口径。 - -已知 scope 请求成功但没有任何 committed Handoff 时,页面会以明确标记的无数据模板预览替代报告控件。点击重试会重新枚举 Handoff heads;一旦某个 scope 完成交接提交,就以真实报告替换预览。预览不会创建 Handoff,也不会请求伪造报告数据。 - -页面默认请求 UTC 当日周期,并提供本日、ISO 本周、自然月和自定义起止日期输入。日期输入的结束日按包含当天解释,发给 API 时转换为下一日零点的排他边界。当前 scope application 只规范化该输入,不提供 Activity event,`activity_coverage=not_configured`,也不返回 period comparison。Handoff 状态来自当前 exact selection,不能把它显示成历史期末状态。 - -概览请求可以关闭 evidence check 以降低延迟;Markdown 下载必须重新请求 `format=markdown`、`download=true` 且默认启用 evidence check。浏览器不得从已经渲染的 DOM 或 canonical JSON 自行拼接 Markdown。当前后台刷新和浏览器下载都要求已保存的 Bearer token,即使 Server 未启用鉴权也是如此;首次和手动报告加载仍可在无 token 时工作。Handoff Report 功能关闭时不注册 `/handoff-reports` 页面和对应 API,原 Dashboard 路径、scope 切换和统计请求保持不变。 +浏览器使用 JSON 投影。下载 Markdown 时,使用相同 selection 重新请求 `format=markdown`、`download=true`;浏览器 +不会从 DOM 重建 Markdown。关闭 Handoff Report 会移除页面和报告 API,不影响 Dashboard selection 和统计行为。 ## 保持安全边界 diff --git a/docs/zh/docs/explanation/core-concepts.md b/docs/zh/docs/explanation/core-concepts.md index 2b63b6c53..84cc5e19f 100644 --- a/docs/zh/docs/explanation/core-concepts.md +++ b/docs/zh/docs/explanation/core-concepts.md @@ -80,8 +80,8 @@ Work Contract 将目标和完成边界保存为 Source 证据。`handoff_current Handoff。只有用户需要保留里程碑时,commit Handoff 才创建长期 Revision。接收方解析 Handoff 并记录 Acknowledgement;Task Outcome 将最终状态和检查结果保存为 Source 证据。 -[Handoff Report](../how-to/use-handoff-report.md) 将当前 Handoff Revision 投影为可检查、可导出的视图。当前 scope -report 尚不包含 Activity event 或 period comparison,也不会改写 Memory 或底层 Handoff history。 +[Handoff Report](../how-to/use-handoff-report.md) 将每个选中 Scope 的最新 Handoff Revision 投影为可检查、可导出的 +视图。它是只读操作,不会改写 Memory 或底层 Handoff history。 ## 各接口暴露同一个 Server 的不同部分 diff --git a/docs/zh/docs/how-to/configure-claude-code.md b/docs/zh/docs/how-to/configure-claude-code.md index 133ecdd10..41cd4a2e9 100644 --- a/docs/zh/docs/how-to/configure-claude-code.md +++ b/docs/zh/docs/how-to/configure-claude-code.md @@ -68,16 +68,15 @@ v1 不安装 `Stop` Hook,不读取 transcript,也不自动采集 Claude 的 scope 按以下顺序解析: 1. 显式设置的 `POWERCONTEXT_CLAUDE_SCOPE_ID`; -2. 与 Codex 共用的 Git-private Workstream 绑定; +2. Git-private Scope binding; 3. Git 顶层目录中规范化后的 `remote.origin.url`; 4. 从解析后的项目目录生成的 `local:sha256:` 标识。 -因此,在已经绑定 Workstream 的 checkout 中,Claude Code 和 Codex 会话会优先得到完全相同的 scope。 -未绑定时,两者仍会共享规范化后的 remote scope。可以用随附 resolver 显式绑定: +使用随附 resolver 将 checkout 绑定到一个已知 Scope: ```bash -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/project_scope.py" \ - --cwd "$PWD" --bind-workstream "WORKSTREAM_SCOPE_ID" +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workspace_scope.py" \ + --cwd "$PWD" --bind-scope "SCOPE_ID" ``` local fallback 在同一个解析后目录中保持稳定,但不用于连接无关的 checkout。只有确实需要主动隔离或共享时, diff --git a/docs/zh/docs/how-to/configure-codex.md b/docs/zh/docs/how-to/configure-codex.md index e07a20e98..4eb684901 100644 --- a/docs/zh/docs/how-to/configure-codex.md +++ b/docs/zh/docs/how-to/configure-codex.md @@ -35,24 +35,19 @@ powercontext setup codex --source oceanbase/powercontext --ref master 交接 ``` -`project-context` Skill 会把这句话视为创建持久交接里程碑的明确授权。如果目录中存在多个 Workstream,Codex 会先 -打开原生工作选择框;只有一个候选时会自动选中。选定后,Codex 把该 Workstream 绑定到当前工作区,在同一轮中检查 -当前对话和仓库,整理目标、分支与工作区状态、改动文件、已执行检查、阻塞项、缺失项和下一步,然后依次调用 -`handoff_current_work` 和 `commit_handoff`。提交成功后,Codex 返回所选 Workstream 和 exact Handoff Revision;用户 -不需要再填写交接内容或重复确认提交。 +`project-context` Skill 会把这句话视为创建持久交接里程碑的明确授权。Codex 在同一轮中检查当前对话和仓库,整理目标、 +分支与工作区状态、改动文件、已执行检查、阻塞项、缺失项和下一步,然后在当前 Session Scope 中依次调用 +`handoff_current_work` 和 `commit_handoff`。提交成功后,Codex 返回 exact Handoff Revision;用户不需要再填写交接 +内容或重复确认提交。 `交接当前工作`、`把当前工作交接出去` 和 `handoff this work` 使用相同行为。若只想检查内容而不写入,请明确说 `预览交接,不要提交`;Skill 此时只在对话中渲染建议内容,不调用写工具。讨论 Handoff 设计或询问 Handoff 如何工作也不会触发持久化。 -Codex scope 按以下顺序解析:显式的 `POWERCONTEXT_CODEX_SCOPE_ID`、当前 Git 工作区持久绑定的 Workstream -scope、规范化后的 Git remote、项目路径。同一工作区后续开启的新 Codex 会话会复用同一个 scope。 - -选择工具返回面向用户的 `work_id` 和权威 `scope_id`。`project-context` Skill 会把这个 exact scope 传给 scope -resolver 的 `--bind-workstream` 操作,并再次校验解析结果。绑定写在 Git 私有目录的 -`powercontext/codex-workspace.json` 中,不进入工作树或提交。随后,新 Handoff 会继续写入所选 Workstream 的同一 -Artifact lifecycle,并得到下一个 Revision。如果 MCP 客户端不支持原生 elicitation,工具会改为返回结构化候选 -列表;集成仍然必须取得用户的明确选择,不得静默选择。 +Session 启动时,Codex 按以下顺序解析 Scope:显式的 `POWERCONTEXT_CODEX_SCOPE_ID`、已有 Session binding、 +host 管理的 workspace binding、Server 默认 Scope。解析出的 Scope 会固定到当前 Session。仓库和目录身份只用于查找 +binding,不生成 Scope ID。Prompt Hook 使用该 binding 完成召回和采集;`PreToolUse` 将同一 binding 注入 data-plane +工具,Agent 输入不能把读写重定向到其他 Scope。Session 切换工作边界时,应由 host 创建或绑定另一个 Scope。 Codex 开始分析提示词前,Hook 只调用一次 `POST /v1/context/prepare`,请求 8000-byte 总预算。它严格校验 `powercontext.prepared-context.v1`,并原样注入返回内容。Runtime 负责把 Memory 内容标记为不可信历史、保留 diff --git a/docs/zh/docs/how-to/configure-hermes.md b/docs/zh/docs/how-to/configure-hermes.md index 2fde0931d..193c32fb6 100644 --- a/docs/zh/docs/how-to/configure-hermes.md +++ b/docs/zh/docs/how-to/configure-hermes.md @@ -51,12 +51,12 @@ hermes powercontext search "Python package manager" ``` 在交互式 Hermes 会话中,`/pc status` 应连接到同一个 active provider。输入 `/pc ` 后按 Tab/Down,可查看 -Memory、Handoff、Experience、Skill、审核、统计、trace 和 Workstream 命令。Hermes 0.20.4 没有为 gateway +Memory、Handoff、Experience、Skill、审核、统计、trace 和 Scope 命令。Hermes 0.20.4 没有为 gateway slash command 提供足够的调用上下文,因此该插件会拒绝 gateway 调用;gateway 会话应使用 provider 提供的 Hermes tools。 -provider 默认连接 `http://127.0.0.1:8000`。在 Git workspace 中,默认启用的 Workstream persistence 会先读取 -共享的 `.git/powercontext/codex-workspace.json` scope binding;显式 scope 配置的优先级更高。两者都没有时, +provider 默认连接 `http://127.0.0.1:8000`。在 Git workspace 中,默认启用的 Scope binding 会先读取 +`.git/powercontext/scope-binding.json`;显式 Scope 配置的优先级更高。两者都没有时, provider 根据当前 Hermes profile 和 gateway user identifier 推导 scope;本地 CLI 会话没有 user identifier 时, 会从 `HERMES_HOME` 推导稳定值。 @@ -78,7 +78,7 @@ provider 根据当前 Hermes profile 和 gateway user identifier 推导 scope; | `POWERCONTEXT_HERMES_CAPTURE_PRE_COMPRESS` | compression 前采集过滤后的新 turn;默认关闭 | | `POWERCONTEXT_HERMES_EVALUATION_TRACE` | 把召回上下文记录到敏感的本地 JSONL trace;默认关闭 | | `POWERCONTEXT_HERMES_EVALUATION_TRACE_PATH` | 覆盖 evaluation trace 目录 | -| `POWERCONTEXT_HERMES_WORKSTREAM` | 读取 Git-private 的共享 Workstream binding;默认启用 | +| `POWERCONTEXT_HERMES_SCOPE_BINDING` | 读取 Git-private Scope binding;默认启用 | 应由 Hermes 向导把 authorization 保存到受保护的 `.env` secret store,不要把 token 写入 `config.json`。明文 HTTP 只用于 loopback Server;连接远程部署前请阅读[部署 Server](deploy-server.md)。Evaluation trace 包含 prompt 和 diff --git a/docs/zh/docs/how-to/configure-workbuddy.md b/docs/zh/docs/how-to/configure-workbuddy.md index a02a53304..bba3fedb7 100644 --- a/docs/zh/docs/how-to/configure-workbuddy.md +++ b/docs/zh/docs/how-to/configure-workbuddy.md @@ -63,8 +63,8 @@ cp "$PLUGIN"/hooks/workbuddy_powercontext_hook.py \ "$PLUGIN"/hooks/workbuddy_settings.py \ "$PLUGIN"/hooks/prepared_context.py \ "$WORKBUDDY_HOOKS_DIR"/ -cp "$PLUGIN/scripts/project_scope.py" \ - "$WORKBUDDY_HOOKS_DIR/powercontext_project_scope.py" +cp "$PLUGIN/scripts/workspace_scope.py" \ + "$WORKBUDDY_HOOKS_DIR/powercontext_scope_binding.py" ``` ### 2. 注册 Hook @@ -122,8 +122,8 @@ EOF ``` 然后把 `~/.workbuddy/skills/project-context/SKILL.md` 中的 `${POWERCONTEXT_PYTHON}` 替换为 shell-safe -的 Python executable 参数,把 `${POWERCONTEXT_PROJECT_SCOPE_SCRIPT}` 替换为 shell-safe 的完整 -`/powercontext_project_scope.py` 路径。 +的 Python executable 参数,把 `${POWERCONTEXT_SCOPE_BINDING_SCRIPT}` 替换为 shell-safe 的完整 +`/powercontext_scope_binding.py` 路径。 ### 5. 启动 Server、重启 WorkBuddy 并验证 @@ -202,13 +202,13 @@ Hook 会校验其 PowerContext MCP URL,并通过去掉末尾 `/mcp` 路径段 WorkBuddy 按以下顺序解析 scope: 1. 显式的 `POWERCONTEXT_WORKBUDDY_SCOPE_ID`; -2. 当前 Git 工作区持久绑定的 Workstream scope(存储在 Git 私有目录的 - `powercontext/codex-workspace.json` 中,与 Codex 和 Claude Code 插件共享); +2. 当前 Git 工作区持久绑定的 Scope(存储在 Git 私有目录的 + `powercontext/scope-binding.json` 中); 3. 规范化后的 Git remote; 4. 解析后的本地项目目录的哈希。 同一工作区后续开启的新 WorkBuddy 会话会复用同一个 scope。`project-context` Skill 的 -`--bind-workstream` 操作会为选中的 Handoff 持久化 Workstream 绑定;绑定不会进入工作树或提交。 +`--bind-scope` 操作会持久化 Scope binding;绑定不会进入工作树或提交。 ## 连接启用鉴权的本地 Server diff --git a/docs/zh/docs/how-to/full-capability-runtime.md b/docs/zh/docs/how-to/full-capability-runtime.md index 72aa5e90b..206e5da2b 100644 --- a/docs/zh/docs/how-to/full-capability-runtime.md +++ b/docs/zh/docs/how-to/full-capability-runtime.md @@ -1,90 +1,50 @@ --- title: 完整功能 Quick Start -description: 5 分钟启动 PowerContext 完整功能。 +description: 配置模型、启动 Server,并验证完整 Memory 闭环。 --- # 完整功能 Quick Start -## 最小运行与完整能力的差别 +`powercontext server run` 不配置模型也可以运行,但依赖模型的提取和向量检索不会启用。引导式配置会启用 generation、 +embedding、定时 Source 处理,并写入 metrics 和 tracing 设置。 -不带任何配置执行 `powercontext server run`,得到的是最小 Server:进程可以启动、可以接收 Source,但依赖模型的 -能力默认关闭。通过 `config init` 引导生成的一份 `.env` 可以把全部能力打开: - -| 能力 | 默认最小运行 | 完整能力运行 | +| 能力 | 最小 Server | 完整功能 Runtime | | --- | --- | --- | -| Source 采集 | 启用 | 启用 | -| Memory 提取 | 关闭;Source 保持 pending | 启用;Scheduler 每 60 秒处理一次 | -| 搜索模式 | 仅 `auto, fts` | `auto, fts, vector, hybrid` | -| Dashboard Scope | 未配置 | 可见 `project:quickstart` | -| MCP 端点 | `/mcp` 启用 | `/mcp` 启用 | - -两种模式默认都使用 SQLite。向量搜索额外使用内置的 `sqlite-vec` 扩展;当 Embedding model 或其 profile 未配置时, -Server 会回退到 SQLite FTS 并报告 `Search modes: auto, fts`。此时召回仍然可用,但语义搜索和混合搜索需要配置 -Embedding model。 - -## 先确定 Scope ID - -Scope ID 是 PowerContext 的数据命名空间,可以把它理解成“项目 ID”。Source、Memory 和 Handoff 都归属于某个 -Scope;只有 Dashboard 和 Coding Agent 使用同一个 Scope ID,网页里才能看到 Agent 写入的数据。 - -同一个 Server 可以保存多个 Scope。Server 启动时配置的是 **Dashboard 可以查看哪些 Scope**,Coding Agent 启动时 -配置的是 **本次会话把数据读写到哪个 Scope**: - -```text -Coding Agent ──读写──> project:quickstart <──展示── Dashboard -``` - -Scope ID 可以使用任意简短、稳定、非空的字符串,不要包含密钥或其他秘密。例如: +| Source capture | 启用 | 启用 | +| Memory extraction | 关闭 | 启用 | +| Search mode | `auto, fts` | `auto, fts, vector, hybrid` | +| Dashboard | 默认 Scope | 默认 Scope 和所有已创建 Scope | +| MCP endpoint | `/mcp` | `/mcp` | -```text -project:quickstart -git:github.com/oceanbase/powercontext -team:payment-service -``` - -下面的 Quick Start 统一使用: - -```text -project:quickstart -``` +Server 首次启动时创建一个使用不透明 ID 的默认 Scope。Dashboard 从 Server 发现 Scope descriptor,不使用预配置列表。 +Integration 可以把 Session 或 workspace 绑定到默认 Scope,也可以绑定到其他已经存在的 Scope。 -## 快速启动 - -### 第一部分:启动 Server - -#### 1. 安装 +## 1. 安装并生成配置 ```bash uv tool install --force "powercontext[cli,server] @ git+https://github.com/oceanbase/powercontext.git@master" +powercontext config init --output .env ``` -#### 2. 生成配置 +按提示输入 provider connection 和 credential。本地 provider 忽略鉴权时,使用该 provider 接受的非秘密占位值。 + +在不打印 credential 的情况下检查并校验配置: ```bash -powercontext config init --output .env +powercontext config show --env-file .env +powercontext config validate --env-file .env ``` -按照提示填写 provider credential。Pydantic AI provider 在构造时要求提供 credential;如果本地服务忽略认证, -请填写该服务允许的非敏感占位值。 - -生成完成后会列出 Codex、Claude Code、DeepSeek Harness、OpenCode 和 Pi 的全部 setup 与启动命令。生成的 `.env` -按组写入了原本需要手工拼装的配置:Server HTTP、Dashboard、Scope、Generation model、Embedding model(含 -profile ID 与维度)、数据库类型与位置、调度间隔,以及各宿主集成 URL。随时可以用 -`powercontext config show --env-file .env` 查看(凭据显示为 ``),用 -`powercontext config validate --env-file .env` 校验语法和模型配置。 +生成文件包含 Server、模型、数据库、Scheduler 和 integration transport 设置。Scope identity 由运行中的 Server 管理, +Config Generator 不会凭空生成 Scope ID。 -#### 3. 启动 Server +## 2. 启动并检查 Server ```bash powercontext server run --env-file .env ``` -使用 `--env-file` 时,文件内的赋值覆盖 shell 中的同名值;文件中没有的旧 `POWERCONTEXT_SERVER_*` 变量会被忽略。 -因此 `config validate` 与 `server run` 使用同一份 Server 配置。 - -#### 4. 验证 Server - -在第二个终端执行: +在另一个终端执行: ```bash set -a @@ -95,117 +55,84 @@ powercontext ready powercontext capabilities ``` -确认输出包含: +Readiness 为 `ready`、Memory extraction 已启用,并且 search mode 包含 `vector` 和 `hybrid` 时,完整 Runtime 可用。 +如果只有 `auto, fts`,检查 Embedding model、profile ID、dimension、credential 和 Base URL。 -```text -package: ok - powercontext -server liveness: ok - http://127.0.0.1:8000 status=ok -server readiness: ok - http://127.0.0.1:8000 status=ready -Status: ready -Memory extraction: enabled -Search modes: auto, fts, vector, hybrid -``` - -`doctor` 全部检查为 `ok`、`Status: ready`、`Memory extraction: enabled`、四种搜索模式齐全,并且 - 的 Dashboard 中存在 `Quick Start`,说明完整功能已经启动。 +打开 ,确认默认 Scope 可见。获取其不透明 ID,供后续 API 检查使用: -如果 `powercontext capabilities` 只列出 `auto, fts`,Server 处于仅 FTS 的回退模式,vector 和 hybrid 搜索 -不可用,因此不满足上面的完整能力检查。 +```bash +SCOPE_ID="$(curl -fsS http://127.0.0.1:8000/v1/scopes/default \ + | python -c 'import json, sys; print(json.load(sys.stdin)["scope_id"])')" +export SCOPE_ID +``` -### 第二部分:验证 Memory 闭环 +## 3. 验证 Memory 闭环 -提取发生在 Source flush 时,因此在启动 Coding Agent 前,先验证一次完整闭环。使用唯一 Source ID,保证重复执行 -本指南时仍能验证本轮行为。在加载了同一环境变量的终端里执行: +使用唯一 ID 捕获 Source: ```bash SOURCE_ID="quickstart-$(date +%s)-$$" curl -fsS -X POST http://127.0.0.1:8000/v1/sources/content \ -H 'content-type: application/json' \ - -d "{\"scope_id\":\"project:quickstart\",\"source_id\":\"${SOURCE_ID}\",\"content\":\"PowerContext quick start check: prefer small, verifiable steps.\"}" + -d "{\"scope_id\":\"${SCOPE_ID}\",\"source_id\":\"${SOURCE_ID}\",\"content\":\"PowerContext quick start check: prefer small, verifiable steps.\"}" ``` -Server 返回 `202`、`"status":"accepted"` 和数字 `position`;记录 Source ID 与 position。然后 flush 该 Scope, -这一步会执行 Memory 提取: +保留响应中的 `position`,再 flush 同一 Scope: ```bash -curl -X POST http://127.0.0.1:8000/v1/memory/flush \ +curl -fsS -X POST http://127.0.0.1:8000/v1/memory/flush \ -H 'content-type: application/json' \ - -d '{"scope_id":"project:quickstart"}' + -d "{\"scope_id\":\"${SCOPE_ID}\"}" ``` -flush 响应包含 `current_cursor`,它必须大于等于 capture 响应中的 `position`。Scheduler 可能已经抢先处理该 -Source;只要 cursor 已到达该 position,`status:"idle"` 也是合法结果。如果尚未到达,请再次 flush。 +返回的 `current_cursor` 必须不小于 capture `position`。Scheduler 已经处理 Source 时,`status: "idle"` 也是有效结果。 -然后列出当前 Memory entry: +列出 Memory entry: ```bash curl -fsS -X POST http://127.0.0.1:8000/v1/memory/entries/list \ -H 'content-type: application/json' \ - -d '{"scope_id":"project:quickstart"}' + -d "{\"scope_id\":\"${SCOPE_ID}\"}" ``` -找到 `source_refs` 中包含本次 capture `source_id` 的 entry,并记录它的 `citation.entry_id`。这才能证明本次 -Source 生成了 Memory。如果没有 entry 引用该 Source,说明提取已运行但没有产生候选项;请换一个新的 Source ID, -写入更明确、可长期保留的事实或偏好后重试。 - -通过向量搜索确认 Embedding 可用: +找到 `source_refs` 包含已捕获 Source 的 entry,记录其 `citation.entry_id`,再验证向量检索: ```bash -curl -X POST http://127.0.0.1:8000/v1/memory/search \ +curl -fsS -X POST http://127.0.0.1:8000/v1/memory/search \ -H 'content-type: application/json' \ - -d '{"scope_id":"project:quickstart","query":"verifiable steps","mode":"vector","limit":50}' + -d "{\"scope_id\":\"${SCOPE_ID}\",\"query\":\"verifiable steps\",\"mode\":\"vector\",\"limit\":50}" ``` -只有响应同时包含 `"mode":"vector"`,并且某个 hit 的 `citation.entry_id` 等于上一步记录的 source-linked entry, -同时其 `matched_by` 包含 `"vector"`,才能确认本轮 Embedding 可用。命中其他 entry、空 `hits` 或 -`"mode":null` 都不能证明本轮闭环。此时检查 `powercontext capabilities`: -如果 `Search modes` 中没有 `vector`,Server 处于仅 FTS 的回退模式;如果存在 `vector`,则本次闭环尚未产生 -可供向量搜索的 Memory。当已有 Memory 但 vector capability 不可用时,显式 vector 请求会返回 HTTP 422。 - -最后检查模型使用统计: +响应包含 `mode: "vector"`、已记录的 `entry_id`,且 `matched_by` 包含 `vector` 时,该闭环验证通过。检查模型用量: ```bash -powercontext stats --scope-id project:quickstart -``` - -```text -Embedding: 1 requests, ... +powercontext stats --scope-id "$SCOPE_ID" ``` -Embedding 请求计数是累计值。非零计数只能辅助说明模型曾被调用;只有上面的 source-linked vector hit 能证明本轮闭环。 - -### 第三部分:启动 Coding Agent - -Config Generator 已经打印全部受支持 Coding Agent 的 setup 和启动命令。新开一个终端,找到要使用的 Agent,复制它下面 -的两行即可;第一行安装 PowerContext 集成,第二行加载刚生成的 `.env` 并启动 Agent,因此不需要再次填写 Scope ID。 - -Coding Agent 启动后,在项目中发送一条普通 prompt。集成会先从 `project:quickstart` 召回相关 Memory,再把本轮 prompt -保存为 Source;Scheduler 会在大约 60 秒内从新 Source 提取 Memory,因此第二部分的 flush 只需做一次用于验证闭环。 - -## 数据存在哪里 +## 4. 启动 Codex -生成的配置不指定数据库位置,因此 Server 把数据保存在用户数据目录,而不是项目内文件。在未设置 -`POWERCONTEXT_HOME` 时,SQLite 的 `powercontext.db` 与调度状态的 `scheduler.db` 位于: +使用 Config Generator 输出的命令安装插件,加载 `.env` 后启动 Codex。普通 Session 流程不要设置 +`POWERCONTEXT_CODEX_SCOPE_ID`:插件依次解析 Session binding、workspace binding 和 Server 默认 Scope。只有宿主必须 +显式选择一个已存在 Scope 时才设置该变量。 -- macOS:`~/Library/Application Support/powercontext/` -- Linux:`~/.local/share/powercontext/` +Codex 启动后发送普通 prompt。插件从绑定 Scope 召回内容,并把 prompt 捕获为 Source 证据。Scheduler 会在配置的间隔内 +处理新 Source。 -如需迁移,在启动 Server 前设置 `POWERCONTEXT_HOME` 即可。之后再修改数据库 URL 会把 Server 指向另一个(可能是 -空的)数据库;需要旧数据时请保留原来的值。 +## 数据与重启 -## 停止与恢复 +没有覆盖数据库设置时,SQLite 在用户数据目录保存 `powercontext.db` 和 `scheduler.db`: -在 Server 终端按 `Ctrl+C` 停止进程。数据持久保存在 SQLite 中,重启不会丢失。恢复时重新加载同一个 `.env`,再次 -执行 `powercontext server run --env-file .env`;pending 的 Source 会在下一次调度或 flush 时继续处理。 +- Linux:`$XDG_DATA_HOME/powercontext`,或 `~/.local/share/powercontext`; +- macOS:`~/Library/Application Support/powercontext`。 -## 快速排障 +按 `Ctrl+C` 停止 Server。使用同一 `.env` 和数据目录重启后,默认 Scope 及其不透明 ID 保持稳定,因为它们保存在数据库中。 | 现象 | 处理方式 | | --- | --- | -| Dashboard 为空 | 对比 Dashboard 与 Agent 的完整 scope 字符串 | -| `ready` 为 `degraded` | 检查 Generation、Embedding 的模型、密钥和 Base URL | -| 没有 `vector`、`hybrid` | 同时配置 Embedding model、profile ID 和正确维度;未配置时召回保持 FTS(`auto, fts`) | -| Source 一直 pending | 启用 scheduler,或调用 `/v1/memory/flush` | -| 原有数据不见了 | 恢复之前的数据库 URL 或 `POWERCONTEXT_HOME` | +| Dashboard 中缺少 Scope | 确认 Scope 已通过 Scope API 创建,然后刷新页面 | +| Readiness 为 `degraded` | 检查模型标识、credential 和 Base URL | +| 没有 `vector` 或 `hybrid` | 同时配置 Embedding model、profile ID 和 dimension | +| Source 一直 pending | 启用 Scheduler,或调用 `/v1/memory/flush` | +| 已有数据消失 | 恢复原数据库 URL 或 `POWERCONTEXT_HOME` | -更多错误状态见[排查问题](troubleshoot.md),完整变量见[配置参考](../reference/configuration.md)。 +更多信息见[故障排查](troubleshoot.md)和[配置](../reference/configuration.md)。 diff --git a/docs/zh/docs/how-to/handoff-with-codex.md b/docs/zh/docs/how-to/handoff-with-codex.md index a2bc44cba..d7673d00a 100644 --- a/docs/zh/docs/how-to/handoff-with-codex.md +++ b/docs/zh/docs/how-to/handoff-with-codex.md @@ -17,7 +17,8 @@ Work Contract → Handoff → Acknowledgement → Task Outcome ## 开始之前 先完成[安装和运行](install-and-run.md),保持 Server 运行,并在当前项目中启动已配置 PowerContext 插件的 Codex -会话。工作记录属于所选 scope。Report catalog 中存在多个 Workstream 时,让 Codex 显示 picker 并选择目标 Workstream。 +会话。Integration 会在读取或写入工作记录前将 Session 绑定到一个 Scope。只有独立工作确实需要单独隔离和继续时, +才为它创建新的 Scope。 不要把密钥、访问令牌或其他敏感信息写入 Work Contract、Handoff、Acknowledgement 或 Outcome。 @@ -37,9 +38,9 @@ Codex 调用 `create_work_contract` 并返回精确 Source receipt。Contract > 使用 PowerContext 交接当前工作。检查当前目标、branch、worktree、changed files、已运行检查、阻塞项、遗漏和下一步, > 提交完成的 Handoff,并给我精确 Revision。 -Codex Skill 会选择 Workstream、检查 live state、调用 `handoff_current_work`,并在同一个 turn 中提交返回的 Prepared -Handoff。成功结果包含 scope 和精确 Handoff Revision。如果 prepare 成功但 commit 失败,boundary Source 已经存在, -但没有创建 durable milestone。 +Codex Skill 会使用当前 Session Scope、检查 live state、调用 `handoff_current_work`,并在同一个 turn 中提交返回的 +Prepared Handoff。成功结果包含 Scope 和精确 Handoff Revision。如果 prepare 成功但 commit 失败,boundary Source +已经存在,但没有创建 durable milestone。 只需要只读预览时,明确要求 preview PowerContext Handoff 且不执行写操作。需要临时转交但不保留 milestone 时,让 Codex 准备 Handoff 但不要 commit。该操作会记录 boundary Source,并返回一份完整 Prepared Handoff 给接收方。 diff --git a/docs/zh/docs/how-to/install-and-run.md b/docs/zh/docs/how-to/install-and-run.md index ac9c3fa93..76f20e053 100644 --- a/docs/zh/docs/how-to/install-and-run.md +++ b/docs/zh/docs/how-to/install-and-run.md @@ -50,7 +50,7 @@ powercontext server run - 监听 `127.0.0.1:8000`; - 在 `/mcp` 启用 Streamable HTTP MCP; -- 在 `/` 启用 Dashboard;尚未配置 scope 时,页面会显示明确的空状态; +- 创建默认 Scope,并在 `/` 启用 Dashboard; - 在操作系统的用户数据目录中创建持久化 SQLite 数据库; - 无需推理服务即可支持显式 Memory 操作。 diff --git a/docs/zh/docs/how-to/use-handoff-report.md b/docs/zh/docs/how-to/use-handoff-report.md index 71180a63c..eb63d07ba 100644 --- a/docs/zh/docs/how-to/use-handoff-report.md +++ b/docs/zh/docs/how-to/use-handoff-report.md @@ -1,12 +1,11 @@ --- title: 使用 Handoff Report -description: 打开 Server 报告,选择 scope,检查 Handoff history,并保存 Revision。 +description: 选择 Scope 视图,检查当前 Handoff,并下载 Markdown 报告。 --- # 使用 Handoff Report -Handoff Report 在 Server 托管的网页中展示 committed Handoff Revision。使用该页面可以按 scope 检查当前工作、 -保存完整 Handoff snapshot,或请求 Markdown projection。 +Handoff Report 是各个选中 Scope 最新 committed Handoff 的只读视图。它不会创建或编辑 Scope、Handoff。 ## 开始之前 @@ -17,58 +16,38 @@ powercontext server run ``` Handoff Report 默认启用,地址是 `http://127.0.0.1:8000/handoff-reports`。它使用 Server 的 listener 和鉴权设置, -但不要求启用统计 Dashboard 或配置其 scope list。启用 Bearer 鉴权后,在页面登录表单中输入配置的 token。 +但不要求启用统计 Dashboard。启用 Bearer 鉴权后,在页面登录表单中输入配置的 token。 -默认未鉴权模式下,首次加载和手动加载不需要 token。当前浏览器代码要求存在已保存的 Bearer token 才执行后台刷新和 -Markdown 下载,因此只有启用鉴权并在页面保存 token 后,这两个控件才会发送报告请求。 +Server 启动时会创建默认 Scope。通过 integration 或 Scope API 创建其他 Scope 后,它们也会出现在报告中。Scope +不需要已经包含 committed Handoff。 -页面会发现至少包含一个 committed Handoff 的 scope。没有这类 scope 时,页面显示无数据模板预览,并禁用搜索、周期 -筛选、编辑和下载。 +## 1. 提交 Handoff -## 1. 在目标 scope 中提交 Handoff +在需要查看报告的 Scope 中创建 durable Handoff milestone。在 Codex 中按照 +[在 Codex 中交接工作](handoff-with-codex.md)操作。Codex integration 会写入当前 Session 绑定的 Scope。 -在需要查看报告的 scope 中创建 durable Handoff milestone。在 Codex 中按照 -[在 Codex 中交接工作](handoff-with-codex.md)操作,并保留结果中的精确 scope 和 Handoff Revision。 +报告只读取 committed Handoff Revision,不包含临时 Prepared Handoff。 -Commit 成功后重新加载 Handoff Report。页面调用 `list_handoff_report_known_scopes`,结果中应包含这个 scope。提交 -Handoff 后即可发现 scope,不需要创建 Report Project 或注册 Workstream。 +## 2. 选择 Scope 视图 -## 2. 选择 scope +打开 Handoff Report,选择一项共用的 Scope selection: -按 `scope_id` 搜索并选择 scope。页面使用 `scope_id` 请求报告,显示当前 objective、state、disposition、next action -和 known omissions。 +- **全部**包含该 Server 可见的所有 Scope。 +- **Scope 及其下级**包含一个根 Scope 及其所有后代。 +- **聚焦**只包含一个精确 Scope。 -报告还会按最新优先展示 Handoff history。JSON projection 最多包含最近 20 个 Revision 摘要,并标明更早的 history -是否被截断。HTTP request schema 保留可选的 `project_id` 字段以兼容旧 wire contract,但该字段已 deprecated, -Server 生成 scope report 时会忽略它。 +Parent 只表达组织关系,不会让父 Scope 隐式看到子 Scope 的 Context 或 Handoff。因此,每一行只显示该 Scope +自身最新的 Handoff。选中的 Scope 没有 committed Handoff 时,显示为**无交接**。 -页面会启动 5 秒刷新 timer,但当前只有保存了 Bearer token 才会发送后台请求。未启用鉴权时,使用 **刷新** 手动加载 -变更。存在未保存编辑或正在执行 Handoff action 时,后台刷新也会暂停。 +Handoff 或 Scope 发生变化后,使用**刷新**重新加载。 -## 3. 保存新的 Handoff Revision +## 3. 阅读或下载报告 -选择 **编辑**,将五个 current snapshot 字段作为一份完整文档修改,再选择 **保存新版本**。Server 会 prepare 并 -commit 完整内容,创建新的不可变 Handoff Revision。 +页面显示选中的 Scope、Parent 关系、Handoff 状态、目标、下一步和精确 Revision 地址。摘要计数和明细行使用同一份 +冻结 selection。 -保存属于写操作。编辑器打开时,scope 切换保持暂停。页面不记录接收方是否接受;Acknowledgement 和 Task Outcome -只作为只读记录显示在 **连续性时间线** 中。 - -## 4. 了解当前周期控件 - -当前 scope report 接收并规范化本日、本周、自然月或自定义 period,但尚未配置 Activity integration。响应不包含 -Activity event,`activity_coverage` 为 `not_configured`,也不会生成 previous-period comparison。 - -目前不要使用周期控件推断历史工作或比较 Activity。Handoff snapshot 表示当前精确 selection,不是根据所选周期结束 -时间重建的状态。 - -## 5. 下载 Markdown - -启用 Bearer 鉴权并在页面保存 token 后,选择 **下载 Markdown**,导出相同 scope、locale 和规范化 period。浏览器 -直接向 Server 请求 Markdown,不会根据已渲染页面重新拼接。下载默认启用 evidence check,文件名为 -`handoff-report.md`。 - -默认未鉴权模式下,当前浏览器 guard 不会发送下载请求。Server 未启用鉴权时,底层 HTTP operation 和 Python Client -仍可在不提供 token 的情况下使用。 +选择**下载 Markdown**,由 Server 生成 Markdown projection;浏览器不会根据已渲染页面重新拼接。JSON 和 Markdown +projection 都带有 selection digest 和 report digest,便于使用方识别本次生成的精确结果。 ## 关闭 Handoff Report @@ -82,5 +61,4 @@ powercontext server run 关闭后不会注册 `/handoff-reports` 和 Report API route。Dashboard、HTTP API、MCP、Memory 和 Handoff operation 仍可独立配置。 -Scope discovery 和 Report operation 见[接口](../reference/interfaces.md),精确 Server 设置见 -[配置](../reference/configuration.md)。 +Scope 和 Report operation 见[接口](../reference/interfaces.md),精确 Server 设置见[配置](../reference/configuration.md)。 diff --git a/docs/zh/docs/reference/configuration.md b/docs/zh/docs/reference/configuration.md index 0cd6b416e..b128f4cb1 100644 --- a/docs/zh/docs/reference/configuration.md +++ b/docs/zh/docs/reference/configuration.md @@ -54,7 +54,6 @@ Server 配置使用 `POWERCONTEXT_SERVER_` 前缀。 | `POWERCONTEXT_SERVER_AUTH_TOKEN` | 未设置 | 静态 Bearer token;启用鉴权时必须设置 | | `POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK` | `false` | 在鉴权关闭时显式允许绑定非 loopback 地址 | | `POWERCONTEXT_SERVER_DASHBOARD_ENABLED` | `true` | 在 Server 根路径 `/` 启用 Dashboard | -| `POWERCONTEXT_SERVER_DASHBOARD_SCOPES` | `[]` | Dashboard 可选择的 scope JSON 数组 | | `POWERCONTEXT_SERVER_HANDOFF_REPORT_ENABLED` | `true` | 启用 Handoff Report 及其 API route | | `POWERCONTEXT_SERVER_LOGGING_LEVEL` | `INFO` | operational log 级别 | | `POWERCONTEXT_SERVER_LOGGING_FORMAT` | `console` | `console` 或结构化 `json` 输出 | @@ -96,8 +95,8 @@ Python Client 和 CLI 对出站请求应用相同规则:配置的明文 `http: 安全的 Docker 和远程访问配置见[部署 Server](../how-to/deploy-server.md)。 -Dashboard 默认启用,并与 HTTP API、MCP 共用监听地址和端口。默认未配置 scope,页面会显示空状态;Dashboard -初始化失败只记录包含直接原因的 warning,不影响 Server 的 HTTP API、MCP 和健康检查启动。 +Dashboard 默认启用,并与 HTTP API、MCP 共用监听地址和端口。它从 Server 发现默认 Scope 和所有已创建 Scope; +Dashboard 初始化失败只记录包含直接原因的 warning,不影响 Server 的 HTTP API、MCP 和健康检查启动。 启用 Bearer 鉴权后,`/`、`/skills`、`/reviews`、`/handoff-reports` 的 HTML 外壳及其静态资源仍保持公开,以便 浏览器渲染登录表单;数据请求仍受鉴权保护。在表单中输入 Server token 后,浏览器只把它保存在当前标签页的 session @@ -270,7 +269,7 @@ powercontext capabilities | 变量 | 默认值 | 含义 | | --- | --- | --- | -| `POWERCONTEXT_CODEX_SCOPE_ID` | 根据 Git remote 或项目路径生成 | 覆盖项目 scope | +| `POWERCONTEXT_CODEX_SCOPE_ID` | 未设置 | 显式选择一个已存在 Scope,不再解析 binding 和 Server 默认 Scope | | `POWERCONTEXT_CODEX_AUTHORIZATION` | 未设置 | Hook 与 MCP 请求使用的完整 `Bearer ` header | | `POWERCONTEXT_CODEX_CAPTURE_PROMPTS` | `true` | 把用户提示词采集为 Source 证据 | | `POWERCONTEXT_CODEX_FLUSH_ON_CAPTURE` | `false` | 采集后等待 Source 处理 | @@ -278,8 +277,9 @@ powercontext capabilities | `POWERCONTEXT_CODEX_HTTP_BUDGET_SECONDS` | `4` | Hook 共享 HTTP 时间预算 | | `POWERCONTEXT_CODEX_FLUSH_MAX_CALLS` | `4` | 每个提示词最多执行的 flush 次数 | -Codex Hook 外层超时为十秒。Server 不可用或拒绝鉴权时,恢复、采集和 flush 独立降级,不会阻塞 Codex。 -该变量必须存在于启动 Codex 的进程环境中;修改后需要重启 Codex。 +Codex Hook 外层超时为十秒。Server 不可用或拒绝鉴权时,恢复、采集和 flush 独立降级,不会阻塞 Codex。未显式指定 +Scope 时,插件依次解析 Session binding、workspace binding 和 Server 默认 Scope。配置变量必须存在于启动 Codex 的 +进程环境中;修改后需要重启 Codex。 ## Claude Code 插件 diff --git a/docs/zh/docs/reference/http-api.md b/docs/zh/docs/reference/http-api.md index c289ca691..571950f4f 100644 --- a/docs/zh/docs/reference/http-api.md +++ b/docs/zh/docs/reference/http-api.md @@ -96,7 +96,7 @@ curl --fail \ | Experience 与 Skill | `/v1/experience/*`、`/v1/skill/*` | propose、generate 和读取 Artifact Revision | | 审核 | `/v1/artifact-candidates/*` | 列出、检查、修订、批准或拒绝 pending Candidate | | 外部 Skill | `/v1/external-skills/*` | 扫描已配置 target,解析或导入 package | -| Handoff Report | `/v1/handoff-reports/*` | 管理 Project、Workstream、activity、report 和 workspace binding | +| Handoff Report | `/v1/handoff-reports/*` | 按 Scope selection 生成只读报告 | | 统计 | `/v1/stats` | 读取指定 scope 的使用统计 | 完整路径、schema、限制和状态码以 OpenAPI 契约为准。高层工作流和 Python 示例见[接口](interfaces.md)。 diff --git a/docs/zh/docs/reference/interfaces.md b/docs/zh/docs/reference/interfaces.md index 3042aac53..25ac6eb7b 100644 --- a/docs/zh/docs/reference/interfaces.md +++ b/docs/zh/docs/reference/interfaces.md @@ -62,17 +62,10 @@ Claim 和 check 要么是没有 evidence 的 `declared`,要么是拥有同 sco 完整 Codex 转交和接收确认流程见[在 Codex 中交接工作](../how-to/handoff-with-codex.md)。 -Handoff Report 会列出包含 committed Handoff 的 scope,`get_handoff_report` 要求提供 `scope_id`。`project_id` 仅作为 -deprecated wire-compatibility input 保留,生成报告时会被忽略。每个返回的 Workstream projection 包含 -`handoff_revision_count`、`handoff_history_truncated` 和 `handoff_history`,最多返回 frozen selection 之前最近 20 个 -Revision 摘要。Web 操作见[使用 Handoff Report](../how-to/use-handoff-report.md)。 - -当前 scope report 不返回 Activity event,`activity_coverage=not_configured`,并且没有 period comparison。Period -输入只会被规范化,不会筛选 Activity。Server 未启用鉴权时,HTTP 和 Python Client 的 Markdown operation 仍可不带 -token 使用;当前浏览器下载和后台刷新控件要求已保存的 Bearer token。 - -Codex scope resolver 支持把当前 Git 工作区一次绑定到固定 Workstream scope,绑定优先于 Git remote 和路径推导,但低于 -显式 scope 配置。 +Handoff Report 是 Scope selection 上的只读投影。`all` 包含全部 Scope,`exact` 只包含列出的 Scope ID,`subtree` +包含一个组织根及其全部后代。每个选中 Scope 提供 latest exact Handoff address,或者明确的 `no_handoff`;Parent 不会 +隐式授予 Context 可见性。Codex 会把普通 Agent 的报告读取固定为当前 Session Scope;更宽的 selection 由 host 和 +Dashboard 使用。 ## DeepSeek Harness 插件 diff --git a/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py b/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py index f335df3f9..ad208a923 100644 --- a/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py +++ b/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py @@ -42,7 +42,7 @@ def override(method: _MethodT, /) -> _MethodT: from claude_code_settings import ClaudeCodePluginSettings # noqa: E402 from hooks import prepared_context as _prepared_context # noqa: E402 -from scripts.project_scope import resolve_scope_id # noqa: E402 +from scripts.workspace_scope import resolve_scope_id # noqa: E402 _MAX_CONTEXT_BYTES = _prepared_context.MAX_CONTEXT_BYTES _InvalidResponseError = _prepared_context.InvalidPreparedContextResponse diff --git a/integrations/claude-code/plugins/powercontext/scripts/project_scope.py b/integrations/claude-code/plugins/powercontext/scripts/workspace_scope.py similarity index 87% rename from integrations/claude-code/plugins/powercontext/scripts/project_scope.py rename to integrations/claude-code/plugins/powercontext/scripts/workspace_scope.py index 19b2d7aac..e509c42a2 100644 --- a/integrations/claude-code/plugins/powercontext/scripts/project_scope.py +++ b/integrations/claude-code/plugins/powercontext/scripts/workspace_scope.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Derive a stable PowerContext scope for one project directory.""" +"""Resolve and persist a PowerContext Scope binding for one workspace.""" from __future__ import annotations @@ -37,9 +37,9 @@ _MAX_SCOPE_LENGTH = 256 _SCP_REMOTE = re.compile(r"^(?:[^@/\s]+@)?(?P[^:/\s]+):(?P.+)$") -_WORKSPACE_STATE_SCHEMA = "powercontext.codex-workspace.v1" +_WORKSPACE_STATE_SCHEMA = "powercontext.scope-binding.v1" _WORKSPACE_STATE_DIRECTORY = "powercontext" -_WORKSPACE_STATE_FILE = "codex-workspace.json" +_WORKSPACE_STATE_FILE = "scope-binding.json" def resolve_scope_id(cwd: str, *, configured_scope_id: str | None = None) -> str: @@ -67,21 +67,21 @@ def derive_scope_id(cwd: str, *, configured_scope_id: str | None = None) -> str: return f"local:{hashlib.sha256(os.fsencode(project_root)).hexdigest()}" -def bind_workstream_scope(cwd: str, scope_id: str, /) -> str: - """Persist one shared Workstream scope in Git-private state.""" +def bind_scope(cwd: str, scope_id: str, /) -> str: + """Persist one Scope binding in Git-private state.""" normalized_scope_id = _bounded_explicit(scope_id.strip()) if not normalized_scope_id: - raise ValueError("Workstream scope must be non-empty") # noqa: TRY003 + raise ValueError("Scope binding must be non-empty") # noqa: TRY003 state_path = _workspace_state_path(cwd) if state_path is None: - raise ValueError("Workstream scope binding requires a Git workspace") # noqa: TRY003 + raise ValueError("Scope binding requires a Git workspace") # noqa: TRY003 _write_workspace_state(state_path, normalized_scope_id) return normalized_scope_id def read_bound_scope_id(cwd: str, /) -> str | None: - """Read the shared Codex/Claude Workstream binding from Git-private state.""" + """Read the Scope binding from Git-private state.""" state_path = _workspace_state_path(cwd) if state_path is None: @@ -103,8 +103,8 @@ def read_bound_scope_id(cwd: str, /) -> str | None: return scope_id -def clear_workstream_scope(cwd: str, /) -> bool: - """Remove only the Git-private shared Workstream binding file.""" +def clear_scope_binding(cwd: str, /) -> bool: + """Remove the Git-private Scope binding file.""" state_path = _workspace_state_path(cwd) if state_path is None: @@ -207,14 +207,14 @@ def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser() parser.add_argument("--cwd", default=os.getcwd()) action = parser.add_mutually_exclusive_group() - action.add_argument("--bind-workstream", metavar="SCOPE_ID") - action.add_argument("--clear-workstream", action="store_true") + action.add_argument("--bind-scope", metavar="SCOPE_ID") + action.add_argument("--clear-scope", action="store_true") arguments = parser.parse_args(argv) - if arguments.bind_workstream is not None: - print(bind_workstream_scope(arguments.cwd, arguments.bind_workstream)) + if arguments.bind_scope is not None: + print(bind_scope(arguments.cwd, arguments.bind_scope)) return 0 - if arguments.clear_workstream: - clear_workstream_scope(arguments.cwd) + if arguments.clear_scope: + clear_scope_binding(arguments.cwd) settings = ClaudeCodePluginSettings.from_environment() print(resolve_scope_id(arguments.cwd, configured_scope_id=settings.scope_id)) return 0 diff --git a/integrations/claude-code/plugins/powercontext/skills/project-context/SKILL.md b/integrations/claude-code/plugins/powercontext/skills/project-context/SKILL.md index 7ce8a6341..409dffbfe 100644 --- a/integrations/claude-code/plugins/powercontext/skills/project-context/SKILL.md +++ b/integrations/claude-code/plugins/powercontext/skills/project-context/SKILL.md @@ -18,36 +18,28 @@ to duplicate the current prompt. Ordinary prompt Sources are not task outcomes. Before the first memory tool call, run: ```bash -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/project_scope.py" --cwd "$PWD" +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workspace_scope.py" --cwd "$PWD" ``` Reuse that exact `scope_id` for the task. -The resolver first honors an explicit plugin scope, then the same Git-private -Workstream binding used by Codex, and finally the normalized remote or project -path. When the user explicitly asks to bind the current checkout to a known -Handoff Report Workstream, run: +The resolver first honors an explicit plugin Scope, then a Git-private Scope +binding, and finally the normalized remote or workspace path. When the user +explicitly asks to bind the current checkout to a known Scope, run: ```bash -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/project_scope.py" \ - --cwd "$PWD" --bind-workstream "WORKSTREAM_SCOPE_ID" +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/workspace_scope.py" \ + --cwd "$PWD" --bind-scope "SCOPE_ID" ``` Then run the normal resolver command again and verify the same scope. The binding is stored below the checkout's Git directory and is not committed. -Never infer one Workstream when multiple candidates remain consequential. - -Before a durable one-turn Handoff or a `latest` Continue without an exact -Workstream, call `select_handoff_workstream` when that MCP tool is available. -Clients with MCP elicitation can present a native picker; otherwise the tool -returns structured choices. On `selected`, bind the returned `scope_id` with -`--bind-workstream`, run the normal resolver again, and require the resolved -scope to match before any Handoff write. On `needs_selection`, present the -returned choices and call the tool again with the user's exact `project_id` and -`work_id`; never choose a fallback candidate silently. On `cancelled` or -`declined`, stop the Handoff flow. If the tool is unavailable or returns -`empty`, preserve the existing resolver behavior. The picker is read-only and -selecting work does not itself prepare or commit a Handoff. +Never infer a Scope when multiple candidates remain consequential. + +Before a durable one-turn Handoff or a `latest` Continue, resolve the intended +Scope explicitly. If the current binding is not the intended boundary, ask the +user or host for the exact Scope ID, bind it, and verify the resolver result +before any Handoff write. Never infer a Scope from a report view. ## Read @@ -68,8 +60,7 @@ Handoff, a design discussion, or a preview request does not authorize a write. When the one-turn flow applies: -1. Select the Workstream when the picker is available, then resolve and verify - the exact scope using the commands above. +1. Resolve and verify the exact Scope using the commands above. 2. Inspect the current conversation and repository before writing. Ground the objective, branch and worktree state, changed files, checks, blockers, omissions, and next executable action without reading or including secrets. diff --git a/integrations/codex/plugins/powercontext/README.md b/integrations/codex/plugins/powercontext/README.md index 38899de80..e54579cc7 100644 --- a/integrations/codex/plugins/powercontext/README.md +++ b/integrations/codex/plugins/powercontext/README.md @@ -51,13 +51,15 @@ The hook runtime is declared by the plugin's `pyproject.toml` and launched with `uv`; this keeps its `pydantic-settings` dependency isolated and reproducible. The hook uses a small synchronous standard-library HTTP adapter because Codex executes it as a short-lived process. It does not expose that adapter as an SDK. -The `project-context` Skill reuses the installed hook virtual environment when -deriving project scope, so a read-only Codex turn does not need to mutate the -`uv` cache. - -Set `POWERCONTEXT_CODEX_SCOPE_ID` to override automatic project scoping. By -default, the scope comes from the normalized Git remote, or from the project -path when no supported remote is available. +`SessionStart` fixes a durable binding from an explicit plugin Scope, an +existing Session binding, a workspace binding preference, or the Server's +default Scope. `UserPromptSubmit` uses that binding for recall and capture. +`PreToolUse` injects the same binding into PowerContext data-plane tools, so an +Agent-supplied `scope_id` cannot redirect a write. Repository and directory +identities are binding lookup inputs only; they never generate a Scope ID. + +Set `POWERCONTEXT_CODEX_SCOPE_ID` only when the host must explicitly bind every +request to one known Scope. `.mcp.json` is the single Server endpoint configuration consumed by Codex and the hook: the hook validates its PowerContext MCP URL and derives the HTTP API base by removing the final `/mcp` path segment. Change that file before diff --git a/integrations/codex/plugins/powercontext/hooks/bind_tools.py b/integrations/codex/plugins/powercontext/hooks/bind_tools.py new file mode 100644 index 000000000..3a1dda7fc --- /dev/null +++ b/integrations/codex/plugins/powercontext/hooks/bind_tools.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Bind PowerContext MCP data-plane calls to the current Codex Session.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from time import monotonic +from typing import Any, cast + +_PLUGIN_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(_PLUGIN_ROOT)) + +from scripts.scope_binding import ScopeBindingError, resolve_scope_id, session_binding_key # noqa: E402 +from settings import CodexPluginSettings # noqa: E402 + +_PREFIX = "mcp__powercontext__" +_CONTROL_OPERATIONS = frozenset({"set_scope_binding", "clear_scope_binding"}) +_HOST_OPERATIONS = frozenset({"create_scope", "get_scope", "list_scopes", "publish_artifact"}) +_SCOPE_BOUND_OPERATIONS = frozenset({ + "acknowledge_handoff", + "activate_handoff", + "approve_artifact_candidate", + "capture_content_source", + "commit_handoff", + "continue_handoff", + "create_work_contract", + "finalize_handoff", + "get_artifact_candidate", + "get_handoff_report", + "get_memory_entry", + "handoff_current_work", + "list_artifact_candidates", + "list_memory_entries", + "record_task_outcome", + "reject_artifact_candidate", + "remember_memory", + "retire_memory_entry", + "revise_artifact_candidate", + "revise_memory_entry", + "search_memory", +}) + + +def main(settings: CodexPluginSettings | None = None) -> int: + try: + payload = cast(dict[str, Any], json.load(sys.stdin)) + tool_name = payload.get("tool_name") + tool_input = payload.get("tool_input") + session_id = payload.get("session_id") + cwd = payload.get("cwd") + if ( + not isinstance(tool_name, str) + or not tool_name.startswith(_PREFIX) + or not isinstance(tool_input, dict) + or not isinstance(session_id, str) + or not isinstance(cwd, str) + ): + return 0 + operation = tool_name.removeprefix(_PREFIX) + if operation in _CONTROL_OPERATIONS: + updated = dict(tool_input) + updated["key"] = session_binding_key(session_id) + _allow(updated) + return 0 + if operation in _HOST_OPERATIONS: + _allow(dict(tool_input)) + return 0 + if operation not in _SCOPE_BOUND_OPERATIONS: + return 0 + settings = CodexPluginSettings() if settings is None else settings + scope_id = resolve_scope_id( + cwd, + session_id=session_id, + settings=settings, + deadline=monotonic() + settings.http_budget_seconds, + ) + updated = dict(tool_input) + if operation == "get_handoff_report": + updated["selection"] = {"mode": "exact", "scope_ids": [scope_id]} + else: + updated["scope_id"] = scope_id + _allow(updated) + except (ScopeBindingError, ValueError, OSError, json.JSONDecodeError): + _deny() + return 0 + + +def _allow(updated_input: dict[str, object]) -> None: + json.dump( + { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "updatedInput": updated_input, + } + }, + sys.stdout, + separators=(",", ":"), + ) + sys.stdout.write("\n") + + +def _deny() -> None: + json.dump( + { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": "PowerContext could not resolve the current Scope binding.", + } + }, + sys.stdout, + separators=(",", ":"), + ) + sys.stdout.write("\n") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/integrations/codex/plugins/powercontext/hooks/hooks.json b/integrations/codex/plugins/powercontext/hooks/hooks.json index fba24ad40..bd55336dc 100644 --- a/integrations/codex/plugins/powercontext/hooks/hooks.json +++ b/integrations/codex/plugins/powercontext/hooks/hooks.json @@ -1,6 +1,30 @@ { "description": "Recall relevant memory and capture the current Codex prompt as a Source.", "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "uv run --frozen --quiet --project \"${PLUGIN_ROOT}\" python \"${PLUGIN_ROOT}/hooks/session_binding.py\"", + "timeout": 10, + "statusMessage": "Binding PowerContext" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "mcp__powercontext__.*", + "hooks": [ + { + "type": "command", + "command": "uv run --frozen --quiet --project \"${PLUGIN_ROOT}\" python \"${PLUGIN_ROOT}/hooks/bind_tools.py\"", + "timeout": 10 + } + ] + } + ], "UserPromptSubmit": [ { "hooks": [ diff --git a/integrations/codex/plugins/powercontext/hooks/recall.py b/integrations/codex/plugins/powercontext/hooks/recall.py index c56ac9398..57e499cad 100644 --- a/integrations/codex/plugins/powercontext/hooks/recall.py +++ b/integrations/codex/plugins/powercontext/hooks/recall.py @@ -36,7 +36,7 @@ sys.path.insert(0, str(_PLUGIN_ROOT)) from hooks import prepared_context as _prepared_context # noqa: E402 -from scripts.project_scope import resolve_scope_id # noqa: E402 +from scripts.scope_binding import resolve_scope_id # noqa: E402 from settings import CodexPluginSettings # noqa: E402 _MAX_CONTEXT_BYTES = _prepared_context.MAX_CONTEXT_BYTES @@ -110,7 +110,13 @@ def main(settings: CodexPluginSettings | None = None) -> int: if not isinstance(prompt, str) or not prompt.strip() or not isinstance(cwd, str): _emit_context_event("skipped") return 0 - scope_id = resolve_scope_id(cwd, configured_scope_id=settings.scope_id) + session_id = _payload_identifier(payload, "session_id", "conversation_id", "thread_id") + scope_id = resolve_scope_id( + cwd, + session_id=session_id, + settings=settings, + deadline=http_deadline, + ) context = _recall_context(prompt, scope_id, settings=settings, deadline=http_deadline) if settings.capture_prompts and len(prompt) <= _MAX_SOURCE_LENGTH: with suppress(Exception): diff --git a/integrations/codex/plugins/powercontext/hooks/session_binding.py b/integrations/codex/plugins/powercontext/hooks/session_binding.py new file mode 100644 index 000000000..a8174fd79 --- /dev/null +++ b/integrations/codex/plugins/powercontext/hooks/session_binding.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Fix one Codex Session binding without blocking Session startup.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from time import monotonic +from typing import Any, cast + +_PLUGIN_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(_PLUGIN_ROOT)) + +from scripts.scope_binding import ScopeBindingError, resolve_scope_id # noqa: E402 +from settings import CodexPluginSettings # noqa: E402 + + +def main(settings: CodexPluginSettings | None = None) -> int: + try: + payload = cast(dict[str, Any], json.load(sys.stdin)) + session_id = payload.get("session_id") + cwd = payload.get("cwd") + if not isinstance(session_id, str) or not isinstance(cwd, str): + return 0 + settings = CodexPluginSettings() if settings is None else settings + resolve_scope_id( + cwd, + session_id=session_id, + settings=settings, + deadline=monotonic() + settings.http_budget_seconds, + persist_session=True, + ) + except (ScopeBindingError, ValueError, OSError, json.JSONDecodeError): + return 0 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/integrations/codex/plugins/powercontext/scripts/project_scope.py b/integrations/codex/plugins/powercontext/scripts/project_scope.py deleted file mode 100644 index 0de5e9e44..000000000 --- a/integrations/codex/plugins/powercontext/scripts/project_scope.py +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Derive a stable PowerContext scope for one project directory.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import os -import re -import subprocess -import sys -from collections.abc import Sequence -from contextlib import suppress -from pathlib import Path -from shutil import which -from urllib.parse import urlsplit - -_PLUGIN_ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(_PLUGIN_ROOT)) - -from settings import CodexPluginSettings # noqa: E402 - -_MAX_SCOPE_LENGTH = 256 -_SCP_REMOTE = re.compile(r"^(?:[^@/\s]+@)?(?P[^:/\s]+):(?P.+)$") -_WORKSPACE_STATE_SCHEMA = "powercontext.codex-workspace.v1" -_WORKSPACE_STATE_DIRECTORY = "powercontext" -_WORKSPACE_STATE_FILE = "codex-workspace.json" - - -def resolve_scope_id(cwd: str, *, configured_scope_id: str | None = None) -> str: - """Return the configured, workspace-bound, or derived scope for one checkout.""" - - if configured_scope_id: - return _bounded_explicit(configured_scope_id) - bound_scope_id = read_bound_scope_id(cwd) - if bound_scope_id is not None: - return bound_scope_id - return derive_scope_id(cwd) - - -def derive_scope_id(cwd: str, *, configured_scope_id: str | None = None) -> str: - """Return an explicit, remote-derived, or path-derived project scope.""" - - if configured_scope_id: - return _bounded_explicit(configured_scope_id) - root_value = _git_value(cwd, "rev-parse", "--show-toplevel") - project_root = Path(root_value or cwd).resolve(strict=False) - remote = _git_value(str(project_root), "config", "--get", "remote.origin.url") - normalized_remote = normalize_git_remote(remote) if remote else None - if normalized_remote: - return _bounded("git", normalized_remote) - return f"local:{hashlib.sha256(os.fsencode(project_root)).hexdigest()}" - - -def bind_workstream_scope(cwd: str, scope_id: str, /) -> str: - """Persist one Workstream scope in Git-private state for later Codex sessions.""" - - normalized_scope_id = _bounded_explicit(scope_id.strip()) - if not normalized_scope_id: - raise ValueError("Workstream scope must be non-empty") # noqa: TRY003 - state_path = _workspace_state_path(cwd) - if state_path is None: - raise ValueError("Workstream scope binding requires a Git workspace") # noqa: TRY003 - _write_workspace_state(state_path, normalized_scope_id) - return normalized_scope_id - - -def read_bound_scope_id(cwd: str, /) -> str | None: - """Read a valid Workstream scope binding without trusting arbitrary state fields.""" - - state_path = _workspace_state_path(cwd) - if state_path is None: - return None - try: - payload = json.loads(state_path.read_text(encoding="utf-8")) - except (OSError, UnicodeError, json.JSONDecodeError): - return None - if not isinstance(payload, dict) or payload.get("schema") != _WORKSPACE_STATE_SCHEMA: - return None - scope_id = payload.get("scope_id") - if ( - not isinstance(scope_id, str) - or not scope_id - or scope_id != scope_id.strip() - or len(scope_id) > _MAX_SCOPE_LENGTH - ): - return None - return scope_id - - -def clear_workstream_scope(cwd: str, /) -> bool: - """Remove only the Git-private Codex Workstream binding file.""" - - state_path = _workspace_state_path(cwd) - if state_path is None: - return False - try: - state_path.unlink() - except FileNotFoundError: - return False - return True - - -def normalize_git_remote(remote: str) -> str | None: - """Normalize common network remotes without retaining credentials.""" - - value = remote.strip() - if not value: - return None - scp_match = _SCP_REMOTE.fullmatch(value) - if scp_match and "://" not in value: - host = scp_match.group("host").lower() - path = _normalize_path(scp_match.group("path")) - return f"{host}/{path}" if path else None - parsed = urlsplit(value) - if parsed.scheme not in {"http", "https", "ssh", "git"} or parsed.hostname is None: - return None - host = parsed.hostname.lower() - if parsed.port is not None: - host = f"{host}:{parsed.port}" - path = _normalize_path(parsed.path) - return f"{host}/{path}" if path else None - - -def _normalize_path(path: str) -> str: - normalized = "/".join(part for part in path.replace("\\", "/").split("/") if part) - if normalized.endswith(".git"): - normalized = normalized[:-4] - return normalized.rstrip("/") - - -def _bounded(prefix: str, value: str) -> str: - candidate = f"{prefix}:{value}" - if len(candidate) <= _MAX_SCOPE_LENGTH: - return candidate - return f"{prefix}:sha256:{hashlib.sha256(value.encode()).hexdigest()}" - - -def _bounded_explicit(value: str) -> str: - if len(value) <= _MAX_SCOPE_LENGTH: - return value - return f"sha256:{hashlib.sha256(value.encode()).hexdigest()}" - - -def _workspace_state_path(cwd: str, /) -> Path | None: - git_directory = _git_value(cwd, "rev-parse", "--absolute-git-dir") - if git_directory is None: - return None - return Path(git_directory) / _WORKSPACE_STATE_DIRECTORY / _WORKSPACE_STATE_FILE - - -def _write_workspace_state(state_path: Path, scope_id: str, /) -> None: - state_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) - temporary_path = state_path.with_name(f".{state_path.name}.{os.getpid()}.tmp") - encoded = ( - json.dumps({"schema": _WORKSPACE_STATE_SCHEMA, "scope_id": scope_id}, separators=(",", ":")) + "\n" - ).encode() - descriptor: int | None = None - try: - descriptor = os.open(temporary_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) - os.write(descriptor, encoded) - os.fsync(descriptor) - os.close(descriptor) - descriptor = None - os.replace(temporary_path, state_path) - finally: - if descriptor is not None: - os.close(descriptor) - with suppress(FileNotFoundError): - temporary_path.unlink() - - -def _git_value(cwd: str, *arguments: str) -> str | None: - executable = which("git") - if executable is None: - return None - try: - completed = subprocess.run( # noqa: S603 - git executable and arguments are integration-owned. - [executable, *arguments], - cwd=cwd, - check=True, - capture_output=True, - text=True, - timeout=2, - ) - except (OSError, subprocess.SubprocessError): - return None - return completed.stdout.strip() or None - - -def main(argv: Sequence[str] | None = None) -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--cwd", default=os.getcwd()) - action = parser.add_mutually_exclusive_group() - action.add_argument("--bind-workstream", metavar="SCOPE_ID") - action.add_argument("--clear-workstream", action="store_true") - arguments = parser.parse_args(argv) - if arguments.bind_workstream is not None: - print(bind_workstream_scope(arguments.cwd, arguments.bind_workstream)) - return 0 - if arguments.clear_workstream: - clear_workstream_scope(arguments.cwd) - settings = CodexPluginSettings() - print(resolve_scope_id(arguments.cwd, configured_scope_id=settings.scope_id)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/integrations/codex/plugins/powercontext/scripts/scope_binding.py b/integrations/codex/plugins/powercontext/scripts/scope_binding.py new file mode 100644 index 000000000..d7d5bf468 --- /dev/null +++ b/integrations/codex/plugins/powercontext/scripts/scope_binding.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Resolve Codex external identities through the PowerContext Scope service.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from collections.abc import Mapping +from hashlib import sha256 +from pathlib import Path +from shutil import which +from time import monotonic +from typing import Any, Protocol +from urllib.error import HTTPError +from urllib.request import HTTPRedirectHandler, Request, build_opener + +from typing_extensions import override + +_PLUGIN_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(_PLUGIN_ROOT)) + +from settings import CodexPluginSettings # noqa: E402 + +_MAX_RESPONSE_BYTES = 1_048_576 +_READ_CHUNK_BYTES = 65_536 +_REQUEST_HEADERS = { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "powercontext-codex-plugin/0.3.0", +} + + +class ScopeBindingError(RuntimeError): + """Raised when the integration cannot establish one current Scope.""" + + +class _Response(Protocol): + fp: object + status: int + + def __enter__(self) -> _Response: ... + + def __exit__(self, *args: object) -> object: ... + + def read(self, amount: int = -1) -> bytes: ... + + +class _RejectRedirects(HTTPRedirectHandler): + @override + def redirect_request( + self, + req: Request, + fp: object, + code: int, + msg: str, + headers: object, + newurl: str, + ) -> Request | None: + return None + + +_URL_OPENER = build_opener(_RejectRedirects) + + +def resolve_scope_id( + cwd: str, + *, + session_id: str | None, + settings: CodexPluginSettings, + deadline: float, + persist_session: bool = False, +) -> str: + """Resolve explicit, session, workspace, then default binding in that order.""" + + keys = binding_keys(cwd, session_id=session_id) + response = _post_json( + "/v1/scope-bindings/resolve", + { + "explicit_scope_id": settings.scope_id, + "binding_keys": keys, + }, + settings=settings, + deadline=deadline, + ) + scope_id = response.get("scope_id") + if not isinstance(scope_id, str) or not scope_id.strip() or scope_id != scope_id.strip(): + raise ScopeBindingError + if persist_session and session_id is not None and settings.scope_id is None: + _post_json( + "/v1/scope-bindings", + { + "key": session_binding_key(session_id), + "scope_id": scope_id, + }, + settings=settings, + deadline=deadline, + method="PUT", + ) + return scope_id + + +def binding_keys(cwd: str, *, session_id: str | None) -> list[dict[str, str]]: + keys: list[dict[str, str]] = [] + if session_id is not None: + keys.append(session_binding_key(session_id)) + keys.append(workspace_binding_key(cwd)) + return keys + + +def session_binding_key(session_id: str) -> dict[str, str]: + value = session_id.strip() + if not value or len(value) > 256: + raise ScopeBindingError + return {"integration": "codex", "kind": "session", "external_id": value} + + +def workspace_binding_key(cwd: str) -> dict[str, str]: + root_value = _git_value(cwd, "rev-parse", "--show-toplevel") + root = Path(root_value or cwd).resolve(strict=False) + external_id = sha256(os.fsencode(root)).hexdigest() + return {"integration": "codex", "kind": "workspace", "external_id": external_id} + + +def _post_json( + path: str, + payload: Mapping[str, object], + *, + settings: CodexPluginSettings, + deadline: float, + method: str = "POST", +) -> Mapping[str, object]: + remaining = deadline - monotonic() + if remaining <= 0: + raise ScopeBindingError + headers = dict(_REQUEST_HEADERS) + if settings.authorization is not None: + headers["Authorization"] = settings.authorization.get_secret_value() + request = Request( # noqa: S310 - settings validates the configured transport. + f"{settings.server_url}{path}", + data=json.dumps(payload, separators=(",", ":")).encode(), + headers=headers, + method=method, + ) + try: + with _URL_OPENER.open( + request, + timeout=min(settings.request_timeout_seconds, remaining), + ) as response: + if response.status < 200 or response.status >= 300: + raise ScopeBindingError + raw = _read_bounded(response) + except (HTTPError, OSError, TimeoutError) as error: + raise ScopeBindingError from error + try: + value: Any = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ScopeBindingError from error + if not isinstance(value, dict): + raise ScopeBindingError + return value + + +def _read_bounded(response: _Response) -> bytes: + chunks: list[bytes] = [] + size = 0 + while chunk := response.read(_READ_CHUNK_BYTES): + size += len(chunk) + if size > _MAX_RESPONSE_BYTES: + raise ScopeBindingError + chunks.append(chunk) + return b"".join(chunks) + + +def _git_value(cwd: str, *arguments: str) -> str | None: + executable = which("git") + if executable is None: + return None + try: + completed = subprocess.run( # noqa: S603 - executable and arguments are integration-owned. + [executable, *arguments], + cwd=cwd, + check=True, + capture_output=True, + text=True, + timeout=2, + ) + except (OSError, subprocess.SubprocessError): + return None + return completed.stdout.strip() or None diff --git a/integrations/codex/plugins/powercontext/skills/project-context/SKILL.md b/integrations/codex/plugins/powercontext/skills/project-context/SKILL.md index 0ba151417..9a50d5806 100644 --- a/integrations/codex/plugins/powercontext/skills/project-context/SKILL.md +++ b/integrations/codex/plugins/powercontext/skills/project-context/SKILL.md @@ -13,42 +13,28 @@ The Server's Source window Trigger and candidate pipeline decide whether that evidence should produce or update Memory. Do not call `remember_memory` merely to duplicate the current prompt. -## Resolve scope - -Before the first memory tool call, run: - -```bash -"$PLUGIN_ROOT/.venv/bin/python" "$PLUGIN_ROOT/scripts/project_scope.py" --cwd "$PWD" -``` - -Reuse that exact `scope_id` for the task. - -The resolver first honors an explicit plugin scope, then a Git-private Workstream -binding, and finally the normalized remote or project path. When the user -explicitly asks to bind the current checkout to a known Handoff Report -Workstream, run: - -```bash -"$PLUGIN_ROOT/.venv/bin/python" "$PLUGIN_ROOT/scripts/project_scope.py" \ - --cwd "$PWD" --bind-workstream "WORKSTREAM_SCOPE_ID" -``` - -Then run the normal resolver command again and verify the same scope. The -binding is stored below the checkout's Git directory and is not committed. -Never infer one Workstream when multiple candidates remain consequential. - -Before a durable one-turn Handoff or a `latest` Continue without an exact -Workstream, call `select_handoff_workstream` when that MCP tool is available. -With multiple candidates, Codex presents the tool's MCP elicitation as a native -picker; one candidate is selected automatically. On `selected`, bind the -returned `scope_id` with `--bind-workstream`, run the normal resolver again, -and require the resolved scope to match before any Handoff write. On -`needs_selection`, present the returned choices and call the tool again with -the user's exact `project_id` and `work_id`; never choose a fallback candidate -silently. On `cancelled` or `declined`, stop the Handoff flow. If the tool is -unavailable or returns `empty`, preserve the existing resolver behavior. The -picker is read-only and selecting work does not itself prepare or commit a -Handoff. +## Scope binding + +The integration binds the current Codex Session before recall, capture, and +PowerContext MCP calls. Do not derive a Scope from the repository, directory, +branch, Agent, or prompt, and do not override the integration binding in an +ordinary data-plane call. + +When the user explicitly asks to start an independent result, use +`create_scope` with a concise title, summary, stable idempotency key, and only +the Parent, Context References, or external references the user established. +Then use `set_scope_binding`; the integration replaces its binding key with the +current Codex Session identity. Reuse an existing Scope instead when the work +does not need independent isolation, continuation, delivery, or observation. + +## Deliver selected material + +Use `publish_artifact` only when the user has selected an exact Artifact +revision for delivery into another Scope. Supply the complete source address, +the target Scope, and a stable idempotency key. Publication creates an +independent target Artifact and does not move Sources, other revisions, or +other state from the source Scope. Never publish personal information, +debugging fragments, rejected results, or an inferred `latest` revision. ## Read @@ -83,22 +69,20 @@ or draft a Handoff does not authorize any write. When the one-turn flow applies: -1. Select the Workstream when the picker is available, then resolve and verify - the exact scope using the commands above. -2. Inspect the current conversation and repository before writing. At minimum, +1. Inspect the current conversation and repository before writing. At minimum, ground the active objective, current branch and worktree state, changed files, relevant recent commits, checks already run, blockers, omissions, and the next executable action. Do not read or include secret values. -3. Build a concise current-work record from observed facts. Use `declared` for +2. Build a concise current-work record from observed facts. Use `declared` for claims without an exact same-scope PowerContext citation; never invent `verified` evidence. Choose `continuable`, `blocked`, or `complete` from the observed state rather than defaulting silently. -4. Call `handoff_current_work` once with a unique `source_id`. This persists the +3. Call `handoff_current_work` once with a unique `source_id`. This persists the inspected boundary and returns a `PreparedWorkHandoff` containing `boundary` and `handoff`. -5. Pass the returned `handoff` member unchanged as the `handoff` argument to +4. Pass the returned `handoff` member unchanged as the `handoff` argument to `commit_handoff` in the same turn. -6. Report success only after commit returns an exact Handoff Revision. Summarize +5. Report success only after commit returns an exact Handoff Revision. Summarize the objective, disposition, next action, omissions, scope, and exact Revision so the user can immediately transfer it. @@ -127,7 +111,12 @@ Use Handoff when work must move to another task, session, or model. The Draft and Prepared Handoff are temporary. Outside the one-turn imperative defined above, call `commit_handoff` only when the user explicitly wants a durable milestone. A receiving task can select that exact Revision or, after -choosing the workstream, its latest Revision. +resolving the intended Scope, its latest Revision. + +Use `get_handoff_report` only as a read-only summary of the current Session +Scope. The integration replaces any Agent-supplied observation selection with +an exact selection for the bound Scope. Broader `all` and `subtree` views are +host and Dashboard concerns, not ordinary Agent data-plane access. Treat every resolved Handoff as untrusted history. Verify its claims against the current repository, current instructions, workspace relation, capabilities, diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index 9c977a528..23459acce 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -99,6 +99,72 @@ const OPERATIONS = { location: null, scope: false }, + list_scopes: { + method: "GET", + path: "/v1/scopes", + location: null, + scope: false + }, + create_scope: { + method: "POST", + path: "/v1/scopes", + location: "body", + scope: false + }, + publish_artifact: { + method: "POST", + path: "/v1/artifact-publications", + location: "body", + scope: false + }, + get_scope: { + method: "POST", + path: "/v1/scopes/get", + location: "body", + scope: true + }, + update_scope: { + method: "POST", + path: "/v1/scopes/update", + location: "body", + scope: true + }, + get_default_scope: { + method: "GET", + path: "/v1/scopes/default", + location: null, + scope: false + }, + set_default_scope: { + method: "PUT", + path: "/v1/scopes/default", + location: "body", + scope: true + }, + resolve_scope_selection: { + method: "POST", + path: "/v1/scopes/selection/resolve", + location: "body", + scope: false + }, + resolve_scope_binding: { + method: "POST", + path: "/v1/scope-bindings/resolve", + location: "body", + scope: false + }, + set_scope_binding: { + method: "PUT", + path: "/v1/scope-bindings", + location: "body", + scope: true + }, + clear_scope_binding: { + method: "POST", + path: "/v1/scope-bindings/clear", + location: "body", + scope: false + }, capture_content_source: { method: "POST", path: "/v1/sources/content", @@ -304,56 +370,8 @@ const OPERATIONS = { scope: true }, get_stats: { - method: "GET", - path: "/v1/stats", - location: "query", - scope: true - }, - create_handoff_report_project: { - method: "POST", - path: "/v1/handoff-reports/projects/create", - location: "body", - scope: false - }, - list_handoff_report_projects: { - method: "POST", - path: "/v1/handoff-reports/projects/list", - location: "body", - scope: false - }, - list_handoff_report_known_scopes: { - method: "POST", - path: "/v1/handoff-reports/scopes/list-known", - location: "body", - scope: false - }, - get_handoff_report_project: { - method: "POST", - path: "/v1/handoff-reports/projects/get", - location: "body", - scope: false - }, - update_handoff_report_project: { - method: "POST", - path: "/v1/handoff-reports/projects/update", - location: "body", - scope: false - }, - register_handoff_report_workstream: { method: "POST", - path: "/v1/handoff-reports/workstreams/register", - location: "body", - scope: true - }, - list_handoff_report_workstreams: { - method: "POST", - path: "/v1/handoff-reports/workstreams/list", - location: "body", - scope: false - }, - update_handoff_report_workstream: { - method: "POST", - path: "/v1/handoff-reports/workstreams/update", + path: "/v1/stats", location: "body", scope: false }, @@ -361,42 +379,6 @@ const OPERATIONS = { method: "POST", path: "/v1/handoff-reports/get", location: "body", - scope: true - }, - record_handoff_report_activity: { - method: "POST", - path: "/v1/handoff-reports/activities/record", - location: "body", - scope: true - }, - list_handoff_report_activities: { - method: "POST", - path: "/v1/handoff-reports/activities/list", - location: "body", - scope: false - }, - purge_handoff_report_activities: { - method: "POST", - path: "/v1/handoff-reports/activities/purge", - location: "body", - scope: false - }, - get_handoff_report_workspace: { - method: "POST", - path: "/v1/handoff-reports/workspace-bindings/get", - location: "body", - scope: false - }, - attach_handoff_report_workspace: { - method: "POST", - path: "/v1/handoff-reports/workspace-bindings/attach", - location: "body", - scope: false - }, - detach_handoff_report_workspace: { - method: "POST", - path: "/v1/handoff-reports/workspace-bindings/detach", - location: "body", scope: false } }; @@ -693,6 +675,13 @@ function toToolResult(error) { }; } function injectScope(operationId, payload, scopeId) { + if (operationId === "get_stats" || operationId === "get_handoff_report") return { + ...payload, + selection: { + mode: "exact", + scope_ids: [scopeId] + } + }; if (!OPERATIONS[operationId].scope) return payload; return { ...payload, diff --git a/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml b/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml index 2c8681f99..25c653946 100644 --- a/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml +++ b/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml @@ -79,6 +79,252 @@ paths: $ref: "#/components/schemas/Capabilities" "401": $ref: "#/components/responses/Unauthorized" + /v1/scopes: + get: + tags: [scopes] + summary: List observable Scopes + operationId: list_scopes + responses: + "200": + description: Durable Scope metadata in deterministic identity order. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopePage" + "401": + $ref: "#/components/responses/Unauthorized" + "503": + $ref: "#/components/responses/Unavailable" + post: + tags: [scopes] + summary: Create an independent Scope boundary + operationId: create_scope + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateScopeRequest" + responses: + "201": + description: The durable Scope descriptor. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopeDescriptor" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" + /v1/artifact-publications: + post: + tags: [scopes] + summary: Publish one exact Artifact revision into another Scope + operationId: publish_artifact + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PublishArtifactRequest" + responses: + "201": + description: Independent target Artifact and its exact source provenance. + content: + application/json: + schema: + $ref: "#/components/schemas/ArtifactPublication" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" + /v1/scopes/get: + post: + tags: [scopes] + summary: Get one Scope descriptor + operationId: get_scope + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/GetScopeRequest" + responses: + "200": + description: The exact Scope descriptor. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopeDescriptor" + "404": + $ref: "#/components/responses/NotFound" + "401": + $ref: "#/components/responses/Unauthorized" + /v1/scopes/update: + post: + tags: [scopes] + summary: Replace mutable Scope metadata and relationships + operationId: update_scope + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateScopeRequest" + responses: + "200": + description: The updated Scope descriptor. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopeDescriptor" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" + /v1/scopes/default: + get: + tags: [scopes] + summary: Get the default Scope binding target + operationId: get_default_scope + responses: + "200": + description: The ordinary Scope selected by the host default pointer. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopeDescriptor" + "404": + $ref: "#/components/responses/NotFound" + "401": + $ref: "#/components/responses/Unauthorized" + put: + tags: [scopes] + summary: Change the default Scope binding target + operationId: set_default_scope + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SetDefaultScopeRequest" + responses: + "200": + description: The selected ordinary Scope. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopeDescriptor" + "404": + $ref: "#/components/responses/NotFound" + "401": + $ref: "#/components/responses/Unauthorized" + /v1/scopes/selection/resolve: + post: + tags: [scopes] + summary: Resolve an observation selection to a frozen Scope set + operationId: resolve_scope_selection + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ResolveScopeSelectionRequest" + responses: + "200": + description: The selected Scope descriptors in deterministic order. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopePage" + "404": + $ref: "#/components/responses/NotFound" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" + /v1/scope-bindings/resolve: + post: + tags: [scope-bindings] + summary: Resolve an explicit durable or default Scope binding + operationId: resolve_scope_binding + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ResolveScopeBindingRequest" + responses: + "200": + description: The resolved Scope descriptor. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopeDescriptor" + "404": + $ref: "#/components/responses/NotFound" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" + /v1/scope-bindings: + put: + tags: [scope-bindings] + summary: Persist an external identity to Scope binding + operationId: set_scope_binding + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SetScopeBindingRequest" + responses: + "200": + description: The durable external binding. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopeBinding" + "404": + $ref: "#/components/responses/NotFound" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" + /v1/scope-bindings/clear: + post: + tags: [scope-bindings] + summary: Remove one durable external Scope binding + operationId: clear_scope_binding + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ClearScopeBindingRequest" + responses: + "200": + description: Whether a durable binding was removed. + content: + application/json: + schema: + $ref: "#/components/schemas/ClearScopeBindingResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" /v1/sources/content: post: tags: [sources] @@ -1178,27 +1424,19 @@ paths: "500": $ref: "#/components/responses/InternalError" /v1/stats: - get: + post: tags: [stats] - summary: Get scoped product statistics + summary: Aggregate product statistics over a Scope selection operationId: get_stats - parameters: - - name: scope_id - in: query - required: true - schema: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - - name: period - in: query - required: false - schema: - $ref: "#/components/schemas/StatsPeriod" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/GetStatsRequest" responses: "200": - description: Current inventory, model usage, and recall token estimates for the scope. + description: Current inventory, model usage, and recall token estimates for the frozen Scope set. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" @@ -1219,2762 +1457,1982 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/projects/create: - post: - tags: [handoff-reports] - summary: Create a Handoff Report Project - operationId: create_handoff_report_project - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/CreateHandoffReportProjectRequest" - responses: - "201": - description: The created Report Project. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ProjectDescriptor" - "409": - $ref: "#/components/responses/Conflict" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/projects/list: + /v1/handoff-reports/get: post: tags: [handoff-reports] - summary: List Handoff Report Projects - operationId: list_handoff_report_projects + summary: Generate a Handoff Report + operationId: get_handoff_report requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ListHandoffReportProjectsRequest" + $ref: "#/components/schemas/GetHandoffReportRequest" responses: "200": - description: A cursor-paginated page of Report Projects. + description: A canonical JSON report, optionally accompanied by Markdown. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" + Cache-Control: + description: Prevent caches from retaining scoped report data. + schema: + type: string + enum: [no-store] + X-PowerContext-Selection-Digest: + description: Digest of the exact report selection. + schema: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + X-PowerContext-Report-Digest: + description: Digest of the selected output projection. + schema: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + Content-Disposition: + description: Safe attachment filename when download is true. + schema: + type: string content: application/json: schema: - $ref: "#/components/schemas/ProjectPage" + $ref: "#/components/schemas/HandoffReportResponse" + text/markdown: + schema: + type: string + "404": + $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" "422": $ref: "#/components/responses/InvalidRequest" + "413": + $ref: "#/components/responses/ReportTooLarge" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/scopes/list-known: - post: - tags: [handoff-reports] - summary: List scopes that contain a committed Handoff - operationId: list_handoff_report_known_scopes - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ListHandoffReportKnownScopesRequest" - responses: - "200": - description: A cursor-paginated page of scopes that can be rendered as Handoff Reports. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/KnownHandoffScopePage" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/projects/get: - post: - tags: [handoff-reports] - summary: Get a Handoff Report Project - operationId: get_handoff_report_project - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/GetHandoffReportProjectRequest" - responses: - "200": - description: The exact current Report Project descriptor. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ProjectDescriptor" - "404": - $ref: "#/components/responses/NotFound" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/projects/update: - post: - tags: [handoff-reports] - summary: Update a Handoff Report Project - operationId: update_handoff_report_project - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/UpdateHandoffReportProjectRequest" - responses: - "200": - description: The updated Report Project descriptor. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ProjectDescriptor" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/Conflict" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workstreams/register: - post: - tags: [handoff-reports] - summary: Register a Handoff Report Workstream - operationId: register_handoff_report_workstream - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/RegisterHandoffReportWorkstreamRequest" - responses: - "201": - description: The registered Report Workstream. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/WorkstreamDescriptor" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/Conflict" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workstreams/list: - post: - tags: [handoff-reports] - summary: List Handoff Report Workstreams - operationId: list_handoff_report_workstreams - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ListHandoffReportWorkstreamsRequest" - responses: - "200": - description: A cursor-paginated page of Report Workstreams. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/WorkstreamPage" - "404": - $ref: "#/components/responses/NotFound" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workstreams/update: - post: - tags: [handoff-reports] - summary: Update a Handoff Report Workstream - operationId: update_handoff_report_workstream - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/UpdateHandoffReportWorkstreamRequest" - responses: - "200": - description: The updated Report Workstream descriptor. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/WorkstreamDescriptor" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/Conflict" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/get: - post: - tags: [handoff-reports] - summary: Generate a Handoff Report - operationId: get_handoff_report - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/GetHandoffReportRequest" - responses: - "200": - description: A canonical JSON report, optionally accompanied by Markdown. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - Cache-Control: - description: Prevent caches from retaining scoped report data. - schema: - type: string - enum: [no-store] - X-PowerContext-Selection-Digest: - description: Digest of the exact report selection. - schema: - type: string - pattern: '^sha256:[0-9a-f]{64}$' - X-PowerContext-Report-Digest: - description: Digest of the selected output projection. - schema: - type: string - pattern: '^sha256:[0-9a-f]{64}$' - Content-Disposition: - description: Safe attachment filename when download is true. - schema: - type: string - content: - application/json: - schema: - $ref: "#/components/schemas/HandoffReportResponse" - text/markdown: - schema: - type: string - "404": - $ref: "#/components/responses/NotFound" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "413": - $ref: "#/components/responses/ReportTooLarge" - "503": - $ref: "#/components/responses/Unavailable" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/activities/record: - post: - tags: [handoff-reports] - summary: Record a Handoff Report Activity - operationId: record_handoff_report_activity - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/RecordHandoffReportActivityRequest" - responses: - "201": - description: The idempotently recorded Report Activity. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/StoredHandoffReportActivity" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/Conflict" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/activities/list: - post: - tags: [handoff-reports] - summary: List Handoff Report Activities - operationId: list_handoff_report_activities - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ListHandoffReportActivitiesRequest" - responses: - "200": - description: A frozen cursor page of Report Activities. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/HandoffReportActivityPage" - "404": - $ref: "#/components/responses/NotFound" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/activities/purge: - post: - tags: [handoff-reports] - summary: Purge Handoff Report Activities - operationId: purge_handoff_report_activities - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/PurgeHandoffReportActivitiesRequest" - responses: - "200": - description: The number of deleted Report-owned Activity rows. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/PurgeHandoffReportActivitiesResponse" - "404": - $ref: "#/components/responses/NotFound" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workspace-bindings/get: - post: - tags: [handoff-reports] - summary: Get a Handoff Report Workspace Binding - operationId: get_handoff_report_workspace - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/GetHandoffReportWorkspaceRequest" - responses: - "200": - description: The confirmed Workspace binding. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/HandoffReportWorkspaceBinding" - "404": - $ref: "#/components/responses/NotFound" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workspace-bindings/attach: - post: - tags: [handoff-reports] - summary: Attach a Handoff Report Workspace Binding - operationId: attach_handoff_report_workspace - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AttachHandoffReportWorkspaceRequest" - responses: - "200": - description: The confirmed Workspace binding. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/HandoffReportWorkspaceBinding" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/Conflict" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workspace-bindings/detach: - post: - tags: [handoff-reports] - summary: Detach a Handoff Report Workspace Binding - operationId: detach_handoff_report_workspace - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/DetachHandoffReportWorkspaceRequest" - responses: - "200": - description: The detached Workspace binding record. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/HandoffReportWorkspaceBinding" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/Conflict" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" -components: - securitySchemes: - BearerAuth: - type: http - scheme: bearer - description: Static bearer token used when local Server authentication is enabled. - headers: - BearerChallenge: - description: Authentication scheme required by the Server. - schema: - type: string - example: Bearer - RequestId: - description: Opaque identifier for correlating one request. - schema: - type: string - responses: - Unauthorized: - description: A valid bearer token is required by this Server deployment. - headers: - WWW-Authenticate: - $ref: "#/components/headers/BearerChallenge" - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - Conflict: - description: The command conflicts with current immutable state. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - InvalidRequest: - description: The request violates the transport or application contract. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - ReportTooLarge: - description: The selected Handoff Report exceeds the deterministic output limit. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - NotFound: - description: The requested immutable Memory value was not found. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - Unavailable: - description: A required Runtime binding or dependency is unavailable. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - InternalError: - description: The Server failed without exposing internal details. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - schemas: - ActivateHandoffRequest: - type: object - additionalProperties: false - required: [scope_id, boundary_source, objective] - properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - boundary_source: - $ref: "#/components/schemas/SourceReference" - objective: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - evidence: - type: array - maxItems: 32 - items: - $ref: "#/components/schemas/HandoffCitation" - default: [] - max_bytes: - type: integer - minimum: 512 - maximum: 32768 - default: 8000 - ArtifactReference: - type: object - additionalProperties: false - required: [family, artifact_id, revision] - properties: - family: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - artifact_id: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - revision: - type: integer - minimum: 1 - ArtifactCandidate: - type: object - additionalProperties: false - required: - - candidate_id - - version - - family - - status - - proposal - - source_refs - - artifact_refs - - target - - reason - - result_artifact - - decision_reason - properties: - candidate_id: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - version: - type: integer - minimum: 1 - family: - $ref: "#/components/schemas/CandidateFamily" - status: - $ref: "#/components/schemas/CandidateStatus" - proposal: - oneOf: - - $ref: "#/components/schemas/ExperienceProposal" - - $ref: "#/components/schemas/SkillProposal" - source_refs: - type: array - maxItems: 32 - description: Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references. - items: - $ref: "#/components/schemas/SourceReference" - artifact_refs: - type: array - maxItems: 32 - description: Exact Artifact evidence. Counted with source_refs toward a combined maximum of 32 references. - items: - $ref: "#/components/schemas/ArtifactReference" - target: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - reason: - type: string - minLength: 1 - maxLength: 2000 - nullable: true - result_artifact: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - decision_reason: - type: string - minLength: 1 - maxLength: 2000 - nullable: true - ArtifactCandidatePage: - type: object - additionalProperties: false - required: [candidates, next_cursor] - properties: - candidates: - type: array - items: - $ref: "#/components/schemas/ArtifactCandidate" - next_cursor: - type: string - nullable: true - ApproveArtifactCandidateRequest: - type: object - additionalProperties: false - required: [scope_id, candidate_id, expected_version] - properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - candidate_id: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - expected_version: - type: integer - minimum: 1 - Capabilities: - type: object - additionalProperties: false - required: - [source_types, artifact_families, memory_extraction, handoff_generation, search_modes, context_versions] - properties: - source_types: - type: array - items: - type: string - artifact_families: - type: array - items: - type: string - memory_extraction: - type: boolean - description: Whether pending Sources can be extracted into Memory. - experience_generation: - type: boolean - default: false - description: Whether the configured model can generate reviewed Experience Candidates. - managed_skill_generation: - type: boolean - default: false - description: Whether the configured model can generate reviewed managed Skill Candidates. - external_skill_registry: - type: boolean - default: false - description: Whether host-local external Skill discovery and exact resolution are configured. - handoff_generation: - type: boolean - description: Whether exact evidence can be generated into an inspectable Handoff Draft. - search_modes: - type: array - items: - $ref: "#/components/schemas/MemorySearchMode" - context_versions: - type: array - items: - $ref: "#/components/schemas/PreparedContextSchema" - FamilyCount: - type: object - additionalProperties: false - required: [family, total] - properties: - family: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - total: - type: integer - minimum: 0 - CandidateFamilyCount: - type: object - additionalProperties: false - required: [family, total, pending, approved, rejected] - properties: - family: - $ref: "#/components/schemas/CandidateFamily" - total: - type: integer - minimum: 0 - pending: - type: integer - minimum: 0 - approved: - type: integer - minimum: 0 - rejected: - type: integer - minimum: 0 - MemoryKindCount: - type: object - additionalProperties: false - required: [kind, total, active, inactive] - properties: - kind: - type: string - minLength: 1 - maxLength: 128 - total: - type: integer - minimum: 0 - active: - type: integer - minimum: 0 - inactive: - type: integer - minimum: 0 - SourceInventoryStatistics: - type: object - additionalProperties: false - required: [total, memory_processed, memory_pending] - properties: - total: - type: integer - minimum: 0 - memory_processed: - type: integer - minimum: 0 - memory_pending: - type: integer - minimum: 0 - ArtifactInventoryStatistics: - type: object - additionalProperties: false - required: [total, by_family] - properties: - total: - type: integer - minimum: 0 - by_family: - type: array - items: - $ref: "#/components/schemas/FamilyCount" - CandidateInventoryStatistics: - type: object - additionalProperties: false - required: [total, pending, approved, rejected, by_family] - properties: - total: - type: integer - minimum: 0 - pending: - type: integer - minimum: 0 - approved: - type: integer - minimum: 0 - rejected: - type: integer - minimum: 0 - by_family: - type: array - items: - $ref: "#/components/schemas/CandidateFamilyCount" - MemoryEntryInventoryStatistics: - type: object - additionalProperties: false - required: [total, active, inactive, by_kind] - properties: - total: - type: integer - minimum: 0 - active: - type: integer - minimum: 0 - inactive: - type: integer - minimum: 0 - by_kind: - type: array - items: - $ref: "#/components/schemas/MemoryKindCount" - MemoryInventoryStatistics: - type: object - additionalProperties: false - required: [entries] - properties: - entries: - $ref: "#/components/schemas/MemoryEntryInventoryStatistics" - InventoryStatistics: - type: object - additionalProperties: false - required: [sources, artifacts, candidates, memory] - properties: - sources: - $ref: "#/components/schemas/SourceInventoryStatistics" - artifacts: - $ref: "#/components/schemas/ArtifactInventoryStatistics" - candidates: - $ref: "#/components/schemas/CandidateInventoryStatistics" - memory: - $ref: "#/components/schemas/MemoryInventoryStatistics" - ModelUsageValue: - type: object - additionalProperties: false - required: [requests, input_tokens, output_tokens] - properties: - requests: - type: integer - minimum: 0 - input_tokens: - type: integer - minimum: 0 - nullable: true - output_tokens: - type: integer - minimum: 0 - nullable: true - ModelUsageStatistics: - type: object - additionalProperties: false - required: [generation, embedding] - properties: - generation: - $ref: "#/components/schemas/ModelUsageValue" - embedding: - $ref: "#/components/schemas/ModelUsageValue" - ModelUsagePurposeBreakdown: - type: object - additionalProperties: false - required: [purpose, generation, embedding] - properties: - purpose: - type: string - minLength: 1 - maxLength: 64 - generation: - $ref: "#/components/schemas/ModelUsageValue" - embedding: - $ref: "#/components/schemas/ModelUsageValue" - ModelUsageDay: - type: object - additionalProperties: false - required: [date, generation, embedding, by_purpose] - properties: - date: - type: string - format: date - generation: - $ref: "#/components/schemas/ModelUsageValue" - embedding: - $ref: "#/components/schemas/ModelUsageValue" - by_purpose: - type: array - maxItems: 16 - items: - $ref: "#/components/schemas/ModelUsagePurposeBreakdown" - ResolvedUsagePeriod: - type: object - additionalProperties: false - required: [preset, start_date, end_date, timezone] - properties: - preset: - $ref: "#/components/schemas/StatsPeriod" - start_date: - type: string - format: date - end_date: - type: string - format: date - timezone: - type: string - enum: [UTC] - UsageStatistics: +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + description: Static bearer token used when local Server authentication is enabled. + headers: + BearerChallenge: + description: Authentication scheme required by the Server. + schema: + type: string + example: Bearer + RequestId: + description: Opaque identifier for correlating one request. + schema: + type: string + responses: + Unauthorized: + description: A valid bearer token is required by this Server deployment. + headers: + WWW-Authenticate: + $ref: "#/components/headers/BearerChallenge" + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + Conflict: + description: The command conflicts with current immutable state. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + InvalidRequest: + description: The request violates the transport or application contract. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + ReportTooLarge: + description: The selected Handoff Report exceeds the deterministic output limit. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + NotFound: + description: The requested immutable Memory value was not found. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + Unavailable: + description: A required Runtime binding or dependency is unavailable. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + InternalError: + description: The Server failed without exposing internal details. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + schemas: + ActivateHandoffRequest: type: object additionalProperties: false - required: [period, totals, by_purpose, daily] - properties: - period: - $ref: "#/components/schemas/ResolvedUsagePeriod" - totals: - $ref: "#/components/schemas/ModelUsageStatistics" - by_purpose: - type: array - maxItems: 16 - items: - $ref: "#/components/schemas/ModelUsagePurposeBreakdown" - daily: + required: [scope_id, boundary_source, objective] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + boundary_source: + $ref: "#/components/schemas/SourceReference" + objective: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + evidence: type: array - maxItems: 30 + maxItems: 32 items: - $ref: "#/components/schemas/ModelUsageDay" - TokenEstimatorProfile: + $ref: "#/components/schemas/HandoffCitation" + default: [] + max_bytes: + type: integer + minimum: 512 + maximum: 32768 + default: 8000 + ArtifactReference: type: object additionalProperties: false - required: [estimator_id, version] + required: [family, artifact_id, revision] properties: - estimator_id: + family: type: string minLength: 1 maxLength: 128 - version: + pattern: '^[\x21-\x7E]+$' + artifact_id: type: string minLength: 1 - maxLength: 64 - RecallTokenValue: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + revision: + type: integer + minimum: 1 + ArtifactAddress: type: object additionalProperties: false - required: [preparations, ready_preparations, comparable_preparations, baseline_tokens, recalled_tokens, token_reduction] + required: [scope_id, artifact] properties: - preparations: - type: integer - minimum: 0 - ready_preparations: - type: integer - minimum: 0 - comparable_preparations: - type: integer - minimum: 0 - baseline_tokens: - type: integer - minimum: 0 - recalled_tokens: - type: integer - minimum: 0 - token_reduction: - type: integer - RecallTokenDay: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + artifact: + $ref: "#/components/schemas/ArtifactReference" + PublishArtifactRequest: type: object additionalProperties: false - required: [date, preparations, ready_preparations, comparable_preparations, baseline_tokens, recalled_tokens, token_reduction] + required: [source, target_scope_id, idempotency_key] properties: - date: + source: + $ref: "#/components/schemas/ArtifactAddress" + target_scope_id: type: string - format: date - preparations: - type: integer - minimum: 0 - ready_preparations: - type: integer - minimum: 0 - comparable_preparations: - type: integer - minimum: 0 - baseline_tokens: - type: integer - minimum: 0 - recalled_tokens: - type: integer - minimum: 0 - token_reduction: - type: integer - RecallTokenStatistics: + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + idempotency_key: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + ArtifactPublication: type: object additionalProperties: false - required: [period, estimator, totals, daily] + required: [source, target, content_digest] properties: - period: - $ref: "#/components/schemas/ResolvedUsagePeriod" - estimator: - $ref: "#/components/schemas/TokenEstimatorProfile" - nullable: true - totals: - $ref: "#/components/schemas/RecallTokenValue" - daily: - type: array - maxItems: 30 - items: - $ref: "#/components/schemas/RecallTokenDay" - ScopedStats: + source: + $ref: "#/components/schemas/ArtifactAddress" + target: + $ref: "#/components/schemas/ArtifactAddress" + content_digest: + type: string + pattern: '^[0-9a-f]{64}$' + ScopeExternalReference: type: object additionalProperties: false - required: [scope_id, as_of, inventory, usage, recall] + required: [kind, value] properties: - scope_id: + kind: type: string - as_of: + minLength: 1 + maxLength: 128 + pattern: '.*\S.*' + value: type: string - format: date-time - inventory: - $ref: "#/components/schemas/InventoryStatistics" - usage: - $ref: "#/components/schemas/UsageStatistics" - recall: - $ref: "#/components/schemas/RecallTokenStatistics" - GetStatsRequest: + minLength: 1 + maxLength: 2000 + pattern: '.*\S.*' + ScopeDescriptor: type: object additionalProperties: false - required: [scope_id] + required: [scope_id, title, summary, context_references, external_references, version] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - period: - $ref: "#/components/schemas/StatsPeriod" - default: 30d - WorkClaimBasis: - type: string - enum: [declared, verified] - WorkClaim: - type: object - additionalProperties: false - required: [text, basis, evidence] - properties: - text: + title: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' - basis: - $ref: "#/components/schemas/WorkClaimBasis" - evidence: - type: array - maxItems: 31 - items: - $ref: "#/components/schemas/HandoffCitation" - WorkContract: - type: object - additionalProperties: false - required: [schema, trust, objective, facts, in_scope, exclusions, completion_criteria, authorization_notes, open_questions] - properties: - schema: - type: string - enum: [powercontext.work-contract.v1] - trust: + summary: type: string - enum: [untrusted_input] - objective: + minLength: 1 + maxLength: 2000 + pattern: '.*\S.*' + parent_scope_id: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' - facts: - type: array - maxItems: 64 - items: - $ref: "#/components/schemas/WorkClaim" - in_scope: - type: array - minItems: 1 - maxItems: 64 - items: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - exclusions: - type: array - maxItems: 64 - items: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - completion_criteria: + nullable: true + context_references: type: array - minItems: 1 - maxItems: 64 + uniqueItems: true items: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' - authorization_notes: + external_references: type: array - maxItems: 64 + uniqueItems: true items: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - open_questions: + $ref: "#/components/schemas/ScopeExternalReference" + version: + type: integer + minimum: 1 + ScopePage: + type: object + additionalProperties: false + required: [items] + properties: + items: type: array - maxItems: 64 items: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - CreateWorkContractRequest: + $ref: "#/components/schemas/ScopeDescriptor" + CreateScopeRequest: type: object additionalProperties: false - required: [scope_id, source_id, contract] + required: [title, summary, idempotency_key] properties: - scope_id: + title: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - source_id: + summary: type: string minLength: 1 - maxLength: 256 + maxLength: 2000 pattern: '.*\S.*' - contract: - $ref: "#/components/schemas/WorkContract" - CurrentWorkHandoff: - type: object - additionalProperties: false - required: [schema, trust, objective, state, disposition, next_action, omissions] - properties: - schema: - type: string - enum: [powercontext.current-work-handoff.v1] - trust: - type: string - enum: [untrusted_input] - objective: + parent_scope_id: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' - state: - type: array - minItems: 1 - maxItems: 64 - items: - $ref: "#/components/schemas/WorkClaim" - disposition: - $ref: "#/components/schemas/HandoffDisposition" - next_action: - $ref: "#/components/schemas/WorkClaim" nullable: true - omissions: + context_references: type: array - maxItems: 64 + uniqueItems: true items: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' - HandoffCurrentWorkRequest: + default: [] + external_references: + type: array + uniqueItems: true + items: + $ref: "#/components/schemas/ScopeExternalReference" + default: [] + idempotency_key: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + GetScopeRequest: type: object additionalProperties: false - required: [scope_id, source_id, handoff] + required: [scope_id] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - source_id: + UpdateScopeRequest: + type: object + additionalProperties: false + required: [scope_id, expected_version, title, summary] + properties: + scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - handoff: - $ref: "#/components/schemas/CurrentWorkHandoff" - WorkSourceKind: - type: string - enum: [work-contract, handoff-boundary, handoff-receipt, task-outcome] - WorkSourceReceipt: - type: object - additionalProperties: false - required: [kind, source, position, content_digest] - properties: - kind: - $ref: "#/components/schemas/WorkSourceKind" - source: - $ref: "#/components/schemas/SourceReference" - position: + expected_version: type: integer minimum: 1 - content_digest: + title: type: string - minLength: 71 - maxLength: 71 - pattern: '^sha256:[0-9a-f]{64}$' - PreparedWorkHandoff: + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + summary: + type: string + minLength: 1 + maxLength: 2000 + pattern: '.*\S.*' + parent_scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + nullable: true + context_references: + type: array + uniqueItems: true + items: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + default: [] + external_references: + type: array + uniqueItems: true + items: + $ref: "#/components/schemas/ScopeExternalReference" + default: [] + SetDefaultScopeRequest: + $ref: "#/components/schemas/GetScopeRequest" + ScopeSelectionMode: + type: string + enum: [all, exact, subtree] + ScopeSelection: type: object additionalProperties: false - required: [boundary, handoff] + required: [mode] properties: - boundary: - $ref: "#/components/schemas/WorkSourceReceipt" - handoff: - $ref: "#/components/schemas/PreparedHandoff" - HandoffReceiptStatus: - type: string - enum: [accepted, needs_clarification, declined] - HandoffAcknowledgementSelection: - type: string - enum: [prepared, exact] - LiveStateCheckStatus: - type: string - enum: [confirmed, mismatch, not_checked] - ReceiverReadinessCheckStatus: - type: string - enum: [confirmed, insufficient, not_checked] - ReceiverChecks: + mode: + $ref: "#/components/schemas/ScopeSelectionMode" + scope_ids: + type: array + uniqueItems: true + items: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + default: [] + root_scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + nullable: true + ResolveScopeSelectionRequest: type: object additionalProperties: false - description: Untrusted receiver self-attestation kept separate from citation availability. All three values must be confirmed when status is accepted. - required: [live_state, capability, authorization] + required: [selection] properties: - live_state: - $ref: "#/components/schemas/LiveStateCheckStatus" - capability: - $ref: "#/components/schemas/ReceiverReadinessCheckStatus" - authorization: - $ref: "#/components/schemas/ReceiverReadinessCheckStatus" - AcknowledgeHandoffRequest: + selection: + $ref: "#/components/schemas/ScopeSelection" + ScopeBindingKey: type: object additionalProperties: false - required: [scope_id, source_id, receiver, status, selection] + required: [integration, kind, external_id] properties: - scope_id: + integration: type: string minLength: 1 - maxLength: 256 + maxLength: 128 pattern: '.*\S.*' - source_id: + kind: type: string minLength: 1 - maxLength: 256 + maxLength: 64 pattern: '.*\S.*' - receiver: + external_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - status: - $ref: "#/components/schemas/HandoffReceiptStatus" - selection: - $ref: "#/components/schemas/HandoffAcknowledgementSelection" - receiver_checks: - $ref: "#/components/schemas/ReceiverChecks" - nullable: true - prepared: - $ref: "#/components/schemas/PreparedHandoff" - nullable: true - revision: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - message: + ScopeBinding: + type: object + additionalProperties: false + required: [key, scope_id] + properties: + key: + $ref: "#/components/schemas/ScopeBindingKey" + scope_id: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' - nullable: true - HandoffAcknowledgement: + SetScopeBindingRequest: + $ref: "#/components/schemas/ScopeBinding" + ClearScopeBindingRequest: type: object additionalProperties: false - required: [resolution, receipt] + required: [key] properties: - resolution: - $ref: "#/components/schemas/HandoffResolution" - receipt: - $ref: "#/components/schemas/WorkSourceReceipt" - TaskOutcomeStatus: - type: string - enum: [succeeded, partial, blocked, failed, cancelled, unknown] - TaskCheckStatus: - type: string - enum: [passed, failed, skipped, timed_out, unavailable, cancelled, unknown] - TaskCheck: + key: + $ref: "#/components/schemas/ScopeBindingKey" + ClearScopeBindingResponse: type: object additionalProperties: false - required: [name, status, basis, evidence] + required: [cleared] properties: - name: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - status: - $ref: "#/components/schemas/TaskCheckStatus" - details: + cleared: + type: boolean + ResolveScopeBindingRequest: + type: object + additionalProperties: false + properties: + explicit_scope_id: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' nullable: true - basis: - $ref: "#/components/schemas/WorkClaimBasis" - evidence: + binding_keys: type: array - maxItems: 32 items: - $ref: "#/components/schemas/HandoffCitation" - TaskOutcome: + $ref: "#/components/schemas/ScopeBindingKey" + default: [] + ArtifactCandidate: type: object additionalProperties: false - required: [schema, trust, objective, status, summary, observations, checks, produced_artifacts, remaining_work] + required: + - candidate_id + - version + - family + - status + - proposal + - source_refs + - artifact_refs + - target + - reason + - result_artifact + - decision_reason properties: - schema: - type: string - enum: [powercontext.task-outcome.v1] - trust: - type: string - enum: [untrusted_observation] - objective: + candidate_id: type: string minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + version: + type: integer + minimum: 1 + family: + $ref: "#/components/schemas/CandidateFamily" status: - $ref: "#/components/schemas/TaskOutcomeStatus" - summary: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - handoff_receipt_ref: - $ref: "#/components/schemas/SourceReference" - nullable: true - observations: - type: array - minItems: 1 - maxItems: 64 - items: - $ref: "#/components/schemas/WorkClaim" - checks: + $ref: "#/components/schemas/CandidateStatus" + proposal: + oneOf: + - $ref: "#/components/schemas/ExperienceProposal" + - $ref: "#/components/schemas/SkillProposal" + source_refs: type: array - maxItems: 64 + maxItems: 32 + description: Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references. items: - $ref: "#/components/schemas/TaskCheck" - produced_artifacts: + $ref: "#/components/schemas/SourceReference" + artifact_refs: type: array maxItems: 32 + description: Exact Artifact evidence. Counted with source_refs toward a combined maximum of 32 references. items: $ref: "#/components/schemas/ArtifactReference" - remaining_work: + target: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + reason: + type: string + minLength: 1 + maxLength: 2000 + nullable: true + result_artifact: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + decision_reason: + type: string + minLength: 1 + maxLength: 2000 + nullable: true + ArtifactCandidatePage: + type: object + additionalProperties: false + required: [candidates, next_cursor] + properties: + candidates: type: array - maxItems: 64 items: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - RecordTaskOutcomeRequest: + $ref: "#/components/schemas/ArtifactCandidate" + next_cursor: + type: string + nullable: true + ApproveArtifactCandidateRequest: type: object additionalProperties: false - required: [scope_id, source_id, outcome] + required: [scope_id, candidate_id, expected_version] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - source_id: + candidate_id: type: string minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - outcome: - $ref: "#/components/schemas/TaskOutcome" - CaptureContentSourceRequest: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + expected_version: + type: integer + minimum: 1 + Capabilities: type: object additionalProperties: false - required: [scope_id, source_id, content] + required: + [source_types, artifact_families, memory_extraction, handoff_generation, search_modes, context_versions] properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - source_id: - type: string - minLength: 1 - maxLength: 256 - content: + source_types: + type: array + items: + type: string + artifact_families: + type: array + items: + type: string + memory_extraction: + type: boolean + description: Whether pending Sources can be extracted into Memory. + experience_generation: + type: boolean + default: false + description: Whether the configured model can generate reviewed Experience Candidates. + managed_skill_generation: + type: boolean + default: false + description: Whether the configured model can generate reviewed managed Skill Candidates. + external_skill_registry: + type: boolean + default: false + description: Whether host-local external Skill discovery and exact resolution are configured. + handoff_generation: + type: boolean + description: Whether exact evidence can be generated into an inspectable Handoff Draft. + search_modes: + type: array + items: + $ref: "#/components/schemas/MemorySearchMode" + context_versions: + type: array + items: + $ref: "#/components/schemas/PreparedContextSchema" + FamilyCount: + type: object + additionalProperties: false + required: [family, total] + properties: + family: type: string minLength: 1 - maxLength: 200000 - metadata: - type: object - additionalProperties: true - nullable: true - CaptureContentSourceResponse: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + total: + type: integer + minimum: 0 + CandidateFamilyCount: type: object additionalProperties: false - required: [status, source, position] + required: [family, total, pending, approved, rejected] properties: - status: - $ref: "#/components/schemas/CaptureStatus" - source: - $ref: "#/components/schemas/SourceReference" - position: + family: + $ref: "#/components/schemas/CandidateFamily" + total: type: integer - minimum: 1 - CommitHandoffRequest: + minimum: 0 + pending: + type: integer + minimum: 0 + approved: + type: integer + minimum: 0 + rejected: + type: integer + minimum: 0 + MemoryKindCount: type: object additionalProperties: false - required: [scope_id, handoff] + required: [kind, total, active, inactive] properties: - scope_id: + kind: type: string minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - handoff: - $ref: "#/components/schemas/PreparedHandoff" - CommittedHandoff: + maxLength: 128 + total: + type: integer + minimum: 0 + active: + type: integer + minimum: 0 + inactive: + type: integer + minimum: 0 + SourceInventoryStatistics: type: object additionalProperties: false - required: [reference, content, source_refs, artifact_refs] + required: [total, memory_processed, memory_pending] properties: - reference: - $ref: "#/components/schemas/ArtifactReference" - content: - $ref: "#/components/schemas/HandoffContent" - source_refs: + total: + type: integer + minimum: 0 + memory_processed: + type: integer + minimum: 0 + memory_pending: + type: integer + minimum: 0 + ArtifactInventoryStatistics: + type: object + additionalProperties: false + required: [total, by_family] + properties: + total: + type: integer + minimum: 0 + by_family: type: array items: - $ref: "#/components/schemas/SourceReference" - artifact_refs: + $ref: "#/components/schemas/FamilyCount" + CandidateInventoryStatistics: + type: object + additionalProperties: false + required: [total, pending, approved, rejected, by_family] + properties: + total: + type: integer + minimum: 0 + pending: + type: integer + minimum: 0 + approved: + type: integer + minimum: 0 + rejected: + type: integer + minimum: 0 + by_family: type: array items: - $ref: "#/components/schemas/ArtifactReference" - ContinueHandoffRequest: + $ref: "#/components/schemas/CandidateFamilyCount" + MemoryEntryInventoryStatistics: type: object additionalProperties: false - required: [scope_id, selection] + required: [total, active, inactive, by_kind] properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - selection: - $ref: "#/components/schemas/HandoffSelection" - prepared: - $ref: "#/components/schemas/PreparedHandoff" - nullable: true - revision: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - FinalizeHandoffRequest: + total: + type: integer + minimum: 0 + active: + type: integer + minimum: 0 + inactive: + type: integer + minimum: 0 + by_kind: + type: array + items: + $ref: "#/components/schemas/MemoryKindCount" + MemoryInventoryStatistics: type: object additionalProperties: false - required: [scope_id, draft] + required: [entries] properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - draft: - $ref: "#/components/schemas/HandoffDraft" - HandoffArtifactCitation: + entries: + $ref: "#/components/schemas/MemoryEntryInventoryStatistics" + InventoryStatistics: type: object additionalProperties: false - required: [kind, artifact_ref] + required: [sources, artifacts, candidates, memory] properties: - kind: - type: string - enum: [artifact] - artifact_ref: - $ref: "#/components/schemas/ArtifactReference" - HandoffActivation: + sources: + $ref: "#/components/schemas/SourceInventoryStatistics" + artifacts: + $ref: "#/components/schemas/ArtifactInventoryStatistics" + candidates: + $ref: "#/components/schemas/CandidateInventoryStatistics" + memory: + $ref: "#/components/schemas/MemoryInventoryStatistics" + ModelUsageValue: type: object additionalProperties: false - required: [status, boundary_source, previous_position, current_position, draft] + required: [requests, input_tokens, output_tokens] properties: - status: - $ref: "#/components/schemas/HandoffActivationStatus" - boundary_source: - $ref: "#/components/schemas/SourceReference" - previous_position: + requests: + type: integer + minimum: 0 + input_tokens: type: integer minimum: 0 - current_position: + nullable: true + output_tokens: type: integer minimum: 0 - draft: - $ref: "#/components/schemas/HandoffDraft" nullable: true - HandoffCitation: - oneOf: - - $ref: "#/components/schemas/HandoffSourceCitation" - - $ref: "#/components/schemas/HandoffArtifactCitation" - - $ref: "#/components/schemas/HandoffMemoryCitation" - discriminator: - propertyName: kind - mapping: - source: "#/components/schemas/HandoffSourceCitation" - artifact: "#/components/schemas/HandoffArtifactCitation" - memory: "#/components/schemas/HandoffMemoryCitation" - HandoffContent: + ModelUsageStatistics: type: object additionalProperties: false - required: [schema, objective, state, disposition, next_action, omissions] + required: [generation, embedding] properties: - schema: - $ref: "#/components/schemas/HandoffSchema" - objective: + generation: + $ref: "#/components/schemas/ModelUsageValue" + embedding: + $ref: "#/components/schemas/ModelUsageValue" + ModelUsagePurposeBreakdown: + type: object + additionalProperties: false + required: [purpose, generation, embedding] + properties: + purpose: type: string minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - state: - type: array - minItems: 1 - maxItems: 64 - items: - $ref: "#/components/schemas/HandoffStatement" - disposition: - $ref: "#/components/schemas/HandoffDisposition" - next_action: - $ref: "#/components/schemas/HandoffStatement" - nullable: true - omissions: + maxLength: 64 + generation: + $ref: "#/components/schemas/ModelUsageValue" + embedding: + $ref: "#/components/schemas/ModelUsageValue" + ModelUsageDay: + type: object + additionalProperties: false + required: [date, generation, embedding, by_purpose] + properties: + date: + type: string + format: date + generation: + $ref: "#/components/schemas/ModelUsageValue" + embedding: + $ref: "#/components/schemas/ModelUsageValue" + by_purpose: type: array - maxItems: 64 + maxItems: 16 items: - $ref: "#/components/schemas/HandoffOmission" - HandoffDraft: + $ref: "#/components/schemas/ModelUsagePurposeBreakdown" + ResolvedUsagePeriod: type: object additionalProperties: false - required: [objective, state, disposition, next_action, omissions] + required: [preset, start_date, end_date, timezone] properties: - objective: + preset: + $ref: "#/components/schemas/StatsPeriod" + start_date: type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - state: + format: date + end_date: + type: string + format: date + timezone: + type: string + enum: [UTC] + UsageStatistics: + type: object + additionalProperties: false + required: [period, totals, by_purpose, daily] + properties: + period: + $ref: "#/components/schemas/ResolvedUsagePeriod" + totals: + $ref: "#/components/schemas/ModelUsageStatistics" + by_purpose: type: array - minItems: 1 - maxItems: 64 + maxItems: 16 items: - $ref: "#/components/schemas/HandoffStatement" - disposition: - $ref: "#/components/schemas/HandoffDisposition" - next_action: - $ref: "#/components/schemas/HandoffStatement" - nullable: true - omissions: + $ref: "#/components/schemas/ModelUsagePurposeBreakdown" + daily: type: array - maxItems: 64 + maxItems: 30 items: - $ref: "#/components/schemas/HandoffOmission" - HandoffEvidenceCheck: + $ref: "#/components/schemas/ModelUsageDay" + TokenEstimatorProfile: type: object additionalProperties: false - required: [claim, state_index, status, unavailable_evidence] + required: [estimator_id, version] properties: - claim: - $ref: "#/components/schemas/HandoffClaim" - state_index: + estimator_id: + type: string + minLength: 1 + maxLength: 128 + version: + type: string + minLength: 1 + maxLength: 64 + RecallTokenValue: + type: object + additionalProperties: false + required: [preparations, ready_preparations, comparable_preparations, baseline_tokens, recalled_tokens, token_reduction] + properties: + preparations: type: integer minimum: 0 - nullable: true - status: - $ref: "#/components/schemas/HandoffEvidenceStatus" - unavailable_evidence: - type: array - maxItems: 32 - items: - $ref: "#/components/schemas/HandoffCitation" - HandoffMemoryCitation: + ready_preparations: + type: integer + minimum: 0 + comparable_preparations: + type: integer + minimum: 0 + baseline_tokens: + type: integer + minimum: 0 + recalled_tokens: + type: integer + minimum: 0 + token_reduction: + type: integer + RecallTokenDay: type: object additionalProperties: false - required: [kind, memory_citation] + required: [date, preparations, ready_preparations, comparable_preparations, baseline_tokens, recalled_tokens, token_reduction] properties: - kind: + date: type: string - enum: [memory] - memory_citation: - $ref: "#/components/schemas/MemoryCitation" - HandoffOmission: + format: date + preparations: + type: integer + minimum: 0 + ready_preparations: + type: integer + minimum: 0 + comparable_preparations: + type: integer + minimum: 0 + baseline_tokens: + type: integer + minimum: 0 + recalled_tokens: + type: integer + minimum: 0 + token_reduction: + type: integer + RecallTokenStatistics: type: object additionalProperties: false - required: [text, citation] + required: [period, estimator, totals, daily] properties: - text: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - citation: - $ref: "#/components/schemas/HandoffCitation" + period: + $ref: "#/components/schemas/ResolvedUsagePeriod" + estimator: + $ref: "#/components/schemas/TokenEstimatorProfile" nullable: true - HandoffResolution: + totals: + $ref: "#/components/schemas/RecallTokenValue" + daily: + type: array + maxItems: 30 + items: + $ref: "#/components/schemas/RecallTokenDay" + ScopedStats: type: object additionalProperties: false - required: - [trust, status, scope_id, content, selection, selected_revision, current_revision, evidence_checks] + required: [selection, scope_ids, as_of, inventory, usage, recall] properties: - trust: - type: string - enum: [untrusted_history] - status: - $ref: "#/components/schemas/HandoffResolutionStatus" - scope_id: - type: string - content: - $ref: "#/components/schemas/HandoffContent" - nullable: true selection: - $ref: "#/components/schemas/HandoffSelection" - nullable: true - selected_revision: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - current_revision: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - evidence_checks: + $ref: "#/components/schemas/ScopeSelection" + scope_ids: type: array - maxItems: 65 + uniqueItems: true items: - $ref: "#/components/schemas/HandoffEvidenceCheck" - HandoffSourceCitation: + type: string + as_of: + type: string + format: date-time + inventory: + $ref: "#/components/schemas/InventoryStatistics" + usage: + $ref: "#/components/schemas/UsageStatistics" + recall: + $ref: "#/components/schemas/RecallTokenStatistics" + GetStatsRequest: type: object additionalProperties: false - required: [kind, source_ref] + required: [selection] properties: - kind: - type: string - enum: [source] - source_ref: - $ref: "#/components/schemas/SourceReference" - HandoffStatement: + selection: + $ref: "#/components/schemas/ScopeSelection" + period: + $ref: "#/components/schemas/StatsPeriod" + default: 30d + WorkClaimBasis: + type: string + enum: [declared, verified] + WorkClaim: type: object additionalProperties: false - required: [text, citations] + required: [text, basis, evidence] properties: text: type: string minLength: 1 maxLength: 8192 pattern: '.*\S.*' - citations: + basis: + $ref: "#/components/schemas/WorkClaimBasis" + evidence: type: array - minItems: 1 - maxItems: 32 + maxItems: 31 items: $ref: "#/components/schemas/HandoffCitation" - PrepareHandoffRequest: + WorkContract: type: object additionalProperties: false - required: [scope_id, objective, evidence] + required: [schema, trust, objective, facts, in_scope, exclusions, completion_criteria, authorization_notes, open_questions] properties: - scope_id: + schema: type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' + enum: [powercontext.work-contract.v1] + trust: + type: string + enum: [untrusted_input] objective: type: string minLength: 1 maxLength: 8192 pattern: '.*\S.*' - evidence: + facts: + type: array + maxItems: 64 + items: + $ref: "#/components/schemas/WorkClaim" + in_scope: type: array minItems: 1 - maxItems: 32 + maxItems: 64 items: - $ref: "#/components/schemas/HandoffCitation" - max_bytes: - type: integer - minimum: 512 - maximum: 32768 - default: 8000 - PreparedHandoff: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + exclusions: + type: array + maxItems: 64 + items: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + completion_criteria: + type: array + minItems: 1 + maxItems: 64 + items: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + authorization_notes: + type: array + maxItems: 64 + items: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + open_questions: + type: array + maxItems: 64 + items: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + CreateWorkContractRequest: type: object additionalProperties: false - required: [schema, scope_id, base, content] + required: [scope_id, source_id, contract] properties: - schema: - $ref: "#/components/schemas/PreparedHandoffSchema" scope_id: type: string - base: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - content: - $ref: "#/components/schemas/HandoffContent" - PreparedContext: + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + source_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + contract: + $ref: "#/components/schemas/WorkContract" + CurrentWorkHandoff: type: object additionalProperties: false - required: [schema, status, content, content_bytes] + required: [schema, trust, objective, state, disposition, next_action, omissions] properties: schema: - $ref: "#/components/schemas/PreparedContextSchema" - status: - $ref: "#/components/schemas/PreparedContextStatus" - content: type: string + enum: [powercontext.current-work-handoff.v1] + trust: + type: string + enum: [untrusted_input] + objective: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + state: + type: array + minItems: 1 + maxItems: 64 + items: + $ref: "#/components/schemas/WorkClaim" + disposition: + $ref: "#/components/schemas/HandoffDisposition" + next_action: + $ref: "#/components/schemas/WorkClaim" nullable: true - content_bytes: - type: integer - minimum: 0 - EntryChange: + omissions: + type: array + maxItems: 64 + items: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + HandoffCurrentWorkRequest: type: object additionalProperties: false - required: [op, entry_id, from_entry_version_id, to_entry_version_id, reason] + required: [scope_id, source_id, handoff] properties: - op: - $ref: "#/components/schemas/EntryChangeOperation" - entry_id: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - from_entry_version_id: + scope_id: type: string minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - nullable: true - to_entry_version_id: + maxLength: 256 + pattern: '.*\S.*' + source_id: type: string minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - nullable: true - reason: + maxLength: 256 + pattern: '.*\S.*' + handoff: + $ref: "#/components/schemas/CurrentWorkHandoff" + WorkSourceKind: + type: string + enum: [work-contract, handoff-boundary, handoff-receipt, task-outcome] + WorkSourceReceipt: + type: object + additionalProperties: false + required: [kind, source, position, content_digest] + properties: + kind: + $ref: "#/components/schemas/WorkSourceKind" + source: + $ref: "#/components/schemas/SourceReference" + position: + type: integer + minimum: 1 + content_digest: type: string - nullable: true - ExperienceArtifact: + minLength: 71 + maxLength: 71 + pattern: '^sha256:[0-9a-f]{64}$' + PreparedWorkHandoff: type: object additionalProperties: false - required: [artifact, content, source_refs, artifact_refs] + required: [boundary, handoff] properties: - artifact: - $ref: "#/components/schemas/ArtifactReference" - content: - $ref: "#/components/schemas/ExperienceProposal" - source_refs: - type: array - items: - $ref: "#/components/schemas/SourceReference" - artifact_refs: - type: array - items: - $ref: "#/components/schemas/ArtifactReference" - ExperienceProposal: + boundary: + $ref: "#/components/schemas/WorkSourceReceipt" + handoff: + $ref: "#/components/schemas/PreparedHandoff" + HandoffReceiptStatus: + type: string + enum: [accepted, needs_clarification, declined] + HandoffAcknowledgementSelection: + type: string + enum: [prepared, exact] + LiveStateCheckStatus: + type: string + enum: [confirmed, mismatch, not_checked] + ReceiverReadinessCheckStatus: + type: string + enum: [confirmed, insufficient, not_checked] + ReceiverChecks: type: object additionalProperties: false - required: [situation, action, outcome, lesson] + description: Untrusted receiver self-attestation kept separate from citation availability. All three values must be confirmed when status is accepted. + required: [live_state, capability, authorization] properties: - situation: + live_state: + $ref: "#/components/schemas/LiveStateCheckStatus" + capability: + $ref: "#/components/schemas/ReceiverReadinessCheckStatus" + authorization: + $ref: "#/components/schemas/ReceiverReadinessCheckStatus" + AcknowledgeHandoffRequest: + type: object + additionalProperties: false + required: [scope_id, source_id, receiver, status, selection] + properties: + scope_id: type: string minLength: 1 - maxLength: 8000 + maxLength: 256 pattern: '.*\S.*' - action: + source_id: type: string minLength: 1 - maxLength: 8000 + maxLength: 256 pattern: '.*\S.*' - outcome: + receiver: type: string minLength: 1 - maxLength: 8000 + maxLength: 256 pattern: '.*\S.*' - lesson: + status: + $ref: "#/components/schemas/HandoffReceiptStatus" + selection: + $ref: "#/components/schemas/HandoffAcknowledgementSelection" + receiver_checks: + $ref: "#/components/schemas/ReceiverChecks" + nullable: true + prepared: + $ref: "#/components/schemas/PreparedHandoff" + nullable: true + revision: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + message: type: string minLength: 1 - maxLength: 8000 + maxLength: 8192 pattern: '.*\S.*' - SkillArtifact: + nullable: true + HandoffAcknowledgement: type: object additionalProperties: false - required: [artifact, content, source_refs, artifact_refs] + required: [resolution, receipt] properties: - artifact: - $ref: "#/components/schemas/ArtifactReference" - content: - $ref: "#/components/schemas/SkillProposal" - source_refs: - type: array - items: - $ref: "#/components/schemas/SourceReference" - artifact_refs: - type: array - items: - $ref: "#/components/schemas/ArtifactReference" - SkillProposal: + resolution: + $ref: "#/components/schemas/HandoffResolution" + receipt: + $ref: "#/components/schemas/WorkSourceReceipt" + TaskOutcomeStatus: + type: string + enum: [succeeded, partial, blocked, failed, cancelled, unknown] + TaskCheckStatus: + type: string + enum: [passed, failed, skipped, timed_out, unavailable, cancelled, unknown] + TaskCheck: type: object additionalProperties: false - required: [name, description, instructions, validation] + required: [name, status, basis, evidence] properties: name: type: string minLength: 1 - maxLength: 128 - pattern: '^\S(?:.*\S)?$' - description: - type: string - minLength: 1 - maxLength: 2000 - pattern: '^\S(?:.*\S)?$' - instructions: + maxLength: 8192 + pattern: '.*\S.*' + status: + $ref: "#/components/schemas/TaskCheckStatus" + details: type: string minLength: 1 - maxLength: 32000 + maxLength: 8192 pattern: '.*\S.*' - validation: + nullable: true + basis: + $ref: "#/components/schemas/WorkClaimBasis" + evidence: type: array - minItems: 1 maxItems: 32 items: - $ref: "#/components/schemas/SkillValidationItem" - SkillValidationItem: - type: string - minLength: 1 - maxLength: 2000 - pattern: '^\S(?:.*\S)?$' - ExternalSkillRegistration: + $ref: "#/components/schemas/HandoffCitation" + TaskOutcome: type: object additionalProperties: false - required: - - external_skill_id - - provider - - agent_kind - - host_id - - installation_scope - - locator - - fingerprint - - name - - description + required: [schema, trust, objective, status, summary, observations, checks, produced_artifacts, remaining_work] properties: - external_skill_id: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - provider: - type: string - enum: [codex, claude_code] - agent_kind: - type: string - enum: [codex, claude_code] - host_id: - type: string - minLength: 1 - maxLength: 128 - pattern: '^\S(?:.*\S)?$' - installation_scope: - $ref: "#/components/schemas/ExternalSkillInstallationScope" - locator: - type: string - minLength: 1 - maxLength: 2000 - pattern: '^\S(?:.*\S)?$' - description: Host-local locator; not a cross-Agent or cross-host contract. - fingerprint: + schema: type: string - pattern: '^[0-9a-f]{64}$' - name: + enum: [powercontext.task-outcome.v1] + trust: type: string - minLength: 1 - maxLength: 128 - pattern: '^\S(?:.*\S)?$' - description: + enum: [untrusted_observation] + objective: type: string minLength: 1 - maxLength: 2000 - pattern: '^\S(?:.*\S)?$' - ExternalSkillResolution: - type: object - additionalProperties: false - required: [registration, status, entrypoint] - properties: - registration: - $ref: "#/components/schemas/ExternalSkillRegistration" + maxLength: 8192 + pattern: '.*\S.*' status: - $ref: "#/components/schemas/ExternalSkillResolutionStatus" - entrypoint: + $ref: "#/components/schemas/TaskOutcomeStatus" + summary: type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + handoff_receipt_ref: + $ref: "#/components/schemas/SourceReference" nullable: true - description: Host-local SKILL.md path; present only when the exact fingerprint is available. - ScanExternalSkillsResponse: - type: object - additionalProperties: false - required: [registrations, skipped] - properties: - registrations: + observations: type: array + minItems: 1 + maxItems: 64 items: - $ref: "#/components/schemas/ExternalSkillRegistration" - skipped: - type: integer - minimum: 0 - ListExternalSkillsResponse: - type: object - additionalProperties: false - required: [skills] - properties: - skills: + $ref: "#/components/schemas/WorkClaim" + checks: type: array + maxItems: 64 items: - $ref: "#/components/schemas/ExternalSkillResolution" - ErrorDetail: - type: object - additionalProperties: false - required: [code, message, details] - properties: - code: - type: string - message: - type: string - details: - type: object - additionalProperties: true - nullable: true - ErrorResponse: - type: object - additionalProperties: false - required: [error] - properties: - error: - $ref: "#/components/schemas/ErrorDetail" - FlushMemoryRequest: + $ref: "#/components/schemas/TaskCheck" + produced_artifacts: + type: array + maxItems: 32 + items: + $ref: "#/components/schemas/ArtifactReference" + remaining_work: + type: array + maxItems: 64 + items: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + RecordTaskOutcomeRequest: type: object additionalProperties: false - required: [scope_id] + required: [scope_id, source_id, outcome] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - FlushMemoryResponse: - type: object - additionalProperties: false - required: [status, previous_cursor, current_cursor, high_watermark, processed_source_count] - properties: - status: - $ref: "#/components/schemas/FlushStatus" - previous_cursor: - type: integer - minimum: 0 - current_cursor: - type: integer - minimum: 0 - high_watermark: - type: integer - minimum: 0 - processed_source_count: - type: integer - minimum: 0 - memory: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - GetMemoryEntryRequest: - type: object - additionalProperties: false - required: [scope_id, citation] - properties: - scope_id: + source_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - citation: - $ref: "#/components/schemas/MemoryCitation" - GetArtifactCandidateRequest: + outcome: + $ref: "#/components/schemas/TaskOutcome" + CaptureContentSourceRequest: type: object additionalProperties: false - required: [scope_id, candidate_id] + required: [scope_id, source_id, content] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - candidate_id: + source_id: type: string minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - GetExperienceRequest: + maxLength: 256 + content: + type: string + minLength: 1 + maxLength: 200000 + metadata: + type: object + additionalProperties: true + nullable: true + CaptureContentSourceResponse: + type: object + additionalProperties: false + required: [status, source, position] + properties: + status: + $ref: "#/components/schemas/CaptureStatus" + source: + $ref: "#/components/schemas/SourceReference" + position: + type: integer + minimum: 1 + CommitHandoffRequest: type: object additionalProperties: false - required: [scope_id, artifact] + required: [scope_id, handoff] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - artifact: + handoff: + $ref: "#/components/schemas/PreparedHandoff" + CommittedHandoff: + type: object + additionalProperties: false + required: [reference, content, source_refs, artifact_refs] + properties: + reference: $ref: "#/components/schemas/ArtifactReference" - GetSkillRequest: + content: + $ref: "#/components/schemas/HandoffContent" + source_refs: + type: array + items: + $ref: "#/components/schemas/SourceReference" + artifact_refs: + type: array + items: + $ref: "#/components/schemas/ArtifactReference" + ContinueHandoffRequest: type: object additionalProperties: false - required: [scope_id, artifact] + required: [scope_id, selection] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - artifact: + selection: + $ref: "#/components/schemas/HandoffSelection" + prepared: + $ref: "#/components/schemas/PreparedHandoff" + nullable: true + revision: $ref: "#/components/schemas/ArtifactReference" - CreateHandoffReportProjectRequest: + nullable: true + FinalizeHandoffRequest: type: object additionalProperties: false - required: [project_key, title] + required: [scope_id, draft] properties: - project_key: - type: string - minLength: 1 - maxLength: 64 - title: - type: string - minLength: 1 - maxLength: 256 - description: - type: string - maxLength: 2000 - nullable: true - default_locale: - $ref: "#/components/schemas/ReportLocale" - default: zh-CN - timezone: + scope_id: type: string minLength: 1 maxLength: 256 - default: UTC - ListHandoffReportProjectsRequest: - type: object - additionalProperties: false - properties: - cursor: - type: string - nullable: true - limit: - type: integer - minimum: 1 - maximum: 100 - default: 50 - include_archived: - type: boolean - default: false - GetHandoffReportProjectRequest: + pattern: '.*\S.*' + draft: + $ref: "#/components/schemas/HandoffDraft" + HandoffArtifactCitation: type: object additionalProperties: false - required: [project_id] + required: [kind, artifact_ref] properties: - project_id: + kind: type: string - minLength: 1 - maxLength: 256 - UpdateHandoffReportProjectRequest: + enum: [artifact] + artifact_ref: + $ref: "#/components/schemas/ArtifactReference" + HandoffActivation: type: object additionalProperties: false - required: [project, expected_version] + required: [status, boundary_source, previous_position, current_position, draft] properties: - project: - $ref: "#/components/schemas/ProjectDescriptor" - expected_version: + status: + $ref: "#/components/schemas/HandoffActivationStatus" + boundary_source: + $ref: "#/components/schemas/SourceReference" + previous_position: type: integer - minimum: 1 - RegisterHandoffReportWorkstreamRequest: + minimum: 0 + current_position: + type: integer + minimum: 0 + draft: + $ref: "#/components/schemas/HandoffDraft" + nullable: true + HandoffCitation: + oneOf: + - $ref: "#/components/schemas/HandoffSourceCitation" + - $ref: "#/components/schemas/HandoffArtifactCitation" + - $ref: "#/components/schemas/HandoffMemoryCitation" + discriminator: + propertyName: kind + mapping: + source: "#/components/schemas/HandoffSourceCitation" + artifact: "#/components/schemas/HandoffArtifactCitation" + memory: "#/components/schemas/HandoffMemoryCitation" + HandoffContent: type: object additionalProperties: false - required: [project_id, scope_id, title, kind] + required: [schema, objective, state, disposition, next_action, omissions] properties: - project_id: - type: string - minLength: 1 - maxLength: 256 - scope_id: - type: string - minLength: 1 - maxLength: 256 - key: - type: string - minLength: 1 - maxLength: 64 - nullable: true - title: + schema: + $ref: "#/components/schemas/HandoffSchema" + objective: type: string minLength: 1 - maxLength: 256 - kind: - $ref: "#/components/schemas/WorkstreamKind" - catalog_state: - $ref: "#/components/schemas/ReportCatalogState" - default: included - external_refs: + maxLength: 8192 + pattern: '.*\S.*' + state: type: array - maxItems: 32 + minItems: 1 + maxItems: 64 items: - $ref: "#/components/schemas/HandoffReportExternalReference" - default: [] - labels: + $ref: "#/components/schemas/HandoffStatement" + disposition: + $ref: "#/components/schemas/HandoffDisposition" + next_action: + $ref: "#/components/schemas/HandoffStatement" + nullable: true + omissions: type: array - maxItems: 32 + maxItems: 64 items: - type: string - minLength: 1 - maxLength: 128 - default: [] - ListHandoffReportWorkstreamsRequest: + $ref: "#/components/schemas/HandoffOmission" + HandoffDraft: type: object additionalProperties: false - required: [project_id] + required: [objective, state, disposition, next_action, omissions] properties: - project_id: + objective: type: string minLength: 1 - maxLength: 256 - cursor: - type: string + maxLength: 8192 + pattern: '.*\S.*' + state: + type: array + minItems: 1 + maxItems: 64 + items: + $ref: "#/components/schemas/HandoffStatement" + disposition: + $ref: "#/components/schemas/HandoffDisposition" + next_action: + $ref: "#/components/schemas/HandoffStatement" nullable: true - limit: - type: integer - minimum: 1 - maximum: 100 - default: 50 - include_archived: - type: boolean - default: false - UpdateHandoffReportWorkstreamRequest: + omissions: + type: array + maxItems: 64 + items: + $ref: "#/components/schemas/HandoffOmission" + HandoffEvidenceCheck: type: object additionalProperties: false - required: [workstream, expected_version] + required: [claim, state_index, status, unavailable_evidence] properties: - workstream: - $ref: "#/components/schemas/WorkstreamDescriptor" - expected_version: + claim: + $ref: "#/components/schemas/HandoffClaim" + state_index: type: integer - minimum: 1 - GetHandoffReportRequest: + minimum: 0 + nullable: true + status: + $ref: "#/components/schemas/HandoffEvidenceStatus" + unavailable_evidence: + type: array + maxItems: 32 + items: + $ref: "#/components/schemas/HandoffCitation" + HandoffMemoryCitation: type: object additionalProperties: false - required: [scope_id] + required: [kind, memory_citation] properties: - scope_id: + kind: type: string - minLength: 1 - maxLength: 256 - project_id: + enum: [memory] + memory_citation: + $ref: "#/components/schemas/MemoryCitation" + HandoffOmission: + type: object + additionalProperties: false + required: [text, citation] + properties: + text: type: string minLength: 1 - maxLength: 256 + maxLength: 8192 + pattern: '.*\S.*' + citation: + $ref: "#/components/schemas/HandoffCitation" + nullable: true + HandoffResolution: + type: object + additionalProperties: false + required: + [trust, status, scope_id, content, selection, selected_revision, current_revision, evidence_checks] + properties: + trust: + type: string + enum: [untrusted_history] + status: + $ref: "#/components/schemas/HandoffResolutionStatus" + scope_id: + type: string + content: + $ref: "#/components/schemas/HandoffContent" nullable: true - deprecated: true - description: Retained for wire compatibility and ignored when generating a scope report. - locale: - $ref: "#/components/schemas/ReportLocale" + selection: + $ref: "#/components/schemas/HandoffSelection" nullable: true - include_evidence_checks: - type: boolean - default: true - format: - $ref: "#/components/schemas/ReportFormat" - default: markdown - include_archived: - type: boolean - default: false - download: - type: boolean - default: false - period: - $ref: "#/components/schemas/HandoffReportPeriodRequest" + selected_revision: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + current_revision: + $ref: "#/components/schemas/ArtifactReference" nullable: true - ListHandoffReportKnownScopesRequest: + evidence_checks: + type: array + maxItems: 65 + items: + $ref: "#/components/schemas/HandoffEvidenceCheck" + HandoffSourceCitation: + type: object + additionalProperties: false + required: [kind, source_ref] + properties: + kind: + type: string + enum: [source] + source_ref: + $ref: "#/components/schemas/SourceReference" + HandoffStatement: type: object additionalProperties: false + required: [text, citations] properties: - cursor: + text: type: string - nullable: true - limit: - type: integer - minimum: 1 - maximum: 100 - default: 50 - KnownHandoffScope: + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + citations: + type: array + minItems: 1 + maxItems: 32 + items: + $ref: "#/components/schemas/HandoffCitation" + PrepareHandoffRequest: type: object additionalProperties: false - required: [scope_id] + required: [scope_id, objective, evidence] properties: scope_id: type: string minLength: 1 maxLength: 256 - KnownHandoffScopePage: - type: object - additionalProperties: false - required: [items] - properties: - items: + pattern: '.*\S.*' + objective: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + evidence: type: array + minItems: 1 + maxItems: 32 items: - $ref: "#/components/schemas/KnownHandoffScope" - next_cursor: - type: string - nullable: true - HandoffReportPeriodRequest: + $ref: "#/components/schemas/HandoffCitation" + max_bytes: + type: integer + minimum: 512 + maximum: 32768 + default: 8000 + PreparedHandoff: type: object additionalProperties: false - required: [start, end] + required: [schema, scope_id, base, content] properties: - start: - type: string - format: date-time - end: - type: string - format: date-time - timezone: + schema: + $ref: "#/components/schemas/PreparedHandoffSchema" + scope_id: type: string - minLength: 1 - maxLength: 256 + base: + $ref: "#/components/schemas/ArtifactReference" nullable: true - compare_to_previous_period: - type: boolean - default: false - HandoffReportResponse: + content: + $ref: "#/components/schemas/HandoffContent" + PreparedContext: type: object additionalProperties: false - required: [format, report, markdown, selection_digest, report_digest] + required: [schema, status, content, content_bytes] properties: - format: - $ref: "#/components/schemas/ReportFormat" - report: - type: object - additionalProperties: true - nullable: true - markdown: + schema: + $ref: "#/components/schemas/PreparedContextSchema" + status: + $ref: "#/components/schemas/PreparedContextStatus" + content: type: string nullable: true - selection_digest: - type: string - pattern: '^sha256:[0-9a-f]{64}$' - report_digest: - type: string - pattern: '^sha256:[0-9a-f]{64}$' - ReportActivitySource: - type: string - enum: [handoff_observation, git_commit, git_worktree, coding_session, other] - ReportTimeBasis: - type: string - enum: [source_reported, host_observed, first_seen, current_only, unknown] - HandoffReportActivityAgent: + content_bytes: + type: integer + minimum: 0 + EntryChange: type: object additionalProperties: false + required: [op, entry_id, from_entry_version_id, to_entry_version_id, reason] properties: - provider: + op: + $ref: "#/components/schemas/EntryChangeOperation" + entry_id: type: string minLength: 1 - maxLength: 64 - nullable: true - label: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + from_entry_version_id: type: string minLength: 1 maxLength: 128 + pattern: '^[\x21-\x7E]+$' nullable: true - HandoffReportActivityVcsContext: - type: object - additionalProperties: false - properties: - branch: + to_entry_version_id: type: string minLength: 1 - maxLength: 256 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' nullable: true - head_revision: + reason: type: string - minLength: 1 - maxLength: 256 nullable: true - RecordHandoffReportActivityRequest: + ExperienceArtifact: + type: object + additionalProperties: false + required: [artifact, content, source_refs, artifact_refs] + properties: + artifact: + $ref: "#/components/schemas/ArtifactReference" + content: + $ref: "#/components/schemas/ExperienceProposal" + source_refs: + type: array + items: + $ref: "#/components/schemas/SourceReference" + artifact_refs: + type: array + items: + $ref: "#/components/schemas/ArtifactReference" + ExperienceProposal: type: object additionalProperties: false - required: [project_id, source, source_event_id, time_basis] + required: [situation, action, outcome, lesson] properties: - project_id: + situation: type: string minLength: 1 - maxLength: 256 - scope_id: + maxLength: 8000 + pattern: '.*\S.*' + action: type: string minLength: 1 - maxLength: 256 - nullable: true - source: - $ref: "#/components/schemas/ReportActivitySource" - source_event_id: + maxLength: 8000 + pattern: '.*\S.*' + outcome: type: string minLength: 1 - maxLength: 256 - source_ref: - $ref: "#/components/schemas/HandoffReportExternalReference" - nullable: true - occurred_at: + maxLength: 8000 + pattern: '.*\S.*' + lesson: type: string - format: date-time - nullable: true - time_basis: - $ref: "#/components/schemas/ReportTimeBasis" - title: + minLength: 1 + maxLength: 8000 + pattern: '.*\S.*' + SkillArtifact: + type: object + additionalProperties: false + required: [artifact, content, source_refs, artifact_refs] + properties: + artifact: + $ref: "#/components/schemas/ArtifactReference" + content: + $ref: "#/components/schemas/SkillProposal" + source_refs: + type: array + items: + $ref: "#/components/schemas/SourceReference" + artifact_refs: + type: array + items: + $ref: "#/components/schemas/ArtifactReference" + SkillProposal: + type: object + additionalProperties: false + required: [name, description, instructions, validation] + properties: + name: type: string minLength: 1 - maxLength: 256 - nullable: true - summary: + maxLength: 128 + pattern: '^\S(?:.*\S)?$' + description: type: string minLength: 1 maxLength: 2000 - nullable: true - agent: - $ref: "#/components/schemas/HandoffReportActivityAgent" - nullable: true - session_id: + pattern: '^\S(?:.*\S)?$' + instructions: type: string minLength: 1 - maxLength: 256 - nullable: true - vcs_context: - $ref: "#/components/schemas/HandoffReportActivityVcsContext" - nullable: true - evidence_refs: + maxLength: 32000 + pattern: '.*\S.*' + validation: type: array + minItems: 1 maxItems: 32 items: - $ref: "#/components/schemas/HandoffReportExternalReference" - default: [] - HandoffReportActivity: + $ref: "#/components/schemas/SkillValidationItem" + SkillValidationItem: + type: string + minLength: 1 + maxLength: 2000 + pattern: '^\S(?:.*\S)?$' + ExternalSkillRegistration: type: object additionalProperties: false - required: [schema, event_id, project_id, scope_id, source, source_event_id, source_ref, occurred_at, observed_at, time_basis, title, summary, agent, session_id, vcs_context, evidence_refs, trust] + required: + - external_skill_id + - provider + - agent_kind + - host_id + - installation_scope + - locator + - fingerprint + - name + - description properties: - schema: - type: string - enum: [powercontext.handoff-report-activity.v1] - event_id: - type: string - minLength: 1 - maxLength: 256 - project_id: - type: string - minLength: 1 - maxLength: 256 - scope_id: - type: string - minLength: 1 - maxLength: 256 - nullable: true - source: - $ref: "#/components/schemas/ReportActivitySource" - source_event_id: + external_skill_id: type: string minLength: 1 - maxLength: 256 - source_ref: - $ref: "#/components/schemas/HandoffReportExternalReference" - nullable: true - occurred_at: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + provider: type: string - format: date-time - nullable: true - observed_at: + enum: [codex, claude_code] + agent_kind: type: string - format: date-time - time_basis: - $ref: "#/components/schemas/ReportTimeBasis" - title: + enum: [codex, claude_code] + host_id: type: string minLength: 1 - maxLength: 256 - nullable: true - summary: + maxLength: 128 + pattern: '^\S(?:.*\S)?$' + installation_scope: + $ref: "#/components/schemas/ExternalSkillInstallationScope" + locator: type: string minLength: 1 maxLength: 2000 - nullable: true - agent: - $ref: "#/components/schemas/HandoffReportActivityAgent" - nullable: true - session_id: + pattern: '^\S(?:.*\S)?$' + description: Host-local locator; not a cross-Agent or cross-host contract. + fingerprint: + type: string + pattern: '^[0-9a-f]{64}$' + name: type: string minLength: 1 - maxLength: 256 - nullable: true - vcs_context: - $ref: "#/components/schemas/HandoffReportActivityVcsContext" - nullable: true - evidence_refs: - type: array - maxItems: 32 - items: - $ref: "#/components/schemas/HandoffReportExternalReference" - trust: + maxLength: 128 + pattern: '^\S(?:.*\S)?$' + description: type: string - enum: [untrusted_observation] - StoredHandoffReportActivity: + minLength: 1 + maxLength: 2000 + pattern: '^\S(?:.*\S)?$' + ExternalSkillResolution: type: object additionalProperties: false - required: [cursor, event] + required: [registration, status, entrypoint] properties: - cursor: - type: integer - minimum: 1 - event: - $ref: "#/components/schemas/HandoffReportActivity" - ListHandoffReportActivitiesRequest: + registration: + $ref: "#/components/schemas/ExternalSkillRegistration" + status: + $ref: "#/components/schemas/ExternalSkillResolutionStatus" + entrypoint: + type: string + nullable: true + description: Host-local SKILL.md path; present only when the exact fingerprint is available. + ScanExternalSkillsResponse: type: object additionalProperties: false - required: [project_id] + required: [registrations, skipped] properties: - project_id: - type: string - minLength: 1 - maxLength: 256 - period_start: - type: string - format: date-time - nullable: true - period_end: - type: string - format: date-time - nullable: true - sources: + registrations: type: array - maxItems: 5 items: - $ref: "#/components/schemas/ReportActivitySource" - nullable: true - after_cursor: - type: integer - minimum: 0 - default: 0 - through_cursor: + $ref: "#/components/schemas/ExternalSkillRegistration" + skipped: type: integer minimum: 0 - nullable: true - limit: - type: integer - minimum: 1 - maximum: 100 - default: 50 - HandoffReportActivityPage: + ListExternalSkillsResponse: type: object additionalProperties: false - required: [items, next_cursor, high_watermark] + required: [skills] properties: - items: + skills: type: array - maxItems: 100 items: - $ref: "#/components/schemas/HandoffReportActivity" - next_cursor: - type: integer - minimum: 1 - nullable: true - high_watermark: - type: integer - minimum: 0 - PurgeHandoffReportActivitiesRequest: + $ref: "#/components/schemas/ExternalSkillResolution" + ErrorDetail: type: object additionalProperties: false - required: [project_id, observed_before] + required: [code, message, details] properties: - project_id: + code: type: string - minLength: 1 - maxLength: 256 - observed_before: + message: type: string - format: date-time - PurgeHandoffReportActivitiesResponse: + details: + type: object + additionalProperties: true + nullable: true + ErrorResponse: type: object additionalProperties: false - required: [deleted_count] + required: [error] properties: - deleted_count: - type: integer - minimum: 0 - HandoffReportRepositoryRef: + error: + $ref: "#/components/schemas/ErrorDetail" + FlushMemoryRequest: type: object additionalProperties: false - required: [provider, repository_id, normalized_remote, subpath] + required: [scope_id] properties: - provider: - type: string - enum: [github, gitlab, local, other] - repository_id: + scope_id: type: string minLength: 1 maxLength: 256 - nullable: true - normalized_remote: - type: string - minLength: 1 - maxLength: 2048 - nullable: true - subpath: - type: string - minLength: 1 - maxLength: 1024 - nullable: true - HandoffReportWorkspaceBinding: + pattern: '.*\S.*' + FlushMemoryResponse: type: object additionalProperties: false - required: [schema, workspace_instance_id, project_id, repository_ref, state, confirmed_at, version] + required: [status, previous_cursor, current_cursor, high_watermark, processed_source_count] properties: - schema: - type: string - enum: [powercontext.workspace-binding.v1] - workspace_instance_id: - type: string - minLength: 1 - maxLength: 256 - project_id: - type: string - minLength: 1 - maxLength: 256 - repository_ref: - $ref: "#/components/schemas/HandoffReportRepositoryRef" - state: - type: string - enum: [confirmed, detached] - confirmed_at: - type: string - format: date-time - version: + status: + $ref: "#/components/schemas/FlushStatus" + previous_cursor: type: integer - minimum: 1 - GetHandoffReportWorkspaceRequest: + minimum: 0 + current_cursor: + type: integer + minimum: 0 + high_watermark: + type: integer + minimum: 0 + processed_source_count: + type: integer + minimum: 0 + memory: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + GetMemoryEntryRequest: type: object additionalProperties: false - required: [workspace_instance_id] + required: [scope_id, citation] properties: - workspace_instance_id: + scope_id: type: string minLength: 1 maxLength: 256 - AttachHandoffReportWorkspaceRequest: + pattern: '.*\S.*' + citation: + $ref: "#/components/schemas/MemoryCitation" + GetArtifactCandidateRequest: type: object additionalProperties: false - required: [workspace_instance_id, project_id, repository_ref, expected_version] + required: [scope_id, candidate_id] properties: - workspace_instance_id: + scope_id: type: string minLength: 1 maxLength: 256 - project_id: + pattern: '.*\S.*' + candidate_id: type: string minLength: 1 - maxLength: 256 - repository_ref: - $ref: "#/components/schemas/HandoffReportRepositoryRef" - expected_version: - type: integer - minimum: 1 - nullable: true - DetachHandoffReportWorkspaceRequest: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + GetExperienceRequest: type: object additionalProperties: false - required: [workspace_instance_id, expected_version] + required: [scope_id, artifact] properties: - workspace_instance_id: + scope_id: type: string minLength: 1 maxLength: 256 - expected_version: - type: integer - minimum: 1 - ProjectDescriptor: + pattern: '.*\S.*' + artifact: + $ref: "#/components/schemas/ArtifactReference" + GetSkillRequest: type: object additionalProperties: false - required: [schema, project_id, project_key, title, description, default_locale, timezone, catalog_state, version] + required: [scope_id, artifact] properties: - schema: - type: string - enum: [powercontext.project.v1] - project_id: - type: string - minLength: 1 - maxLength: 256 - project_key: - type: string - minLength: 1 - maxLength: 64 - title: - type: string - minLength: 1 - maxLength: 256 - description: - type: string - maxLength: 2000 - nullable: true - default_locale: - $ref: "#/components/schemas/ReportLocale" - timezone: + scope_id: type: string minLength: 1 maxLength: 256 - catalog_state: - $ref: "#/components/schemas/ReportCatalogState" - version: - type: integer - minimum: 1 - ProjectPage: + pattern: '.*\S.*' + artifact: + $ref: "#/components/schemas/ArtifactReference" + GetHandoffReportRequest: type: object additionalProperties: false - required: [items, next_cursor] + required: [selection] properties: - items: - type: array - maxItems: 100 - items: - $ref: "#/components/schemas/ProjectDescriptor" - next_cursor: - type: string - nullable: true - WorkstreamDescriptor: + selection: + $ref: "#/components/schemas/ScopeSelection" + format: + $ref: "#/components/schemas/ReportFormat" + default: json + download: + type: boolean + default: false + HandoffReportResponse: type: object additionalProperties: false - required: [schema, scope_id, project_id, key, title, kind, catalog_state, external_refs, labels, version] + required: [format, report, markdown, selection_digest, report_digest] properties: - schema: - type: string - enum: [powercontext.workstream.v1] - scope_id: - type: string - minLength: 1 - maxLength: 256 - project_id: - type: string - minLength: 1 - maxLength: 256 - key: - type: string - maxLength: 64 + format: + $ref: "#/components/schemas/ReportFormat" + report: + type: object + additionalProperties: true nullable: true - title: - type: string - minLength: 1 - maxLength: 256 - kind: - $ref: "#/components/schemas/WorkstreamKind" - catalog_state: - $ref: "#/components/schemas/ReportCatalogState" - external_refs: - type: array - maxItems: 32 - items: - $ref: "#/components/schemas/HandoffReportExternalReference" - labels: - type: array - maxItems: 32 - items: - type: string - minLength: 1 - maxLength: 128 - version: - type: integer - minimum: 1 - WorkstreamPage: - type: object - additionalProperties: false - required: [items, next_cursor] - properties: - items: - type: array - maxItems: 100 - items: - $ref: "#/components/schemas/WorkstreamDescriptor" - next_cursor: + markdown: type: string nullable: true - HandoffReportExternalReference: - type: object - additionalProperties: false - required: [kind, provider, external_id, url] - properties: - kind: - type: string - enum: [issue, task, pull_request, branch, feature, release, program, other] - provider: - type: string - minLength: 1 - maxLength: 64 - external_id: + selection_digest: type: string - minLength: 1 - maxLength: 256 - url: + pattern: '^sha256:[0-9a-f]{64}$' + report_digest: type: string - maxLength: 2048 - nullable: true - ReportLocale: - type: string - enum: [zh-CN, en] + pattern: '^sha256:[0-9a-f]{64}$' ReportFormat: type: string enum: [json, markdown] - ReportCatalogState: - type: string - enum: [included, archived] - WorkstreamKind: - type: string - enum: [feature, bug, refactor, operations, research, other] HealthResponse: type: object additionalProperties: false diff --git a/integrations/dsh/plugins/powercontext/src/invoke.ts b/integrations/dsh/plugins/powercontext/src/invoke.ts index 0c59706c5..e1ad97dbf 100644 --- a/integrations/dsh/plugins/powercontext/src/invoke.ts +++ b/integrations/dsh/plugins/powercontext/src/invoke.ts @@ -103,6 +103,9 @@ export function injectScope( payload: JsonObject | undefined, scopeId: string, ): JsonObject | undefined { + if (operationId === 'get_stats' || operationId === 'get_handoff_report') { + return { ...payload, selection: { mode: 'exact', scope_ids: [scopeId] } } + } if (!OPERATIONS[operationId].scope) return payload return { ...payload, scope_id: scopeId } } diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 092c7108a..f6f47b093 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -20,6 +20,17 @@ export const OPERATIONS = { get_liveness: { method: 'GET', path: '/health/live', location: null, scope: false }, get_readiness: { method: 'GET', path: '/health/ready', location: null, scope: false }, get_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scope: false }, + list_scopes: { method: 'GET', path: '/v1/scopes', location: null, scope: false }, + create_scope: { method: 'POST', path: '/v1/scopes', location: "body", scope: false }, + publish_artifact: { method: 'POST', path: '/v1/artifact-publications', location: "body", scope: false }, + get_scope: { method: 'POST', path: '/v1/scopes/get', location: "body", scope: true }, + update_scope: { method: 'POST', path: '/v1/scopes/update', location: "body", scope: true }, + get_default_scope: { method: 'GET', path: '/v1/scopes/default', location: null, scope: false }, + set_default_scope: { method: 'PUT', path: '/v1/scopes/default', location: "body", scope: true }, + resolve_scope_selection: { method: 'POST', path: '/v1/scopes/selection/resolve', location: "body", scope: false }, + resolve_scope_binding: { method: 'POST', path: '/v1/scope-bindings/resolve', location: "body", scope: false }, + set_scope_binding: { method: 'PUT', path: '/v1/scope-bindings', location: "body", scope: true }, + clear_scope_binding: { method: 'POST', path: '/v1/scope-bindings/clear', location: "body", scope: false }, capture_content_source: { method: 'POST', path: '/v1/sources/content', location: "body", scope: true }, prepare_context: { method: 'POST', path: '/v1/context/prepare', location: "body", scope: true }, create_work_contract: { method: 'POST', path: '/v1/work/contracts/create', location: "body", scope: true }, @@ -54,22 +65,8 @@ export const OPERATIONS = { approve_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/approve', location: "body", scope: true }, reject_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/reject', location: "body", scope: true }, revise_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/revise', location: "body", scope: true }, - get_stats: { method: 'GET', path: '/v1/stats', location: "query", scope: true }, - create_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/create', location: "body", scope: false }, - list_handoff_report_projects: { method: 'POST', path: '/v1/handoff-reports/projects/list', location: "body", scope: false }, - list_handoff_report_known_scopes: { method: 'POST', path: '/v1/handoff-reports/scopes/list-known', location: "body", scope: false }, - get_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/get', location: "body", scope: false }, - update_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/update', location: "body", scope: false }, - register_handoff_report_workstream: { method: 'POST', path: '/v1/handoff-reports/workstreams/register', location: "body", scope: true }, - list_handoff_report_workstreams: { method: 'POST', path: '/v1/handoff-reports/workstreams/list', location: "body", scope: false }, - update_handoff_report_workstream: { method: 'POST', path: '/v1/handoff-reports/workstreams/update', location: "body", scope: false }, - get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scope: true }, - record_handoff_report_activity: { method: 'POST', path: '/v1/handoff-reports/activities/record', location: "body", scope: true }, - list_handoff_report_activities: { method: 'POST', path: '/v1/handoff-reports/activities/list', location: "body", scope: false }, - purge_handoff_report_activities: { method: 'POST', path: '/v1/handoff-reports/activities/purge', location: "body", scope: false }, - get_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/get', location: "body", scope: false }, - attach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/attach', location: "body", scope: false }, - detach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/detach', location: "body", scope: false }, + get_stats: { method: 'POST', path: '/v1/stats', location: "body", scope: false }, + get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scope: false }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/integrations/dsh/plugins/powercontext/tests/client.spec.ts b/integrations/dsh/plugins/powercontext/tests/client.spec.ts index 835e15920..90d486371 100644 --- a/integrations/dsh/plugins/powercontext/tests/client.spec.ts +++ b/integrations/dsh/plugins/powercontext/tests/client.spec.ts @@ -61,11 +61,14 @@ describe('PowerContextClient', () => { expect(fetchImpl).toHaveBeenCalledOnce() }) - it('sends get_stats as a GET query string', async () => { + it('sends get_stats as a POST selection', async () => { const fetchImpl = vi.fn(async (url: string, init?: RequestInit) => { - expect(url).toBe('http://127.0.0.1:8000/v1/stats?scope_id=project%3Ademo&period=7d') - expect(init?.method).toBe('GET') - expect(init?.body).toBeUndefined() + expect(url).toBe('http://127.0.0.1:8000/v1/stats') + expect(init?.method).toBe('POST') + expect(JSON.parse(String(init?.body))).toEqual({ + selection: { mode: 'exact', scope_ids: ['project:demo'] }, + period: '7d', + }) return jsonResponse(200, { memories: 1 }) }) const client = new PowerContextClient({ @@ -73,7 +76,10 @@ describe('PowerContextClient', () => { requestTimeoutMs: 1000, fetch: fetchImpl, }) - await client.request('get_stats', { scope_id: 'project:demo', period: '7d' }) + await client.request('get_stats', { + selection: { mode: 'exact', scope_ids: ['project:demo'] }, + period: '7d', + }) expect(fetchImpl).toHaveBeenCalledOnce() }) @@ -83,11 +89,12 @@ describe('PowerContextClient', () => { requestTimeoutMs: 1000, fetch: async () => new Response('# Report', { status: 200 }), }) - await expect(markdownClient.request('get_handoff_report', { project_id: 'p1', format: 'markdown' })).resolves.toMatchObject({ + const selection = { mode: 'exact', scope_ids: ['scope-1'] } + await expect(markdownClient.request('get_handoff_report', { selection, format: 'markdown' })).resolves.toMatchObject({ kind: 'text', value: '# Report', }) - await expect(markdownClient.request('get_handoff_report', { project_id: 'p1' })).resolves.toMatchObject({ + await expect(markdownClient.request('get_handoff_report', { selection })).resolves.toMatchObject({ kind: 'text', value: '# Report', }) @@ -96,7 +103,7 @@ describe('PowerContextClient', () => { requestTimeoutMs: 1000, fetch: async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 }), }) - const downloaded = await bytesClient.request('get_handoff_report', { project_id: 'p1', download: true }) + const downloaded = await bytesClient.request('get_handoff_report', { selection, download: true }) expect(downloaded.kind).toBe('bytes') if (downloaded.kind === 'bytes') expect([...downloaded.value]).toEqual([1, 2, 3]) }) diff --git a/integrations/dsh/plugins/powercontext/tests/e2e/call-through.spec.ts b/integrations/dsh/plugins/powercontext/tests/e2e/call-through.spec.ts index 02e3d9a9c..cfd5769ba 100644 --- a/integrations/dsh/plugins/powercontext/tests/e2e/call-through.spec.ts +++ b/integrations/dsh/plugins/powercontext/tests/e2e/call-through.spec.ts @@ -34,7 +34,7 @@ vi.mock('../../src/peers.ts', () => ({ }, })) -const SCOPE_ID = 'project:dsh-e2e' +let scopeId = '' const TEXT = 'Keep the DSH plugin on the public HTTP contract.' const REQUIRED_SERVICES = ['tools', 'agents', 'commands', 'skills', 'systemPrompt'] as const @@ -211,7 +211,7 @@ async function withHarness( baseUrl: string, callback: (harness: PluginHarness) => Promise, ): Promise { - const harness = new PluginHarness(baseUrl, SCOPE_ID) + const harness = new PluginHarness(baseUrl, scopeId) try { return await callback(harness) } finally { @@ -229,6 +229,9 @@ describe('plugin HTTP call-through without a model', () => { baseUrl: server.baseUrl, requestTimeoutMs: 5000, }) + const resolved = await client.request('get_default_scope') + expect(resolved.kind).toBe('json') + scopeId = (resolved.value as { scope_id: string }).scope_id }, 60_000) afterAll(async () => { @@ -246,14 +249,14 @@ describe('plugin HTTP call-through without a model', () => { it('remembers, searches, prepares, and captures over HTTP', async () => { const remembered = await client.request('remember_memory', { - scope_id: SCOPE_ID, + scope_id: scopeId, kind: 'decision', text: TEXT, }) expect(remembered.kind).toBe('json') const found = await client.request('search_memory', { - scope_id: SCOPE_ID, + scope_id: scopeId, query: 'DSH plugin HTTP contract', }) expect(found.kind).toBe('json') @@ -262,14 +265,14 @@ describe('plugin HTTP call-through without a model', () => { expect(hits.some((hit) => hit.text === TEXT)).toBe(true) const prepared = await client.request('prepare_context', { - scope_id: SCOPE_ID, + scope_id: scopeId, query: 'DSH plugin HTTP contract', }) expect(prepared.kind).toBe('json') expect(typeof prepared.value.content === 'string' || prepared.value.content === null).toBe(true) const captured = await client.request('capture_content_source', { - scope_id: SCOPE_ID, + scope_id: scopeId, source_id: 'dsh-e2e-turn-1', content: 'Call through the plugin client without a model.', metadata: { origin: 'dsh', event: 'e2e' }, @@ -309,7 +312,7 @@ describe('plugin HTTP call-through without a model', () => { })]) const status = await invokePc(harness.commandHandler(), '') expect(status.kind).toBe('success') - expect(status.text).toContain(`scope=${SCOPE_ID}`) + expect(status.text).toContain(`scope=${scopeId}`) }) }) diff --git a/integrations/dsh/plugins/powercontext/tests/invoke.spec.ts b/integrations/dsh/plugins/powercontext/tests/invoke.spec.ts index 82394b596..78bfc08b0 100644 --- a/integrations/dsh/plugins/powercontext/tests/invoke.spec.ts +++ b/integrations/dsh/plugins/powercontext/tests/invoke.spec.ts @@ -72,6 +72,32 @@ describe('invokeOperation', () => { }) }) + it('limits observation selections to the derived workspace scope', async () => { + const bodies: unknown[] = [] + const client = new PowerContextClient({ + baseUrl: 'http://127.0.0.1:8000', + requestTimeoutMs: 1000, + fetch: async (_url, init) => { + bodies.push(JSON.parse(String(init?.body))) + return new Response(JSON.stringify({ ok: true }), { status: 200 }) + }, + }) + + await invokeOperation(client, 'get_stats', { selection: { mode: 'all' } }, 'project:derived-workspace') + await invokeOperation(client, 'get_handoff_report', { + selection: { mode: 'subtree', root_scope_id: 'scope:other' }, + format: 'json', + }, 'project:derived-workspace') + + expect(bodies).toEqual([ + { selection: { mode: 'exact', scope_ids: ['project:derived-workspace'] } }, + { + selection: { mode: 'exact', scope_ids: ['project:derived-workspace'] }, + format: 'json', + }, + ]) + }) + it('returns unavailable instead of throwing when the server is down', async () => { const client = new PowerContextClient({ baseUrl: 'http://127.0.0.1:8000', diff --git a/integrations/dsh/plugins/powercontext/tests/operations-coverage.spec.ts b/integrations/dsh/plugins/powercontext/tests/operations-coverage.spec.ts index 61ce821ff..a1b3f88d3 100644 --- a/integrations/dsh/plugins/powercontext/tests/operations-coverage.spec.ts +++ b/integrations/dsh/plugins/powercontext/tests/operations-coverage.spec.ts @@ -40,10 +40,10 @@ describe('operations coverage', () => { scope: false, }) expect(OPERATIONS.get_stats).toEqual({ - method: 'GET', + method: 'POST', path: '/v1/stats', - location: 'query', - scope: true, + location: 'body', + scope: false, }) expect(OPERATIONS.remember_memory).toEqual({ method: 'POST', @@ -51,7 +51,7 @@ describe('operations coverage', () => { location: 'body', scope: true, }) - expect(OPERATIONS.get_handoff_report.scope).toBe(true) + expect(OPERATIONS.get_handoff_report.scope).toBe(false) expect(OPERATIONS.get_capabilities.location).toBeNull() }) diff --git a/integrations/hermes/README.md b/integrations/hermes/README.md index c4c658709..a86165076 100644 --- a/integrations/hermes/README.md +++ b/integrations/hermes/README.md @@ -90,7 +90,7 @@ Configuration can also be stored manually in `$HERMES_HOME/powercontext/config.j "flush_on_session_end": true, "capture_pre_compress": false, "evaluation_trace": false, - "workstream_persistence": true + "scope_binding": true } ``` @@ -110,7 +110,7 @@ Environment variables override file values: | `POWERCONTEXT_HERMES_CAPTURE_PRE_COMPRESS` | Capture filtered new user/assistant turns before compression; disabled by default | | `POWERCONTEXT_HERMES_EVALUATION_TRACE` | Record recalled context in per-session local JSONL files; disabled by default | | `POWERCONTEXT_HERMES_EVALUATION_TRACE_PATH` | Override the evaluation trace directory | -| `POWERCONTEXT_HERMES_WORKSTREAM` | Read the shared Git-private Workstream scope binding; enabled by default | +| `POWERCONTEXT_HERMES_SCOPE_BINDING` | Read the Git-private Scope binding; enabled by default | The default scope template is `hermes:{profile}:{user_id}`. The provider uses the active Hermes profile and gateway user identifier when available. For local @@ -139,9 +139,9 @@ keeping memories available across sessions. - Mutating operations are described as explicit user-authorized actions. Artifact approval and rejection should only be used after the candidate has been reviewed. -- When Workstream persistence is enabled, Hermes reads - .git/powercontext/codex-workspace.json, the same Git-private binding used by - the other integrations. An explicit scope_id configuration takes precedence. +- When Scope binding is enabled, Hermes reads + `.git/powercontext/scope-binding.json`. An explicit `scope_id` configuration + takes precedence. - When evaluation tracing is enabled, each session gets its own JSONL file under `powercontext/evaluation-trace/sessions/`. Events include the session ID, parent session ID, scope, turn number, and a unique event ID. @@ -199,7 +199,7 @@ Hermes exposes that invocation context. /pc skill {propose|generate|get} PAYLOAD_JSON /pc external-skills {scan|list|resolve|import} [PAYLOAD_JSON] /pc review {list|get|approve|reject|revise} [PAYLOAD_JSON] -/pc workstream {status|bind SCOPE_ID|clear} +/pc scope {status|bind SCOPE_ID|clear} /pc call OPERATION [PAYLOAD_JSON] ``` diff --git a/integrations/hermes/plugins/powercontext-command/__init__.py b/integrations/hermes/plugins/powercontext-command/__init__.py index 5cb71a073..63bba9a77 100644 --- a/integrations/hermes/plugins/powercontext-command/__init__.py +++ b/integrations/hermes/plugins/powercontext-command/__init__.py @@ -49,7 +49,7 @@ "skill", "external-skills", "review", - "workstream", + "scope", "trace", "call", ) @@ -123,7 +123,7 @@ def register(ctx: Any) -> None: name, handler, description="Inspect and manage PowerContext memory, handoffs, artifacts, and traces.", - args_hint="status|search|list|changes|get|remember|revise|retire|flush|stats|handoff|experience|skill|external-skills|review|workstream|trace|call ...", + args_hint="status|search|list|changes|get|remember|revise|retire|flush|stats|handoff|experience|skill|external-skills|review|scope|trace|call ...", ) _register_subcommands() diff --git a/integrations/hermes/plugins/powercontext/README.md b/integrations/hermes/plugins/powercontext/README.md index fc81ba6aa..02ac0b07d 100644 --- a/integrations/hermes/plugins/powercontext/README.md +++ b/integrations/hermes/plugins/powercontext/README.md @@ -66,11 +66,10 @@ authorization. When the provider is active, it also registers the bundled powercontext skill guide so Hermes has the workflow and authorization rules for those operations. -Workstream persistence is enabled by default. When the current directory is a -Git workspace, Hermes reads the shared -.git/powercontext/codex-workspace.json binding used by the other integrations. -An explicit scope_id configuration takes precedence. The /pc workstream -command can inspect, create, or clear the binding. +Scope binding is enabled by default. When the current directory is a Git +workspace, Hermes reads `.git/powercontext/scope-binding.json`. An explicit +`scope_id` configuration takes precedence. The `/pc scope` command can inspect, +create, or clear the binding. The standalone companion registers `/pc` and `/powercontext` during normal Hermes plugin discovery, so both aliases are known before the first Agent is @@ -105,7 +104,7 @@ exposes that invocation context. /pc skill {propose|generate|get} PAYLOAD_JSON /pc external-skills {scan|list|resolve|import} [PAYLOAD_JSON] /pc review {list|get|approve|reject|revise} [PAYLOAD_JSON] -/pc workstream {status|bind SCOPE_ID|clear} +/pc scope {status|bind SCOPE_ID|clear} /pc call OPERATION [PAYLOAD_JSON] ``` diff --git a/integrations/hermes/plugins/powercontext/commands.py b/integrations/hermes/plugins/powercontext/commands.py index 4478cb572..8f3c5fbf5 100644 --- a/integrations/hermes/plugins/powercontext/commands.py +++ b/integrations/hermes/plugins/powercontext/commands.py @@ -30,7 +30,7 @@ config_value, ) from .operations import OPERATION_REQUIRED_FIELDS, OPERATION_TOOL_MAP -from .workstream import clear_scope, write_scope +from .scope_binding import clear_scope, write_scope try: from tools.registry import tool_error # ty: ignore[unresolved-import] @@ -58,7 +58,7 @@ def tool_error(message: str) -> str: "skill", "external-skills", "review", - "workstream", + "scope", "trace", "call", ) @@ -174,14 +174,14 @@ def _split_json_argument(raw_args: str, command: str, label: str) -> tuple[str, return json_text, tail[end:].lstrip() -def workstream_command(provider: Any, args: list[str]) -> str: +def scope_command(provider: Any, args: list[str]) -> str: action = args[0].lower() if args else "status" if action == "status": return json.dumps( { - "cwd": provider._workstream_cwd, - "path": str(provider._workstream_path) if provider._workstream_path else None, - "bound_scope_id": provider._workstream_bound_scope or None, + "cwd": provider._scope_binding_cwd, + "path": str(provider._scope_binding_path) if provider._scope_binding_path else None, + "bound_scope_id": provider._bound_scope_id or None, "active_scope_id": provider._scope_id, }, ensure_ascii=False, @@ -189,28 +189,28 @@ def workstream_command(provider: Any, args: list[str]) -> str: ) if action == "bind": if len(args) < 2 or not args[1].strip(): - return tool_error("Usage: /pc workstream bind SCOPE_ID") + return tool_error("Usage: /pc scope bind SCOPE_ID") try: - path = write_scope(provider._workstream_cwd, args[1]) + path = write_scope(provider._scope_binding_cwd, args[1]) except (OSError, ValueError) as error: return tool_error(str(error)) from .helpers import safe_scope - provider._workstream_bound_scope = safe_scope(args[1]) - provider._switch_workstream_scope(provider._workstream_bound_scope) - provider._record_trace_event("workstream_bound", scope_id=provider._scope_id, path=str(path)) + provider._bound_scope_id = safe_scope(args[1]) + provider._switch_scope(provider._bound_scope_id) + provider._record_trace_event("scope_bound", scope_id=provider._scope_id, path=str(path)) return json.dumps( {"status": "bound", "scope_id": provider._scope_id, "path": str(path)}, ensure_ascii=False, indent=2, ) if action == "clear": - cleared = clear_scope(provider._workstream_cwd) - provider._workstream_bound_scope = "" - provider._switch_workstream_scope(provider._default_scope_id) - provider._record_trace_event("workstream_cleared", cleared=cleared) + cleared = clear_scope(provider._scope_binding_cwd) + provider._bound_scope_id = "" + provider._switch_scope(provider._default_scope_id) + provider._record_trace_event("scope_binding_cleared", cleared=cleared) return json.dumps({"status": "cleared" if cleared else "not_found"}, ensure_ascii=False, indent=2) - return "Usage: /pc workstream {status|bind SCOPE_ID|clear}" + return "Usage: /pc scope {status|bind SCOPE_ID|clear}" def operation_command(provider: Any, operation: str, args: list[str]) -> str: @@ -353,7 +353,7 @@ def status_command(provider: Any) -> str: result: dict[str, Any] = { "scope_id": provider._scope_id, "session_id": provider._session_id, - "workstream_scope_id": provider._workstream_bound_scope or None, + "bound_scope_id": provider._bound_scope_id or None, } if provider._client: for name, method_name in (("liveness", "get_liveness"), ("readiness", "get_readiness")): @@ -399,9 +399,9 @@ def handle_slash_command(provider: Any, raw_args: str) -> str: # noqa: C901 if not args or args[0].lower() in {"help", "-h", "--help"}: return ( "Usage: /pc {status|search|list|changes|get|remember|revise|retire|flush|stats|" - "handoff|experience|skill|external-skills|review|workstream|trace|call} ...\n" + "handoff|experience|skill|external-skills|review|scope|trace|call} ...\n" "Advanced operations accept a JSON payload: /pc call OPERATION PAYLOAD_JSON\n" - "Workstream binding: /pc workstream {status|bind SCOPE_ID|clear}" + "Scope binding: /pc scope {status|bind SCOPE_ID|clear}" ) command = args[0].lower() try: @@ -411,8 +411,8 @@ def handle_slash_command(provider: Any, raw_args: str) -> str: # noqa: C901 return status_command(provider) if command in {"search", "list", "changes", "get", "remember", "revise", "retire", "flush", "stats"}: return memory_command(provider, args) - if command == "workstream": - return workstream_command(provider, args[1:]) + if command == "scope": + return scope_command(provider, args[1:]) if command in {"handoff", "experience", "skill", "external-skills", "review"}: return group_command(provider, command, args[1:]) if command == "call": diff --git a/integrations/hermes/plugins/powercontext/config_schema.py b/integrations/hermes/plugins/powercontext/config_schema.py index 5e89a9a4b..6886a78e0 100644 --- a/integrations/hermes/plugins/powercontext/config_schema.py +++ b/integrations/hermes/plugins/powercontext/config_schema.py @@ -102,11 +102,11 @@ description="Optional directory for per-session evaluation trace files.", ), ProviderField( - key="workstream_persistence", - label="Git-private Workstream binding", + key="scope_binding", + label="Git-private Scope binding", kind=KIND_BOOL, default="true", - description="Use the shared .git/powercontext/codex-workspace.json scope binding when present.", + description="Use .git/powercontext/scope-binding.json when present.", ), ), ) diff --git a/integrations/hermes/plugins/powercontext/provider.py b/integrations/hermes/plugins/powercontext/provider.py index 726a75ccd..560c7e889 100644 --- a/integrations/hermes/plugins/powercontext/provider.py +++ b/integrations/hermes/plugins/powercontext/provider.py @@ -91,8 +91,8 @@ redact_secrets as _redact_secrets, ) from .operations import OPERATION_TOOL_MAP as _OPERATION_TOOL_MAP -from .workstream import read_scope as _read_workstream_scope -from .workstream import state_path as _workstream_state_path +from .scope_binding import read_scope as _read_bound_scope +from .scope_binding import state_path as _scope_binding_state_path try: from agent.memory_provider import MemoryProvider, RecallStatus # ty: ignore[unresolved-import] @@ -146,9 +146,9 @@ def __init__(self, config: dict[str, Any] | None = None, *, client_factory=None) self._trace_enabled = False self._trace_turn = 0 self._trace_lock = threading.Lock() - self._workstream_cwd = "" - self._workstream_path: Path | None = None - self._workstream_bound_scope = "" + self._scope_binding_cwd = "" + self._scope_binding_path: Path | None = None + self._bound_scope_id = "" @property def name(self) -> str: @@ -226,8 +226,8 @@ def get_config_schema(self) -> list[dict[str, Any]]: "default": "", }, { - "key": "workstream_persistence", - "description": "Use the Git-private Workstream scope binding when present", + "key": "scope_binding", + "description": "Use the Git-private Scope binding when present", "default": "true", "choices": ["true", "false"], }, @@ -263,11 +263,11 @@ def initialize(self, session_id: str, **kwargs: Any) -> None: self._precompress_snapshot = [] self._memory_map_path = Path(hermes_home) / "powercontext-memory-map.json" self._memory_map = self._load_memory_map() - self._workstream_cwd = str( + self._scope_binding_cwd = str( kwargs.get("cwd") or kwargs.get("working_directory") or kwargs.get("project_root") or os.getcwd() ) - self._workstream_path = _workstream_state_path(self._workstream_cwd) - self._workstream_bound_scope = "" + self._scope_binding_path = _scope_binding_state_path(self._scope_binding_cwd) + self._bound_scope_id = "" agent_identity = str(kwargs.get("agent_identity") or "default") self._profile = agent_identity user_id = str(kwargs.get("user_id") or "") @@ -278,10 +278,10 @@ def initialize(self, session_id: str, **kwargs: Any) -> None: and str(configured_scope).strip() != _DEFAULT_SCOPE_TEMPLATE ) if not explicit_scope and _as_bool( - _config_value(merged_config, "workstream_persistence", "POWERCONTEXT_HERMES_WORKSTREAM", True), + _config_value(merged_config, "scope_binding", "POWERCONTEXT_HERMES_SCOPE_BINDING", True), True, ): - self._workstream_bound_scope = _read_workstream_scope(self._workstream_cwd) or "" + self._bound_scope_id = _read_bound_scope(self._scope_binding_cwd) or "" scope_template = str(configured_scope or _DEFAULT_SCOPE_TEMPLATE) self._default_scope_id = _format_scope( scope_template, @@ -289,8 +289,8 @@ def initialize(self, session_id: str, **kwargs: Any) -> None: agent_identity=agent_identity, user_id=user_id, ) - if self._workstream_bound_scope: - self._scope_id = self._workstream_bound_scope + if self._bound_scope_id: + self._scope_id = self._bound_scope_id else: self._scope_id = self._default_scope_id self._client = self._client_factory(merged_config) @@ -440,7 +440,7 @@ def _cancel_queued_memory_writes(self) -> int: memory_queue.put_nowait(None) return cancelled - def _switch_workstream_scope(self, scope_id: str) -> None: + def _switch_scope(self, scope_id: str) -> None: """Switch scopes without allowing old queued work to use the new scope.""" old_scope_id = self._scope_id if not scope_id or scope_id == old_scope_id: @@ -979,8 +979,8 @@ def _request_operation(self, operation: str, payload: dict[str, Any] | None = No def _parse_json_object(value: str, label: str) -> dict[str, Any]: return commands.parse_json_object(value, label) - def _workstream_command(self, args: list[str]) -> str: - return commands.workstream_command(self, args) + def _scope_command(self, args: list[str]) -> str: + return commands.scope_command(self, args) def _operation_command(self, operation: str, args: list[str]) -> str: return commands.operation_command(self, operation, args) diff --git a/integrations/hermes/plugins/powercontext/workstream.py b/integrations/hermes/plugins/powercontext/scope_binding.py similarity index 80% rename from integrations/hermes/plugins/powercontext/workstream.py rename to integrations/hermes/plugins/powercontext/scope_binding.py index 31ace7aa2..0ed3df2c2 100644 --- a/integrations/hermes/plugins/powercontext/workstream.py +++ b/integrations/hermes/plugins/powercontext/scope_binding.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Git-private Workstream scope binding for the Hermes integration.""" +"""Git-private Scope binding for the Hermes integration.""" from __future__ import annotations @@ -24,9 +24,9 @@ from .helpers import safe_scope -WORKSTREAM_STATE_SCHEMA = "powercontext.codex-workspace.v1" -WORKSTREAM_STATE_DIRECTORY = "powercontext" -WORKSTREAM_STATE_FILE = "codex-workspace.json" +SCOPE_BINDING_SCHEMA = "powercontext.scope-binding.v1" +SCOPE_BINDING_DIRECTORY = "powercontext" +SCOPE_BINDING_FILE = "scope-binding.json" def git_value(cwd: str, *arguments: str) -> str | None: @@ -51,7 +51,7 @@ def state_path(cwd: str) -> Path | None: git_directory = git_value(cwd, "rev-parse", "--absolute-git-dir") if git_directory is None: return None - return Path(git_directory) / WORKSTREAM_STATE_DIRECTORY / WORKSTREAM_STATE_FILE + return Path(git_directory) / SCOPE_BINDING_DIRECTORY / SCOPE_BINDING_FILE def read_scope(cwd: str) -> str | None: @@ -62,7 +62,7 @@ def read_scope(cwd: str) -> str | None: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, json.JSONDecodeError): return None - if not isinstance(value, dict) or value.get("schema") != WORKSTREAM_STATE_SCHEMA: + if not isinstance(value, dict) or value.get("schema") != SCOPE_BINDING_SCHEMA: return None scope_id = value.get("scope_id") if not isinstance(scope_id, str) or not scope_id.strip() or len(scope_id) > 256: @@ -73,14 +73,13 @@ def read_scope(cwd: str) -> str | None: def write_scope(cwd: str, scope_id: str) -> Path: path = state_path(cwd) if path is None: - raise ValueError("Workstream binding requires a Git workspace") # noqa: TRY003 + raise ValueError("Scope binding requires a Git workspace") # noqa: TRY003 normalized_scope_id = safe_scope(scope_id) path.parent.mkdir(parents=True, exist_ok=True) temporary_path = path.with_name(f".{path.name}.{os.getpid()}.tmp") try: temporary_path.write_text( - json.dumps({"schema": WORKSTREAM_STATE_SCHEMA, "scope_id": normalized_scope_id}, separators=(",", ":")) - + "\n", + json.dumps({"schema": SCOPE_BINDING_SCHEMA, "scope_id": normalized_scope_id}, separators=(",", ":")) + "\n", encoding="utf-8", ) os.replace(temporary_path, path) diff --git a/integrations/hermes/plugins/powercontext/skills/powercontext/SKILL.md b/integrations/hermes/plugins/powercontext/skills/powercontext/SKILL.md index bb6ca89ef..adba66bf7 100644 --- a/integrations/hermes/plugins/powercontext/skills/powercontext/SKILL.md +++ b/integrations/hermes/plugins/powercontext/skills/powercontext/SKILL.md @@ -53,7 +53,7 @@ fingerprint before import. Use /pc for operational actions and review decisions: - /pc trace ... inspects evaluation traces. -- /pc workstream ... manages the Git-private cross-session scope binding. +- /pc scope ... manages the Git-private cross-session Scope binding. - /pc review ... lists, reads, approves, rejects, or revises candidates. - /pc call OPERATION PAYLOAD_JSON is available for an operation not covered by a short command. diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 092c7108a..f6f47b093 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -20,6 +20,17 @@ export const OPERATIONS = { get_liveness: { method: 'GET', path: '/health/live', location: null, scope: false }, get_readiness: { method: 'GET', path: '/health/ready', location: null, scope: false }, get_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scope: false }, + list_scopes: { method: 'GET', path: '/v1/scopes', location: null, scope: false }, + create_scope: { method: 'POST', path: '/v1/scopes', location: "body", scope: false }, + publish_artifact: { method: 'POST', path: '/v1/artifact-publications', location: "body", scope: false }, + get_scope: { method: 'POST', path: '/v1/scopes/get', location: "body", scope: true }, + update_scope: { method: 'POST', path: '/v1/scopes/update', location: "body", scope: true }, + get_default_scope: { method: 'GET', path: '/v1/scopes/default', location: null, scope: false }, + set_default_scope: { method: 'PUT', path: '/v1/scopes/default', location: "body", scope: true }, + resolve_scope_selection: { method: 'POST', path: '/v1/scopes/selection/resolve', location: "body", scope: false }, + resolve_scope_binding: { method: 'POST', path: '/v1/scope-bindings/resolve', location: "body", scope: false }, + set_scope_binding: { method: 'PUT', path: '/v1/scope-bindings', location: "body", scope: true }, + clear_scope_binding: { method: 'POST', path: '/v1/scope-bindings/clear', location: "body", scope: false }, capture_content_source: { method: 'POST', path: '/v1/sources/content', location: "body", scope: true }, prepare_context: { method: 'POST', path: '/v1/context/prepare', location: "body", scope: true }, create_work_contract: { method: 'POST', path: '/v1/work/contracts/create', location: "body", scope: true }, @@ -54,22 +65,8 @@ export const OPERATIONS = { approve_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/approve', location: "body", scope: true }, reject_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/reject', location: "body", scope: true }, revise_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/revise', location: "body", scope: true }, - get_stats: { method: 'GET', path: '/v1/stats', location: "query", scope: true }, - create_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/create', location: "body", scope: false }, - list_handoff_report_projects: { method: 'POST', path: '/v1/handoff-reports/projects/list', location: "body", scope: false }, - list_handoff_report_known_scopes: { method: 'POST', path: '/v1/handoff-reports/scopes/list-known', location: "body", scope: false }, - get_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/get', location: "body", scope: false }, - update_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/update', location: "body", scope: false }, - register_handoff_report_workstream: { method: 'POST', path: '/v1/handoff-reports/workstreams/register', location: "body", scope: true }, - list_handoff_report_workstreams: { method: 'POST', path: '/v1/handoff-reports/workstreams/list', location: "body", scope: false }, - update_handoff_report_workstream: { method: 'POST', path: '/v1/handoff-reports/workstreams/update', location: "body", scope: false }, - get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scope: true }, - record_handoff_report_activity: { method: 'POST', path: '/v1/handoff-reports/activities/record', location: "body", scope: true }, - list_handoff_report_activities: { method: 'POST', path: '/v1/handoff-reports/activities/list', location: "body", scope: false }, - purge_handoff_report_activities: { method: 'POST', path: '/v1/handoff-reports/activities/purge', location: "body", scope: false }, - get_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/get', location: "body", scope: false }, - attach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/attach', location: "body", scope: false }, - detach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/detach', location: "body", scope: false }, + get_stats: { method: 'POST', path: '/v1/stats', location: "body", scope: false }, + get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scope: false }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/integrations/pi/plugins/powercontext/src/client.ts b/integrations/pi/plugins/powercontext/src/client.ts index f0720040f..535d59d4a 100644 --- a/integrations/pi/plugins/powercontext/src/client.ts +++ b/integrations/pi/plugins/powercontext/src/client.ts @@ -112,15 +112,6 @@ function decodeError(bytes: Uint8Array): { code?: string; message?: string } { } } -function queryString(payload: JsonObject | undefined): string { - const params = new URLSearchParams() - for (const [key, value] of Object.entries(payload ?? {})) { - if (value !== undefined && value !== null) params.set(key, String(value)) - } - const encoded = params.toString() - return encoded ? `?${encoded}` : '' -} - function isRedirect(status: number): boolean { return status >= 300 && status < 400 } @@ -141,7 +132,7 @@ export class PowerContextClient { async request(id: string, payload?: JsonObject, signal?: AbortSignal): Promise { if (!(id in OPERATIONS)) throw new UnknownOperationError(id) const spec = OPERATIONS[id as OperationId] - const url = this.buildUrl(spec, payload) + const url = this.buildUrl(spec) try { const response = await this.fetchImpl(url, this.buildInit(spec, payload, signal)) return await this.parseResponse(spec, response) @@ -157,9 +148,8 @@ export class PowerContextClient { } } - private buildUrl(spec: OperationSpec, payload: JsonObject | undefined): string { - const suffix = spec.location === 'query' ? queryString(payload) : '' - return `${this.baseUrl}${spec.path}${suffix}` + private buildUrl(spec: OperationSpec): string { + return `${this.baseUrl}${spec.path}` } private buildInit(spec: OperationSpec, payload: JsonObject | undefined, signal?: AbortSignal): RequestInit { diff --git a/integrations/pi/plugins/powercontext/src/invoke.ts b/integrations/pi/plugins/powercontext/src/invoke.ts index f2f37ed12..ec3ab7eec 100644 --- a/integrations/pi/plugins/powercontext/src/invoke.ts +++ b/integrations/pi/plugins/powercontext/src/invoke.ts @@ -137,6 +137,9 @@ export function injectScope( payload: JsonObject | undefined, scopeId: string, ): JsonObject | undefined { + if (operationId === 'get_stats' || operationId === 'get_handoff_report') { + return { ...payload, selection: { mode: 'exact', scope_ids: [scopeId] } } + } if (!OPERATIONS[operationId].scope) return payload return { ...payload, scope_id: scopeId } } diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index 092c7108a..f6f47b093 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -20,6 +20,17 @@ export const OPERATIONS = { get_liveness: { method: 'GET', path: '/health/live', location: null, scope: false }, get_readiness: { method: 'GET', path: '/health/ready', location: null, scope: false }, get_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scope: false }, + list_scopes: { method: 'GET', path: '/v1/scopes', location: null, scope: false }, + create_scope: { method: 'POST', path: '/v1/scopes', location: "body", scope: false }, + publish_artifact: { method: 'POST', path: '/v1/artifact-publications', location: "body", scope: false }, + get_scope: { method: 'POST', path: '/v1/scopes/get', location: "body", scope: true }, + update_scope: { method: 'POST', path: '/v1/scopes/update', location: "body", scope: true }, + get_default_scope: { method: 'GET', path: '/v1/scopes/default', location: null, scope: false }, + set_default_scope: { method: 'PUT', path: '/v1/scopes/default', location: "body", scope: true }, + resolve_scope_selection: { method: 'POST', path: '/v1/scopes/selection/resolve', location: "body", scope: false }, + resolve_scope_binding: { method: 'POST', path: '/v1/scope-bindings/resolve', location: "body", scope: false }, + set_scope_binding: { method: 'PUT', path: '/v1/scope-bindings', location: "body", scope: true }, + clear_scope_binding: { method: 'POST', path: '/v1/scope-bindings/clear', location: "body", scope: false }, capture_content_source: { method: 'POST', path: '/v1/sources/content', location: "body", scope: true }, prepare_context: { method: 'POST', path: '/v1/context/prepare', location: "body", scope: true }, create_work_contract: { method: 'POST', path: '/v1/work/contracts/create', location: "body", scope: true }, @@ -54,22 +65,8 @@ export const OPERATIONS = { approve_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/approve', location: "body", scope: true }, reject_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/reject', location: "body", scope: true }, revise_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/revise', location: "body", scope: true }, - get_stats: { method: 'GET', path: '/v1/stats', location: "query", scope: true }, - create_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/create', location: "body", scope: false }, - list_handoff_report_projects: { method: 'POST', path: '/v1/handoff-reports/projects/list', location: "body", scope: false }, - list_handoff_report_known_scopes: { method: 'POST', path: '/v1/handoff-reports/scopes/list-known', location: "body", scope: false }, - get_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/get', location: "body", scope: false }, - update_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/update', location: "body", scope: false }, - register_handoff_report_workstream: { method: 'POST', path: '/v1/handoff-reports/workstreams/register', location: "body", scope: true }, - list_handoff_report_workstreams: { method: 'POST', path: '/v1/handoff-reports/workstreams/list', location: "body", scope: false }, - update_handoff_report_workstream: { method: 'POST', path: '/v1/handoff-reports/workstreams/update', location: "body", scope: false }, - get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scope: true }, - record_handoff_report_activity: { method: 'POST', path: '/v1/handoff-reports/activities/record', location: "body", scope: true }, - list_handoff_report_activities: { method: 'POST', path: '/v1/handoff-reports/activities/list', location: "body", scope: false }, - purge_handoff_report_activities: { method: 'POST', path: '/v1/handoff-reports/activities/purge', location: "body", scope: false }, - get_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/get', location: "body", scope: false }, - attach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/attach', location: "body", scope: false }, - detach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/detach', location: "body", scope: false }, + get_stats: { method: 'POST', path: '/v1/stats', location: "body", scope: false }, + get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scope: false }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/integrations/pi/plugins/powercontext/tests/commands.spec.ts b/integrations/pi/plugins/powercontext/tests/commands.spec.ts index da11dfc1a..69fdbf8d2 100644 --- a/integrations/pi/plugins/powercontext/tests/commands.spec.ts +++ b/integrations/pi/plugins/powercontext/tests/commands.spec.ts @@ -19,7 +19,7 @@ import { handlePcCommand } from '../src/commands.ts' import { PowerContextClient } from '../src/client.ts' import type { PluginRuntime } from '../src/recall.ts' -function runtime(fetch: typeof globalThis.fetch, resolveScope = async () => 'project:demo'): PluginRuntime { +function runtime(fetch: typeof globalThis.fetch, resolveScope = async () => 'scope:demo'): PluginRuntime { return { client: new PowerContextClient({ baseUrl: 'http://127.0.0.1:8000', @@ -46,7 +46,7 @@ afterEach(() => { }) describe('/pc command', () => { - it('dispatches query commands with the current project scope and reports results', async () => { + it('dispatches query commands with the current Scope and reports results', async () => { const requests: Array<{ url: URL; body: unknown }> = [] const notifications: Array<{ message: string; level: 'info' | 'error' }> = [] const fetch = async (url: string | URL | Request, init?: RequestInit) => { @@ -76,9 +76,9 @@ describe('/pc command', () => { const stats = requests.find(({ url }) => url.pathname === '/v1/stats') const live = requests.find(({ url }) => url.pathname === '/health/live') const ready = requests.find(({ url }) => url.pathname === '/health/ready') - expect(search?.body).toEqual({ query: 'prior decision', limit: 8, mode: 'auto', scope_id: 'project:demo' }) - expect(flush?.body).toEqual({ scope_id: 'project:demo' }) - expect(stats?.url.searchParams.get('scope_id')).toBe('project:demo') + expect(search?.body).toEqual({ query: 'prior decision', limit: 8, mode: 'auto', scope_id: 'scope:demo' }) + expect(flush?.body).toEqual({ scope_id: 'scope:demo' }) + expect(stats?.body).toEqual({ selection: { mode: 'exact', scope_ids: ['scope:demo'] } }) expect(live).toBeDefined() expect(ready).toBeDefined() expect(notifications.some(({ message }) => JSON.parse(message).ok === true)).toBe(true) diff --git a/integrations/pi/plugins/powercontext/tests/invoke.spec.ts b/integrations/pi/plugins/powercontext/tests/invoke.spec.ts index 8e387a38e..34c674407 100644 --- a/integrations/pi/plugins/powercontext/tests/invoke.spec.ts +++ b/integrations/pi/plugins/powercontext/tests/invoke.spec.ts @@ -47,16 +47,39 @@ describe('Pi native tool invocation', () => { await expect(invokeOperation(client, 'search_memory', { query: 'prior decision', - scope_id: 'project:untrusted', - }, 'project:derived')).resolves.toMatchObject({ ok: true }) - expect(JSON.parse(body ?? '{}')).toMatchObject({ scope_id: 'project:derived' }) + scope_id: 'scope:untrusted', + }, 'scope:derived')).resolves.toMatchObject({ ok: true }) + expect(JSON.parse(body ?? '{}')).toMatchObject({ scope_id: 'scope:derived' }) await expect(invokeOperation(client, 'remember_memory', { kind: 'agent-note', text: 'api_key=secret', - }, 'project:derived')).resolves.toMatchObject({ + }, 'scope:derived')).resolves.toMatchObject({ ok: false, code: 'secret_rejected', }) }) + + it('limits observation requests to the derived Scope', async () => { + const bodies: unknown[] = [] + const client = new PowerContextClient({ + baseUrl: 'http://127.0.0.1:8000', + requestTimeoutMs: 1000, + fetch: async (_url, init) => { + bodies.push(JSON.parse(String(init?.body))) + return new Response(JSON.stringify({}), { status: 200 }) + }, + }) + + await invokeOperation(client, 'get_stats', { selection: { mode: 'all' } }, 'scope:derived') + await invokeOperation(client, 'get_handoff_report', { + selection: { mode: 'subtree', root_scope_id: 'scope:other' }, + format: 'json', + }, 'scope:derived') + + expect(bodies).toEqual([ + { selection: { mode: 'exact', scope_ids: ['scope:derived'] } }, + { selection: { mode: 'exact', scope_ids: ['scope:derived'] }, format: 'json' }, + ]) + }) }) diff --git a/integrations/workbuddy/README.md b/integrations/workbuddy/README.md index 1a3d32e63..4dde516e4 100644 --- a/integrations/workbuddy/README.md +++ b/integrations/workbuddy/README.md @@ -72,8 +72,8 @@ cp "$PLUGIN"/hooks/workbuddy_powercontext_hook.py \ "$PLUGIN"/hooks/workbuddy_settings.py \ "$PLUGIN"/hooks/prepared_context.py \ "$WORKBUDDY_HOOKS_DIR"/ -cp "$PLUGIN/scripts/project_scope.py" \ - "$WORKBUDDY_HOOKS_DIR/powercontext_project_scope.py" +cp "$PLUGIN/scripts/workspace_scope.py" \ + "$WORKBUDDY_HOOKS_DIR/powercontext_scope_binding.py" ``` The resulting layout is: @@ -83,7 +83,7 @@ The resulting layout is: workbuddy_powercontext_hook.py workbuddy_settings.py prepared_context.py - powercontext_project_scope.py + powercontext_scope_binding.py ``` #### 2. Register the hook @@ -150,8 +150,8 @@ EOF Then open `~/.workbuddy/skills/project-context/SKILL.md`. Replace `${POWERCONTEXT_PYTHON}` with a shell-safe Python executable argument and -`${POWERCONTEXT_PROJECT_SCOPE_SCRIPT}` with a shell-safe complete path to -`/powercontext_project_scope.py`. +`${POWERCONTEXT_SCOPE_BINDING_SCRIPT}` with a shell-safe complete path to +`/powercontext_scope_binding.py`. #### 5. Start the Server, restart WorkBuddy, and verify diff --git a/integrations/workbuddy/plugins/powercontext/README.md b/integrations/workbuddy/plugins/powercontext/README.md index 503ebaadd..437c87f51 100644 --- a/integrations/workbuddy/plugins/powercontext/README.md +++ b/integrations/workbuddy/plugins/powercontext/README.md @@ -16,10 +16,9 @@ Automatic recall and prompt capture run on `UserPromptSubmit`. The hook never reads the WorkBuddy transcript or captures WorkBuddy's final response. Prompt Sources are evidence and are never marked as `task-outcome` by the hook. -Project scope is resolved from an explicit override, a Git-private Workstream -binding, the normalized Git origin, or a hash of the resolved local project -directory, in that order. The rules match the Codex and Claude Code plugins so -all three agents can share the same project Memory. +Scope is resolved from an explicit override, a Git-private Scope binding, the +normalized Git origin, or a hash of the resolved local workspace directory, in +that order. WorkBuddy and Claude Code can use the same host-local binding. The plugin defaults to `http://127.0.0.1:8000`. Its Hook and MCP transport share `POWERCONTEXT_WORKBUDDY_AUTHORIZATION` when optional bearer authentication is diff --git a/integrations/workbuddy/plugins/powercontext/hooks/powercontext_project_scope.py b/integrations/workbuddy/plugins/powercontext/hooks/powercontext_scope_binding.py similarity index 92% rename from integrations/workbuddy/plugins/powercontext/hooks/powercontext_project_scope.py rename to integrations/workbuddy/plugins/powercontext/hooks/powercontext_scope_binding.py index d0bebeba3..ff4ace828 100644 --- a/integrations/workbuddy/plugins/powercontext/hooks/powercontext_project_scope.py +++ b/integrations/workbuddy/plugins/powercontext/hooks/powercontext_scope_binding.py @@ -14,6 +14,6 @@ """Expose the repository scope resolver under its installed WorkBuddy module name.""" -from scripts.project_scope import resolve_scope_id +from scripts.workspace_scope import resolve_scope_id __all__ = ["resolve_scope_id"] diff --git a/integrations/workbuddy/plugins/powercontext/hooks/workbuddy_powercontext_hook.py b/integrations/workbuddy/plugins/powercontext/hooks/workbuddy_powercontext_hook.py index 91cbf91bc..72bd60092 100644 --- a/integrations/workbuddy/plugins/powercontext/hooks/workbuddy_powercontext_hook.py +++ b/integrations/workbuddy/plugins/powercontext/hooks/workbuddy_powercontext_hook.py @@ -48,10 +48,10 @@ def override(method: _MethodT, /) -> _MethodT: import prepared_context as _prepared_context # noqa: E402 from workbuddy_settings import WorkBuddyPluginSettings # noqa: E402 -if (_HOOKS_ROOT / "powercontext_project_scope.py").is_file(): - from powercontext_project_scope import resolve_scope_id +if (_HOOKS_ROOT / "powercontext_scope_binding.py").is_file(): + from powercontext_scope_binding import resolve_scope_id else: - from scripts.project_scope import resolve_scope_id + from scripts.workspace_scope import resolve_scope_id _MAX_CONTEXT_BYTES = _prepared_context.MAX_CONTEXT_BYTES _InvalidResponseError = _prepared_context.InvalidPreparedContextResponse diff --git a/integrations/workbuddy/plugins/powercontext/scripts/project_scope.py b/integrations/workbuddy/plugins/powercontext/scripts/workspace_scope.py similarity index 86% rename from integrations/workbuddy/plugins/powercontext/scripts/project_scope.py rename to integrations/workbuddy/plugins/powercontext/scripts/workspace_scope.py index b63ba7fae..e7a85a13b 100644 --- a/integrations/workbuddy/plugins/powercontext/scripts/project_scope.py +++ b/integrations/workbuddy/plugins/powercontext/scripts/workspace_scope.py @@ -14,9 +14,9 @@ # limitations under the License. # Adapted for WorkBuddy from the PowerContext Claude Code plugin -# (integrations/claude-code/plugins/powercontext/scripts/project_scope.py). +# (integrations/claude-code/plugins/powercontext/scripts/workspace_scope.py). -"""Derive a stable PowerContext scope for one project directory.""" +"""Resolve and persist a PowerContext Scope binding for one workspace.""" from __future__ import annotations @@ -44,9 +44,9 @@ _MAX_SCOPE_LENGTH = 256 _SCP_REMOTE = re.compile(r"^(?:[^@/\s]+@)?(?P[^:/\s]+):(?P.+)$") -_WORKSPACE_STATE_SCHEMA = "powercontext.codex-workspace.v1" +_WORKSPACE_STATE_SCHEMA = "powercontext.scope-binding.v1" _WORKSPACE_STATE_DIRECTORY = "powercontext" -_WORKSPACE_STATE_FILE = "codex-workspace.json" +_WORKSPACE_STATE_FILE = "scope-binding.json" def resolve_scope_id(cwd: str, *, configured_scope_id: str | None = None) -> str: @@ -74,21 +74,21 @@ def derive_scope_id(cwd: str, *, configured_scope_id: str | None = None) -> str: return f"local:{hashlib.sha256(os.fsencode(project_root)).hexdigest()}" -def bind_workstream_scope(cwd: str, scope_id: str, /) -> str: - """Persist one shared Workstream scope in Git-private state.""" +def bind_scope(cwd: str, scope_id: str, /) -> str: + """Persist one Scope binding in Git-private state.""" normalized_scope_id = _bounded_explicit(scope_id.strip()) if not normalized_scope_id: - raise ValueError("Workstream scope must be non-empty") # noqa: TRY003 + raise ValueError("Scope binding must be non-empty") # noqa: TRY003 state_path = _workspace_state_path(cwd) if state_path is None: - raise ValueError("Workstream scope binding requires a Git workspace") # noqa: TRY003 + raise ValueError("Scope binding requires a Git workspace") # noqa: TRY003 _write_workspace_state(state_path, normalized_scope_id) return normalized_scope_id def read_bound_scope_id(cwd: str, /) -> str | None: - """Read the shared Codex/Claude Workstream binding from Git-private state.""" + """Read the Scope binding from Git-private state.""" state_path = _workspace_state_path(cwd) if state_path is None: @@ -110,8 +110,8 @@ def read_bound_scope_id(cwd: str, /) -> str | None: return scope_id -def clear_workstream_scope(cwd: str, /) -> bool: - """Remove only the Git-private shared Workstream binding file.""" +def clear_scope_binding(cwd: str, /) -> bool: + """Remove the Git-private Scope binding file.""" state_path = _workspace_state_path(cwd) if state_path is None: @@ -214,14 +214,14 @@ def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser() parser.add_argument("--cwd", default=os.getcwd()) action = parser.add_mutually_exclusive_group() - action.add_argument("--bind-workstream", metavar="SCOPE_ID") - action.add_argument("--clear-workstream", action="store_true") + action.add_argument("--bind-scope", metavar="SCOPE_ID") + action.add_argument("--clear-scope", action="store_true") arguments = parser.parse_args(argv) - if arguments.bind_workstream is not None: - print(bind_workstream_scope(arguments.cwd, arguments.bind_workstream)) + if arguments.bind_scope is not None: + print(bind_scope(arguments.cwd, arguments.bind_scope)) return 0 - if arguments.clear_workstream: - clear_workstream_scope(arguments.cwd) + if arguments.clear_scope: + clear_scope_binding(arguments.cwd) settings = WorkBuddyPluginSettings.from_environment() print(resolve_scope_id(arguments.cwd, configured_scope_id=settings.scope_id)) return 0 diff --git a/integrations/workbuddy/plugins/powercontext/skills/project-context/SKILL.md b/integrations/workbuddy/plugins/powercontext/skills/project-context/SKILL.md index ee12870fe..9d5498483 100644 --- a/integrations/workbuddy/plugins/powercontext/skills/project-context/SKILL.md +++ b/integrations/workbuddy/plugins/powercontext/skills/project-context/SKILL.md @@ -5,9 +5,9 @@ description: Create and commit a current-work Handoff when the user says "交接 # Project Context @@ -25,36 +25,28 @@ to duplicate the current prompt. Ordinary prompt Sources are not task outcomes. Before the first memory tool call, run: ```bash -${POWERCONTEXT_PYTHON} ${POWERCONTEXT_PROJECT_SCOPE_SCRIPT} --cwd "$PWD" +${POWERCONTEXT_PYTHON} ${POWERCONTEXT_SCOPE_BINDING_SCRIPT} --cwd "$PWD" ``` Reuse that exact `scope_id` for the task. -The resolver first honors an explicit plugin scope, then the same Git-private -Workstream binding used by Codex, and finally the normalized remote or project -path. When the user explicitly asks to bind the current checkout to a known -Handoff Report Workstream, run: +The resolver first honors an explicit plugin Scope, then a Git-private Scope +binding, and finally the normalized remote or workspace path. When the user +explicitly asks to bind the current checkout to a known Scope, run: ```bash -${POWERCONTEXT_PYTHON} ${POWERCONTEXT_PROJECT_SCOPE_SCRIPT} \ - --cwd "$PWD" --bind-workstream "WORKSTREAM_SCOPE_ID" +${POWERCONTEXT_PYTHON} ${POWERCONTEXT_SCOPE_BINDING_SCRIPT} \ + --cwd "$PWD" --bind-scope "SCOPE_ID" ``` Then run the normal resolver command again and verify the same scope. The binding is stored below the checkout's Git directory and is not committed. -Never infer one Workstream when multiple candidates remain consequential. - -Before a durable one-turn Handoff or a `latest` Continue without an exact -Workstream, call `select_handoff_workstream` when that MCP tool is available. -Clients with MCP elicitation can present a native picker; otherwise the tool -returns structured choices. On `selected`, bind the returned `scope_id` with -`--bind-workstream`, run the normal resolver again, and require the resolved -scope to match before any Handoff write. On `needs_selection`, present the -returned choices and call the tool again with the user's exact `project_id` and -`work_id`; never choose a fallback candidate silently. On `cancelled` or -`declined`, stop the Handoff flow. If the tool is unavailable or returns -`empty`, preserve the existing resolver behavior. The picker is read-only and -selecting work does not itself prepare or commit a Handoff. +Never infer a Scope when multiple candidates remain consequential. + +Before a durable one-turn Handoff or a `latest` Continue, resolve the intended +Scope explicitly. If the current binding is not the intended boundary, ask the +user or host for the exact Scope ID, bind it, and verify the resolver result +before any Handoff write. Never infer a Scope from a report view. ## Read @@ -75,8 +67,7 @@ Handoff, a design discussion, or a preview request does not authorize a write. When the one-turn flow applies: -1. Select the Workstream when the picker is available, then resolve and verify - the exact scope using the commands above. +1. Resolve and verify the exact Scope using the commands above. 2. Inspect the current conversation and repository before writing. Ground the objective, branch and worktree state, changed files, checks, blockers, omissions, and next executable action without reading or including secrets. diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 2c8681f99..25c653946 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -79,6 +79,252 @@ paths: $ref: "#/components/schemas/Capabilities" "401": $ref: "#/components/responses/Unauthorized" + /v1/scopes: + get: + tags: [scopes] + summary: List observable Scopes + operationId: list_scopes + responses: + "200": + description: Durable Scope metadata in deterministic identity order. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopePage" + "401": + $ref: "#/components/responses/Unauthorized" + "503": + $ref: "#/components/responses/Unavailable" + post: + tags: [scopes] + summary: Create an independent Scope boundary + operationId: create_scope + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateScopeRequest" + responses: + "201": + description: The durable Scope descriptor. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopeDescriptor" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" + /v1/artifact-publications: + post: + tags: [scopes] + summary: Publish one exact Artifact revision into another Scope + operationId: publish_artifact + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PublishArtifactRequest" + responses: + "201": + description: Independent target Artifact and its exact source provenance. + content: + application/json: + schema: + $ref: "#/components/schemas/ArtifactPublication" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" + /v1/scopes/get: + post: + tags: [scopes] + summary: Get one Scope descriptor + operationId: get_scope + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/GetScopeRequest" + responses: + "200": + description: The exact Scope descriptor. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopeDescriptor" + "404": + $ref: "#/components/responses/NotFound" + "401": + $ref: "#/components/responses/Unauthorized" + /v1/scopes/update: + post: + tags: [scopes] + summary: Replace mutable Scope metadata and relationships + operationId: update_scope + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateScopeRequest" + responses: + "200": + description: The updated Scope descriptor. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopeDescriptor" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" + /v1/scopes/default: + get: + tags: [scopes] + summary: Get the default Scope binding target + operationId: get_default_scope + responses: + "200": + description: The ordinary Scope selected by the host default pointer. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopeDescriptor" + "404": + $ref: "#/components/responses/NotFound" + "401": + $ref: "#/components/responses/Unauthorized" + put: + tags: [scopes] + summary: Change the default Scope binding target + operationId: set_default_scope + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SetDefaultScopeRequest" + responses: + "200": + description: The selected ordinary Scope. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopeDescriptor" + "404": + $ref: "#/components/responses/NotFound" + "401": + $ref: "#/components/responses/Unauthorized" + /v1/scopes/selection/resolve: + post: + tags: [scopes] + summary: Resolve an observation selection to a frozen Scope set + operationId: resolve_scope_selection + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ResolveScopeSelectionRequest" + responses: + "200": + description: The selected Scope descriptors in deterministic order. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopePage" + "404": + $ref: "#/components/responses/NotFound" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" + /v1/scope-bindings/resolve: + post: + tags: [scope-bindings] + summary: Resolve an explicit durable or default Scope binding + operationId: resolve_scope_binding + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ResolveScopeBindingRequest" + responses: + "200": + description: The resolved Scope descriptor. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopeDescriptor" + "404": + $ref: "#/components/responses/NotFound" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" + /v1/scope-bindings: + put: + tags: [scope-bindings] + summary: Persist an external identity to Scope binding + operationId: set_scope_binding + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SetScopeBindingRequest" + responses: + "200": + description: The durable external binding. + content: + application/json: + schema: + $ref: "#/components/schemas/ScopeBinding" + "404": + $ref: "#/components/responses/NotFound" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" + /v1/scope-bindings/clear: + post: + tags: [scope-bindings] + summary: Remove one durable external Scope binding + operationId: clear_scope_binding + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ClearScopeBindingRequest" + responses: + "200": + description: Whether a durable binding was removed. + content: + application/json: + schema: + $ref: "#/components/schemas/ClearScopeBindingResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" /v1/sources/content: post: tags: [sources] @@ -1178,27 +1424,19 @@ paths: "500": $ref: "#/components/responses/InternalError" /v1/stats: - get: + post: tags: [stats] - summary: Get scoped product statistics + summary: Aggregate product statistics over a Scope selection operationId: get_stats - parameters: - - name: scope_id - in: query - required: true - schema: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - - name: period - in: query - required: false - schema: - $ref: "#/components/schemas/StatsPeriod" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/GetStatsRequest" responses: "200": - description: Current inventory, model usage, and recall token estimates for the scope. + description: Current inventory, model usage, and recall token estimates for the frozen Scope set. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" @@ -1219,2762 +1457,1982 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/projects/create: - post: - tags: [handoff-reports] - summary: Create a Handoff Report Project - operationId: create_handoff_report_project - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/CreateHandoffReportProjectRequest" - responses: - "201": - description: The created Report Project. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ProjectDescriptor" - "409": - $ref: "#/components/responses/Conflict" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/projects/list: + /v1/handoff-reports/get: post: tags: [handoff-reports] - summary: List Handoff Report Projects - operationId: list_handoff_report_projects + summary: Generate a Handoff Report + operationId: get_handoff_report requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ListHandoffReportProjectsRequest" + $ref: "#/components/schemas/GetHandoffReportRequest" responses: "200": - description: A cursor-paginated page of Report Projects. + description: A canonical JSON report, optionally accompanied by Markdown. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" + Cache-Control: + description: Prevent caches from retaining scoped report data. + schema: + type: string + enum: [no-store] + X-PowerContext-Selection-Digest: + description: Digest of the exact report selection. + schema: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + X-PowerContext-Report-Digest: + description: Digest of the selected output projection. + schema: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + Content-Disposition: + description: Safe attachment filename when download is true. + schema: + type: string content: application/json: schema: - $ref: "#/components/schemas/ProjectPage" + $ref: "#/components/schemas/HandoffReportResponse" + text/markdown: + schema: + type: string + "404": + $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" "422": $ref: "#/components/responses/InvalidRequest" + "413": + $ref: "#/components/responses/ReportTooLarge" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/scopes/list-known: - post: - tags: [handoff-reports] - summary: List scopes that contain a committed Handoff - operationId: list_handoff_report_known_scopes - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ListHandoffReportKnownScopesRequest" - responses: - "200": - description: A cursor-paginated page of scopes that can be rendered as Handoff Reports. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/KnownHandoffScopePage" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/projects/get: - post: - tags: [handoff-reports] - summary: Get a Handoff Report Project - operationId: get_handoff_report_project - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/GetHandoffReportProjectRequest" - responses: - "200": - description: The exact current Report Project descriptor. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ProjectDescriptor" - "404": - $ref: "#/components/responses/NotFound" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/projects/update: - post: - tags: [handoff-reports] - summary: Update a Handoff Report Project - operationId: update_handoff_report_project - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/UpdateHandoffReportProjectRequest" - responses: - "200": - description: The updated Report Project descriptor. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ProjectDescriptor" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/Conflict" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workstreams/register: - post: - tags: [handoff-reports] - summary: Register a Handoff Report Workstream - operationId: register_handoff_report_workstream - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/RegisterHandoffReportWorkstreamRequest" - responses: - "201": - description: The registered Report Workstream. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/WorkstreamDescriptor" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/Conflict" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workstreams/list: - post: - tags: [handoff-reports] - summary: List Handoff Report Workstreams - operationId: list_handoff_report_workstreams - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ListHandoffReportWorkstreamsRequest" - responses: - "200": - description: A cursor-paginated page of Report Workstreams. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/WorkstreamPage" - "404": - $ref: "#/components/responses/NotFound" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workstreams/update: - post: - tags: [handoff-reports] - summary: Update a Handoff Report Workstream - operationId: update_handoff_report_workstream - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/UpdateHandoffReportWorkstreamRequest" - responses: - "200": - description: The updated Report Workstream descriptor. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/WorkstreamDescriptor" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/Conflict" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/get: - post: - tags: [handoff-reports] - summary: Generate a Handoff Report - operationId: get_handoff_report - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/GetHandoffReportRequest" - responses: - "200": - description: A canonical JSON report, optionally accompanied by Markdown. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - Cache-Control: - description: Prevent caches from retaining scoped report data. - schema: - type: string - enum: [no-store] - X-PowerContext-Selection-Digest: - description: Digest of the exact report selection. - schema: - type: string - pattern: '^sha256:[0-9a-f]{64}$' - X-PowerContext-Report-Digest: - description: Digest of the selected output projection. - schema: - type: string - pattern: '^sha256:[0-9a-f]{64}$' - Content-Disposition: - description: Safe attachment filename when download is true. - schema: - type: string - content: - application/json: - schema: - $ref: "#/components/schemas/HandoffReportResponse" - text/markdown: - schema: - type: string - "404": - $ref: "#/components/responses/NotFound" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "413": - $ref: "#/components/responses/ReportTooLarge" - "503": - $ref: "#/components/responses/Unavailable" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/activities/record: - post: - tags: [handoff-reports] - summary: Record a Handoff Report Activity - operationId: record_handoff_report_activity - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/RecordHandoffReportActivityRequest" - responses: - "201": - description: The idempotently recorded Report Activity. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/StoredHandoffReportActivity" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/Conflict" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/activities/list: - post: - tags: [handoff-reports] - summary: List Handoff Report Activities - operationId: list_handoff_report_activities - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ListHandoffReportActivitiesRequest" - responses: - "200": - description: A frozen cursor page of Report Activities. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/HandoffReportActivityPage" - "404": - $ref: "#/components/responses/NotFound" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/activities/purge: - post: - tags: [handoff-reports] - summary: Purge Handoff Report Activities - operationId: purge_handoff_report_activities - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/PurgeHandoffReportActivitiesRequest" - responses: - "200": - description: The number of deleted Report-owned Activity rows. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/PurgeHandoffReportActivitiesResponse" - "404": - $ref: "#/components/responses/NotFound" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workspace-bindings/get: - post: - tags: [handoff-reports] - summary: Get a Handoff Report Workspace Binding - operationId: get_handoff_report_workspace - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/GetHandoffReportWorkspaceRequest" - responses: - "200": - description: The confirmed Workspace binding. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/HandoffReportWorkspaceBinding" - "404": - $ref: "#/components/responses/NotFound" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workspace-bindings/attach: - post: - tags: [handoff-reports] - summary: Attach a Handoff Report Workspace Binding - operationId: attach_handoff_report_workspace - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AttachHandoffReportWorkspaceRequest" - responses: - "200": - description: The confirmed Workspace binding. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/HandoffReportWorkspaceBinding" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/Conflict" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workspace-bindings/detach: - post: - tags: [handoff-reports] - summary: Detach a Handoff Report Workspace Binding - operationId: detach_handoff_report_workspace - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/DetachHandoffReportWorkspaceRequest" - responses: - "200": - description: The detached Workspace binding record. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/HandoffReportWorkspaceBinding" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/Conflict" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "500": - $ref: "#/components/responses/InternalError" -components: - securitySchemes: - BearerAuth: - type: http - scheme: bearer - description: Static bearer token used when local Server authentication is enabled. - headers: - BearerChallenge: - description: Authentication scheme required by the Server. - schema: - type: string - example: Bearer - RequestId: - description: Opaque identifier for correlating one request. - schema: - type: string - responses: - Unauthorized: - description: A valid bearer token is required by this Server deployment. - headers: - WWW-Authenticate: - $ref: "#/components/headers/BearerChallenge" - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - Conflict: - description: The command conflicts with current immutable state. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - InvalidRequest: - description: The request violates the transport or application contract. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - ReportTooLarge: - description: The selected Handoff Report exceeds the deterministic output limit. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - NotFound: - description: The requested immutable Memory value was not found. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - Unavailable: - description: A required Runtime binding or dependency is unavailable. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - InternalError: - description: The Server failed without exposing internal details. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - schemas: - ActivateHandoffRequest: - type: object - additionalProperties: false - required: [scope_id, boundary_source, objective] - properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - boundary_source: - $ref: "#/components/schemas/SourceReference" - objective: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - evidence: - type: array - maxItems: 32 - items: - $ref: "#/components/schemas/HandoffCitation" - default: [] - max_bytes: - type: integer - minimum: 512 - maximum: 32768 - default: 8000 - ArtifactReference: - type: object - additionalProperties: false - required: [family, artifact_id, revision] - properties: - family: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - artifact_id: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - revision: - type: integer - minimum: 1 - ArtifactCandidate: - type: object - additionalProperties: false - required: - - candidate_id - - version - - family - - status - - proposal - - source_refs - - artifact_refs - - target - - reason - - result_artifact - - decision_reason - properties: - candidate_id: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - version: - type: integer - minimum: 1 - family: - $ref: "#/components/schemas/CandidateFamily" - status: - $ref: "#/components/schemas/CandidateStatus" - proposal: - oneOf: - - $ref: "#/components/schemas/ExperienceProposal" - - $ref: "#/components/schemas/SkillProposal" - source_refs: - type: array - maxItems: 32 - description: Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references. - items: - $ref: "#/components/schemas/SourceReference" - artifact_refs: - type: array - maxItems: 32 - description: Exact Artifact evidence. Counted with source_refs toward a combined maximum of 32 references. - items: - $ref: "#/components/schemas/ArtifactReference" - target: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - reason: - type: string - minLength: 1 - maxLength: 2000 - nullable: true - result_artifact: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - decision_reason: - type: string - minLength: 1 - maxLength: 2000 - nullable: true - ArtifactCandidatePage: - type: object - additionalProperties: false - required: [candidates, next_cursor] - properties: - candidates: - type: array - items: - $ref: "#/components/schemas/ArtifactCandidate" - next_cursor: - type: string - nullable: true - ApproveArtifactCandidateRequest: - type: object - additionalProperties: false - required: [scope_id, candidate_id, expected_version] - properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - candidate_id: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - expected_version: - type: integer - minimum: 1 - Capabilities: - type: object - additionalProperties: false - required: - [source_types, artifact_families, memory_extraction, handoff_generation, search_modes, context_versions] - properties: - source_types: - type: array - items: - type: string - artifact_families: - type: array - items: - type: string - memory_extraction: - type: boolean - description: Whether pending Sources can be extracted into Memory. - experience_generation: - type: boolean - default: false - description: Whether the configured model can generate reviewed Experience Candidates. - managed_skill_generation: - type: boolean - default: false - description: Whether the configured model can generate reviewed managed Skill Candidates. - external_skill_registry: - type: boolean - default: false - description: Whether host-local external Skill discovery and exact resolution are configured. - handoff_generation: - type: boolean - description: Whether exact evidence can be generated into an inspectable Handoff Draft. - search_modes: - type: array - items: - $ref: "#/components/schemas/MemorySearchMode" - context_versions: - type: array - items: - $ref: "#/components/schemas/PreparedContextSchema" - FamilyCount: - type: object - additionalProperties: false - required: [family, total] - properties: - family: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - total: - type: integer - minimum: 0 - CandidateFamilyCount: - type: object - additionalProperties: false - required: [family, total, pending, approved, rejected] - properties: - family: - $ref: "#/components/schemas/CandidateFamily" - total: - type: integer - minimum: 0 - pending: - type: integer - minimum: 0 - approved: - type: integer - minimum: 0 - rejected: - type: integer - minimum: 0 - MemoryKindCount: - type: object - additionalProperties: false - required: [kind, total, active, inactive] - properties: - kind: - type: string - minLength: 1 - maxLength: 128 - total: - type: integer - minimum: 0 - active: - type: integer - minimum: 0 - inactive: - type: integer - minimum: 0 - SourceInventoryStatistics: - type: object - additionalProperties: false - required: [total, memory_processed, memory_pending] - properties: - total: - type: integer - minimum: 0 - memory_processed: - type: integer - minimum: 0 - memory_pending: - type: integer - minimum: 0 - ArtifactInventoryStatistics: - type: object - additionalProperties: false - required: [total, by_family] - properties: - total: - type: integer - minimum: 0 - by_family: - type: array - items: - $ref: "#/components/schemas/FamilyCount" - CandidateInventoryStatistics: - type: object - additionalProperties: false - required: [total, pending, approved, rejected, by_family] - properties: - total: - type: integer - minimum: 0 - pending: - type: integer - minimum: 0 - approved: - type: integer - minimum: 0 - rejected: - type: integer - minimum: 0 - by_family: - type: array - items: - $ref: "#/components/schemas/CandidateFamilyCount" - MemoryEntryInventoryStatistics: - type: object - additionalProperties: false - required: [total, active, inactive, by_kind] - properties: - total: - type: integer - minimum: 0 - active: - type: integer - minimum: 0 - inactive: - type: integer - minimum: 0 - by_kind: - type: array - items: - $ref: "#/components/schemas/MemoryKindCount" - MemoryInventoryStatistics: - type: object - additionalProperties: false - required: [entries] - properties: - entries: - $ref: "#/components/schemas/MemoryEntryInventoryStatistics" - InventoryStatistics: - type: object - additionalProperties: false - required: [sources, artifacts, candidates, memory] - properties: - sources: - $ref: "#/components/schemas/SourceInventoryStatistics" - artifacts: - $ref: "#/components/schemas/ArtifactInventoryStatistics" - candidates: - $ref: "#/components/schemas/CandidateInventoryStatistics" - memory: - $ref: "#/components/schemas/MemoryInventoryStatistics" - ModelUsageValue: - type: object - additionalProperties: false - required: [requests, input_tokens, output_tokens] - properties: - requests: - type: integer - minimum: 0 - input_tokens: - type: integer - minimum: 0 - nullable: true - output_tokens: - type: integer - minimum: 0 - nullable: true - ModelUsageStatistics: - type: object - additionalProperties: false - required: [generation, embedding] - properties: - generation: - $ref: "#/components/schemas/ModelUsageValue" - embedding: - $ref: "#/components/schemas/ModelUsageValue" - ModelUsagePurposeBreakdown: - type: object - additionalProperties: false - required: [purpose, generation, embedding] - properties: - purpose: - type: string - minLength: 1 - maxLength: 64 - generation: - $ref: "#/components/schemas/ModelUsageValue" - embedding: - $ref: "#/components/schemas/ModelUsageValue" - ModelUsageDay: - type: object - additionalProperties: false - required: [date, generation, embedding, by_purpose] - properties: - date: - type: string - format: date - generation: - $ref: "#/components/schemas/ModelUsageValue" - embedding: - $ref: "#/components/schemas/ModelUsageValue" - by_purpose: - type: array - maxItems: 16 - items: - $ref: "#/components/schemas/ModelUsagePurposeBreakdown" - ResolvedUsagePeriod: - type: object - additionalProperties: false - required: [preset, start_date, end_date, timezone] - properties: - preset: - $ref: "#/components/schemas/StatsPeriod" - start_date: - type: string - format: date - end_date: - type: string - format: date - timezone: - type: string - enum: [UTC] - UsageStatistics: +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + description: Static bearer token used when local Server authentication is enabled. + headers: + BearerChallenge: + description: Authentication scheme required by the Server. + schema: + type: string + example: Bearer + RequestId: + description: Opaque identifier for correlating one request. + schema: + type: string + responses: + Unauthorized: + description: A valid bearer token is required by this Server deployment. + headers: + WWW-Authenticate: + $ref: "#/components/headers/BearerChallenge" + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + Conflict: + description: The command conflicts with current immutable state. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + InvalidRequest: + description: The request violates the transport or application contract. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + ReportTooLarge: + description: The selected Handoff Report exceeds the deterministic output limit. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + NotFound: + description: The requested immutable Memory value was not found. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + Unavailable: + description: A required Runtime binding or dependency is unavailable. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + InternalError: + description: The Server failed without exposing internal details. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + schemas: + ActivateHandoffRequest: type: object additionalProperties: false - required: [period, totals, by_purpose, daily] - properties: - period: - $ref: "#/components/schemas/ResolvedUsagePeriod" - totals: - $ref: "#/components/schemas/ModelUsageStatistics" - by_purpose: - type: array - maxItems: 16 - items: - $ref: "#/components/schemas/ModelUsagePurposeBreakdown" - daily: + required: [scope_id, boundary_source, objective] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + boundary_source: + $ref: "#/components/schemas/SourceReference" + objective: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + evidence: type: array - maxItems: 30 + maxItems: 32 items: - $ref: "#/components/schemas/ModelUsageDay" - TokenEstimatorProfile: + $ref: "#/components/schemas/HandoffCitation" + default: [] + max_bytes: + type: integer + minimum: 512 + maximum: 32768 + default: 8000 + ArtifactReference: type: object additionalProperties: false - required: [estimator_id, version] + required: [family, artifact_id, revision] properties: - estimator_id: + family: type: string minLength: 1 maxLength: 128 - version: + pattern: '^[\x21-\x7E]+$' + artifact_id: type: string minLength: 1 - maxLength: 64 - RecallTokenValue: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + revision: + type: integer + minimum: 1 + ArtifactAddress: type: object additionalProperties: false - required: [preparations, ready_preparations, comparable_preparations, baseline_tokens, recalled_tokens, token_reduction] + required: [scope_id, artifact] properties: - preparations: - type: integer - minimum: 0 - ready_preparations: - type: integer - minimum: 0 - comparable_preparations: - type: integer - minimum: 0 - baseline_tokens: - type: integer - minimum: 0 - recalled_tokens: - type: integer - minimum: 0 - token_reduction: - type: integer - RecallTokenDay: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + artifact: + $ref: "#/components/schemas/ArtifactReference" + PublishArtifactRequest: type: object additionalProperties: false - required: [date, preparations, ready_preparations, comparable_preparations, baseline_tokens, recalled_tokens, token_reduction] + required: [source, target_scope_id, idempotency_key] properties: - date: + source: + $ref: "#/components/schemas/ArtifactAddress" + target_scope_id: type: string - format: date - preparations: - type: integer - minimum: 0 - ready_preparations: - type: integer - minimum: 0 - comparable_preparations: - type: integer - minimum: 0 - baseline_tokens: - type: integer - minimum: 0 - recalled_tokens: - type: integer - minimum: 0 - token_reduction: - type: integer - RecallTokenStatistics: + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + idempotency_key: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + ArtifactPublication: type: object additionalProperties: false - required: [period, estimator, totals, daily] + required: [source, target, content_digest] properties: - period: - $ref: "#/components/schemas/ResolvedUsagePeriod" - estimator: - $ref: "#/components/schemas/TokenEstimatorProfile" - nullable: true - totals: - $ref: "#/components/schemas/RecallTokenValue" - daily: - type: array - maxItems: 30 - items: - $ref: "#/components/schemas/RecallTokenDay" - ScopedStats: + source: + $ref: "#/components/schemas/ArtifactAddress" + target: + $ref: "#/components/schemas/ArtifactAddress" + content_digest: + type: string + pattern: '^[0-9a-f]{64}$' + ScopeExternalReference: type: object additionalProperties: false - required: [scope_id, as_of, inventory, usage, recall] + required: [kind, value] properties: - scope_id: + kind: type: string - as_of: + minLength: 1 + maxLength: 128 + pattern: '.*\S.*' + value: type: string - format: date-time - inventory: - $ref: "#/components/schemas/InventoryStatistics" - usage: - $ref: "#/components/schemas/UsageStatistics" - recall: - $ref: "#/components/schemas/RecallTokenStatistics" - GetStatsRequest: + minLength: 1 + maxLength: 2000 + pattern: '.*\S.*' + ScopeDescriptor: type: object additionalProperties: false - required: [scope_id] + required: [scope_id, title, summary, context_references, external_references, version] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - period: - $ref: "#/components/schemas/StatsPeriod" - default: 30d - WorkClaimBasis: - type: string - enum: [declared, verified] - WorkClaim: - type: object - additionalProperties: false - required: [text, basis, evidence] - properties: - text: + title: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' - basis: - $ref: "#/components/schemas/WorkClaimBasis" - evidence: - type: array - maxItems: 31 - items: - $ref: "#/components/schemas/HandoffCitation" - WorkContract: - type: object - additionalProperties: false - required: [schema, trust, objective, facts, in_scope, exclusions, completion_criteria, authorization_notes, open_questions] - properties: - schema: - type: string - enum: [powercontext.work-contract.v1] - trust: + summary: type: string - enum: [untrusted_input] - objective: + minLength: 1 + maxLength: 2000 + pattern: '.*\S.*' + parent_scope_id: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' - facts: - type: array - maxItems: 64 - items: - $ref: "#/components/schemas/WorkClaim" - in_scope: - type: array - minItems: 1 - maxItems: 64 - items: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - exclusions: - type: array - maxItems: 64 - items: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - completion_criteria: + nullable: true + context_references: type: array - minItems: 1 - maxItems: 64 + uniqueItems: true items: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' - authorization_notes: + external_references: type: array - maxItems: 64 + uniqueItems: true items: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - open_questions: + $ref: "#/components/schemas/ScopeExternalReference" + version: + type: integer + minimum: 1 + ScopePage: + type: object + additionalProperties: false + required: [items] + properties: + items: type: array - maxItems: 64 items: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - CreateWorkContractRequest: + $ref: "#/components/schemas/ScopeDescriptor" + CreateScopeRequest: type: object additionalProperties: false - required: [scope_id, source_id, contract] + required: [title, summary, idempotency_key] properties: - scope_id: + title: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - source_id: + summary: type: string minLength: 1 - maxLength: 256 + maxLength: 2000 pattern: '.*\S.*' - contract: - $ref: "#/components/schemas/WorkContract" - CurrentWorkHandoff: - type: object - additionalProperties: false - required: [schema, trust, objective, state, disposition, next_action, omissions] - properties: - schema: - type: string - enum: [powercontext.current-work-handoff.v1] - trust: - type: string - enum: [untrusted_input] - objective: + parent_scope_id: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' - state: - type: array - minItems: 1 - maxItems: 64 - items: - $ref: "#/components/schemas/WorkClaim" - disposition: - $ref: "#/components/schemas/HandoffDisposition" - next_action: - $ref: "#/components/schemas/WorkClaim" nullable: true - omissions: + context_references: type: array - maxItems: 64 + uniqueItems: true items: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' - HandoffCurrentWorkRequest: + default: [] + external_references: + type: array + uniqueItems: true + items: + $ref: "#/components/schemas/ScopeExternalReference" + default: [] + idempotency_key: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + GetScopeRequest: type: object additionalProperties: false - required: [scope_id, source_id, handoff] + required: [scope_id] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - source_id: + UpdateScopeRequest: + type: object + additionalProperties: false + required: [scope_id, expected_version, title, summary] + properties: + scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - handoff: - $ref: "#/components/schemas/CurrentWorkHandoff" - WorkSourceKind: - type: string - enum: [work-contract, handoff-boundary, handoff-receipt, task-outcome] - WorkSourceReceipt: - type: object - additionalProperties: false - required: [kind, source, position, content_digest] - properties: - kind: - $ref: "#/components/schemas/WorkSourceKind" - source: - $ref: "#/components/schemas/SourceReference" - position: + expected_version: type: integer minimum: 1 - content_digest: + title: type: string - minLength: 71 - maxLength: 71 - pattern: '^sha256:[0-9a-f]{64}$' - PreparedWorkHandoff: + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + summary: + type: string + minLength: 1 + maxLength: 2000 + pattern: '.*\S.*' + parent_scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + nullable: true + context_references: + type: array + uniqueItems: true + items: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + default: [] + external_references: + type: array + uniqueItems: true + items: + $ref: "#/components/schemas/ScopeExternalReference" + default: [] + SetDefaultScopeRequest: + $ref: "#/components/schemas/GetScopeRequest" + ScopeSelectionMode: + type: string + enum: [all, exact, subtree] + ScopeSelection: type: object additionalProperties: false - required: [boundary, handoff] + required: [mode] properties: - boundary: - $ref: "#/components/schemas/WorkSourceReceipt" - handoff: - $ref: "#/components/schemas/PreparedHandoff" - HandoffReceiptStatus: - type: string - enum: [accepted, needs_clarification, declined] - HandoffAcknowledgementSelection: - type: string - enum: [prepared, exact] - LiveStateCheckStatus: - type: string - enum: [confirmed, mismatch, not_checked] - ReceiverReadinessCheckStatus: - type: string - enum: [confirmed, insufficient, not_checked] - ReceiverChecks: + mode: + $ref: "#/components/schemas/ScopeSelectionMode" + scope_ids: + type: array + uniqueItems: true + items: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + default: [] + root_scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + nullable: true + ResolveScopeSelectionRequest: type: object additionalProperties: false - description: Untrusted receiver self-attestation kept separate from citation availability. All three values must be confirmed when status is accepted. - required: [live_state, capability, authorization] + required: [selection] properties: - live_state: - $ref: "#/components/schemas/LiveStateCheckStatus" - capability: - $ref: "#/components/schemas/ReceiverReadinessCheckStatus" - authorization: - $ref: "#/components/schemas/ReceiverReadinessCheckStatus" - AcknowledgeHandoffRequest: + selection: + $ref: "#/components/schemas/ScopeSelection" + ScopeBindingKey: type: object additionalProperties: false - required: [scope_id, source_id, receiver, status, selection] + required: [integration, kind, external_id] properties: - scope_id: + integration: type: string minLength: 1 - maxLength: 256 + maxLength: 128 pattern: '.*\S.*' - source_id: + kind: type: string minLength: 1 - maxLength: 256 + maxLength: 64 pattern: '.*\S.*' - receiver: + external_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - status: - $ref: "#/components/schemas/HandoffReceiptStatus" - selection: - $ref: "#/components/schemas/HandoffAcknowledgementSelection" - receiver_checks: - $ref: "#/components/schemas/ReceiverChecks" - nullable: true - prepared: - $ref: "#/components/schemas/PreparedHandoff" - nullable: true - revision: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - message: + ScopeBinding: + type: object + additionalProperties: false + required: [key, scope_id] + properties: + key: + $ref: "#/components/schemas/ScopeBindingKey" + scope_id: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' - nullable: true - HandoffAcknowledgement: + SetScopeBindingRequest: + $ref: "#/components/schemas/ScopeBinding" + ClearScopeBindingRequest: type: object additionalProperties: false - required: [resolution, receipt] + required: [key] properties: - resolution: - $ref: "#/components/schemas/HandoffResolution" - receipt: - $ref: "#/components/schemas/WorkSourceReceipt" - TaskOutcomeStatus: - type: string - enum: [succeeded, partial, blocked, failed, cancelled, unknown] - TaskCheckStatus: - type: string - enum: [passed, failed, skipped, timed_out, unavailable, cancelled, unknown] - TaskCheck: + key: + $ref: "#/components/schemas/ScopeBindingKey" + ClearScopeBindingResponse: type: object additionalProperties: false - required: [name, status, basis, evidence] + required: [cleared] properties: - name: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - status: - $ref: "#/components/schemas/TaskCheckStatus" - details: + cleared: + type: boolean + ResolveScopeBindingRequest: + type: object + additionalProperties: false + properties: + explicit_scope_id: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' nullable: true - basis: - $ref: "#/components/schemas/WorkClaimBasis" - evidence: + binding_keys: type: array - maxItems: 32 items: - $ref: "#/components/schemas/HandoffCitation" - TaskOutcome: + $ref: "#/components/schemas/ScopeBindingKey" + default: [] + ArtifactCandidate: type: object additionalProperties: false - required: [schema, trust, objective, status, summary, observations, checks, produced_artifacts, remaining_work] + required: + - candidate_id + - version + - family + - status + - proposal + - source_refs + - artifact_refs + - target + - reason + - result_artifact + - decision_reason properties: - schema: - type: string - enum: [powercontext.task-outcome.v1] - trust: - type: string - enum: [untrusted_observation] - objective: + candidate_id: type: string minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + version: + type: integer + minimum: 1 + family: + $ref: "#/components/schemas/CandidateFamily" status: - $ref: "#/components/schemas/TaskOutcomeStatus" - summary: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - handoff_receipt_ref: - $ref: "#/components/schemas/SourceReference" - nullable: true - observations: - type: array - minItems: 1 - maxItems: 64 - items: - $ref: "#/components/schemas/WorkClaim" - checks: + $ref: "#/components/schemas/CandidateStatus" + proposal: + oneOf: + - $ref: "#/components/schemas/ExperienceProposal" + - $ref: "#/components/schemas/SkillProposal" + source_refs: type: array - maxItems: 64 + maxItems: 32 + description: Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references. items: - $ref: "#/components/schemas/TaskCheck" - produced_artifacts: + $ref: "#/components/schemas/SourceReference" + artifact_refs: type: array maxItems: 32 + description: Exact Artifact evidence. Counted with source_refs toward a combined maximum of 32 references. items: $ref: "#/components/schemas/ArtifactReference" - remaining_work: + target: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + reason: + type: string + minLength: 1 + maxLength: 2000 + nullable: true + result_artifact: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + decision_reason: + type: string + minLength: 1 + maxLength: 2000 + nullable: true + ArtifactCandidatePage: + type: object + additionalProperties: false + required: [candidates, next_cursor] + properties: + candidates: type: array - maxItems: 64 items: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - RecordTaskOutcomeRequest: + $ref: "#/components/schemas/ArtifactCandidate" + next_cursor: + type: string + nullable: true + ApproveArtifactCandidateRequest: type: object additionalProperties: false - required: [scope_id, source_id, outcome] + required: [scope_id, candidate_id, expected_version] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - source_id: + candidate_id: type: string minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - outcome: - $ref: "#/components/schemas/TaskOutcome" - CaptureContentSourceRequest: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + expected_version: + type: integer + minimum: 1 + Capabilities: type: object additionalProperties: false - required: [scope_id, source_id, content] + required: + [source_types, artifact_families, memory_extraction, handoff_generation, search_modes, context_versions] properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - source_id: - type: string - minLength: 1 - maxLength: 256 - content: + source_types: + type: array + items: + type: string + artifact_families: + type: array + items: + type: string + memory_extraction: + type: boolean + description: Whether pending Sources can be extracted into Memory. + experience_generation: + type: boolean + default: false + description: Whether the configured model can generate reviewed Experience Candidates. + managed_skill_generation: + type: boolean + default: false + description: Whether the configured model can generate reviewed managed Skill Candidates. + external_skill_registry: + type: boolean + default: false + description: Whether host-local external Skill discovery and exact resolution are configured. + handoff_generation: + type: boolean + description: Whether exact evidence can be generated into an inspectable Handoff Draft. + search_modes: + type: array + items: + $ref: "#/components/schemas/MemorySearchMode" + context_versions: + type: array + items: + $ref: "#/components/schemas/PreparedContextSchema" + FamilyCount: + type: object + additionalProperties: false + required: [family, total] + properties: + family: type: string minLength: 1 - maxLength: 200000 - metadata: - type: object - additionalProperties: true - nullable: true - CaptureContentSourceResponse: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + total: + type: integer + minimum: 0 + CandidateFamilyCount: type: object additionalProperties: false - required: [status, source, position] + required: [family, total, pending, approved, rejected] properties: - status: - $ref: "#/components/schemas/CaptureStatus" - source: - $ref: "#/components/schemas/SourceReference" - position: + family: + $ref: "#/components/schemas/CandidateFamily" + total: type: integer - minimum: 1 - CommitHandoffRequest: + minimum: 0 + pending: + type: integer + minimum: 0 + approved: + type: integer + minimum: 0 + rejected: + type: integer + minimum: 0 + MemoryKindCount: type: object additionalProperties: false - required: [scope_id, handoff] + required: [kind, total, active, inactive] properties: - scope_id: + kind: type: string minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - handoff: - $ref: "#/components/schemas/PreparedHandoff" - CommittedHandoff: + maxLength: 128 + total: + type: integer + minimum: 0 + active: + type: integer + minimum: 0 + inactive: + type: integer + minimum: 0 + SourceInventoryStatistics: type: object additionalProperties: false - required: [reference, content, source_refs, artifact_refs] + required: [total, memory_processed, memory_pending] properties: - reference: - $ref: "#/components/schemas/ArtifactReference" - content: - $ref: "#/components/schemas/HandoffContent" - source_refs: + total: + type: integer + minimum: 0 + memory_processed: + type: integer + minimum: 0 + memory_pending: + type: integer + minimum: 0 + ArtifactInventoryStatistics: + type: object + additionalProperties: false + required: [total, by_family] + properties: + total: + type: integer + minimum: 0 + by_family: type: array items: - $ref: "#/components/schemas/SourceReference" - artifact_refs: + $ref: "#/components/schemas/FamilyCount" + CandidateInventoryStatistics: + type: object + additionalProperties: false + required: [total, pending, approved, rejected, by_family] + properties: + total: + type: integer + minimum: 0 + pending: + type: integer + minimum: 0 + approved: + type: integer + minimum: 0 + rejected: + type: integer + minimum: 0 + by_family: type: array items: - $ref: "#/components/schemas/ArtifactReference" - ContinueHandoffRequest: + $ref: "#/components/schemas/CandidateFamilyCount" + MemoryEntryInventoryStatistics: type: object additionalProperties: false - required: [scope_id, selection] + required: [total, active, inactive, by_kind] properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - selection: - $ref: "#/components/schemas/HandoffSelection" - prepared: - $ref: "#/components/schemas/PreparedHandoff" - nullable: true - revision: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - FinalizeHandoffRequest: + total: + type: integer + minimum: 0 + active: + type: integer + minimum: 0 + inactive: + type: integer + minimum: 0 + by_kind: + type: array + items: + $ref: "#/components/schemas/MemoryKindCount" + MemoryInventoryStatistics: type: object additionalProperties: false - required: [scope_id, draft] + required: [entries] properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - draft: - $ref: "#/components/schemas/HandoffDraft" - HandoffArtifactCitation: + entries: + $ref: "#/components/schemas/MemoryEntryInventoryStatistics" + InventoryStatistics: type: object additionalProperties: false - required: [kind, artifact_ref] + required: [sources, artifacts, candidates, memory] properties: - kind: - type: string - enum: [artifact] - artifact_ref: - $ref: "#/components/schemas/ArtifactReference" - HandoffActivation: + sources: + $ref: "#/components/schemas/SourceInventoryStatistics" + artifacts: + $ref: "#/components/schemas/ArtifactInventoryStatistics" + candidates: + $ref: "#/components/schemas/CandidateInventoryStatistics" + memory: + $ref: "#/components/schemas/MemoryInventoryStatistics" + ModelUsageValue: type: object additionalProperties: false - required: [status, boundary_source, previous_position, current_position, draft] + required: [requests, input_tokens, output_tokens] properties: - status: - $ref: "#/components/schemas/HandoffActivationStatus" - boundary_source: - $ref: "#/components/schemas/SourceReference" - previous_position: + requests: + type: integer + minimum: 0 + input_tokens: type: integer minimum: 0 - current_position: + nullable: true + output_tokens: type: integer minimum: 0 - draft: - $ref: "#/components/schemas/HandoffDraft" nullable: true - HandoffCitation: - oneOf: - - $ref: "#/components/schemas/HandoffSourceCitation" - - $ref: "#/components/schemas/HandoffArtifactCitation" - - $ref: "#/components/schemas/HandoffMemoryCitation" - discriminator: - propertyName: kind - mapping: - source: "#/components/schemas/HandoffSourceCitation" - artifact: "#/components/schemas/HandoffArtifactCitation" - memory: "#/components/schemas/HandoffMemoryCitation" - HandoffContent: + ModelUsageStatistics: type: object additionalProperties: false - required: [schema, objective, state, disposition, next_action, omissions] + required: [generation, embedding] properties: - schema: - $ref: "#/components/schemas/HandoffSchema" - objective: + generation: + $ref: "#/components/schemas/ModelUsageValue" + embedding: + $ref: "#/components/schemas/ModelUsageValue" + ModelUsagePurposeBreakdown: + type: object + additionalProperties: false + required: [purpose, generation, embedding] + properties: + purpose: type: string minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - state: - type: array - minItems: 1 - maxItems: 64 - items: - $ref: "#/components/schemas/HandoffStatement" - disposition: - $ref: "#/components/schemas/HandoffDisposition" - next_action: - $ref: "#/components/schemas/HandoffStatement" - nullable: true - omissions: + maxLength: 64 + generation: + $ref: "#/components/schemas/ModelUsageValue" + embedding: + $ref: "#/components/schemas/ModelUsageValue" + ModelUsageDay: + type: object + additionalProperties: false + required: [date, generation, embedding, by_purpose] + properties: + date: + type: string + format: date + generation: + $ref: "#/components/schemas/ModelUsageValue" + embedding: + $ref: "#/components/schemas/ModelUsageValue" + by_purpose: type: array - maxItems: 64 + maxItems: 16 items: - $ref: "#/components/schemas/HandoffOmission" - HandoffDraft: + $ref: "#/components/schemas/ModelUsagePurposeBreakdown" + ResolvedUsagePeriod: type: object additionalProperties: false - required: [objective, state, disposition, next_action, omissions] + required: [preset, start_date, end_date, timezone] properties: - objective: + preset: + $ref: "#/components/schemas/StatsPeriod" + start_date: type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - state: + format: date + end_date: + type: string + format: date + timezone: + type: string + enum: [UTC] + UsageStatistics: + type: object + additionalProperties: false + required: [period, totals, by_purpose, daily] + properties: + period: + $ref: "#/components/schemas/ResolvedUsagePeriod" + totals: + $ref: "#/components/schemas/ModelUsageStatistics" + by_purpose: type: array - minItems: 1 - maxItems: 64 + maxItems: 16 items: - $ref: "#/components/schemas/HandoffStatement" - disposition: - $ref: "#/components/schemas/HandoffDisposition" - next_action: - $ref: "#/components/schemas/HandoffStatement" - nullable: true - omissions: + $ref: "#/components/schemas/ModelUsagePurposeBreakdown" + daily: type: array - maxItems: 64 + maxItems: 30 items: - $ref: "#/components/schemas/HandoffOmission" - HandoffEvidenceCheck: + $ref: "#/components/schemas/ModelUsageDay" + TokenEstimatorProfile: type: object additionalProperties: false - required: [claim, state_index, status, unavailable_evidence] + required: [estimator_id, version] properties: - claim: - $ref: "#/components/schemas/HandoffClaim" - state_index: + estimator_id: + type: string + minLength: 1 + maxLength: 128 + version: + type: string + minLength: 1 + maxLength: 64 + RecallTokenValue: + type: object + additionalProperties: false + required: [preparations, ready_preparations, comparable_preparations, baseline_tokens, recalled_tokens, token_reduction] + properties: + preparations: type: integer minimum: 0 - nullable: true - status: - $ref: "#/components/schemas/HandoffEvidenceStatus" - unavailable_evidence: - type: array - maxItems: 32 - items: - $ref: "#/components/schemas/HandoffCitation" - HandoffMemoryCitation: + ready_preparations: + type: integer + minimum: 0 + comparable_preparations: + type: integer + minimum: 0 + baseline_tokens: + type: integer + minimum: 0 + recalled_tokens: + type: integer + minimum: 0 + token_reduction: + type: integer + RecallTokenDay: type: object additionalProperties: false - required: [kind, memory_citation] + required: [date, preparations, ready_preparations, comparable_preparations, baseline_tokens, recalled_tokens, token_reduction] properties: - kind: + date: type: string - enum: [memory] - memory_citation: - $ref: "#/components/schemas/MemoryCitation" - HandoffOmission: + format: date + preparations: + type: integer + minimum: 0 + ready_preparations: + type: integer + minimum: 0 + comparable_preparations: + type: integer + minimum: 0 + baseline_tokens: + type: integer + minimum: 0 + recalled_tokens: + type: integer + minimum: 0 + token_reduction: + type: integer + RecallTokenStatistics: type: object additionalProperties: false - required: [text, citation] + required: [period, estimator, totals, daily] properties: - text: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - citation: - $ref: "#/components/schemas/HandoffCitation" + period: + $ref: "#/components/schemas/ResolvedUsagePeriod" + estimator: + $ref: "#/components/schemas/TokenEstimatorProfile" nullable: true - HandoffResolution: + totals: + $ref: "#/components/schemas/RecallTokenValue" + daily: + type: array + maxItems: 30 + items: + $ref: "#/components/schemas/RecallTokenDay" + ScopedStats: type: object additionalProperties: false - required: - [trust, status, scope_id, content, selection, selected_revision, current_revision, evidence_checks] + required: [selection, scope_ids, as_of, inventory, usage, recall] properties: - trust: - type: string - enum: [untrusted_history] - status: - $ref: "#/components/schemas/HandoffResolutionStatus" - scope_id: - type: string - content: - $ref: "#/components/schemas/HandoffContent" - nullable: true selection: - $ref: "#/components/schemas/HandoffSelection" - nullable: true - selected_revision: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - current_revision: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - evidence_checks: + $ref: "#/components/schemas/ScopeSelection" + scope_ids: type: array - maxItems: 65 + uniqueItems: true items: - $ref: "#/components/schemas/HandoffEvidenceCheck" - HandoffSourceCitation: + type: string + as_of: + type: string + format: date-time + inventory: + $ref: "#/components/schemas/InventoryStatistics" + usage: + $ref: "#/components/schemas/UsageStatistics" + recall: + $ref: "#/components/schemas/RecallTokenStatistics" + GetStatsRequest: type: object additionalProperties: false - required: [kind, source_ref] + required: [selection] properties: - kind: - type: string - enum: [source] - source_ref: - $ref: "#/components/schemas/SourceReference" - HandoffStatement: + selection: + $ref: "#/components/schemas/ScopeSelection" + period: + $ref: "#/components/schemas/StatsPeriod" + default: 30d + WorkClaimBasis: + type: string + enum: [declared, verified] + WorkClaim: type: object additionalProperties: false - required: [text, citations] + required: [text, basis, evidence] properties: text: type: string minLength: 1 maxLength: 8192 pattern: '.*\S.*' - citations: + basis: + $ref: "#/components/schemas/WorkClaimBasis" + evidence: type: array - minItems: 1 - maxItems: 32 + maxItems: 31 items: $ref: "#/components/schemas/HandoffCitation" - PrepareHandoffRequest: + WorkContract: type: object additionalProperties: false - required: [scope_id, objective, evidence] + required: [schema, trust, objective, facts, in_scope, exclusions, completion_criteria, authorization_notes, open_questions] properties: - scope_id: + schema: type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' + enum: [powercontext.work-contract.v1] + trust: + type: string + enum: [untrusted_input] objective: type: string minLength: 1 maxLength: 8192 pattern: '.*\S.*' - evidence: + facts: + type: array + maxItems: 64 + items: + $ref: "#/components/schemas/WorkClaim" + in_scope: type: array minItems: 1 - maxItems: 32 + maxItems: 64 items: - $ref: "#/components/schemas/HandoffCitation" - max_bytes: - type: integer - minimum: 512 - maximum: 32768 - default: 8000 - PreparedHandoff: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + exclusions: + type: array + maxItems: 64 + items: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + completion_criteria: + type: array + minItems: 1 + maxItems: 64 + items: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + authorization_notes: + type: array + maxItems: 64 + items: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + open_questions: + type: array + maxItems: 64 + items: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + CreateWorkContractRequest: type: object additionalProperties: false - required: [schema, scope_id, base, content] + required: [scope_id, source_id, contract] properties: - schema: - $ref: "#/components/schemas/PreparedHandoffSchema" scope_id: type: string - base: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - content: - $ref: "#/components/schemas/HandoffContent" - PreparedContext: + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + source_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + contract: + $ref: "#/components/schemas/WorkContract" + CurrentWorkHandoff: type: object additionalProperties: false - required: [schema, status, content, content_bytes] + required: [schema, trust, objective, state, disposition, next_action, omissions] properties: schema: - $ref: "#/components/schemas/PreparedContextSchema" - status: - $ref: "#/components/schemas/PreparedContextStatus" - content: type: string + enum: [powercontext.current-work-handoff.v1] + trust: + type: string + enum: [untrusted_input] + objective: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + state: + type: array + minItems: 1 + maxItems: 64 + items: + $ref: "#/components/schemas/WorkClaim" + disposition: + $ref: "#/components/schemas/HandoffDisposition" + next_action: + $ref: "#/components/schemas/WorkClaim" nullable: true - content_bytes: - type: integer - minimum: 0 - EntryChange: + omissions: + type: array + maxItems: 64 + items: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + HandoffCurrentWorkRequest: type: object additionalProperties: false - required: [op, entry_id, from_entry_version_id, to_entry_version_id, reason] + required: [scope_id, source_id, handoff] properties: - op: - $ref: "#/components/schemas/EntryChangeOperation" - entry_id: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - from_entry_version_id: + scope_id: type: string minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - nullable: true - to_entry_version_id: + maxLength: 256 + pattern: '.*\S.*' + source_id: type: string minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - nullable: true - reason: + maxLength: 256 + pattern: '.*\S.*' + handoff: + $ref: "#/components/schemas/CurrentWorkHandoff" + WorkSourceKind: + type: string + enum: [work-contract, handoff-boundary, handoff-receipt, task-outcome] + WorkSourceReceipt: + type: object + additionalProperties: false + required: [kind, source, position, content_digest] + properties: + kind: + $ref: "#/components/schemas/WorkSourceKind" + source: + $ref: "#/components/schemas/SourceReference" + position: + type: integer + minimum: 1 + content_digest: type: string - nullable: true - ExperienceArtifact: + minLength: 71 + maxLength: 71 + pattern: '^sha256:[0-9a-f]{64}$' + PreparedWorkHandoff: type: object additionalProperties: false - required: [artifact, content, source_refs, artifact_refs] + required: [boundary, handoff] properties: - artifact: - $ref: "#/components/schemas/ArtifactReference" - content: - $ref: "#/components/schemas/ExperienceProposal" - source_refs: - type: array - items: - $ref: "#/components/schemas/SourceReference" - artifact_refs: - type: array - items: - $ref: "#/components/schemas/ArtifactReference" - ExperienceProposal: + boundary: + $ref: "#/components/schemas/WorkSourceReceipt" + handoff: + $ref: "#/components/schemas/PreparedHandoff" + HandoffReceiptStatus: + type: string + enum: [accepted, needs_clarification, declined] + HandoffAcknowledgementSelection: + type: string + enum: [prepared, exact] + LiveStateCheckStatus: + type: string + enum: [confirmed, mismatch, not_checked] + ReceiverReadinessCheckStatus: + type: string + enum: [confirmed, insufficient, not_checked] + ReceiverChecks: type: object additionalProperties: false - required: [situation, action, outcome, lesson] + description: Untrusted receiver self-attestation kept separate from citation availability. All three values must be confirmed when status is accepted. + required: [live_state, capability, authorization] properties: - situation: + live_state: + $ref: "#/components/schemas/LiveStateCheckStatus" + capability: + $ref: "#/components/schemas/ReceiverReadinessCheckStatus" + authorization: + $ref: "#/components/schemas/ReceiverReadinessCheckStatus" + AcknowledgeHandoffRequest: + type: object + additionalProperties: false + required: [scope_id, source_id, receiver, status, selection] + properties: + scope_id: type: string minLength: 1 - maxLength: 8000 + maxLength: 256 pattern: '.*\S.*' - action: + source_id: type: string minLength: 1 - maxLength: 8000 + maxLength: 256 pattern: '.*\S.*' - outcome: + receiver: type: string minLength: 1 - maxLength: 8000 + maxLength: 256 pattern: '.*\S.*' - lesson: + status: + $ref: "#/components/schemas/HandoffReceiptStatus" + selection: + $ref: "#/components/schemas/HandoffAcknowledgementSelection" + receiver_checks: + $ref: "#/components/schemas/ReceiverChecks" + nullable: true + prepared: + $ref: "#/components/schemas/PreparedHandoff" + nullable: true + revision: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + message: type: string minLength: 1 - maxLength: 8000 + maxLength: 8192 pattern: '.*\S.*' - SkillArtifact: + nullable: true + HandoffAcknowledgement: type: object additionalProperties: false - required: [artifact, content, source_refs, artifact_refs] + required: [resolution, receipt] properties: - artifact: - $ref: "#/components/schemas/ArtifactReference" - content: - $ref: "#/components/schemas/SkillProposal" - source_refs: - type: array - items: - $ref: "#/components/schemas/SourceReference" - artifact_refs: - type: array - items: - $ref: "#/components/schemas/ArtifactReference" - SkillProposal: + resolution: + $ref: "#/components/schemas/HandoffResolution" + receipt: + $ref: "#/components/schemas/WorkSourceReceipt" + TaskOutcomeStatus: + type: string + enum: [succeeded, partial, blocked, failed, cancelled, unknown] + TaskCheckStatus: + type: string + enum: [passed, failed, skipped, timed_out, unavailable, cancelled, unknown] + TaskCheck: type: object additionalProperties: false - required: [name, description, instructions, validation] + required: [name, status, basis, evidence] properties: name: type: string minLength: 1 - maxLength: 128 - pattern: '^\S(?:.*\S)?$' - description: - type: string - minLength: 1 - maxLength: 2000 - pattern: '^\S(?:.*\S)?$' - instructions: + maxLength: 8192 + pattern: '.*\S.*' + status: + $ref: "#/components/schemas/TaskCheckStatus" + details: type: string minLength: 1 - maxLength: 32000 + maxLength: 8192 pattern: '.*\S.*' - validation: + nullable: true + basis: + $ref: "#/components/schemas/WorkClaimBasis" + evidence: type: array - minItems: 1 maxItems: 32 items: - $ref: "#/components/schemas/SkillValidationItem" - SkillValidationItem: - type: string - minLength: 1 - maxLength: 2000 - pattern: '^\S(?:.*\S)?$' - ExternalSkillRegistration: + $ref: "#/components/schemas/HandoffCitation" + TaskOutcome: type: object additionalProperties: false - required: - - external_skill_id - - provider - - agent_kind - - host_id - - installation_scope - - locator - - fingerprint - - name - - description + required: [schema, trust, objective, status, summary, observations, checks, produced_artifacts, remaining_work] properties: - external_skill_id: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - provider: - type: string - enum: [codex, claude_code] - agent_kind: - type: string - enum: [codex, claude_code] - host_id: - type: string - minLength: 1 - maxLength: 128 - pattern: '^\S(?:.*\S)?$' - installation_scope: - $ref: "#/components/schemas/ExternalSkillInstallationScope" - locator: - type: string - minLength: 1 - maxLength: 2000 - pattern: '^\S(?:.*\S)?$' - description: Host-local locator; not a cross-Agent or cross-host contract. - fingerprint: + schema: type: string - pattern: '^[0-9a-f]{64}$' - name: + enum: [powercontext.task-outcome.v1] + trust: type: string - minLength: 1 - maxLength: 128 - pattern: '^\S(?:.*\S)?$' - description: + enum: [untrusted_observation] + objective: type: string minLength: 1 - maxLength: 2000 - pattern: '^\S(?:.*\S)?$' - ExternalSkillResolution: - type: object - additionalProperties: false - required: [registration, status, entrypoint] - properties: - registration: - $ref: "#/components/schemas/ExternalSkillRegistration" + maxLength: 8192 + pattern: '.*\S.*' status: - $ref: "#/components/schemas/ExternalSkillResolutionStatus" - entrypoint: + $ref: "#/components/schemas/TaskOutcomeStatus" + summary: type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + handoff_receipt_ref: + $ref: "#/components/schemas/SourceReference" nullable: true - description: Host-local SKILL.md path; present only when the exact fingerprint is available. - ScanExternalSkillsResponse: - type: object - additionalProperties: false - required: [registrations, skipped] - properties: - registrations: + observations: type: array + minItems: 1 + maxItems: 64 items: - $ref: "#/components/schemas/ExternalSkillRegistration" - skipped: - type: integer - minimum: 0 - ListExternalSkillsResponse: - type: object - additionalProperties: false - required: [skills] - properties: - skills: + $ref: "#/components/schemas/WorkClaim" + checks: type: array + maxItems: 64 items: - $ref: "#/components/schemas/ExternalSkillResolution" - ErrorDetail: - type: object - additionalProperties: false - required: [code, message, details] - properties: - code: - type: string - message: - type: string - details: - type: object - additionalProperties: true - nullable: true - ErrorResponse: - type: object - additionalProperties: false - required: [error] - properties: - error: - $ref: "#/components/schemas/ErrorDetail" - FlushMemoryRequest: + $ref: "#/components/schemas/TaskCheck" + produced_artifacts: + type: array + maxItems: 32 + items: + $ref: "#/components/schemas/ArtifactReference" + remaining_work: + type: array + maxItems: 64 + items: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + RecordTaskOutcomeRequest: type: object additionalProperties: false - required: [scope_id] + required: [scope_id, source_id, outcome] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - FlushMemoryResponse: - type: object - additionalProperties: false - required: [status, previous_cursor, current_cursor, high_watermark, processed_source_count] - properties: - status: - $ref: "#/components/schemas/FlushStatus" - previous_cursor: - type: integer - minimum: 0 - current_cursor: - type: integer - minimum: 0 - high_watermark: - type: integer - minimum: 0 - processed_source_count: - type: integer - minimum: 0 - memory: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - GetMemoryEntryRequest: - type: object - additionalProperties: false - required: [scope_id, citation] - properties: - scope_id: + source_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - citation: - $ref: "#/components/schemas/MemoryCitation" - GetArtifactCandidateRequest: + outcome: + $ref: "#/components/schemas/TaskOutcome" + CaptureContentSourceRequest: type: object additionalProperties: false - required: [scope_id, candidate_id] + required: [scope_id, source_id, content] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - candidate_id: + source_id: type: string minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - GetExperienceRequest: + maxLength: 256 + content: + type: string + minLength: 1 + maxLength: 200000 + metadata: + type: object + additionalProperties: true + nullable: true + CaptureContentSourceResponse: + type: object + additionalProperties: false + required: [status, source, position] + properties: + status: + $ref: "#/components/schemas/CaptureStatus" + source: + $ref: "#/components/schemas/SourceReference" + position: + type: integer + minimum: 1 + CommitHandoffRequest: type: object additionalProperties: false - required: [scope_id, artifact] + required: [scope_id, handoff] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - artifact: + handoff: + $ref: "#/components/schemas/PreparedHandoff" + CommittedHandoff: + type: object + additionalProperties: false + required: [reference, content, source_refs, artifact_refs] + properties: + reference: $ref: "#/components/schemas/ArtifactReference" - GetSkillRequest: + content: + $ref: "#/components/schemas/HandoffContent" + source_refs: + type: array + items: + $ref: "#/components/schemas/SourceReference" + artifact_refs: + type: array + items: + $ref: "#/components/schemas/ArtifactReference" + ContinueHandoffRequest: type: object additionalProperties: false - required: [scope_id, artifact] + required: [scope_id, selection] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - artifact: + selection: + $ref: "#/components/schemas/HandoffSelection" + prepared: + $ref: "#/components/schemas/PreparedHandoff" + nullable: true + revision: $ref: "#/components/schemas/ArtifactReference" - CreateHandoffReportProjectRequest: + nullable: true + FinalizeHandoffRequest: type: object additionalProperties: false - required: [project_key, title] + required: [scope_id, draft] properties: - project_key: - type: string - minLength: 1 - maxLength: 64 - title: - type: string - minLength: 1 - maxLength: 256 - description: - type: string - maxLength: 2000 - nullable: true - default_locale: - $ref: "#/components/schemas/ReportLocale" - default: zh-CN - timezone: + scope_id: type: string minLength: 1 maxLength: 256 - default: UTC - ListHandoffReportProjectsRequest: - type: object - additionalProperties: false - properties: - cursor: - type: string - nullable: true - limit: - type: integer - minimum: 1 - maximum: 100 - default: 50 - include_archived: - type: boolean - default: false - GetHandoffReportProjectRequest: + pattern: '.*\S.*' + draft: + $ref: "#/components/schemas/HandoffDraft" + HandoffArtifactCitation: type: object additionalProperties: false - required: [project_id] + required: [kind, artifact_ref] properties: - project_id: + kind: type: string - minLength: 1 - maxLength: 256 - UpdateHandoffReportProjectRequest: + enum: [artifact] + artifact_ref: + $ref: "#/components/schemas/ArtifactReference" + HandoffActivation: type: object additionalProperties: false - required: [project, expected_version] + required: [status, boundary_source, previous_position, current_position, draft] properties: - project: - $ref: "#/components/schemas/ProjectDescriptor" - expected_version: + status: + $ref: "#/components/schemas/HandoffActivationStatus" + boundary_source: + $ref: "#/components/schemas/SourceReference" + previous_position: type: integer - minimum: 1 - RegisterHandoffReportWorkstreamRequest: + minimum: 0 + current_position: + type: integer + minimum: 0 + draft: + $ref: "#/components/schemas/HandoffDraft" + nullable: true + HandoffCitation: + oneOf: + - $ref: "#/components/schemas/HandoffSourceCitation" + - $ref: "#/components/schemas/HandoffArtifactCitation" + - $ref: "#/components/schemas/HandoffMemoryCitation" + discriminator: + propertyName: kind + mapping: + source: "#/components/schemas/HandoffSourceCitation" + artifact: "#/components/schemas/HandoffArtifactCitation" + memory: "#/components/schemas/HandoffMemoryCitation" + HandoffContent: type: object additionalProperties: false - required: [project_id, scope_id, title, kind] + required: [schema, objective, state, disposition, next_action, omissions] properties: - project_id: - type: string - minLength: 1 - maxLength: 256 - scope_id: - type: string - minLength: 1 - maxLength: 256 - key: - type: string - minLength: 1 - maxLength: 64 - nullable: true - title: + schema: + $ref: "#/components/schemas/HandoffSchema" + objective: type: string minLength: 1 - maxLength: 256 - kind: - $ref: "#/components/schemas/WorkstreamKind" - catalog_state: - $ref: "#/components/schemas/ReportCatalogState" - default: included - external_refs: + maxLength: 8192 + pattern: '.*\S.*' + state: type: array - maxItems: 32 + minItems: 1 + maxItems: 64 items: - $ref: "#/components/schemas/HandoffReportExternalReference" - default: [] - labels: + $ref: "#/components/schemas/HandoffStatement" + disposition: + $ref: "#/components/schemas/HandoffDisposition" + next_action: + $ref: "#/components/schemas/HandoffStatement" + nullable: true + omissions: type: array - maxItems: 32 + maxItems: 64 items: - type: string - minLength: 1 - maxLength: 128 - default: [] - ListHandoffReportWorkstreamsRequest: + $ref: "#/components/schemas/HandoffOmission" + HandoffDraft: type: object additionalProperties: false - required: [project_id] + required: [objective, state, disposition, next_action, omissions] properties: - project_id: + objective: type: string minLength: 1 - maxLength: 256 - cursor: - type: string + maxLength: 8192 + pattern: '.*\S.*' + state: + type: array + minItems: 1 + maxItems: 64 + items: + $ref: "#/components/schemas/HandoffStatement" + disposition: + $ref: "#/components/schemas/HandoffDisposition" + next_action: + $ref: "#/components/schemas/HandoffStatement" nullable: true - limit: - type: integer - minimum: 1 - maximum: 100 - default: 50 - include_archived: - type: boolean - default: false - UpdateHandoffReportWorkstreamRequest: + omissions: + type: array + maxItems: 64 + items: + $ref: "#/components/schemas/HandoffOmission" + HandoffEvidenceCheck: type: object additionalProperties: false - required: [workstream, expected_version] + required: [claim, state_index, status, unavailable_evidence] properties: - workstream: - $ref: "#/components/schemas/WorkstreamDescriptor" - expected_version: + claim: + $ref: "#/components/schemas/HandoffClaim" + state_index: type: integer - minimum: 1 - GetHandoffReportRequest: + minimum: 0 + nullable: true + status: + $ref: "#/components/schemas/HandoffEvidenceStatus" + unavailable_evidence: + type: array + maxItems: 32 + items: + $ref: "#/components/schemas/HandoffCitation" + HandoffMemoryCitation: type: object additionalProperties: false - required: [scope_id] + required: [kind, memory_citation] properties: - scope_id: + kind: type: string - minLength: 1 - maxLength: 256 - project_id: + enum: [memory] + memory_citation: + $ref: "#/components/schemas/MemoryCitation" + HandoffOmission: + type: object + additionalProperties: false + required: [text, citation] + properties: + text: type: string minLength: 1 - maxLength: 256 + maxLength: 8192 + pattern: '.*\S.*' + citation: + $ref: "#/components/schemas/HandoffCitation" + nullable: true + HandoffResolution: + type: object + additionalProperties: false + required: + [trust, status, scope_id, content, selection, selected_revision, current_revision, evidence_checks] + properties: + trust: + type: string + enum: [untrusted_history] + status: + $ref: "#/components/schemas/HandoffResolutionStatus" + scope_id: + type: string + content: + $ref: "#/components/schemas/HandoffContent" nullable: true - deprecated: true - description: Retained for wire compatibility and ignored when generating a scope report. - locale: - $ref: "#/components/schemas/ReportLocale" + selection: + $ref: "#/components/schemas/HandoffSelection" nullable: true - include_evidence_checks: - type: boolean - default: true - format: - $ref: "#/components/schemas/ReportFormat" - default: markdown - include_archived: - type: boolean - default: false - download: - type: boolean - default: false - period: - $ref: "#/components/schemas/HandoffReportPeriodRequest" + selected_revision: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + current_revision: + $ref: "#/components/schemas/ArtifactReference" nullable: true - ListHandoffReportKnownScopesRequest: + evidence_checks: + type: array + maxItems: 65 + items: + $ref: "#/components/schemas/HandoffEvidenceCheck" + HandoffSourceCitation: + type: object + additionalProperties: false + required: [kind, source_ref] + properties: + kind: + type: string + enum: [source] + source_ref: + $ref: "#/components/schemas/SourceReference" + HandoffStatement: type: object additionalProperties: false + required: [text, citations] properties: - cursor: + text: type: string - nullable: true - limit: - type: integer - minimum: 1 - maximum: 100 - default: 50 - KnownHandoffScope: + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + citations: + type: array + minItems: 1 + maxItems: 32 + items: + $ref: "#/components/schemas/HandoffCitation" + PrepareHandoffRequest: type: object additionalProperties: false - required: [scope_id] + required: [scope_id, objective, evidence] properties: scope_id: type: string minLength: 1 maxLength: 256 - KnownHandoffScopePage: - type: object - additionalProperties: false - required: [items] - properties: - items: + pattern: '.*\S.*' + objective: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + evidence: type: array + minItems: 1 + maxItems: 32 items: - $ref: "#/components/schemas/KnownHandoffScope" - next_cursor: - type: string - nullable: true - HandoffReportPeriodRequest: + $ref: "#/components/schemas/HandoffCitation" + max_bytes: + type: integer + minimum: 512 + maximum: 32768 + default: 8000 + PreparedHandoff: type: object additionalProperties: false - required: [start, end] + required: [schema, scope_id, base, content] properties: - start: - type: string - format: date-time - end: - type: string - format: date-time - timezone: + schema: + $ref: "#/components/schemas/PreparedHandoffSchema" + scope_id: type: string - minLength: 1 - maxLength: 256 + base: + $ref: "#/components/schemas/ArtifactReference" nullable: true - compare_to_previous_period: - type: boolean - default: false - HandoffReportResponse: + content: + $ref: "#/components/schemas/HandoffContent" + PreparedContext: type: object additionalProperties: false - required: [format, report, markdown, selection_digest, report_digest] + required: [schema, status, content, content_bytes] properties: - format: - $ref: "#/components/schemas/ReportFormat" - report: - type: object - additionalProperties: true - nullable: true - markdown: + schema: + $ref: "#/components/schemas/PreparedContextSchema" + status: + $ref: "#/components/schemas/PreparedContextStatus" + content: type: string nullable: true - selection_digest: - type: string - pattern: '^sha256:[0-9a-f]{64}$' - report_digest: - type: string - pattern: '^sha256:[0-9a-f]{64}$' - ReportActivitySource: - type: string - enum: [handoff_observation, git_commit, git_worktree, coding_session, other] - ReportTimeBasis: - type: string - enum: [source_reported, host_observed, first_seen, current_only, unknown] - HandoffReportActivityAgent: + content_bytes: + type: integer + minimum: 0 + EntryChange: type: object additionalProperties: false + required: [op, entry_id, from_entry_version_id, to_entry_version_id, reason] properties: - provider: + op: + $ref: "#/components/schemas/EntryChangeOperation" + entry_id: type: string minLength: 1 - maxLength: 64 - nullable: true - label: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + from_entry_version_id: type: string minLength: 1 maxLength: 128 + pattern: '^[\x21-\x7E]+$' nullable: true - HandoffReportActivityVcsContext: - type: object - additionalProperties: false - properties: - branch: + to_entry_version_id: type: string minLength: 1 - maxLength: 256 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' nullable: true - head_revision: + reason: type: string - minLength: 1 - maxLength: 256 nullable: true - RecordHandoffReportActivityRequest: + ExperienceArtifact: + type: object + additionalProperties: false + required: [artifact, content, source_refs, artifact_refs] + properties: + artifact: + $ref: "#/components/schemas/ArtifactReference" + content: + $ref: "#/components/schemas/ExperienceProposal" + source_refs: + type: array + items: + $ref: "#/components/schemas/SourceReference" + artifact_refs: + type: array + items: + $ref: "#/components/schemas/ArtifactReference" + ExperienceProposal: type: object additionalProperties: false - required: [project_id, source, source_event_id, time_basis] + required: [situation, action, outcome, lesson] properties: - project_id: + situation: type: string minLength: 1 - maxLength: 256 - scope_id: + maxLength: 8000 + pattern: '.*\S.*' + action: type: string minLength: 1 - maxLength: 256 - nullable: true - source: - $ref: "#/components/schemas/ReportActivitySource" - source_event_id: + maxLength: 8000 + pattern: '.*\S.*' + outcome: type: string minLength: 1 - maxLength: 256 - source_ref: - $ref: "#/components/schemas/HandoffReportExternalReference" - nullable: true - occurred_at: + maxLength: 8000 + pattern: '.*\S.*' + lesson: type: string - format: date-time - nullable: true - time_basis: - $ref: "#/components/schemas/ReportTimeBasis" - title: + minLength: 1 + maxLength: 8000 + pattern: '.*\S.*' + SkillArtifact: + type: object + additionalProperties: false + required: [artifact, content, source_refs, artifact_refs] + properties: + artifact: + $ref: "#/components/schemas/ArtifactReference" + content: + $ref: "#/components/schemas/SkillProposal" + source_refs: + type: array + items: + $ref: "#/components/schemas/SourceReference" + artifact_refs: + type: array + items: + $ref: "#/components/schemas/ArtifactReference" + SkillProposal: + type: object + additionalProperties: false + required: [name, description, instructions, validation] + properties: + name: type: string minLength: 1 - maxLength: 256 - nullable: true - summary: + maxLength: 128 + pattern: '^\S(?:.*\S)?$' + description: type: string minLength: 1 maxLength: 2000 - nullable: true - agent: - $ref: "#/components/schemas/HandoffReportActivityAgent" - nullable: true - session_id: + pattern: '^\S(?:.*\S)?$' + instructions: type: string minLength: 1 - maxLength: 256 - nullable: true - vcs_context: - $ref: "#/components/schemas/HandoffReportActivityVcsContext" - nullable: true - evidence_refs: + maxLength: 32000 + pattern: '.*\S.*' + validation: type: array + minItems: 1 maxItems: 32 items: - $ref: "#/components/schemas/HandoffReportExternalReference" - default: [] - HandoffReportActivity: + $ref: "#/components/schemas/SkillValidationItem" + SkillValidationItem: + type: string + minLength: 1 + maxLength: 2000 + pattern: '^\S(?:.*\S)?$' + ExternalSkillRegistration: type: object additionalProperties: false - required: [schema, event_id, project_id, scope_id, source, source_event_id, source_ref, occurred_at, observed_at, time_basis, title, summary, agent, session_id, vcs_context, evidence_refs, trust] + required: + - external_skill_id + - provider + - agent_kind + - host_id + - installation_scope + - locator + - fingerprint + - name + - description properties: - schema: - type: string - enum: [powercontext.handoff-report-activity.v1] - event_id: - type: string - minLength: 1 - maxLength: 256 - project_id: - type: string - minLength: 1 - maxLength: 256 - scope_id: - type: string - minLength: 1 - maxLength: 256 - nullable: true - source: - $ref: "#/components/schemas/ReportActivitySource" - source_event_id: + external_skill_id: type: string minLength: 1 - maxLength: 256 - source_ref: - $ref: "#/components/schemas/HandoffReportExternalReference" - nullable: true - occurred_at: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + provider: type: string - format: date-time - nullable: true - observed_at: + enum: [codex, claude_code] + agent_kind: type: string - format: date-time - time_basis: - $ref: "#/components/schemas/ReportTimeBasis" - title: + enum: [codex, claude_code] + host_id: type: string minLength: 1 - maxLength: 256 - nullable: true - summary: + maxLength: 128 + pattern: '^\S(?:.*\S)?$' + installation_scope: + $ref: "#/components/schemas/ExternalSkillInstallationScope" + locator: type: string minLength: 1 maxLength: 2000 - nullable: true - agent: - $ref: "#/components/schemas/HandoffReportActivityAgent" - nullable: true - session_id: + pattern: '^\S(?:.*\S)?$' + description: Host-local locator; not a cross-Agent or cross-host contract. + fingerprint: + type: string + pattern: '^[0-9a-f]{64}$' + name: type: string minLength: 1 - maxLength: 256 - nullable: true - vcs_context: - $ref: "#/components/schemas/HandoffReportActivityVcsContext" - nullable: true - evidence_refs: - type: array - maxItems: 32 - items: - $ref: "#/components/schemas/HandoffReportExternalReference" - trust: + maxLength: 128 + pattern: '^\S(?:.*\S)?$' + description: type: string - enum: [untrusted_observation] - StoredHandoffReportActivity: + minLength: 1 + maxLength: 2000 + pattern: '^\S(?:.*\S)?$' + ExternalSkillResolution: type: object additionalProperties: false - required: [cursor, event] + required: [registration, status, entrypoint] properties: - cursor: - type: integer - minimum: 1 - event: - $ref: "#/components/schemas/HandoffReportActivity" - ListHandoffReportActivitiesRequest: + registration: + $ref: "#/components/schemas/ExternalSkillRegistration" + status: + $ref: "#/components/schemas/ExternalSkillResolutionStatus" + entrypoint: + type: string + nullable: true + description: Host-local SKILL.md path; present only when the exact fingerprint is available. + ScanExternalSkillsResponse: type: object additionalProperties: false - required: [project_id] + required: [registrations, skipped] properties: - project_id: - type: string - minLength: 1 - maxLength: 256 - period_start: - type: string - format: date-time - nullable: true - period_end: - type: string - format: date-time - nullable: true - sources: + registrations: type: array - maxItems: 5 items: - $ref: "#/components/schemas/ReportActivitySource" - nullable: true - after_cursor: - type: integer - minimum: 0 - default: 0 - through_cursor: + $ref: "#/components/schemas/ExternalSkillRegistration" + skipped: type: integer minimum: 0 - nullable: true - limit: - type: integer - minimum: 1 - maximum: 100 - default: 50 - HandoffReportActivityPage: + ListExternalSkillsResponse: type: object additionalProperties: false - required: [items, next_cursor, high_watermark] + required: [skills] properties: - items: + skills: type: array - maxItems: 100 items: - $ref: "#/components/schemas/HandoffReportActivity" - next_cursor: - type: integer - minimum: 1 - nullable: true - high_watermark: - type: integer - minimum: 0 - PurgeHandoffReportActivitiesRequest: + $ref: "#/components/schemas/ExternalSkillResolution" + ErrorDetail: type: object additionalProperties: false - required: [project_id, observed_before] + required: [code, message, details] properties: - project_id: + code: type: string - minLength: 1 - maxLength: 256 - observed_before: + message: type: string - format: date-time - PurgeHandoffReportActivitiesResponse: + details: + type: object + additionalProperties: true + nullable: true + ErrorResponse: type: object additionalProperties: false - required: [deleted_count] + required: [error] properties: - deleted_count: - type: integer - minimum: 0 - HandoffReportRepositoryRef: + error: + $ref: "#/components/schemas/ErrorDetail" + FlushMemoryRequest: type: object additionalProperties: false - required: [provider, repository_id, normalized_remote, subpath] + required: [scope_id] properties: - provider: - type: string - enum: [github, gitlab, local, other] - repository_id: + scope_id: type: string minLength: 1 maxLength: 256 - nullable: true - normalized_remote: - type: string - minLength: 1 - maxLength: 2048 - nullable: true - subpath: - type: string - minLength: 1 - maxLength: 1024 - nullable: true - HandoffReportWorkspaceBinding: + pattern: '.*\S.*' + FlushMemoryResponse: type: object additionalProperties: false - required: [schema, workspace_instance_id, project_id, repository_ref, state, confirmed_at, version] + required: [status, previous_cursor, current_cursor, high_watermark, processed_source_count] properties: - schema: - type: string - enum: [powercontext.workspace-binding.v1] - workspace_instance_id: - type: string - minLength: 1 - maxLength: 256 - project_id: - type: string - minLength: 1 - maxLength: 256 - repository_ref: - $ref: "#/components/schemas/HandoffReportRepositoryRef" - state: - type: string - enum: [confirmed, detached] - confirmed_at: - type: string - format: date-time - version: + status: + $ref: "#/components/schemas/FlushStatus" + previous_cursor: type: integer - minimum: 1 - GetHandoffReportWorkspaceRequest: + minimum: 0 + current_cursor: + type: integer + minimum: 0 + high_watermark: + type: integer + minimum: 0 + processed_source_count: + type: integer + minimum: 0 + memory: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + GetMemoryEntryRequest: type: object additionalProperties: false - required: [workspace_instance_id] + required: [scope_id, citation] properties: - workspace_instance_id: + scope_id: type: string minLength: 1 maxLength: 256 - AttachHandoffReportWorkspaceRequest: + pattern: '.*\S.*' + citation: + $ref: "#/components/schemas/MemoryCitation" + GetArtifactCandidateRequest: type: object additionalProperties: false - required: [workspace_instance_id, project_id, repository_ref, expected_version] + required: [scope_id, candidate_id] properties: - workspace_instance_id: + scope_id: type: string minLength: 1 maxLength: 256 - project_id: + pattern: '.*\S.*' + candidate_id: type: string minLength: 1 - maxLength: 256 - repository_ref: - $ref: "#/components/schemas/HandoffReportRepositoryRef" - expected_version: - type: integer - minimum: 1 - nullable: true - DetachHandoffReportWorkspaceRequest: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + GetExperienceRequest: type: object additionalProperties: false - required: [workspace_instance_id, expected_version] + required: [scope_id, artifact] properties: - workspace_instance_id: + scope_id: type: string minLength: 1 maxLength: 256 - expected_version: - type: integer - minimum: 1 - ProjectDescriptor: + pattern: '.*\S.*' + artifact: + $ref: "#/components/schemas/ArtifactReference" + GetSkillRequest: type: object additionalProperties: false - required: [schema, project_id, project_key, title, description, default_locale, timezone, catalog_state, version] + required: [scope_id, artifact] properties: - schema: - type: string - enum: [powercontext.project.v1] - project_id: - type: string - minLength: 1 - maxLength: 256 - project_key: - type: string - minLength: 1 - maxLength: 64 - title: - type: string - minLength: 1 - maxLength: 256 - description: - type: string - maxLength: 2000 - nullable: true - default_locale: - $ref: "#/components/schemas/ReportLocale" - timezone: + scope_id: type: string minLength: 1 maxLength: 256 - catalog_state: - $ref: "#/components/schemas/ReportCatalogState" - version: - type: integer - minimum: 1 - ProjectPage: + pattern: '.*\S.*' + artifact: + $ref: "#/components/schemas/ArtifactReference" + GetHandoffReportRequest: type: object additionalProperties: false - required: [items, next_cursor] + required: [selection] properties: - items: - type: array - maxItems: 100 - items: - $ref: "#/components/schemas/ProjectDescriptor" - next_cursor: - type: string - nullable: true - WorkstreamDescriptor: + selection: + $ref: "#/components/schemas/ScopeSelection" + format: + $ref: "#/components/schemas/ReportFormat" + default: json + download: + type: boolean + default: false + HandoffReportResponse: type: object additionalProperties: false - required: [schema, scope_id, project_id, key, title, kind, catalog_state, external_refs, labels, version] + required: [format, report, markdown, selection_digest, report_digest] properties: - schema: - type: string - enum: [powercontext.workstream.v1] - scope_id: - type: string - minLength: 1 - maxLength: 256 - project_id: - type: string - minLength: 1 - maxLength: 256 - key: - type: string - maxLength: 64 + format: + $ref: "#/components/schemas/ReportFormat" + report: + type: object + additionalProperties: true nullable: true - title: - type: string - minLength: 1 - maxLength: 256 - kind: - $ref: "#/components/schemas/WorkstreamKind" - catalog_state: - $ref: "#/components/schemas/ReportCatalogState" - external_refs: - type: array - maxItems: 32 - items: - $ref: "#/components/schemas/HandoffReportExternalReference" - labels: - type: array - maxItems: 32 - items: - type: string - minLength: 1 - maxLength: 128 - version: - type: integer - minimum: 1 - WorkstreamPage: - type: object - additionalProperties: false - required: [items, next_cursor] - properties: - items: - type: array - maxItems: 100 - items: - $ref: "#/components/schemas/WorkstreamDescriptor" - next_cursor: + markdown: type: string nullable: true - HandoffReportExternalReference: - type: object - additionalProperties: false - required: [kind, provider, external_id, url] - properties: - kind: - type: string - enum: [issue, task, pull_request, branch, feature, release, program, other] - provider: - type: string - minLength: 1 - maxLength: 64 - external_id: + selection_digest: type: string - minLength: 1 - maxLength: 256 - url: + pattern: '^sha256:[0-9a-f]{64}$' + report_digest: type: string - maxLength: 2048 - nullable: true - ReportLocale: - type: string - enum: [zh-CN, en] + pattern: '^sha256:[0-9a-f]{64}$' ReportFormat: type: string enum: [json, markdown] - ReportCatalogState: - type: string - enum: [included, archived] - WorkstreamKind: - type: string - enum: [feature, bug, refactor, operations, research, other] HealthResponse: type: object additionalProperties: false diff --git a/src/powercontext/__init__.py b/src/powercontext/__init__.py index 9ae96033b..c22b37893 100644 --- a/src/powercontext/__init__.py +++ b/src/powercontext/__init__.py @@ -16,6 +16,7 @@ from powercontext.artifacts import ( Artifact, + ArtifactAddress, ArtifactCatalog, ArtifactDraft, ArtifactLineage, @@ -52,6 +53,7 @@ __all__ = [ "Artifact", + "ArtifactAddress", "ArtifactCatalog", "ArtifactDraft", "ArtifactError", diff --git a/src/powercontext/artifacts/__init__.py b/src/powercontext/artifacts/__init__.py index a68c0c2b9..fff5989b0 100644 --- a/src/powercontext/artifacts/__init__.py +++ b/src/powercontext/artifacts/__init__.py @@ -14,11 +14,12 @@ """Immutable artifacts and their read-only catalog contract.""" -from powercontext.artifacts.models import Artifact, ArtifactDraft, ArtifactLineage, ArtifactRef +from powercontext.artifacts.models import Artifact, ArtifactAddress, ArtifactDraft, ArtifactLineage, ArtifactRef from powercontext.artifacts.protocols import ArtifactCatalog, ArtifactStore __all__ = [ "Artifact", + "ArtifactAddress", "ArtifactCatalog", "ArtifactDraft", "ArtifactLineage", diff --git a/src/powercontext/artifacts/models.py b/src/powercontext/artifacts/models.py index 3451feb94..adc214ab9 100644 --- a/src/powercontext/artifacts/models.py +++ b/src/powercontext/artifacts/models.py @@ -21,7 +21,7 @@ from pydantic import BaseModel, Field, StrictInt, field_validator, model_validator from powercontext.errors import InvalidArtifactReferenceError -from powercontext.limits import MAX_ARTIFACT_FAMILY_LENGTH, MAX_ARTIFACT_ID_LENGTH +from powercontext.limits import MAX_ARTIFACT_FAMILY_LENGTH, MAX_ARTIFACT_ID_LENGTH, MAX_SCOPE_ID_LENGTH from powercontext.sources.models import SourceRef ContentT = TypeVar("ContentT", covariant=True) @@ -44,6 +44,24 @@ def validate_identity(cls, value: str, info) -> str: return value +class ArtifactAddress(BaseModel): + """A complete address for one exact Artifact revision across Scope boundaries.""" + + scope_id: str + artifact: ArtifactRef + + @field_validator("scope_id") + @classmethod + def validate_scope_id(cls, value: str) -> str: + _validate_reference_part("scope_id", value) + if len(value) > MAX_SCOPE_ID_LENGTH: + raise InvalidArtifactReferenceError( + "scope_id", + f"must not exceed {MAX_SCOPE_ID_LENGTH} characters", + ) + return value + + class ArtifactLineage(BaseModel): """The direct evidence used to produce one artifact revision.""" diff --git a/src/powercontext/builtin/artifacts/handoff/service.py b/src/powercontext/builtin/artifacts/handoff/service.py index 8c46dbbd1..3095431ac 100644 --- a/src/powercontext/builtin/artifacts/handoff/service.py +++ b/src/powercontext/builtin/artifacts/handoff/service.py @@ -167,7 +167,7 @@ async def continue_from( ) async def continue_latest(self) -> HandoffResolution: - """Resolve the latest milestone after the caller selects the current workstream.""" + """Resolve the latest milestone after the caller selects the current Scope.""" current = await self._backend.latest(self.artifact_id) if current is None: diff --git a/src/powercontext/builtin/handoff_report/__init__.py b/src/powercontext/builtin/handoff_report/__init__.py index 16af8671b..873247d2b 100644 --- a/src/powercontext/builtin/handoff_report/__init__.py +++ b/src/powercontext/builtin/handoff_report/__init__.py @@ -12,14 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Optional, read-only Handoff Report domain values.""" +"""Scope-based Handoff Report projection.""" -from powercontext.builtin.handoff_report.adapters import RuntimeHandoffReadAdapter, RuntimeWorkContinuityReadAdapter -from powercontext.builtin.handoff_report.application import ( - HandoffReportApplication, - ReportActivityPage, - ReportPeriodInput, -) +from powercontext.builtin.handoff_report.adapters import RuntimeHandoffReadAdapter +from powercontext.builtin.handoff_report.application import HandoffReportApplication from powercontext.builtin.handoff_report.canonical import ( ReportCanonicalizationError, canonical_json_bytes, @@ -28,164 +24,34 @@ selection_digest, selection_envelope, ) -from powercontext.builtin.handoff_report.catalog import HandoffReportCatalog, ProjectIdFactory -from powercontext.builtin.handoff_report.catalog_store import ( - DEFAULT_CATALOG_PAGE_SIZE, - HANDOFF_REPORT_CATALOG_TABLES, - MAX_CATALOG_PAGE_SIZE, - CatalogPage, - ReportCatalogRepository, -) from powercontext.builtin.handoff_report.errors import ( - HandoffReportBusyError, - HandoffReportCatalogArgumentError, HandoffReportError, - HandoffReportEvidenceCheckUnavailableError, HandoffReportInconsistentError, HandoffReportTooLargeError, - InvalidStoredCatalogError, - ProjectConflictError, - ProjectNotFoundError, - ScopeAlreadyGroupedError, - WorkspaceBindingConflictError, - WorkspaceBindingNotFoundError, - WorkstreamConflictError, - WorkstreamNotFoundError, -) -from powercontext.builtin.handoff_report.models import ( - ActivityAgent, - ActivityVcsContext, - CatalogState, - ExternalReference, - ExternalReferenceKind, - GeneratedSummaryTrust, - HandoffReportTrust, - ProjectDescriptor, - ReportActivityEvent, - ReportActivitySource, - ReportActivityTrust, - ReportLocale, - ReportSelectionConsistency, - ReportSelectionEntry, - ReportSelectionStatus, - ReportTimeBasis, - RepositoryProvider, - RepositoryRef, - WorkspaceBinding, - WorkspaceBindingState, - WorkstreamDescriptor, - WorkstreamKind, - activity_sort_key, - normalize_repository_ref, - normalized_sort_text, - selection_sort_key, - workstream_sort_key, ) from powercontext.builtin.handoff_report.rendering import render_markdown from powercontext.builtin.handoff_report.report import ( HandoffReport, - HandoffRevisionSummary, - ReportActivityCoverageStatus, - ReportActivityStatus, - ReportCoverage, - ReportEvidenceChecks, - ReportFormat, - ReportHandoffActivityRelation, - ReportKind, - ReportPeriodComparison, - ReportReportingStatus, - ReportSummary, - ReportWorkStatus, - WorkstreamReport, -) -from powercontext.builtin.handoff_report.selection import select_optimistic_stable_handoffs -from powercontext.builtin.handoff_report.service import HandoffReportService -from powercontext.builtin.handoff_report.workspace import WorkspaceBindingService -from powercontext.builtin.handoff_report.workspace_store import ( - HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE, - HANDOFF_REPORT_WORKSPACE_TABLES, - WorkspaceBindingRepository, + HandoffReportStatus, + HandoffReportSummary, + ScopeHandoffReport, ) __all__ = [ - "DEFAULT_CATALOG_PAGE_SIZE", - "HANDOFF_REPORT_CATALOG_TABLES", - "HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE", - "HANDOFF_REPORT_WORKSPACE_TABLES", - "MAX_CATALOG_PAGE_SIZE", - "ActivityAgent", - "ActivityVcsContext", - "CatalogPage", - "CatalogState", - "ExternalReference", - "ExternalReferenceKind", - "GeneratedSummaryTrust", "HandoffReport", "HandoffReportApplication", - "HandoffReportBusyError", - "HandoffReportCatalog", - "HandoffReportCatalogArgumentError", "HandoffReportError", - "HandoffReportEvidenceCheckUnavailableError", "HandoffReportInconsistentError", - "HandoffReportService", + "HandoffReportStatus", + "HandoffReportSummary", "HandoffReportTooLargeError", - "HandoffReportTrust", - "HandoffRevisionSummary", - "InvalidStoredCatalogError", - "ProjectConflictError", - "ProjectDescriptor", - "ProjectIdFactory", - "ProjectNotFoundError", - "ReportActivityCoverageStatus", - "ReportActivityEvent", - "ReportActivityPage", - "ReportActivitySource", - "ReportActivityStatus", - "ReportActivityTrust", "ReportCanonicalizationError", - "ReportCatalogRepository", - "ReportCoverage", - "ReportEvidenceChecks", - "ReportFormat", - "ReportHandoffActivityRelation", - "ReportKind", - "ReportLocale", - "ReportPeriodComparison", - "ReportPeriodInput", - "ReportReportingStatus", - "ReportSelectionConsistency", - "ReportSelectionEntry", - "ReportSelectionStatus", - "ReportSummary", - "ReportTimeBasis", - "ReportWorkStatus", - "RepositoryProvider", - "RepositoryRef", "RuntimeHandoffReadAdapter", - "RuntimeWorkContinuityReadAdapter", - "ScopeAlreadyGroupedError", - "WorkspaceBinding", - "WorkspaceBindingConflictError", - "WorkspaceBindingNotFoundError", - "WorkspaceBindingRepository", - "WorkspaceBindingService", - "WorkspaceBindingState", - "WorkstreamConflictError", - "WorkstreamDescriptor", - "WorkstreamKind", - "WorkstreamNotFoundError", - "WorkstreamReport", - "activity_sort_key", + "ScopeHandoffReport", "canonical_json_bytes", "finalize_digests", - "normalize_repository_ref", - "normalized_sort_text", "render_markdown", "report_digest", - "select_optimistic_stable_handoffs", "selection_digest", "selection_envelope", - "selection_sort_key", - "workstream_sort_key", ] diff --git a/src/powercontext/builtin/handoff_report/adapters.py b/src/powercontext/builtin/handoff_report/adapters.py index d43175cd8..6f617edeb 100644 --- a/src/powercontext/builtin/handoff_report/adapters.py +++ b/src/powercontext/builtin/handoff_report/adapters.py @@ -12,19 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Adapters from existing Runtime behavior into Handoff Report read ports.""" +"""Runtime adapter for read-only Handoff report projection.""" from __future__ import annotations from typing import Protocol from powercontext.artifacts import ArtifactRef -from powercontext.builtin.artifacts.handoff import ( - Handoff, - HandoffEvidenceCheck, -) -from powercontext.builtin.handoff_report.errors import HandoffReportEvidenceCheckUnavailableError -from powercontext.builtin.work import WorkContinuity +from powercontext.builtin.artifacts.handoff import Handoff class _ScopedHandoffReader(Protocol): @@ -32,24 +27,12 @@ async def latest(self) -> Handoff | None: ... async def revision(self, reference: ArtifactRef, /) -> Handoff: ... - async def revisions(self) -> tuple[Handoff, ...]: ... - class _HandoffApplicationReader(Protocol): def for_scope(self, scope_id: str, /) -> _ScopedHandoffReader: ... -class _ScopedWorkReader(Protocol): - async def continuity(self, selected_handoff: ArtifactRef | None = None) -> WorkContinuity: ... - - -class _WorkApplicationReader(Protocol): - def for_scope(self, scope_id: str, /) -> _ScopedWorkReader: ... - - class RuntimeHandoffReadAdapter: - """Use the existing public Runtime Handoff application as a read-only source.""" - def __init__(self, application: _HandoffApplicationReader, /) -> None: self._application = application @@ -59,30 +42,5 @@ async def latest(self, scope_id: str, /) -> Handoff | None: async def get(self, scope_id: str, reference: ArtifactRef, /) -> Handoff: return await self._application.for_scope(scope_id).revision(reference) - async def revisions(self, scope_id: str, /) -> tuple[Handoff, ...]: - return await self._application.for_scope(scope_id).revisions() - - async def check_evidence( - self, - scope_id: str, - reference: ArtifactRef, - /, - ) -> tuple[HandoffEvidenceCheck, ...]: - del scope_id, reference - # The current Runtime exposes evidence checks through Continue only. - # Report must not enter that control flow, so it degrades explicitly - # until an independent read-only capability exists. - raise HandoffReportEvidenceCheckUnavailableError - - -class RuntimeWorkContinuityReadAdapter: - """Project Work continuity through the Runtime's high-level read application.""" - - def __init__(self, application: _WorkApplicationReader, /) -> None: - self._application = application - - async def get(self, scope_id: str, reference: ArtifactRef | None, /) -> WorkContinuity: - return await self._application.for_scope(scope_id).continuity(reference) - -__all__ = ["RuntimeHandoffReadAdapter", "RuntimeWorkContinuityReadAdapter"] +__all__ = ["RuntimeHandoffReadAdapter"] diff --git a/src/powercontext/builtin/handoff_report/application.py b/src/powercontext/builtin/handoff_report/application.py index 0135c4afb..2f5ad4039 100644 --- a/src/powercontext/builtin/handoff_report/application.py +++ b/src/powercontext/builtin/handoff_report/application.py @@ -12,412 +12,71 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Runtime-facing application service for Handoff Report operations.""" +"""Read-only Handoff reports over a resolved Scope selection.""" from __future__ import annotations -import json -from bisect import bisect_right -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from datetime import UTC, datetime, timedelta -from typing import cast -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError +from datetime import UTC, datetime -from pydantic import JsonValue - -from powercontext.builtin.handoff_report.catalog import HandoffReportCatalog -from powercontext.builtin.handoff_report.catalog_store import ( - DEFAULT_CATALOG_PAGE_SIZE, - CatalogPage, -) -from powercontext.builtin.handoff_report.errors import HandoffReportCatalogArgumentError -from powercontext.builtin.handoff_report.models import ( - CatalogState, - ExternalReference, - ProjectDescriptor, - ReportActivityEvent, - ReportLocale, - RepositoryRef, - WorkspaceBinding, - WorkstreamDescriptor, - WorkstreamKind, -) -from powercontext.builtin.handoff_report.protocols import HandoffReadAdapter, WorkContinuityReadAdapter -from powercontext.builtin.handoff_report.report import HandoffReport, ReportFormat -from powercontext.builtin.handoff_report.repository import ActivityEventRepository, StoredActivityEvent -from powercontext.builtin.handoff_report.service import HandoffReportService -from powercontext.builtin.handoff_report.sqlite import SQLiteActivityEventRepository -from powercontext.builtin.handoff_report.workspace import WorkspaceBindingService -from powercontext.builtin.persistence.database import AsyncDatabase -from powercontext.builtin.sources import validate_scope_id - - -@dataclass(frozen=True, slots=True) -class ReportActivityPage: - """One cursor page plus the frozen current Project high watermark.""" - - items: tuple[ReportActivityEvent, ...] - next_cursor: int | None - high_watermark: int - - -@dataclass(frozen=True, slots=True) -class ReportPeriodInput: - """Explicit half-open period requested by a Report consumer.""" - - start: datetime - end: datetime - timezone: str | None = None - compare_to_previous_period: bool = False - - -@dataclass(frozen=True, slots=True) -class KnownScopePage: - """One cursor page of scopes that contain a committed Handoff.""" - - items: tuple[str, ...] - next_cursor: str | None +from powercontext.artifacts import ArtifactAddress +from powercontext.builtin.handoff_report.canonical import finalize_digests +from powercontext.builtin.handoff_report.errors import HandoffReportInconsistentError +from powercontext.builtin.handoff_report.protocols import HandoffReadAdapter, ScopeSelectionResolver +from powercontext.builtin.handoff_report.report import HandoffReport, HandoffReportSummary, ScopeHandoffReport +from powercontext.builtin.scope.models import ScopeSelection class HandoffReportApplication: - """Coordinate Report-owned persistence with the existing Handoff read port.""" - - def __init__( - self, - database: AsyncDatabase, - handoffs: HandoffReadAdapter, - /, - *, - activities: ActivityEventRepository | None = None, - workspace_bindings: WorkspaceBindingService | None = None, - continuity: WorkContinuityReadAdapter | None = None, - scope_ids: Callable[[], Awaitable[tuple[str, ...]]] | None = None, - ) -> None: - self._database = database - self._catalog = HandoffReportCatalog() - self._activities = SQLiteActivityEventRepository() if activities is None else activities - self._workspace_bindings = WorkspaceBindingService() if workspace_bindings is None else workspace_bindings - self._reports = HandoffReportService(handoffs, continuity=continuity) - self._scope_ids = scope_ids - - async def list_known_scopes( - self, - *, - cursor: str | None = None, - limit: int = DEFAULT_CATALOG_PAGE_SIZE, - ) -> KnownScopePage: - """List exact scope identities backed by a committed Handoff.""" - - if limit < 1 or limit > 100: - raise HandoffReportCatalogArgumentError("limit", "must be between 1 and 100") - if cursor is not None and (not cursor.strip() or cursor != cursor.strip()): - raise HandoffReportCatalogArgumentError("cursor", "must be non-empty trimmed text") - scopes = () if self._scope_ids is None else tuple(sorted(set(await self._scope_ids()))) - start = 0 if cursor is None else bisect_right(scopes, cursor) - items = scopes[start : start + limit] - next_cursor = items[-1] if start + len(items) < len(scopes) else None - return KnownScopePage(items=items, next_cursor=next_cursor) - - async def create_project( - self, - *, - project_key: str, - title: str, - description: str | None = None, - default_locale: ReportLocale = "zh-CN", - timezone: str = "UTC", - effective_at: datetime | None = None, - ) -> ProjectDescriptor: - async with self._database.transaction() as connection: - return await self._catalog.create_project( - connection, - project_key=project_key, - title=title, - description=description, - default_locale=default_locale, - timezone=timezone, - effective_at=effective_at, - ) - - async def get_project(self, project_id: str, /) -> ProjectDescriptor: - async with self._database.transaction() as connection: - return await self._catalog.get_project(connection, project_id) - - async def update_project( - self, - descriptor: ProjectDescriptor, - expected_version: int, - /, - *, - effective_at: datetime | None = None, - ) -> ProjectDescriptor: - async with self._database.transaction() as connection: - return await self._catalog.update_project( - connection, - descriptor, - expected_version, - effective_at=effective_at, - ) - - async def list_projects( - self, - *, - cursor: str | None = None, - limit: int = DEFAULT_CATALOG_PAGE_SIZE, - include_archived: bool = False, - ) -> CatalogPage[ProjectDescriptor]: - async with self._database.transaction() as connection: - return await self._catalog.list_projects( - connection, - cursor=cursor, - limit=limit, - include_archived=include_archived, - ) - - async def register_workstream( - self, - *, - project_id: str, - scope_id: str, - title: str, - kind: WorkstreamKind, - key: str | None = None, - catalog_state: CatalogState = "included", - external_refs: tuple[ExternalReference, ...] = (), - labels: tuple[str, ...] = (), - effective_at: datetime | None = None, - ) -> WorkstreamDescriptor: - async with self._database.transaction() as connection: - return await self._catalog.register_workstream( - connection, - project_id=project_id, - scope_id=scope_id, - title=title, - kind=kind, - key=key, - catalog_state=catalog_state, - external_refs=external_refs, - labels=labels, - effective_at=effective_at, - ) - - async def list_workstreams( - self, - project_id: str, - /, - *, - cursor: str | None = None, - limit: int = DEFAULT_CATALOG_PAGE_SIZE, - include_archived: bool = False, - ) -> CatalogPage[WorkstreamDescriptor]: - async with self._database.transaction() as connection: - return await self._catalog.list_workstreams( - connection, - project_id, - cursor=cursor, - limit=limit, - include_archived=include_archived, - ) - - async def update_workstream( - self, - descriptor: WorkstreamDescriptor, - expected_version: int, - /, - *, - effective_at: datetime | None = None, - ) -> WorkstreamDescriptor: - async with self._database.transaction() as connection: - return await self._catalog.update_workstream( - connection, - descriptor, - expected_version, - effective_at=effective_at, - ) - - async def record_activity(self, event: ReportActivityEvent, /) -> StoredActivityEvent: - """Record an explicit observation without entering the Handoff write path.""" - - async with self._database.transaction() as connection: - await self._catalog.get_project(connection, event.project_id) - return await self._activities.record(connection, event) - - async def list_activities( - self, - project_id: str, - /, - *, - period_start: datetime | None = None, - period_end: datetime | None = None, - sources: tuple[str, ...] | None = None, - after_cursor: int = 0, - through_cursor: int | None = None, - limit: int = DEFAULT_CATALOG_PAGE_SIZE, - ) -> ReportActivityPage: - """Read a stable cursor page from the Report-owned Activity Store.""" - - async with self._database.transaction() as connection: - await self._catalog.get_project(connection, project_id) - high_watermark = await self._activities.high_watermark(connection, project_id) - frozen_cursor = high_watermark if through_cursor is None else through_cursor - stored = await self._activities.list( - connection, - project_id, - period_start=period_start, - period_end=period_end, - sources=sources, - after_cursor=after_cursor, - through_cursor=frozen_cursor, - limit=limit + 1, - ) - has_more = len(stored) > limit - selected = stored[:limit] - return ReportActivityPage( - items=tuple(_activity_event(item) for item in selected), - next_cursor=selected[-1].cursor if has_more and selected else None, - high_watermark=high_watermark, - ) - - async def purge_activities(self, project_id: str, observed_before: datetime, /) -> int: - """Purge only Report-owned Activity rows for one Project.""" - - async with self._database.transaction() as connection: - await self._catalog.get_project(connection, project_id) - return await self._activities.purge(connection, project_id, observed_before) + """Build exact Handoff state without introducing another organization model.""" - async def get_workspace_binding(self, workspace_instance_id: str, /) -> WorkspaceBinding: - async with self._database.transaction() as connection: - return await self._workspace_bindings.get(connection, workspace_instance_id) - - async def attach_workspace_binding( - self, - *, - workspace_instance_id: str, - project_id: str, - repository_ref: RepositoryRef, - expected_version: int | None, - ) -> WorkspaceBinding: - async with self._database.transaction() as connection: - return await self._workspace_bindings.attach( - connection, - workspace_instance_id=workspace_instance_id, - project_id=project_id, - repository_ref=repository_ref, - expected_version=expected_version, - ) - - async def detach_workspace_binding( - self, - workspace_instance_id: str, - expected_version: int, - /, - ) -> WorkspaceBinding: - async with self._database.transaction() as connection: - return await self._workspace_bindings.detach(connection, workspace_instance_id, expected_version) + def __init__(self, scopes: ScopeSelectionResolver, handoffs: HandoffReadAdapter, /) -> None: + self._scopes = scopes + self._handoffs = handoffs async def get_report( self, - scope_id: str, + selection: ScopeSelection, /, *, - locale: ReportLocale | None = None, - include_evidence_checks: bool = True, - report_format: ReportFormat = "markdown", - include_archived: bool = False, - normalized_filters: dict[str, JsonValue] | None = None, - period: ReportPeriodInput | None = None, + generated_at: datetime | None = None, ) -> HandoffReport: - del include_archived - project = _scope_report_project(scope_id) - workstreams = (_scope_report_workstream(scope_id),) - period_values = _normalize_period(project, period) - normalized_period = _normalized_period(period, period_values) - return await self._reports.generate( - project, - workstreams, - locale=locale, - include_evidence_checks=include_evidence_checks, - activities=(), - activity_cursor=0, - activity_coverage="not_configured", - report_format=report_format, - report_kind="handoff" if period is None else "periodic", - normalized_filters={} if normalized_filters is None else normalized_filters, - normalized_period=normalized_period, - period_comparison=None, - ) - - -def _scope_report_project(scope_id: str) -> ProjectDescriptor: - scope = validate_scope_id(scope_id) - return ProjectDescriptor( - project_id="unused", - project_key="unused", - title=scope, - default_locale="zh-CN", - timezone="UTC", - version=1, - ) - - -def _scope_report_workstream(scope_id: str) -> WorkstreamDescriptor: - scope = validate_scope_id(scope_id) - return WorkstreamDescriptor( - scope_id=scope, - project_id="unused", - title=scope, - kind="other", - version=1, - ) - - -def _normalized_period( - period: ReportPeriodInput | None, - values: tuple[datetime, datetime, str] | None, -) -> dict[str, JsonValue] | None: - if values is None: - return None - requested = cast(ReportPeriodInput, period) - start, end, timezone = values - return { - "start": _utc_text(start), - "end": _utc_text(end), - "timezone": timezone, - "compare_to_previous_period": requested.compare_to_previous_period, - } - - -def _activity_event(value: StoredActivityEvent) -> ReportActivityEvent: - return ReportActivityEvent.model_validate_json(json.dumps(value.payload)) - - -def _normalize_period( - project: ProjectDescriptor, - period: ReportPeriodInput | None, -) -> tuple[datetime, datetime, str] | None: - if period is None: - return None - if period.start.tzinfo is None or period.start.utcoffset() is None: - raise HandoffReportCatalogArgumentError("period.start", "must include a UTC offset") - if period.end.tzinfo is None or period.end.utcoffset() is None: - raise HandoffReportCatalogArgumentError("period.end", "must include a UTC offset") - start = period.start.astimezone(UTC) - end = period.end.astimezone(UTC) - if start >= end: - raise HandoffReportCatalogArgumentError("period", "start must precede end") - if end - start > timedelta(days=366): - raise HandoffReportCatalogArgumentError("period", "must not exceed 366 days") - timezone = project.timezone if period.timezone is None else period.timezone - try: - ZoneInfo(timezone) - except ZoneInfoNotFoundError as error: - raise HandoffReportCatalogArgumentError("period.timezone", "must be a recognized IANA timezone") from error - return start, end, timezone - + """Resolve one common selection and freeze each Scope at an exact Handoff revision.""" + + scopes = await self._scopes.resolve_selection(selection) + projected: list[ScopeHandoffReport] = [] + for scope in scopes: + latest = await self._handoffs.latest(scope.scope_id) + if latest is None: + projected.append(ScopeHandoffReport(scope=scope, status="no_handoff")) + continue + + reference = latest.as_ref() + frozen = await self._handoffs.get(scope.scope_id, reference) + if frozen.as_ref() != reference: + raise HandoffReportInconsistentError(scope.scope_id) + projected.append( + ScopeHandoffReport( + scope=scope, + status=frozen.content.disposition, + handoff=ArtifactAddress(scope_id=scope.scope_id, artifact=reference), + content=frozen.content, + ) + ) -def _utc_text(value: datetime) -> str: - return value.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z") + entries = tuple(projected) + report = HandoffReport( + selection=selection, + scope_ids=tuple(scope.scope_id for scope in scopes), + generated_at=datetime.now(UTC) if generated_at is None else generated_at, + summary=HandoffReportSummary( + continuable_count=sum(entry.status == "continuable" for entry in entries), + blocked_count=sum(entry.status == "blocked" for entry in entries), + complete_count=sum(entry.status == "complete" for entry in entries), + no_handoff_count=sum(entry.status == "no_handoff" for entry in entries), + ), + scopes=entries, + ) + return finalize_digests(report) -__all__ = ["HandoffReportApplication", "KnownScopePage", "ReportActivityPage", "ReportPeriodInput"] +__all__ = ["HandoffReportApplication"] diff --git a/src/powercontext/builtin/handoff_report/canonical.py b/src/powercontext/builtin/handoff_report/canonical.py index 65e8a084d..eea1b0b1b 100644 --- a/src/powercontext/builtin/handoff_report/canonical.py +++ b/src/powercontext/builtin/handoff_report/canonical.py @@ -12,93 +12,54 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Canonical JSON and digest helpers for Handoff Reports.""" +"""Canonical digests for Handoff Report snapshots.""" + +# ruff: noqa: TRY003 from __future__ import annotations from collections.abc import Mapping, Sequence from datetime import UTC, datetime from hashlib import sha256 -from typing import TYPE_CHECKING, Any, cast +from typing import Any, cast from unicodedata import normalize import rfc8785 from pydantic import BaseModel, JsonValue -if TYPE_CHECKING: - from powercontext.builtin.handoff_report.report import HandoffReport +from powercontext.builtin.handoff_report.report import HandoffReport class ReportCanonicalizationError(ValueError): - """Raised when a report digest input cannot be represented canonically.""" - - def __init__(self, code: str, detail: object | None = None) -> None: - messages = { - "unknown-event": f"activity selection references unknown event {detail!r}", - "timestamp": "digest timestamps must be timezone-aware", - "float": "digest inputs must not contain floating-point values", - "key-type": "digest object keys must be strings", - "key-collision": "digest object keys collide after NFC normalization", - "unsupported-type": f"digest input contains unsupported value type {detail}", - } - super().__init__(messages[code]) + pass def canonical_json_bytes(value: object, /) -> bytes: - """Return RFC 8785 JSON bytes after applying the report NFC rules.""" - return rfc8785.dumps(cast(Any, _normalize_json(value))) def selection_envelope(report: HandoffReport, /) -> dict[str, object]: - """Build the locale-independent exact selection envelope for one report.""" - - events = {event.event_id: event for item in report.workstreams for event in item.activities} - events.update({event.event_id: event for event in report.unassigned_activity}) - activity_selection = [] - for event_id in report.activity_selection: - event = events.get(event_id) - if event is None: - raise ReportCanonicalizationError("unknown-event", event_id) - activity_selection.append({ - "event_id": event.event_id, - "source": event.source, - "source_event_id": event.source_event_id, - "occurred_at": event.occurred_at, - "observed_at": event.observed_at, - "time_basis": event.time_basis, - }) + """Describe the exact resolved observation independently of rendering time.""" + return { - "schema": "powercontext.handoff-report-selection.v1", - "project_id": report.project.project_id, - "project_revision": report.project_revision, - "normalized_filters": report.normalized_filters, - "normalized_period": report.normalized_period, - "selection_consistency": report.selection_consistency, - "activity_cursor": report.activity_cursor, - "baseline_selection": report.baseline_selection, - "end_selection": report.end_selection, - "activity_selection": activity_selection, + "schema": "powercontext.handoff-report-selection.v2", + "selection": report.selection, + "scope_ids": report.scope_ids, + "handoffs": tuple(entry.handoff for entry in report.scopes), } def selection_digest(report: HandoffReport, /) -> str: - """Hash the exact selection independently of locale and renderer.""" - return _digest(selection_envelope(report)) def report_digest(report: HandoffReport, /) -> str: - """Hash the complete report payload, excluding its own digest field.""" - payload = report.model_dump(mode="python", by_alias=True, exclude_none=False) payload.pop("report_digest", None) return _digest(payload) def finalize_digests(report: HandoffReport, /) -> HandoffReport: - """Return a report with selection and output-specific digests populated.""" - selected = report.model_copy(update={"selection_digest": selection_digest(report)}) return selected.model_copy(update={"report_digest": report_digest(selected)}) @@ -107,40 +68,32 @@ def _digest(value: object) -> str: return f"sha256:{sha256(canonical_json_bytes(value)).hexdigest()}" -def _normalize_json(value: object) -> JsonValue: +def _normalize_json(value: object) -> JsonValue: # noqa: C901 if isinstance(value, BaseModel): return _normalize_json(value.model_dump(mode="python", by_alias=True, exclude_none=False)) if isinstance(value, datetime): if value.tzinfo is None or value.utcoffset() is None: - raise ReportCanonicalizationError("timestamp") + raise ReportCanonicalizationError("digest timestamps must be timezone-aware") return value.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z") if isinstance(value, str): return normalize("NFC", value) if value is None or isinstance(value, (bool, int)): return value if isinstance(value, float): - raise ReportCanonicalizationError("float") + raise ReportCanonicalizationError("digest inputs must not contain floating-point values") if isinstance(value, Mapping): - return _normalize_mapping(cast(Mapping[object, object], value)) + normalized: dict[str, JsonValue] = {} + for key, item in cast(Mapping[object, object], value).items(): + if not isinstance(key, str): + raise ReportCanonicalizationError("digest object keys must be strings") + normalized_key = normalize("NFC", key) + if normalized_key in normalized: + raise ReportCanonicalizationError("digest object keys collide after normalization") + normalized[normalized_key] = _normalize_json(item) + return normalized if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray, memoryview)): - return _normalize_sequence(value) - raise ReportCanonicalizationError("unsupported-type", type(value).__name__) - - -def _normalize_mapping(value: Mapping[object, object]) -> dict[str, JsonValue]: - normalized: dict[str, JsonValue] = {} - for key, item in value.items(): - if not isinstance(key, str): - raise ReportCanonicalizationError("key-type") - normalized_key = normalize("NFC", key) - if normalized_key in normalized: - raise ReportCanonicalizationError("key-collision") - normalized[normalized_key] = _normalize_json(item) - return normalized - - -def _normalize_sequence(value: Sequence[object]) -> list[JsonValue]: - return [_normalize_json(item) for item in value] + return [_normalize_json(item) for item in value] + raise ReportCanonicalizationError(f"unsupported digest value: {type(value).__name__}") __all__ = [ diff --git a/src/powercontext/builtin/handoff_report/catalog.py b/src/powercontext/builtin/handoff_report/catalog.py deleted file mode 100644 index 09bce97f9..000000000 --- a/src/powercontext/builtin/handoff_report/catalog.py +++ /dev/null @@ -1,223 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Application service for the Report-owned Project catalog.""" - -from __future__ import annotations - -from collections.abc import Callable -from datetime import datetime -from uuid import uuid4 - -from sqlalchemy.ext.asyncio import AsyncConnection - -from powercontext.builtin.handoff_report.catalog_store import ( - DEFAULT_CATALOG_PAGE_SIZE, - CatalogPage, - ReportCatalogRepository, -) -from powercontext.builtin.handoff_report.models import ( - CatalogState, - ExternalReference, - ProjectDescriptor, - ReportLocale, - WorkstreamDescriptor, - WorkstreamKind, -) - -ProjectIdFactory = Callable[[], str] - - -class HandoffReportCatalog: - """Coordinate server-generated Project identity with catalog persistence. - - The service only mutates Report-owned catalog tables. It never creates a - Core scope, writes Handoff data, or infers membership from repository - signals. - """ - - def __init__( - self, - repository: ReportCatalogRepository | None = None, - *, - project_id_factory: ProjectIdFactory | None = None, - ) -> None: - self._repository = ReportCatalogRepository() if repository is None else repository - self._project_id_factory = _new_project_id if project_id_factory is None else project_id_factory - - async def create_project( - self, - connection: AsyncConnection, - *, - project_key: str, - title: str, - description: str | None = None, - default_locale: ReportLocale = "zh-CN", - timezone: str = "UTC", - effective_at: datetime | None = None, - ) -> ProjectDescriptor: - descriptor = ProjectDescriptor( - project_id=self._project_id_factory(), - project_key=project_key, - title=title, - description=description, - default_locale=default_locale, - timezone=timezone, - version=1, - ) - return await self._repository.create_project(connection, descriptor, effective_at=effective_at) - - async def get_project(self, connection: AsyncConnection, project_id: str, /) -> ProjectDescriptor: - return await self._repository.get_project(connection, project_id) - - async def list_projects( - self, - connection: AsyncConnection, - /, - *, - cursor: str | None = None, - limit: int = DEFAULT_CATALOG_PAGE_SIZE, - include_archived: bool = False, - ) -> CatalogPage[ProjectDescriptor]: - return await self._repository.list_projects( - connection, - cursor=cursor, - limit=limit, - include_archived=include_archived, - ) - - async def update_project( - self, - connection: AsyncConnection, - descriptor: ProjectDescriptor, - expected_version: int, - *, - effective_at: datetime | None = None, - ) -> ProjectDescriptor: - return await self._repository.update_project( - connection, - descriptor, - expected_version, - effective_at=effective_at, - ) - - async def project_revision( - self, - connection: AsyncConnection, - project_id: str, - version: int, - /, - ) -> ProjectDescriptor: - return await self._repository.project_revision(connection, project_id, version) - - async def project_at( - self, - connection: AsyncConnection, - project_id: str, - effective_at: datetime, - /, - ) -> ProjectDescriptor | None: - return await self._repository.project_at(connection, project_id, effective_at) - - async def register_workstream( - self, - connection: AsyncConnection, - *, - project_id: str, - scope_id: str, - title: str, - kind: WorkstreamKind, - key: str | None = None, - catalog_state: CatalogState = "included", - external_refs: tuple[ExternalReference, ...] = (), - labels: tuple[str, ...] = (), - effective_at: datetime | None = None, - ) -> WorkstreamDescriptor: - descriptor = WorkstreamDescriptor( - scope_id=scope_id, - project_id=project_id, - key=key, - title=title, - kind=kind, - catalog_state=catalog_state, - external_refs=external_refs, - labels=labels, - version=1, - ) - return await self._repository.create_workstream(connection, descriptor, effective_at=effective_at) - - async def get_workstream(self, connection: AsyncConnection, scope_id: str, /) -> WorkstreamDescriptor: - return await self._repository.get_workstream(connection, scope_id) - - async def list_workstreams( - self, - connection: AsyncConnection, - project_id: str, - /, - *, - cursor: str | None = None, - limit: int = DEFAULT_CATALOG_PAGE_SIZE, - include_archived: bool = False, - ) -> CatalogPage[WorkstreamDescriptor]: - return await self._repository.list_workstreams( - connection, - project_id, - cursor=cursor, - limit=limit, - include_archived=include_archived, - ) - - async def update_workstream( - self, - connection: AsyncConnection, - descriptor: WorkstreamDescriptor, - expected_version: int, - *, - effective_at: datetime | None = None, - ) -> WorkstreamDescriptor: - return await self._repository.update_workstream( - connection, - descriptor, - expected_version, - effective_at=effective_at, - ) - - async def workstream_revision( - self, - connection: AsyncConnection, - scope_id: str, - version: int, - /, - ) -> WorkstreamDescriptor: - return await self._repository.workstream_revision(connection, scope_id, version) - - async def workstream_at( - self, - connection: AsyncConnection, - scope_id: str, - effective_at: datetime, - /, - ) -> WorkstreamDescriptor | None: - return await self._repository.workstream_at(connection, scope_id, effective_at) - - async def project_for_scope(self, connection: AsyncConnection, scope_id: str, /) -> ProjectDescriptor: - workstream = await self._repository.get_workstream(connection, scope_id) - return await self._repository.get_project(connection, workstream.project_id) - - -def _new_project_id() -> str: - return f"prj_{uuid4().hex}" - - -__all__ = ["HandoffReportCatalog", "ProjectIdFactory"] diff --git a/src/powercontext/builtin/handoff_report/catalog_store.py b/src/powercontext/builtin/handoff_report/catalog_store.py deleted file mode 100644 index dda79e185..000000000 --- a/src/powercontext/builtin/handoff_report/catalog_store.py +++ /dev/null @@ -1,751 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Report-owned Project and Workstream catalog persistence. - -The tables in this module deliberately keep ``scope_id`` opaque. They are -application-layer metadata and do not create foreign keys into Core Handoff, -Artifact, Source, Memory, or Context tables. -""" - -from __future__ import annotations - -import json -from collections.abc import Mapping -from datetime import UTC, datetime -from typing import Any, Generic, TypeVar - -from pydantic import ValidationError -from sqlalchemy import ( - CheckConstraint, - Column, - Index, - Integer, - MetaData, - Table, - Text, - UniqueConstraint, - insert, - select, - update, -) -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncConnection -from typing_extensions import override - -from powercontext.builtin.handoff_report.errors import ( - HandoffReportCatalogArgumentError, - InvalidStoredCatalogError, - ProjectConflictError, - ProjectNotFoundError, - ScopeAlreadyGroupedError, - WorkstreamConflictError, - WorkstreamNotFoundError, -) -from powercontext.builtin.handoff_report.models import ( - MAX_PROJECT_KEY_LENGTH, - MAX_REPORT_ID_LENGTH, - MAX_WORKSTREAM_KEY_LENGTH, - ProjectDescriptor, - WorkstreamDescriptor, -) -from powercontext.builtin.persistence.tables import identity_string -from powercontext.limits import MAX_SCOPE_ID_LENGTH - -HANDOFF_REPORT_CATALOG_METADATA = MetaData() - -HANDOFF_REPORT_PROJECTS_TABLE = Table( - "pc_handoff_report_projects", - HANDOFF_REPORT_CATALOG_METADATA, - Column("project_id", identity_string(MAX_REPORT_ID_LENGTH), primary_key=True), - Column("project_key", identity_string(MAX_PROJECT_KEY_LENGTH), nullable=False, unique=True), - Column("version", Integer, nullable=False), - Column("catalog_state", identity_string(16), nullable=False), - Column("payload", Text, nullable=False), - CheckConstraint("version > 0", name="ck_pc_handoff_report_projects_version_positive"), -) - -HANDOFF_REPORT_PROJECT_REVISIONS_TABLE = Table( - "pc_handoff_report_project_revisions", - HANDOFF_REPORT_CATALOG_METADATA, - Column("project_id", identity_string(MAX_REPORT_ID_LENGTH), primary_key=True), - Column("version", Integer, primary_key=True), - Column("effective_at", identity_string(32), nullable=False), - Column("payload", Text, nullable=False), - CheckConstraint("version > 0", name="ck_pc_handoff_report_project_revisions_version_positive"), -) -Index( - "ix_pc_handoff_report_project_revisions_effective_at", - HANDOFF_REPORT_PROJECT_REVISIONS_TABLE.c.project_id, - HANDOFF_REPORT_PROJECT_REVISIONS_TABLE.c.effective_at, - HANDOFF_REPORT_PROJECT_REVISIONS_TABLE.c.version, -) - -HANDOFF_REPORT_WORKSTREAMS_TABLE = Table( - "pc_handoff_report_workstreams", - HANDOFF_REPORT_CATALOG_METADATA, - Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), - Column("project_id", identity_string(MAX_REPORT_ID_LENGTH), nullable=False), - Column("workstream_key", identity_string(MAX_WORKSTREAM_KEY_LENGTH)), - Column("version", Integer, nullable=False), - Column("catalog_state", identity_string(16), nullable=False), - Column("payload", Text, nullable=False), - UniqueConstraint( - "project_id", - "workstream_key", - name="uq_pc_handoff_report_workstreams_project_key", - ), - CheckConstraint("version > 0", name="ck_pc_handoff_report_workstreams_version_positive"), -) -Index( - "ix_pc_handoff_report_workstreams_project_scope", - HANDOFF_REPORT_WORKSTREAMS_TABLE.c.project_id, - HANDOFF_REPORT_WORKSTREAMS_TABLE.c.scope_id, -) - -HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE = Table( - "pc_handoff_report_workstream_revisions", - HANDOFF_REPORT_CATALOG_METADATA, - Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), - Column("version", Integer, primary_key=True), - Column("project_id", identity_string(MAX_REPORT_ID_LENGTH), nullable=False), - Column("effective_at", identity_string(32), nullable=False), - Column("payload", Text, nullable=False), - CheckConstraint("version > 0", name="ck_pc_handoff_report_workstream_revisions_version_positive"), -) -Index( - "ix_pc_handoff_report_workstream_revisions_effective_at", - HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE.c.scope_id, - HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE.c.effective_at, - HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE.c.version, -) - -HANDOFF_REPORT_CATALOG_TABLES = ( - HANDOFF_REPORT_PROJECTS_TABLE, - HANDOFF_REPORT_PROJECT_REVISIONS_TABLE, - HANDOFF_REPORT_WORKSTREAMS_TABLE, - HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE, -) - -DEFAULT_CATALOG_PAGE_SIZE = 50 -MAX_CATALOG_PAGE_SIZE = 100 -CatalogItemT = TypeVar("CatalogItemT") - - -class CatalogPage(Generic[CatalogItemT]): - """A stable identity-cursor page returned by the Report catalog.""" - - __slots__ = ("items", "next_cursor") - - def __init__(self, items: tuple[CatalogItemT, ...], next_cursor: str | None) -> None: - self.items = items - self.next_cursor = next_cursor - - @override - def __repr__(self) -> str: - return f"CatalogPage(items={self.items!r}, next_cursor={self.next_cursor!r})" - - @override - def __eq__(self, other: object) -> bool: - return isinstance(other, CatalogPage) and self.items == other.items and self.next_cursor == other.next_cursor - - -class ReportCatalogRepository: - """Persist mutable catalog heads and immutable descriptor revisions.""" - - async def create_project( - self, - connection: AsyncConnection, - descriptor: ProjectDescriptor, - /, - *, - effective_at: datetime | None = None, - ) -> ProjectDescriptor: - _validate_project_descriptor(descriptor) - if descriptor.version != 1: - raise HandoffReportCatalogArgumentError("version", "a new Project must start at version 1") - if await self._find_project(connection, descriptor.project_id) is not None: - raise ProjectConflictError(descriptor.project_id, None, descriptor.version) - key_owner = await self._find_project_by_key(connection, descriptor.project_key) - if key_owner is not None: - raise ProjectConflictError( - descriptor.project_id, - None, - int(key_owner["version"]), - detail=f"Project key {descriptor.project_key!r} is already in use", - ) - - effective_text = _effective_at_text(effective_at) - try: - await connection.execute( - insert(HANDOFF_REPORT_PROJECTS_TABLE).values( - project_id=descriptor.project_id, - project_key=descriptor.project_key, - version=descriptor.version, - catalog_state=descriptor.catalog_state, - payload=_dump_descriptor(descriptor), - ) - ) - await connection.execute( - insert(HANDOFF_REPORT_PROJECT_REVISIONS_TABLE).values( - project_id=descriptor.project_id, - version=descriptor.version, - effective_at=effective_text, - payload=_dump_descriptor(descriptor), - ) - ) - except IntegrityError as error: - raise ProjectConflictError( - descriptor.project_id, - None, - None, - detail=f"Project key {descriptor.project_key!r} conflicts with the current catalog", - ) from error - return descriptor - - async def get_project(self, connection: AsyncConnection, project_id: str, /) -> ProjectDescriptor: - project_id = _identifier("project_id", project_id, MAX_REPORT_ID_LENGTH) - row = await self._find_project(connection, project_id) - if row is None: - raise ProjectNotFoundError(project_id) - return _decode_project(row) - - async def list_projects( - self, - connection: AsyncConnection, - /, - *, - cursor: str | None = None, - limit: int = DEFAULT_CATALOG_PAGE_SIZE, - include_archived: bool = False, - ) -> CatalogPage[ProjectDescriptor]: - cursor = None if cursor is None else _identifier("cursor", cursor, MAX_REPORT_ID_LENGTH) - _page_limit(limit) - statement = ( - select(HANDOFF_REPORT_PROJECTS_TABLE).order_by(HANDOFF_REPORT_PROJECTS_TABLE.c.project_id).limit(limit + 1) - ) - if cursor is not None: - statement = statement.where(HANDOFF_REPORT_PROJECTS_TABLE.c.project_id > cursor) - if not include_archived: - statement = statement.where(HANDOFF_REPORT_PROJECTS_TABLE.c.catalog_state == "included") - rows = list((await connection.execute(statement)).mappings()) - has_more = len(rows) > limit - selected = rows[:limit] - items = tuple(_decode_project(row) for row in selected) - return CatalogPage(items, items[-1].project_id if has_more and items else None) - - async def update_project( - self, - connection: AsyncConnection, - descriptor: ProjectDescriptor, - expected_version: int, - /, - *, - effective_at: datetime | None = None, - ) -> ProjectDescriptor: - _validate_project_descriptor(descriptor) - _version("expected_version", expected_version) - if descriptor.version != expected_version + 1: - raise HandoffReportCatalogArgumentError( - "version", - "updated Project version must equal expected_version + 1", - ) - current = await self._find_project(connection, descriptor.project_id) - if current is None: - raise ProjectNotFoundError(descriptor.project_id) - current_version = int(current["version"]) - if current_version != expected_version: - raise ProjectConflictError(descriptor.project_id, expected_version, current_version) - key_owner = await self._find_project_by_key(connection, descriptor.project_key) - if key_owner is not None and str(key_owner["project_id"]) != descriptor.project_id: - raise ProjectConflictError( - descriptor.project_id, - expected_version, - current_version, - detail=f"Project key {descriptor.project_key!r} is already in use", - ) - - effective_text = _effective_at_text(effective_at) - try: - result = await connection.execute( - update(HANDOFF_REPORT_PROJECTS_TABLE) - .where( - HANDOFF_REPORT_PROJECTS_TABLE.c.project_id == descriptor.project_id, - HANDOFF_REPORT_PROJECTS_TABLE.c.version == expected_version, - ) - .values( - project_key=descriptor.project_key, - version=descriptor.version, - catalog_state=descriptor.catalog_state, - payload=_dump_descriptor(descriptor), - ) - ) - if result.rowcount != 1: - current = await self._find_project(connection, descriptor.project_id) - if current is None: - raise ProjectNotFoundError(descriptor.project_id) - raise ProjectConflictError( - descriptor.project_id, - expected_version, - int(current["version"]), - ) - await connection.execute( - insert(HANDOFF_REPORT_PROJECT_REVISIONS_TABLE).values( - project_id=descriptor.project_id, - version=descriptor.version, - effective_at=effective_text, - payload=_dump_descriptor(descriptor), - ) - ) - except IntegrityError as error: - raise ProjectConflictError( - descriptor.project_id, - expected_version, - current_version, - detail=f"Project key {descriptor.project_key!r} conflicts with the current catalog", - ) from error - return descriptor - - async def project_revision( - self, - connection: AsyncConnection, - project_id: str, - version: int, - /, - ) -> ProjectDescriptor: - project_id = _identifier("project_id", project_id, MAX_REPORT_ID_LENGTH) - _version("version", version) - row = ( - ( - await connection.execute( - select(HANDOFF_REPORT_PROJECT_REVISIONS_TABLE).where( - HANDOFF_REPORT_PROJECT_REVISIONS_TABLE.c.project_id == project_id, - HANDOFF_REPORT_PROJECT_REVISIONS_TABLE.c.version == version, - ) - ) - ) - .mappings() - .one_or_none() - ) - if row is None: - raise ProjectNotFoundError(project_id) - return _decode_project(row) - - async def project_at( - self, - connection: AsyncConnection, - project_id: str, - effective_at: datetime, - /, - ) -> ProjectDescriptor | None: - project_id = _identifier("project_id", project_id, MAX_REPORT_ID_LENGTH) - boundary = _effective_at_text(effective_at) - row = ( - ( - await connection.execute( - select(HANDOFF_REPORT_PROJECT_REVISIONS_TABLE) - .where( - HANDOFF_REPORT_PROJECT_REVISIONS_TABLE.c.project_id == project_id, - HANDOFF_REPORT_PROJECT_REVISIONS_TABLE.c.effective_at <= boundary, - ) - .order_by( - HANDOFF_REPORT_PROJECT_REVISIONS_TABLE.c.effective_at.desc(), - HANDOFF_REPORT_PROJECT_REVISIONS_TABLE.c.version.desc(), - ) - .limit(1) - ) - ) - .mappings() - .one_or_none() - ) - return None if row is None else _decode_project(row) - - async def create_workstream( - self, - connection: AsyncConnection, - descriptor: WorkstreamDescriptor, - /, - *, - effective_at: datetime | None = None, - ) -> WorkstreamDescriptor: - _validate_workstream_descriptor(descriptor) - if descriptor.version != 1: - raise HandoffReportCatalogArgumentError("version", "a new Workstream must start at version 1") - await self.get_project(connection, descriptor.project_id) - existing = await self._find_workstream(connection, descriptor.scope_id) - if existing is not None: - existing_project = str(existing["project_id"]) - if existing_project != descriptor.project_id: - raise ScopeAlreadyGroupedError(descriptor.scope_id, existing_project) - raise WorkstreamConflictError( - descriptor.scope_id, - None, - int(existing["version"]), - detail=f"scope {descriptor.scope_id!r} is already registered", - ) - key_owner = await self._find_workstream_by_key(connection, descriptor.project_id, descriptor.key) - if key_owner is not None: - raise WorkstreamConflictError( - descriptor.scope_id, - None, - int(key_owner["version"]), - detail=f"Workstream key {descriptor.key!r} is already in use", - ) - - effective_text = _effective_at_text(effective_at) - try: - await connection.execute( - insert(HANDOFF_REPORT_WORKSTREAMS_TABLE).values( - scope_id=descriptor.scope_id, - project_id=descriptor.project_id, - workstream_key=descriptor.key, - version=descriptor.version, - catalog_state=descriptor.catalog_state, - payload=_dump_descriptor(descriptor), - ) - ) - await connection.execute( - insert(HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE).values( - scope_id=descriptor.scope_id, - version=descriptor.version, - project_id=descriptor.project_id, - effective_at=effective_text, - payload=_dump_descriptor(descriptor), - ) - ) - except IntegrityError as error: - raise WorkstreamConflictError( - descriptor.scope_id, - None, - None, - detail=f"Workstream key {descriptor.key!r} conflicts with the current catalog", - ) from error - return descriptor - - async def get_workstream(self, connection: AsyncConnection, scope_id: str, /) -> WorkstreamDescriptor: - scope_id = _identifier("scope_id", scope_id, MAX_SCOPE_ID_LENGTH) - row = await self._find_workstream(connection, scope_id) - if row is None: - raise WorkstreamNotFoundError(scope_id) - return _decode_workstream(row) - - async def list_workstreams( - self, - connection: AsyncConnection, - project_id: str, - /, - *, - cursor: str | None = None, - limit: int = DEFAULT_CATALOG_PAGE_SIZE, - include_archived: bool = False, - ) -> CatalogPage[WorkstreamDescriptor]: - project_id = _identifier("project_id", project_id, MAX_REPORT_ID_LENGTH) - cursor = None if cursor is None else _identifier("cursor", cursor, MAX_SCOPE_ID_LENGTH) - _page_limit(limit) - statement = ( - select(HANDOFF_REPORT_WORKSTREAMS_TABLE) - .where(HANDOFF_REPORT_WORKSTREAMS_TABLE.c.project_id == project_id) - .order_by(HANDOFF_REPORT_WORKSTREAMS_TABLE.c.scope_id) - .limit(limit + 1) - ) - if cursor is not None: - statement = statement.where(HANDOFF_REPORT_WORKSTREAMS_TABLE.c.scope_id > cursor) - if not include_archived: - statement = statement.where(HANDOFF_REPORT_WORKSTREAMS_TABLE.c.catalog_state == "included") - rows = list((await connection.execute(statement)).mappings()) - has_more = len(rows) > limit - selected = rows[:limit] - items = tuple(_decode_workstream(row) for row in selected) - return CatalogPage(items, items[-1].scope_id if has_more and items else None) - - async def update_workstream( - self, - connection: AsyncConnection, - descriptor: WorkstreamDescriptor, - expected_version: int, - /, - *, - effective_at: datetime | None = None, - ) -> WorkstreamDescriptor: - _validate_workstream_descriptor(descriptor) - _version("expected_version", expected_version) - if descriptor.version != expected_version + 1: - raise HandoffReportCatalogArgumentError( - "version", - "updated Workstream version must equal expected_version + 1", - ) - current = await self._find_workstream(connection, descriptor.scope_id) - if current is None: - raise WorkstreamNotFoundError(descriptor.scope_id) - current_project = str(current["project_id"]) - current_version = int(current["version"]) - if current_project != descriptor.project_id: - raise HandoffReportCatalogArgumentError( - "project_id", - "Workstream membership cannot move between Projects", - ) - if current_version != expected_version: - raise WorkstreamConflictError(descriptor.scope_id, expected_version, current_version) - key_owner = await self._find_workstream_by_key(connection, descriptor.project_id, descriptor.key) - if key_owner is not None and str(key_owner["scope_id"]) != descriptor.scope_id: - raise WorkstreamConflictError( - descriptor.scope_id, - expected_version, - current_version, - detail=f"Workstream key {descriptor.key!r} is already in use", - ) - - effective_text = _effective_at_text(effective_at) - try: - result = await connection.execute( - update(HANDOFF_REPORT_WORKSTREAMS_TABLE) - .where( - HANDOFF_REPORT_WORKSTREAMS_TABLE.c.scope_id == descriptor.scope_id, - HANDOFF_REPORT_WORKSTREAMS_TABLE.c.version == expected_version, - ) - .values( - workstream_key=descriptor.key, - version=descriptor.version, - catalog_state=descriptor.catalog_state, - payload=_dump_descriptor(descriptor), - ) - ) - if result.rowcount != 1: - current = await self._find_workstream(connection, descriptor.scope_id) - if current is None: - raise WorkstreamNotFoundError(descriptor.scope_id) - raise WorkstreamConflictError( - descriptor.scope_id, - expected_version, - int(current["version"]), - ) - await connection.execute( - insert(HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE).values( - scope_id=descriptor.scope_id, - version=descriptor.version, - project_id=descriptor.project_id, - effective_at=effective_text, - payload=_dump_descriptor(descriptor), - ) - ) - except IntegrityError as error: - raise WorkstreamConflictError( - descriptor.scope_id, - expected_version, - current_version, - detail=f"Workstream key {descriptor.key!r} conflicts with the current catalog", - ) from error - return descriptor - - async def workstream_revision( - self, - connection: AsyncConnection, - scope_id: str, - version: int, - /, - ) -> WorkstreamDescriptor: - scope_id = _identifier("scope_id", scope_id, MAX_SCOPE_ID_LENGTH) - _version("version", version) - row = ( - ( - await connection.execute( - select(HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE).where( - HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE.c.scope_id == scope_id, - HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE.c.version == version, - ) - ) - ) - .mappings() - .one_or_none() - ) - if row is None: - raise WorkstreamNotFoundError(scope_id) - return _decode_workstream(row) - - async def workstream_at( - self, - connection: AsyncConnection, - scope_id: str, - effective_at: datetime, - /, - ) -> WorkstreamDescriptor | None: - scope_id = _identifier("scope_id", scope_id, MAX_SCOPE_ID_LENGTH) - boundary = _effective_at_text(effective_at) - row = ( - ( - await connection.execute( - select(HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE) - .where( - HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE.c.scope_id == scope_id, - HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE.c.effective_at <= boundary, - ) - .order_by( - HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE.c.effective_at.desc(), - HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE.c.version.desc(), - ) - .limit(1) - ) - ) - .mappings() - .one_or_none() - ) - return None if row is None else _decode_workstream(row) - - async def _find_project(self, connection: AsyncConnection, project_id: str) -> Mapping[Any, Any] | None: - return ( - ( - await connection.execute( - select(HANDOFF_REPORT_PROJECTS_TABLE).where( - HANDOFF_REPORT_PROJECTS_TABLE.c.project_id == project_id - ) - ) - ) - .mappings() - .one_or_none() - ) - - async def _find_project_by_key(self, connection: AsyncConnection, project_key: str) -> Mapping[Any, Any] | None: - return ( - ( - await connection.execute( - select(HANDOFF_REPORT_PROJECTS_TABLE).where( - HANDOFF_REPORT_PROJECTS_TABLE.c.project_key == project_key - ) - ) - ) - .mappings() - .one_or_none() - ) - - async def _find_workstream(self, connection: AsyncConnection, scope_id: str) -> Mapping[Any, Any] | None: - return ( - ( - await connection.execute( - select(HANDOFF_REPORT_WORKSTREAMS_TABLE).where( - HANDOFF_REPORT_WORKSTREAMS_TABLE.c.scope_id == scope_id - ) - ) - ) - .mappings() - .one_or_none() - ) - - async def _find_workstream_by_key( - self, - connection: AsyncConnection, - project_id: str, - key: str | None, - ) -> Mapping[Any, Any] | None: - if key is None: - return None - return ( - ( - await connection.execute( - select(HANDOFF_REPORT_WORKSTREAMS_TABLE).where( - HANDOFF_REPORT_WORKSTREAMS_TABLE.c.project_id == project_id, - HANDOFF_REPORT_WORKSTREAMS_TABLE.c.workstream_key == key, - ) - ) - ) - .mappings() - .one_or_none() - ) - - -def _validate_project_descriptor(value: ProjectDescriptor) -> None: - if not isinstance(value, ProjectDescriptor): - raise HandoffReportCatalogArgumentError("descriptor", "must be a ProjectDescriptor") - - -def _validate_workstream_descriptor(value: WorkstreamDescriptor) -> None: - if not isinstance(value, WorkstreamDescriptor): - raise HandoffReportCatalogArgumentError("descriptor", "must be a WorkstreamDescriptor") - - -def _dump_descriptor(value: ProjectDescriptor | WorkstreamDescriptor) -> str: - return json.dumps( - value.model_dump(mode="json", by_alias=True), - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - allow_nan=False, - ) - - -def _decode_project(row: Mapping[Any, Any]) -> ProjectDescriptor: - try: - value = ProjectDescriptor.model_validate_json(str(row["payload"])) - except ValidationError as error: - raise InvalidStoredCatalogError("Project descriptor", "does not match its schema") from error # noqa: TRY003 - if value.project_id != str(row["project_id"]) or value.version != int(row["version"]): - raise InvalidStoredCatalogError( # noqa: TRY003 - "Project descriptor", - "identity does not match indexed columns", - ) - return value - - -def _decode_workstream(row: Mapping[Any, Any]) -> WorkstreamDescriptor: - try: - value = WorkstreamDescriptor.model_validate_json(str(row["payload"])) - except ValidationError as error: - raise InvalidStoredCatalogError("Workstream descriptor", "does not match its schema") from error # noqa: TRY003 - if value.scope_id != str(row["scope_id"]) or value.version != int(row["version"]): - raise InvalidStoredCatalogError( # noqa: TRY003 - "Workstream descriptor", - "identity does not match indexed columns", - ) - return value - - -def _identifier(field: str, value: object, maximum: int) -> str: - if not isinstance(value, str) or not value or value != value.strip(): - raise HandoffReportCatalogArgumentError(field, "must be a non-empty trimmed string") - if len(value) > maximum: - raise HandoffReportCatalogArgumentError(field, f"must not exceed {maximum} characters") - return value - - -def _version(field: str, value: object) -> None: - if not isinstance(value, int) or isinstance(value, bool) or value < 1: - raise HandoffReportCatalogArgumentError(field, "must be a positive integer") - - -def _page_limit(value: object) -> None: - if not isinstance(value, int) or isinstance(value, bool) or not 1 <= value <= MAX_CATALOG_PAGE_SIZE: - raise HandoffReportCatalogArgumentError( - "limit", - f"must be between 1 and {MAX_CATALOG_PAGE_SIZE}", - ) - - -def _effective_at_text(value: datetime | None) -> str: - current = datetime.now(UTC) if value is None else value - if current.tzinfo is None or current.utcoffset() is None: - raise HandoffReportCatalogArgumentError("effective_at", "must include a UTC offset") - return current.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z") - - -__all__ = [ - "DEFAULT_CATALOG_PAGE_SIZE", - "HANDOFF_REPORT_CATALOG_METADATA", - "HANDOFF_REPORT_CATALOG_TABLES", - "HANDOFF_REPORT_PROJECTS_TABLE", - "HANDOFF_REPORT_PROJECT_REVISIONS_TABLE", - "HANDOFF_REPORT_WORKSTREAMS_TABLE", - "HANDOFF_REPORT_WORKSTREAM_REVISIONS_TABLE", - "MAX_CATALOG_PAGE_SIZE", - "CatalogPage", - "ReportCatalogRepository", -] diff --git a/src/powercontext/builtin/handoff_report/errors.py b/src/powercontext/builtin/handoff_report/errors.py index 778dfa23d..771e9f81e 100644 --- a/src/powercontext/builtin/handoff_report/errors.py +++ b/src/powercontext/builtin/handoff_report/errors.py @@ -12,185 +12,26 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Typed failures owned by the optional Handoff Report feature.""" - -from __future__ import annotations +"""Failures specific to Handoff Report projection.""" from powercontext.errors import PowerContextError class HandoffReportError(PowerContextError): - """Base class for failures isolated to Handoff Report operations.""" - - -class HandoffReportBusyError(HandoffReportError): - """Raised when repeated head reads cannot form an optimistic-stable selection.""" - - def __init__(self, attempts: int) -> None: - self.attempts = attempts - super().__init__(f"Handoff heads remained unstable after {attempts} attempts") + pass class HandoffReportInconsistentError(HandoffReportError): - """Raised when an adapter cannot return the exact Handoff frozen in selection.""" - def __init__(self, scope_id: str) -> None: self.scope_id = scope_id - super().__init__(f"the frozen Handoff selection became inconsistent for scope {scope_id!r}") - - -class HandoffReportEvidenceCheckUnavailableError(HandoffReportError): - """Raised when a read adapter has no independent evidence-check capability.""" + super().__init__(f"the exact Handoff could not be read for Scope {scope_id!r}") class HandoffReportTooLargeError(HandoffReportError): - """Raised when an untruncated report exceeds a deterministic resource limit.""" - - def __init__( - self, - *, - selected_workstreams: int, - selected_activities: int, - estimated_bytes: int | None = None, - ) -> None: - self.selected_workstreams = selected_workstreams - self.selected_activities = selected_activities + def __init__(self, *, selected_scopes: int, estimated_bytes: int | None = None) -> None: + self.selected_scopes = selected_scopes self.estimated_bytes = estimated_bytes - super().__init__("the Handoff Report exceeds the configured projection limit") - - -class HandoffReportCatalogArgumentError(HandoffReportError, ValueError): - """Raised when a catalog operation receives an invalid control value.""" - - def __init__(self, field: str, detail: str) -> None: - self.field = field - self.detail = detail - super().__init__(f"invalid Handoff Report catalog argument {field}: {detail}") - - -class InvalidStoredCatalogError(HandoffReportError): - """Raised when a persisted catalog descriptor is malformed or inconsistent.""" - - def __init__(self, kind: str, detail: str) -> None: - self.kind = kind - self.detail = detail - super().__init__(f"invalid stored Handoff Report {kind}: {detail}") - - -class ProjectNotFoundError(HandoffReportError, LookupError): - """Raised when a Report Project is absent.""" - - code = "project_not_found" - - def __init__(self, project_id: str) -> None: - self.project_id = project_id - super().__init__(f"Report Project {project_id!r} was not found") - - -class WorkstreamNotFoundError(HandoffReportError, LookupError): - """Raised when a Report Workstream is absent.""" - - code = "scope_not_grouped" - - def __init__(self, scope_id: str) -> None: - self.scope_id = scope_id - super().__init__(f"Report Workstream {scope_id!r} was not found") - - -class ProjectConflictError(HandoffReportError, ValueError): - """Raised when Project CAS or uniqueness validation fails.""" - - code = "project_conflict" - - def __init__( - self, - project_id: str, - expected_version: int | None, - current_version: int | None, - *, - detail: str = "Project version or key conflicts with the current catalog", - ) -> None: - self.project_id = project_id - self.expected_version = expected_version - self.current_version = current_version - self.detail = detail - super().__init__(detail) - - -class WorkstreamConflictError(HandoffReportError, ValueError): - """Raised when Workstream CAS or uniqueness validation fails.""" - - code = "workstream_conflict" - - def __init__( - self, - scope_id: str, - expected_version: int | None, - current_version: int | None, - *, - detail: str = "Workstream version or key conflicts with the current catalog", - ) -> None: - self.scope_id = scope_id - self.expected_version = expected_version - self.current_version = current_version - self.detail = detail - super().__init__(detail) - - -class ScopeAlreadyGroupedError(HandoffReportError, ValueError): - """Raised when a scope is already a member of another Project.""" - - code = "scope_already_grouped" - - def __init__(self, scope_id: str, project_id: str) -> None: - self.scope_id = scope_id - self.project_id = project_id - super().__init__(f"scope {scope_id!r} already belongs to Project {project_id!r}") - - -class WorkspaceBindingNotFoundError(HandoffReportError, LookupError): - """Raised when a workspace has no confirmed Project binding.""" - - code = "workspace_not_bound" - - def __init__(self, workspace_instance_id: str) -> None: - self.workspace_instance_id = workspace_instance_id - super().__init__(f"workspace {workspace_instance_id!r} has no confirmed Report binding") - - -class WorkspaceBindingConflictError(HandoffReportError, ValueError): - """Raised when workspace binding CAS or single-binding rules fail.""" - - code = "workspace_binding_conflict" - - def __init__( - self, - workspace_instance_id: str, - expected_version: int | None, - current_version: int | None, - *, - detail: str = "workspace binding version conflicts with the current catalog", - ) -> None: - self.workspace_instance_id = workspace_instance_id - self.expected_version = expected_version - self.current_version = current_version - self.detail = detail - super().__init__(detail) + super().__init__("the Handoff Report exceeds the response limit") -__all__ = [ - "HandoffReportBusyError", - "HandoffReportCatalogArgumentError", - "HandoffReportError", - "HandoffReportEvidenceCheckUnavailableError", - "HandoffReportInconsistentError", - "HandoffReportTooLargeError", - "InvalidStoredCatalogError", - "ProjectConflictError", - "ProjectNotFoundError", - "ScopeAlreadyGroupedError", - "WorkspaceBindingConflictError", - "WorkspaceBindingNotFoundError", - "WorkstreamConflictError", - "WorkstreamNotFoundError", -] +__all__ = ["HandoffReportError", "HandoffReportInconsistentError", "HandoffReportTooLargeError"] diff --git a/src/powercontext/builtin/handoff_report/models.py b/src/powercontext/builtin/handoff_report/models.py deleted file mode 100644 index 5348d2e29..000000000 --- a/src/powercontext/builtin/handoff_report/models.py +++ /dev/null @@ -1,483 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Immutable domain values owned by the optional Handoff Report feature.""" - -from __future__ import annotations - -import posixpath -from datetime import UTC, datetime, timedelta -from typing import Annotated, Literal, TypeAlias -from unicodedata import normalize -from urllib.parse import urlsplit, urlunsplit -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError - -from pydantic import BaseModel, ConfigDict, Field, StrictInt, field_validator, model_validator - -from powercontext.artifacts import ArtifactRef -from powercontext.limits import MAX_SCOPE_ID_LENGTH - -MAX_REPORT_ID_LENGTH = 256 -MAX_PROJECT_KEY_LENGTH = 64 -MAX_WORKSTREAM_KEY_LENGTH = 64 -MAX_REPORT_TITLE_LENGTH = 256 -MAX_REPORT_DESCRIPTION_LENGTH = 2_000 -MAX_REPORT_LABEL_LENGTH = 128 -MAX_REPORT_PROVIDER_LENGTH = 64 -MAX_REPORT_AGENT_LABEL_LENGTH = 128 -MAX_REPORT_SOURCE_SUMMARY_LENGTH = 2_000 -MAX_REPORT_EXTERNAL_ID_LENGTH = 256 -MAX_REPORT_URL_LENGTH = 2_048 -MAX_REPORT_REPOSITORY_ID_LENGTH = 256 -MAX_REPORT_NORMALIZED_REMOTE_LENGTH = 2_048 -MAX_REPORT_SUBPATH_LENGTH = 1_024 -MAX_WORKSPACE_INSTANCE_ID_LENGTH = 256 -MAX_REPORT_EXTERNAL_REFS = 32 -MAX_REPORT_LABELS = 32 -MAX_REPORT_EVIDENCE_REFS = 32 - -ReportLocale: TypeAlias = Literal["zh-CN", "en"] -CatalogState: TypeAlias = Literal["included", "archived"] -WorkstreamKind: TypeAlias = Literal["feature", "bug", "refactor", "operations", "research", "other"] -ExternalReferenceKind: TypeAlias = Literal[ - "issue", - "task", - "pull_request", - "branch", - "feature", - "release", - "program", - "other", -] -ReportActivitySource: TypeAlias = Literal[ - "handoff_observation", - "git_commit", - "git_worktree", - "coding_session", - "other", -] -ReportTimeBasis: TypeAlias = Literal[ - "source_reported", - "host_observed", - "first_seen", - "current_only", - "unknown", -] -ReportSelectionConsistency: TypeAlias = Literal["exact_input", "optimistic_stable"] -ReportSelectionStatus: TypeAlias = Literal["selected", "no_handoff"] -ReportActivityTrust: TypeAlias = Literal["untrusted_observation"] -HandoffReportTrust: TypeAlias = Literal["untrusted_history"] -GeneratedSummaryTrust: TypeAlias = Literal["generated_untrusted"] -RepositoryProvider: TypeAlias = Literal["github", "gitlab", "local", "other"] -WorkspaceBindingState: TypeAlias = Literal["confirmed", "detached"] - - -class _ReportValue(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True, strict=True) - - -class ExternalReference(_ReportValue): - """A navigation or filtering reference that is not identity or evidence by itself.""" - - kind: ExternalReferenceKind - provider: Annotated[str, Field(max_length=MAX_REPORT_PROVIDER_LENGTH)] - external_id: Annotated[str, Field(max_length=MAX_REPORT_EXTERNAL_ID_LENGTH)] - url: Annotated[str, Field(max_length=MAX_REPORT_URL_LENGTH)] | None = None - - @field_validator("provider", "external_id", "url") - @classmethod - def require_trimmed_text(cls, value: str | None, info) -> str | None: - return _require_optional_text(info.field_name, value) - - -class RepositoryRef(_ReportValue): - """Credential-free repository identity hints attached to a workspace.""" - - provider: RepositoryProvider - repository_id: Annotated[str, Field(max_length=MAX_REPORT_REPOSITORY_ID_LENGTH)] | None = None - normalized_remote: Annotated[str, Field(max_length=MAX_REPORT_NORMALIZED_REMOTE_LENGTH)] | None = None - subpath: Annotated[str, Field(max_length=MAX_REPORT_SUBPATH_LENGTH)] | None = None - - @field_validator("repository_id", "normalized_remote", "subpath") - @classmethod - def require_trimmed_text(cls, value: str | None, info) -> str | None: - return _require_optional_text(info.field_name, value) - - @model_validator(mode="after") - def require_repository_signal(self) -> RepositoryRef: - if self.repository_id is None and self.normalized_remote is None and self.subpath is None: - raise ValueError("repository_ref must contain a repository id, remote, or subpath") # noqa: TRY003 - if self.normalized_remote is not None and any(marker in self.normalized_remote for marker in ("@", "?", "#")): - raise ValueError("normalized_remote must not contain credentials or query fragments") # noqa: TRY003 - return self - - -class WorkspaceBinding(_ReportValue): - """One CAS-versioned binding between a local checkout and a Report Project.""" - - schema_version: Literal["powercontext.workspace-binding.v1"] = Field( - default="powercontext.workspace-binding.v1", - alias="schema", - ) - workspace_instance_id: Annotated[str, Field(max_length=MAX_WORKSPACE_INSTANCE_ID_LENGTH)] - project_id: Annotated[str, Field(max_length=MAX_REPORT_ID_LENGTH)] - repository_ref: RepositoryRef - state: WorkspaceBindingState = "confirmed" - confirmed_at: datetime - version: StrictInt = Field(ge=1) - - @field_validator("workspace_instance_id", "project_id") - @classmethod - def require_trimmed_text(cls, value: str, info) -> str: - return _require_text(info.field_name, value) - - @field_validator("confirmed_at") - @classmethod - def normalize_confirmed_at(cls, value: datetime) -> datetime: - if value.tzinfo is None or value.utcoffset() is None: - raise ValueError("confirmed_at must include a UTC offset") # noqa: TRY003 - return value.astimezone(UTC) - - -class ProjectDescriptor(_ReportValue): - """One versioned Report-owned Project catalog snapshot.""" - - schema_version: Literal["powercontext.project.v1"] = Field( - default="powercontext.project.v1", - alias="schema", - ) - project_id: Annotated[str, Field(max_length=MAX_REPORT_ID_LENGTH)] - project_key: Annotated[str, Field(max_length=MAX_PROJECT_KEY_LENGTH)] - title: Annotated[str, Field(max_length=MAX_REPORT_TITLE_LENGTH)] - description: Annotated[str, Field(max_length=MAX_REPORT_DESCRIPTION_LENGTH)] | None = None - default_locale: ReportLocale = "zh-CN" - timezone: Annotated[str, Field(max_length=MAX_REPORT_ID_LENGTH)] - catalog_state: CatalogState = "included" - version: StrictInt = Field(ge=1) - - @field_validator("project_id", "project_key", "title", "description") - @classmethod - def require_trimmed_text(cls, value: str | None, info) -> str | None: - return _require_optional_text(info.field_name, value) - - @field_validator("timezone") - @classmethod - def require_iana_timezone(cls, value: str) -> str: - _require_text("timezone", value) - try: - ZoneInfo(value) - except ZoneInfoNotFoundError as error: - raise ValueError("timezone must be a recognized IANA timezone") from error # noqa: TRY003 - return value - - -class WorkstreamDescriptor(_ReportValue): - """One versioned Report-owned descriptor for an existing Handoff scope.""" - - schema_version: Literal["powercontext.workstream.v1"] = Field( - default="powercontext.workstream.v1", - alias="schema", - ) - scope_id: Annotated[str, Field(max_length=MAX_SCOPE_ID_LENGTH)] - project_id: Annotated[str, Field(max_length=MAX_REPORT_ID_LENGTH)] - key: Annotated[str, Field(max_length=MAX_WORKSTREAM_KEY_LENGTH)] | None = None - title: Annotated[str, Field(max_length=MAX_REPORT_TITLE_LENGTH)] - kind: WorkstreamKind - catalog_state: CatalogState = "included" - external_refs: Annotated[tuple[ExternalReference, ...], Field(max_length=MAX_REPORT_EXTERNAL_REFS)] = () - labels: Annotated[tuple[str, ...], Field(max_length=MAX_REPORT_LABELS)] = () - version: StrictInt = Field(ge=1) - - @field_validator("scope_id", "project_id", "key", "title") - @classmethod - def require_trimmed_text(cls, value: str | None, info) -> str | None: - return _require_optional_text(info.field_name, value) - - @field_validator("labels") - @classmethod - def require_valid_labels(cls, values: tuple[str, ...]) -> tuple[str, ...]: - for value in values: - _require_text("label", value) - if len(value) > MAX_REPORT_LABEL_LENGTH: - raise ValueError(f"label must not exceed {MAX_REPORT_LABEL_LENGTH} characters") # noqa: TRY003 - if len(set(values)) != len(values): - raise ValueError("Workstream labels must be unique") # noqa: TRY003 - return values - - @model_validator(mode="after") - def require_unique_external_refs(self) -> WorkstreamDescriptor: - if len(set(self.external_refs)) != len(self.external_refs): - raise ValueError("Workstream external references must be unique") # noqa: TRY003 - return self - - -class ActivityAgent(_ReportValue): - """Untrusted Agent attribution reported by an activity source.""" - - provider: Annotated[str, Field(max_length=MAX_REPORT_PROVIDER_LENGTH)] | None = None - label: Annotated[str, Field(max_length=MAX_REPORT_AGENT_LABEL_LENGTH)] | None = None - - @field_validator("provider", "label") - @classmethod - def require_trimmed_text(cls, value: str | None, info) -> str | None: - return _require_optional_text(info.field_name, value) - - @model_validator(mode="after") - def require_attribution(self) -> ActivityAgent: - if self.provider is None and self.label is None: - raise ValueError("Activity Agent must contain a provider or label") # noqa: TRY003 - return self - - -class ActivityVcsContext(_ReportValue): - """Untrusted VCS display context observed for an activity.""" - - branch: Annotated[str, Field(max_length=MAX_REPORT_TITLE_LENGTH)] | None = None - head_revision: Annotated[str, Field(max_length=MAX_REPORT_ID_LENGTH)] | None = None - - @field_validator("branch", "head_revision") - @classmethod - def require_trimmed_text(cls, value: str | None, info) -> str | None: - return _require_optional_text(info.field_name, value) - - @model_validator(mode="after") - def require_context(self) -> ActivityVcsContext: - if self.branch is None and self.head_revision is None: - raise ValueError("Activity VCS context must contain a branch or head revision") # noqa: TRY003 - return self - - -class ReportActivityEvent(_ReportValue): - """One idempotent, untrusted observation in the independent Report activity store.""" - - schema_version: Literal["powercontext.handoff-report-activity.v1"] = Field( - default="powercontext.handoff-report-activity.v1", - alias="schema", - ) - event_id: Annotated[str, Field(max_length=MAX_REPORT_ID_LENGTH)] - project_id: Annotated[str, Field(max_length=MAX_REPORT_ID_LENGTH)] - scope_id: Annotated[str, Field(max_length=MAX_SCOPE_ID_LENGTH)] | None = None - source: ReportActivitySource - source_event_id: Annotated[str, Field(max_length=MAX_REPORT_ID_LENGTH)] - source_ref: ExternalReference | None = None - occurred_at: datetime | None = None - observed_at: datetime - time_basis: ReportTimeBasis - title: Annotated[str, Field(max_length=MAX_REPORT_TITLE_LENGTH)] | None = None - summary: Annotated[str, Field(max_length=MAX_REPORT_SOURCE_SUMMARY_LENGTH)] | None = None - agent: ActivityAgent | None = None - session_id: Annotated[str, Field(max_length=MAX_REPORT_ID_LENGTH)] | None = None - vcs_context: ActivityVcsContext | None = None - evidence_refs: Annotated[tuple[ExternalReference, ...], Field(max_length=MAX_REPORT_EVIDENCE_REFS)] = () - trust: ReportActivityTrust = "untrusted_observation" - - @field_validator("event_id", "project_id", "scope_id", "source_event_id", "title", "summary", "session_id") - @classmethod - def require_trimmed_text(cls, value: str | None, info) -> str | None: - return _require_optional_text(info.field_name, value) - - @field_validator("occurred_at", "observed_at") - @classmethod - def require_aware_timestamp(cls, value: datetime | None, info) -> datetime | None: - if value is not None and (value.tzinfo is None or value.utcoffset() is None): - raise ValueError(f"{info.field_name} must include a UTC offset") # noqa: TRY003 - return value - - @field_validator("observed_at") - @classmethod - def require_utc_observation(cls, value: datetime) -> datetime: - if value.utcoffset() != timedelta(0): - raise ValueError("observed_at must be UTC") # noqa: TRY003 - return value - - @model_validator(mode="after") - def validate_time_semantics_and_evidence(self) -> ReportActivityEvent: - if self.time_basis == "source_reported": - if self.occurred_at is None: - raise ValueError("source-reported activity must contain occurred_at") # noqa: TRY003 - elif self.occurred_at is not None: - raise ValueError("occurred_at is only valid for source-reported activity") # noqa: TRY003 - if len(set(self.evidence_refs)) != len(self.evidence_refs): - raise ValueError("Activity evidence references must be unique") # noqa: TRY003 - return self - - def effective_period_time(self) -> datetime | None: - """Return the reportable event time, without inventing time for current or unknown activity.""" - - if self.time_basis == "source_reported": - return self.occurred_at - if self.time_basis in {"host_observed", "first_seen"}: - return self.observed_at - return None - - -class ReportSelectionEntry(_ReportValue): - """Freeze one Workstream descriptor revision and exact Handoff selection.""" - - scope_id: Annotated[str, Field(max_length=MAX_SCOPE_ID_LENGTH)] - workstream_revision: StrictInt = Field(ge=1) - status: ReportSelectionStatus - handoff_ref: ArtifactRef | None = None - - @field_validator("scope_id") - @classmethod - def require_scope_id(cls, value: str) -> str: - return _require_text("scope_id", value) - - @model_validator(mode="after") - def validate_selection(self) -> ReportSelectionEntry: - if self.status == "selected": - if self.handoff_ref is None: - raise ValueError("selected Report entry must contain an exact Handoff reference") # noqa: TRY003 - if self.handoff_ref.family != "handoff": - raise ValueError("selected Report entry must reference the Handoff family") # noqa: TRY003 - elif self.handoff_ref is not None: - raise ValueError("no-handoff Report entry cannot contain a Handoff reference") # noqa: TRY003 - return self - - -def normalized_sort_text(value: str) -> str: - """Normalize user-visible text for deterministic, locale-independent ordering.""" - - return normalize("NFC", value).casefold() - - -def workstream_sort_key(workstream: WorkstreamDescriptor) -> tuple[str, str]: - """Sort descriptors by normalized title and stable scope identity.""" - - return normalized_sort_text(workstream.title), workstream.scope_id - - -def activity_sort_key(event: ReportActivityEvent) -> tuple[bool, datetime, datetime, str]: - """Sort reportable activity first and unknown/current activity deterministically last.""" - - effective_time = event.effective_period_time() - return effective_time is None, effective_time or event.observed_at, event.observed_at, event.event_id - - -def selection_sort_key(entry: ReportSelectionEntry) -> str: - """Sort exact selections by canonical Workstream identity.""" - - return entry.scope_id - - -def normalize_repository_ref(value: RepositoryRef) -> RepositoryRef: - """Apply the versioned, credential-free normalization used for binding keys.""" - - if not isinstance(value, RepositoryRef): - raise TypeError("repository_ref must be a RepositoryRef") # noqa: TRY003 - remote = _normalize_repository_remote(value.normalized_remote) - subpath = _normalize_repository_subpath(value.subpath) - - return RepositoryRef( - provider=value.provider, - repository_id=value.repository_id, - normalized_remote=remote, - subpath=subpath, - ) - - -def _normalize_repository_remote(value: str | None) -> str | None: - if value is None or "://" not in value: - return None if value is None else value.rstrip("/") - parsed = urlsplit(value) - if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment: - raise ValueError("normalized_remote must not contain credentials or query fragments") # noqa: TRY003 - if parsed.hostname is None: - raise ValueError("normalized_remote must contain a host") # noqa: TRY003 - try: - port = parsed.port - except ValueError as error: - raise ValueError("normalized_remote must contain a valid port") from error # noqa: TRY003 - host = parsed.hostname.lower() - netloc = host if port is None else f"{host}:{port}" - path = "/" + "/".join(part for part in parsed.path.split("/") if part not in {"", "."}) - return urlunsplit((parsed.scheme.lower(), netloc, path or "/", "", "")) - - -def _normalize_repository_subpath(value: str | None) -> str | None: - if value is None: - return None - candidate = value.replace("\\", "/") - if any(part == ".." for part in candidate.split("/")): - raise ValueError("repository subpath must not contain parent traversal") # noqa: TRY003 - normalized = posixpath.normpath(candidate) - if normalized in {"", "."}: - return "." - if normalized.startswith("/"): - raise ValueError("repository subpath must be relative") # noqa: TRY003 - return normalized - - -def _require_text(field_name: str, value: str) -> str: - if not value.strip(): - raise ValueError(f"{field_name} must contain non-whitespace text") # noqa: TRY003 - if value != value.strip(): - raise ValueError(f"{field_name} must not contain leading or trailing whitespace") # noqa: TRY003 - return value - - -def _require_optional_text(field_name: str, value: str | None) -> str | None: - if value is not None: - _require_text(field_name, value) - return value - - -__all__ = [ - "MAX_PROJECT_KEY_LENGTH", - "MAX_REPORT_AGENT_LABEL_LENGTH", - "MAX_REPORT_DESCRIPTION_LENGTH", - "MAX_REPORT_EVIDENCE_REFS", - "MAX_REPORT_EXTERNAL_ID_LENGTH", - "MAX_REPORT_EXTERNAL_REFS", - "MAX_REPORT_ID_LENGTH", - "MAX_REPORT_LABELS", - "MAX_REPORT_LABEL_LENGTH", - "MAX_REPORT_NORMALIZED_REMOTE_LENGTH", - "MAX_REPORT_PROVIDER_LENGTH", - "MAX_REPORT_REPOSITORY_ID_LENGTH", - "MAX_REPORT_SOURCE_SUMMARY_LENGTH", - "MAX_REPORT_SUBPATH_LENGTH", - "MAX_REPORT_TITLE_LENGTH", - "MAX_REPORT_URL_LENGTH", - "MAX_WORKSPACE_INSTANCE_ID_LENGTH", - "MAX_WORKSTREAM_KEY_LENGTH", - "ActivityAgent", - "ActivityVcsContext", - "CatalogState", - "ExternalReference", - "ExternalReferenceKind", - "GeneratedSummaryTrust", - "HandoffReportTrust", - "ProjectDescriptor", - "ReportActivityEvent", - "ReportActivitySource", - "ReportActivityTrust", - "ReportLocale", - "ReportSelectionConsistency", - "ReportSelectionEntry", - "ReportSelectionStatus", - "ReportTimeBasis", - "RepositoryProvider", - "RepositoryRef", - "WorkspaceBinding", - "WorkspaceBindingState", - "WorkstreamDescriptor", - "WorkstreamKind", - "activity_sort_key", - "normalize_repository_ref", - "normalized_sort_text", - "selection_sort_key", - "workstream_sort_key", -] diff --git a/src/powercontext/builtin/handoff_report/protocols.py b/src/powercontext/builtin/handoff_report/protocols.py index 5d77cae1b..8ffc98f2c 100644 --- a/src/powercontext/builtin/handoff_report/protocols.py +++ b/src/powercontext/builtin/handoff_report/protocols.py @@ -12,50 +12,25 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Read-only ports consumed by the optional Handoff Report feature.""" +"""Read ports used by Handoff Report projection.""" from __future__ import annotations from typing import Protocol from powercontext.artifacts import ArtifactRef -from powercontext.builtin.artifacts.handoff import Handoff, HandoffEvidenceCheck -from powercontext.builtin.work import WorkContinuity +from powercontext.builtin.artifacts.handoff import Handoff +from powercontext.builtin.scope.models import ScopeDescriptor, ScopeSelection class HandoffReadAdapter(Protocol): - """Read committed Handoffs without extending their persistence protocol.""" + async def latest(self, scope_id: str, /) -> Handoff | None: ... - async def latest(self, scope_id: str, /) -> Handoff | None: - """Return one scope's current committed Handoff, if it exists.""" + async def get(self, scope_id: str, reference: ArtifactRef, /) -> Handoff: ... - ... - async def get(self, scope_id: str, reference: ArtifactRef, /) -> Handoff: - """Return the exact committed Handoff addressed by ``reference``.""" +class ScopeSelectionResolver(Protocol): + async def resolve_selection(self, selection: ScopeSelection, /) -> tuple[ScopeDescriptor, ...]: ... - ... - async def revisions(self, scope_id: str, /) -> tuple[Handoff, ...]: - """Return one scope's committed Handoffs in ascending Revision order.""" - - ... - - async def check_evidence( - self, - scope_id: str, - reference: ArtifactRef, - /, - ) -> tuple[HandoffEvidenceCheck, ...]: - """Recheck evidence readability for one exact committed Handoff.""" - - ... - - -class WorkContinuityReadAdapter(Protocol): - """Read the high-level Work loop projection for one scope.""" - - async def get(self, scope_id: str, reference: ArtifactRef | None, /) -> WorkContinuity: ... - - -__all__ = ["HandoffReadAdapter", "WorkContinuityReadAdapter"] +__all__ = ["HandoffReadAdapter", "ScopeSelectionResolver"] diff --git a/src/powercontext/builtin/handoff_report/rendering.py b/src/powercontext/builtin/handoff_report/rendering.py index 00d2d1da4..669177353 100644 --- a/src/powercontext/builtin/handoff_report/rendering.py +++ b/src/powercontext/builtin/handoff_report/rendering.py @@ -12,443 +12,58 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Deterministic Markdown rendering for canonical Handoff Reports.""" +"""Markdown rendering for Scope-based Handoff Reports.""" from __future__ import annotations -import html -import json -import re -from datetime import datetime -from unicodedata import category - -from powercontext.builtin.handoff_report.canonical import finalize_digests -from powercontext.builtin.handoff_report.models import ExternalReference, ReportActivityEvent -from powercontext.builtin.handoff_report.report import HandoffReport, WorkstreamReport - -_LABELS = { - "zh-CN": { - "title": "PowerContext 项目交接报告", - "overview": "项目概览", - "blockers": "阻塞事项", - "workstreams": "Workstream 状态", - "details": "Workstream 详情", - "objective": "目标", - "progress": "当前进度", - "next": "下一步", - "omissions": "缺失信息", - "activities": "观察到的 Activity", - "unassigned_activities": "未分配 Activity", - "event": "事件", - "schema": "Schema", - "event_id": "事件 ID", - "project_id": "Project ID", - "source": "来源", - "source_event_id": "来源事件 ID", - "scope": "Scope", - "time_basis": "时间依据", - "occurred_at": "发生时间", - "observed_at": "观察时间", - "event_title": "标题", - "event_summary": "摘要", - "source_ref": "来源引用", - "agent": "Agent", - "session": "Session", - "vcs": "VCS 上下文", - "evidence": "证据引用", - "evidence_checks": "Evidence 检查", - "revision_history": "Handoff Revision 历史", - "revision_history_summary": "共 {total} 个 Revision,显示最近 {shown} 个。", # noqa: RUF001 - "revision_state_count": "状态条目", - "revision_omission_count": "缺失条目", - "continuity": "连续性时间线", - "transfer_state": "交接状态", - "outcome_state": "结果状态", - "journal_order_notice": "按 Source journal 的稳定位置排序;位置表示先后顺序,不代表时间戳。", # noqa: RUF001 - "invalid_work_records": "无法读取的 Work 记录", - "metadata": "报告元数据", - "selection_digest": "Selection Digest", - "report_digest": "Report Digest", - "report_kind": "报告类型", - "period": "报告周期", - "period_comparison": "与前一周期对比", - "current_activity_count": "本周期 Activity 数", - "previous_activity_count": "前一周期 Activity 数", - "activity_delta": "Activity 变化", - "handoff_boundary_coverage": "Handoff 时间边界覆盖", - "format": "格式", - "trust": "信任标记", - "none": "无", - "activity_notice": "Activity Adapter 未配置;此处不能解释为没有活动。", # noqa: RUF001 - }, - "en": { - "title": "PowerContext Project Handoff Report", - "overview": "Project Overview", - "blockers": "Blockers", - "workstreams": "Workstream Status", - "details": "Workstream Details", - "objective": "Objective", - "progress": "Current Progress", - "next": "Next Action", - "omissions": "Omissions", - "activities": "Observed Activity", - "unassigned_activities": "Unassigned Activity", - "event": "Event", - "schema": "Schema", - "event_id": "Event ID", - "project_id": "Project ID", - "source": "Source", - "source_event_id": "Source Event ID", - "scope": "Scope", - "time_basis": "Time Basis", - "occurred_at": "Occurred At", - "observed_at": "Observed At", - "event_title": "Title", - "event_summary": "Summary", - "source_ref": "Source Reference", - "agent": "Agent", - "session": "Session", - "vcs": "VCS Context", - "evidence": "Evidence References", - "evidence_checks": "Evidence Checks", - "revision_history": "Handoff Revision History", - "revision_history_summary": "{total} Revisions total. Showing the latest {shown}.", - "revision_state_count": "State Items", - "revision_omission_count": "Omissions", - "continuity": "Continuity Timeline", - "transfer_state": "Transfer State", - "outcome_state": "Outcome State", - "journal_order_notice": "Ordered by stable Source journal position; positions show sequence, not timestamps.", - "invalid_work_records": "Unreadable Work Records", - "metadata": "Report Metadata", - "selection_digest": "Selection Digest", - "report_digest": "Report Digest", - "report_kind": "Report Kind", - "period": "Report Period", - "period_comparison": "Previous Period Comparison", - "current_activity_count": "Current Activity Count", - "previous_activity_count": "Previous Activity Count", - "activity_delta": "Activity Delta", - "handoff_boundary_coverage": "Handoff Boundary Coverage", - "format": "Format", - "trust": "Trust", - "none": "None", - "activity_notice": "Activity adapters are not configured; this does not mean that no activity occurred.", - }, -} +from powercontext.builtin.handoff_report.report import HandoffReport, ScopeHandoffReport def render_markdown(report: HandoffReport, /) -> str: - """Render one stable human projection without invoking a model or parsing Markdown input.""" - - projection = finalize_digests(report.model_copy(update={"format": "markdown", "renderer_version": "markdown-v1"})) - labels = _LABELS[projection.locale] - lines = _front_matter_lines(projection) - overview_identity = ( - f"Scope: {_code_span(projection.workstreams[0].workstream.scope_id)}" - if _is_scope_report(projection) - else f"Project: {_text(projection.project.title)}" - ) - lines.extend([ - "---", - "", - f"# {labels['title']}", + lines = [ + "# Handoff Report", "", - f"## {labels['overview']}", + f"Selection: `{report.selection.mode}`", + f"Scopes: {len(report.scopes)}", "", - f"- {overview_identity}", - f"- Workstreams: {projection.coverage.selected_workstreams}", - f"- Missing Handoff: {projection.coverage.missing_handoff_workstreams}", - f"- Continuable: {projection.summary.continuable_count}", - f"- Blocked: {projection.summary.blocked_count}", - f"- Complete: {projection.summary.complete_count}", - f"- No Handoff: {projection.summary.no_handoff_count}", - ]) - if projection.coverage.activity_coverage == "not_configured": - lines.extend((f"- {_text(labels['activity_notice'])}", "")) - else: - lines.append("") - if projection.normalized_period is not None: - lines.extend(( - f"## {labels['period']}", - "", - f"- Start: {_code_span(str(projection.normalized_period['start']))}", - f"- End: {_code_span(str(projection.normalized_period['end']))}", - f"- Timezone: {_code_span(str(projection.normalized_period['timezone']))}", - "", - )) - if projection.period_comparison is not None: - comparison = projection.period_comparison - lines.extend(( - f"## {labels['period_comparison']}", - "", - f"- {labels['current_activity_count']}: {comparison.current_activity_count}", - f"- {labels['previous_activity_count']}: {comparison.previous_activity_count}", - f"- {labels['activity_delta']}: {comparison.activity_delta:+d}", - f"- {labels['handoff_boundary_coverage']}: {_code_span(comparison.handoff_boundary_coverage)}", - "", - )) - lines.extend((f"## {labels['blockers']}", "")) - blockers = tuple(item for item in projection.workstreams if item.work_status == "blocked") - if blockers: - lines.extend( - f"- {_text(item.workstream.title)} ({_code_span(item.workstream.scope_id)}): " - f"{_code_span(item.reporting_status)}" - for item in blockers - ) - else: - lines.extend((labels["none"], "")) - lines.extend((f"## {labels['workstreams']}", "")) - lines.extend( - f"- {_text(item.workstream.title)} ({_code_span(item.workstream.scope_id)}): " - f"{_code_span(item.work_status)} / {_code_span(item.reporting_status)}" - for item in projection.workstreams - ) - lines.extend(("", f"## {labels['details']}", "")) - for item in projection.workstreams: - lines.extend(_render_workstream(item, labels)) - lines.extend((f"## {labels['unassigned_activities']}", "")) - if projection.unassigned_activity: - for event in projection.unassigned_activity: - lines.extend(_render_activity(event, labels)) - else: - lines.extend((labels["none"], "")) - lines.extend((f"## {labels['metadata']}", "")) - lines.extend(( - f"- {labels['selection_digest']}: {_code_span(projection.selection_digest or labels['none'])}", - f"- {labels['report_digest']}: {_code_span(projection.report_digest or labels['none'])}", - f"- {labels['report_kind']}: {_code_span(projection.report_kind)}", - f"- {labels['format']}: {_code_span(projection.format)}", - f"- {labels['trust']}: {_code_span(projection.trust)}", - )) - return "\n".join(lines).rstrip() + "\n" - - -def _render_workstream(item: WorkstreamReport, labels: dict[str, str]) -> list[str]: - lines = [f"### {_text(item.workstream.title)} ({_code_span(item.workstream.scope_id)})", ""] - if item.content is None: - lines.extend((f"#### {labels['objective']}", "", labels["none"], "")) - else: - lines.extend((f"#### {labels['objective']}", "", _text(item.content.objective), "")) - lines.extend((f"#### {labels['progress']}", "")) - lines.extend(f"- {_text(statement.text)}" for statement in item.content.state) - lines.extend(("", f"#### {labels['next']}", "")) - lines.append(labels["none"] if item.content.next_action is None else _text(item.content.next_action.text)) - lines.extend(("", f"#### {labels['omissions']}", "")) - if item.content.omissions: - lines.extend(f"- {_text(omission.text)}" for omission in item.content.omissions) - else: - lines.append(labels["none"]) - lines.append("") - lines.extend((f"#### {labels['evidence_checks']}", "")) - if item.evidence_checks == "not_checked": - evidence_state = "not_checked" - if item.evidence_unavailable: - evidence_state = "not_checked (adapter unavailable)" - lines.extend((_code_span(evidence_state), "")) - elif item.evidence_checks: - lines.extend(f"- {_code_span(check.claim)}: {_code_span(check.status)}" for check in item.evidence_checks) - lines.append("") - lines.extend(_render_revision_history(item, labels)) - continuity = item.continuity - lines.extend((f"#### {labels['continuity']}", "")) - lines.extend(( - f"- {labels['transfer_state']}: {_code_span(continuity.coverage.transfer_state)}", - f"- {labels['outcome_state']}: {_code_span(continuity.coverage.outcome_state)}", - f"- {labels['invalid_work_records']}: {continuity.invalid_record_count}", - f"- {_text(labels['journal_order_notice'])}", - )) - if continuity.events: - for event in continuity.events: - detail = event.summary or event.actor or labels["none"] - lines.append( - f"- {_code_span(f'#{event.position}')} {_code_span(event.kind)} / " - f"{_code_span(event.status)}: {_text(detail)}" - ) - else: - lines.append(labels["none"]) - lines.append("") - lines.extend((f"#### {labels['activities']}", "")) - if item.activities: - for event in item.activities: - lines.extend(_render_activity(event, labels)) - else: - lines.extend((labels["none"], "")) - return lines - - -def _render_revision_history(item: WorkstreamReport, labels: dict[str, str]) -> list[str]: - lines = [f"#### {labels['revision_history']}", ""] - if item.handoff_history: - lines.extend(( - _text( - labels["revision_history_summary"].format( - total=item.handoff_revision_count, - shown=len(item.handoff_history), - ) - ), - "", - )) - for revision in reversed(item.handoff_history): - reference = revision.reference - lines.append( - f"- {_code_span(f'@{reference.revision}')} {_code_span(revision.disposition)}: " - f"{_text(revision.objective_excerpt)}" - ) - lines.append( - f" - {labels['revision_state_count']}: {revision.state_count}; " - f"{labels['revision_omission_count']}: {revision.omission_count}" - ) - if revision.next_action_excerpt is not None: - lines.append(f" - {labels['next']}: {_text(revision.next_action_excerpt)}") - else: - lines.append(labels["none"]) - lines.append("") - return lines - - -def _render_activity(event: ReportActivityEvent, labels: dict[str, str]) -> list[str]: - lines = [f"- **{labels['event']}** {_code_span(event.event_id)}"] - lines.extend(( - f" - {labels['schema']}: {_code_span(event.schema_version)}", - f" - {labels['event_id']}: {_code_span(event.event_id)}", - f" - {labels['project_id']}: {_code_span(event.project_id)}", - f" - {labels['source']}: {_code_span(event.source)}", - f" - {labels['source_event_id']}: {_code_span(event.source_event_id)}", - f" - {labels['scope']}: {_optional_code(event.scope_id, labels)}", - f" - {labels['time_basis']}: {_code_span(event.time_basis)}", - f" - {labels['occurred_at']}: {_optional_timestamp(event.occurred_at, labels)}", - f" - {labels['observed_at']}: {_code_span(event.observed_at.isoformat())}", - f" - {labels['event_title']}: {_optional_text(event.title, labels)}", - f" - {labels['event_summary']}: {_optional_text(event.summary, labels)}", - f" - {labels['source_ref']}: {_optional_reference(event.source_ref, labels)}", - f" - {labels['agent']}: {_agent(event, labels)}", - f" - {labels['session']}: {_optional_code(event.session_id, labels)}", - f" - {labels['vcs']}: {_vcs(event, labels)}", - f" - {labels['trust']}: {_code_span(event.trust)}", - f" - {labels['evidence']}:", - )) - if event.evidence_refs: - lines.extend(f" - {_reference(reference)}" for reference in event.evidence_refs) - else: - lines.append(f" - {labels['none']}") - lines.append("") - return lines - - -def _front_matter_lines(report: HandoffReport) -> list[str]: - lines = [ - "---", - "schema: powercontext.handoff-report.v1", - f"locale: {report.locale}", - "format: markdown", + "| Scope | Parent | Status | Handoff |", + "| --- | --- | --- | --- |", ] - if _is_scope_report(report): - lines.append(f"scope_id: {_yaml_string(report.workstreams[0].workstream.scope_id)}") - else: - lines.extend(( - f"project_id: {_yaml_string(report.project.project_id)}", - f"project_key: {_yaml_string(report.project.project_key)}", - f"project_version: {report.project.version}", - )) - lines.extend(( - f"report_kind: {report.report_kind}", - f"selection_digest: {_yaml_string(report.selection_digest or '')}", - f"report_digest: {_yaml_string(report.report_digest or '')}", - f"generated_at: {_yaml_string(report.generated_at.isoformat())}", - f"trust: {report.trust}", - f"selection_consistency: {report.selection_consistency}", - f"activity_cursor: {report.activity_cursor}", - )) - if report.end_selection: - lines.append("end_selection:") - for entry in report.end_selection: - lines.extend(( - f" - scope_id: {_yaml_string(entry.scope_id)}", - f" workstream_revision: {entry.workstream_revision}", - f" status: {entry.status}", - )) - if entry.handoff_ref is None: - lines.append(" handoff_ref: null") - else: - lines.extend(( - " handoff_ref:", - f" family: {_yaml_string(entry.handoff_ref.family)}", - f" artifact_id: {_yaml_string(entry.handoff_ref.artifact_id)}", - f" revision: {entry.handoff_ref.revision}", - )) - else: - lines.append("end_selection: []") - if report.activity_selection: - lines.append("activity_selection:") - lines.extend(f" - {_yaml_string(event_id)}" for event_id in report.activity_selection) - else: - lines.append("activity_selection: []") + for entry in report.scopes: + parent = entry.scope.parent_scope_id or "—" + reference = "—" if entry.handoff is None else _format_address(entry) + lines.append(f"| {_cell(entry.scope.title)} | `{_cell(parent)}` | {entry.status} | {reference} |") + + for entry in report.scopes: + lines.extend(_scope_section(entry)) + lines.extend(["", f"Selection digest: `{report.selection_digest}`", f"Report digest: `{report.report_digest}`", ""]) + return "\n".join(lines) + + +def _scope_section(entry: ScopeHandoffReport) -> list[str]: + lines = ["", f"## {entry.scope.title}", "", entry.scope.summary] + if entry.content is None: + return [*lines, "", "No committed Handoff."] + lines.extend(["", f"Status: **{entry.status}**", "", f"Objective: {entry.content.objective}", "", "Current state:"]) + lines.extend(f"- {statement.text}" for statement in entry.content.state) + if entry.content.next_action is not None: + lines.extend(["", f"Next action: {entry.content.next_action.text}"]) + if entry.content.omissions: + lines.extend(["", "Known omissions:"]) + lines.extend(f"- {omission.text}" for omission in entry.content.omissions) return lines -def _is_scope_report(report: HandoffReport) -> bool: - return report.project.project_id == "unused" and len(report.workstreams) == 1 - - -def _agent(event: ReportActivityEvent, labels: dict[str, str]) -> str: - if event.agent is None: - return labels["none"] - values = tuple(value for value in (event.agent.provider, event.agent.label) if value is not None) - return " / ".join(_code_span(value) for value in values) - - -def _vcs(event: ReportActivityEvent, labels: dict[str, str]) -> str: - if event.vcs_context is None: - return labels["none"] - values = tuple(value for value in (event.vcs_context.branch, event.vcs_context.head_revision) if value is not None) - return " / ".join(_code_span(value) for value in values) - - -def _optional_reference(reference: ExternalReference | None, labels: dict[str, str]) -> str: - return labels["none"] if reference is None else _reference(reference) - - -def _reference(reference: ExternalReference) -> str: - values = [_code_span(reference.kind), _code_span(reference.provider), _code_span(reference.external_id)] - if reference.url is not None: - values.append(_code_span(reference.url)) - return " / ".join(values) - - -def _optional_text(value: str | None, labels: dict[str, str]) -> str: - return labels["none"] if value is None else _text(value) - - -def _optional_code(value: str | None, labels: dict[str, str]) -> str: - return labels["none"] if value is None else _code_span(value) - - -def _optional_timestamp(value: datetime | None, labels: dict[str, str]) -> str: - return labels["none"] if value is None else _code_span(value.isoformat()) - - -def _collapse_lines(value: str) -> str: - flattened = " ".join(value.splitlines()) - return "".join(" " if category(character) == "Cc" else character for character in flattened) - - -def _text(value: str) -> str: - escaped_html = html.escape(_collapse_lines(value), quote=True) - return re.sub(r"([\\`*_{}\[\]()#+\-.!|>~])", r"\\\1", escaped_html) - - -def _code_span(value: str) -> str: - escaped_html = html.escape(_collapse_lines(value), quote=True) - runs = tuple(len(match.group(0)) for match in re.finditer(r"`+", escaped_html)) - delimiter = "`" * (max(runs, default=0) + 1) - if runs: - return f"{delimiter} {escaped_html} {delimiter}" - return f"{delimiter}{escaped_html}{delimiter}" +def _format_address(entry: ScopeHandoffReport) -> str: + handoff = entry.handoff + if handoff is None: + return "—" + artifact = handoff.artifact + return f"`{handoff.scope_id}/{artifact.family}/{artifact.artifact_id}@{artifact.revision}`" -def _yaml_string(value: str) -> str: - return json.dumps(value, ensure_ascii=False) +def _cell(value: str) -> str: + return value.replace("|", "\\|").replace("\n", " ") __all__ = ["render_markdown"] diff --git a/src/powercontext/builtin/handoff_report/report.py b/src/powercontext/builtin/handoff_report/report.py index 7e0d6d662..d43597a97 100644 --- a/src/powercontext/builtin/handoff_report/report.py +++ b/src/powercontext/builtin/handoff_report/report.py @@ -12,371 +12,86 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Canonical output values for the optional Handoff Report feature.""" +"""Canonical Handoff Report values.""" from __future__ import annotations -from datetime import UTC, datetime -from typing import Annotated, Literal, TypeAlias +from datetime import datetime +from typing import Literal -from pydantic import BaseModel, ConfigDict, Field, JsonValue, StrictInt, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, StrictInt, model_validator -from powercontext.artifacts import ArtifactRef -from powercontext.builtin.artifacts.handoff import HandoffContent, HandoffDisposition, HandoffEvidenceCheck -from powercontext.builtin.handoff_report.models import ( - HandoffReportTrust, - ProjectDescriptor, - ReportActivityEvent, - ReportLocale, - ReportSelectionConsistency, - ReportSelectionEntry, - WorkstreamDescriptor, -) -from powercontext.builtin.work import WorkContinuity +from powercontext.artifacts import ArtifactAddress +from powercontext.builtin.artifacts.handoff import HandoffContent +from powercontext.builtin.scope.models import ScopeDescriptor, ScopeSelection -ReportEvidenceChecks: TypeAlias = tuple[HandoffEvidenceCheck, ...] | Literal["not_checked"] -ReportActivityCoverageStatus: TypeAlias = Literal["not_configured", "captured", "unavailable"] -ReportFormat: TypeAlias = Literal["json", "markdown"] -ReportKind: TypeAlias = Literal["handoff", "periodic"] -ReportWorkStatus: TypeAlias = Literal["continuable", "blocked", "complete", "no_handoff"] -ReportActivityStatus: TypeAlias = Literal[ - "no_observed_activity", - "activity_after_handoff", - "activity_without_handoff", - "current_only", - "unknown", -] -ReportReportingStatus: TypeAlias = Literal[ - "reported", - "reported_with_omissions", - "evidence_unavailable", - "no_handoff", -] -ReportHandoffActivityRelation: TypeAlias = Literal[ - "activity_after_handoff", - "no_observed_activity_after_handoff", - "unknown", -] -ReportHandoffBoundaryCoverage: TypeAlias = Literal["unavailable"] -MAX_REPORT_WORKSTREAMS = 100 -MAX_REPORT_ACTIVITIES = 5_000 -MAX_REPORT_HANDOFF_HISTORY = 20 -MAX_REPORT_HANDOFF_HISTORY_EXCERPT_LENGTH = 240 +HandoffReportStatus = Literal["continuable", "blocked", "complete", "no_handoff"] -class _ReportOutputValue(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True, strict=True) +class _ReportValue(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) -class ReportCoverage(_ReportOutputValue): - """Counts and explicit adapter coverage for one frozen report.""" +class ScopeHandoffReport(_ReportValue): + """The exact latest Handoff projected for one selected Scope.""" - total_included_workstreams: StrictInt = Field(ge=0) - catalog_matched_workstreams: StrictInt = Field(default=0, ge=0) - selected_workstreams: StrictInt = Field(ge=0) - missing_handoff_workstreams: StrictInt = Field(ge=0) - reported_with_omissions: StrictInt = Field(ge=0) - unchecked_evidence_workstreams: StrictInt = Field(default=0, ge=0) - unavailable_evidence_workstreams: StrictInt = Field(ge=0) - activity_without_handoff_workstreams: StrictInt = Field(ge=0) - activity_after_handoff_workstreams: StrictInt = Field(default=0, ge=0) - unknown_time_events: StrictInt = Field(default=0, ge=0) - unassigned_activity_count: StrictInt = Field(ge=0) - unassigned_activity_events: StrictInt = Field(default=0, ge=0) - activity_coverage: ReportActivityCoverageStatus + scope: ScopeDescriptor + status: HandoffReportStatus + handoff: ArtifactAddress | None = None + content: HandoffContent | None = None + @model_validator(mode="after") + def validate_state(self) -> ScopeHandoffReport: + if self.status == "no_handoff": + if self.handoff is not None or self.content is not None: + raise ValueError("no_handoff cannot contain Handoff data") # noqa: TRY003 + return self + if self.handoff is None or self.content is None: + raise ValueError("reported Scope must contain an exact Handoff") # noqa: TRY003 + if self.handoff.scope_id != self.scope.scope_id: + raise ValueError("Handoff address must belong to the reported Scope") # noqa: TRY003 + if self.status != self.content.disposition: + raise ValueError("report status must match Handoff disposition") # noqa: TRY003 + return self -class ReportSummary(_ReportOutputValue): - """Deterministic work-status counts derived from exact Handoff content.""" +class HandoffReportSummary(_ReportValue): continuable_count: StrictInt = Field(ge=0) blocked_count: StrictInt = Field(ge=0) complete_count: StrictInt = Field(ge=0) no_handoff_count: StrictInt = Field(ge=0) -class ReportPeriodComparison(_ReportOutputValue): - """Truthful Activity comparison when Handoff boundary time is unavailable.""" - - previous_start: datetime - previous_end: datetime - current_activity_count: StrictInt = Field(ge=0) - previous_activity_count: StrictInt = Field(ge=0) - activity_delta: StrictInt - handoff_boundary_coverage: ReportHandoffBoundaryCoverage = "unavailable" - - @field_validator("previous_start", "previous_end") - @classmethod - def require_aware_boundary(cls, value: datetime) -> datetime: - if value.tzinfo is None or value.utcoffset() is None: - raise ValueError("period comparison boundaries must be timezone-aware") # noqa: TRY003 - return value.astimezone(UTC) - - @model_validator(mode="after") - def validate_comparison(self) -> ReportPeriodComparison: - if self.previous_start >= self.previous_end: - raise ValueError("previous period start must precede its end") # noqa: TRY003 - if self.activity_delta != self.current_activity_count - self.previous_activity_count: - raise ValueError("activity_delta must match current minus previous Activity count") # noqa: TRY003 - return self - - -class HandoffRevisionSummary(_ReportOutputValue): - """Bounded display metadata for one committed Handoff Revision.""" - - reference: ArtifactRef - objective_excerpt: Annotated[str, Field(max_length=MAX_REPORT_HANDOFF_HISTORY_EXCERPT_LENGTH)] - disposition: HandoffDisposition - next_action_excerpt: Annotated[str | None, Field(max_length=MAX_REPORT_HANDOFF_HISTORY_EXCERPT_LENGTH)] = None - state_count: StrictInt = Field(ge=1) - omission_count: StrictInt = Field(ge=0) - - -class WorkstreamReport(_ReportOutputValue): - """One Workstream projected from an exact Handoff selection.""" - - workstream: WorkstreamDescriptor - continuity: WorkContinuity - handoff_ref: ArtifactRef | None - content: HandoffContent | None - handoff_revision_count: StrictInt = Field(default=0, ge=0) - handoff_history_truncated: bool = False - handoff_history: Annotated[tuple[HandoffRevisionSummary, ...], Field(max_length=MAX_REPORT_HANDOFF_HISTORY)] = () - evidence_checks: ReportEvidenceChecks = "not_checked" - evidence_unavailable: bool = False - activities: Annotated[tuple[ReportActivityEvent, ...], Field(max_length=MAX_REPORT_ACTIVITIES)] = () - work_status: ReportWorkStatus - reporting_status: ReportReportingStatus - activity_status: ReportActivityStatus - handoff_activity_relation: ReportHandoffActivityRelation | None = None - observed_activity_count: StrictInt = Field(default=0, ge=0) - - @model_validator(mode="after") - def validate_handoff_projection(self) -> WorkstreamReport: - if self.continuity.scope_id != self.workstream.scope_id: - raise ValueError("continuity scope must match its Workstream") # noqa: TRY003 - if self.handoff_ref is None: - _validate_no_handoff(self) - else: - _validate_selected_handoff(self) - if self.observed_activity_count != len(self.activities): - raise ValueError("observed_activity_count must match the Workstream activity count") # noqa: TRY003 - return self - - -def _validate_no_handoff(report: WorkstreamReport) -> None: - if report.content is not None: - raise ValueError("a Workstream without Handoff cannot contain Handoff content") # noqa: TRY003 - if report.evidence_checks != "not_checked": - raise ValueError("a Workstream without Handoff cannot contain evidence checks") # noqa: TRY003 - if report.evidence_unavailable: - raise ValueError("a Workstream without Handoff cannot have unavailable evidence checks") # noqa: TRY003 - if report.work_status != "no_handoff": - raise ValueError("a Workstream without Handoff must have no_handoff work status") # noqa: TRY003 - if report.reporting_status != "no_handoff": - raise ValueError("a Workstream without Handoff must report missing Handoff state") # noqa: TRY003 - if report.handoff_revision_count != 0 or report.handoff_history or report.handoff_history_truncated: - raise ValueError("a Workstream without Handoff cannot contain Handoff Revision history") # noqa: TRY003 +class HandoffReport(_ReportValue): + """A read-only projection over all, exact, or subtree Scope selection.""" - -def _validate_selected_handoff(report: WorkstreamReport) -> None: - if report.content is None: - raise ValueError("an exact Handoff selection must contain Handoff content") # noqa: TRY003 - if report.evidence_unavailable and report.evidence_checks != "not_checked": - raise ValueError("an unavailable evidence check must remain not_checked") # noqa: TRY003 - if report.work_status != report.content.disposition: - raise ValueError("work status must match the exact Handoff disposition") # noqa: TRY003 - if report.reporting_status == "no_handoff": - raise ValueError("an exact Handoff selection cannot report missing Handoff state") # noqa: TRY003 - _validate_handoff_history(report) - - -def _validate_handoff_history(report: WorkstreamReport) -> None: - handoff_ref = report.handoff_ref - if handoff_ref is None: - raise ValueError("Handoff Revision history requires an exact selected Handoff") # noqa: TRY003 - if not report.handoff_history: - raise ValueError("an exact Handoff selection must contain Handoff Revision history") # noqa: TRY003 - if report.handoff_history[-1].reference != handoff_ref: - raise ValueError("Handoff Revision history must end at the exact selected Handoff") # noqa: TRY003 - if report.handoff_revision_count < len(report.handoff_history): - raise ValueError("Handoff Revision count cannot be smaller than its projected history") # noqa: TRY003 - if report.handoff_history_truncated != (report.handoff_revision_count > len(report.handoff_history)): - raise ValueError("Handoff Revision truncation must match its projected history") # noqa: TRY003 - references = tuple(item.reference for item in report.handoff_history) - if any( - reference.family != handoff_ref.family or reference.artifact_id != handoff_ref.artifact_id - for reference in references - ): - raise ValueError("Handoff Revision history must belong to the selected Artifact lifecycle") # noqa: TRY003 - if tuple(reference.revision for reference in references) != tuple( - sorted({reference.revision for reference in references}) - ): - raise ValueError("Handoff Revision history must be unique and ascending") # noqa: TRY003 - - -class HandoffReport(_ReportOutputValue): - """Language-neutral canonical report used by renderers and Agents.""" - - schema_version: Literal["powercontext.handoff-report.v1"] = Field( - default="powercontext.handoff-report.v1", + schema_version: Literal["powercontext.handoff-report.v2"] = Field( + default="powercontext.handoff-report.v2", alias="schema", ) - trust: HandoffReportTrust = "untrusted_history" - locale: ReportLocale - format: ReportFormat = "json" - report_kind: ReportKind = "handoff" - renderer_version: str = "canonical-v1" + selection: ScopeSelection + scope_ids: tuple[str, ...] generated_at: datetime - selection_consistency: ReportSelectionConsistency - project: ProjectDescriptor - project_revision: StrictInt = Field(default=1, ge=1) - normalized_filters: dict[str, JsonValue] = Field(default_factory=dict) - normalized_period: dict[str, JsonValue] | None = None - period_comparison: ReportPeriodComparison | None = None - baseline_selection: Annotated[tuple[ReportSelectionEntry, ...], Field(max_length=MAX_REPORT_WORKSTREAMS)] | None = ( - None - ) - end_selection: Annotated[tuple[ReportSelectionEntry, ...], Field(max_length=MAX_REPORT_WORKSTREAMS)] - activity_cursor: StrictInt = Field(ge=0) - activity_selection: Annotated[tuple[str, ...], Field(max_length=MAX_REPORT_ACTIVITIES)] = () - selection_digest: str | None = Field(default=None, pattern=r"sha256:[0-9a-f]{64}") - report_digest: str | None = Field(default=None, pattern=r"sha256:[0-9a-f]{64}") - coverage: ReportCoverage - summary: ReportSummary - unassigned_activity: Annotated[tuple[ReportActivityEvent, ...], Field(max_length=MAX_REPORT_ACTIVITIES)] = () - workstreams: Annotated[tuple[WorkstreamReport, ...], Field(max_length=MAX_REPORT_WORKSTREAMS)] - - @field_validator("generated_at") - @classmethod - def require_utc_generated_at(cls, value: datetime) -> datetime: - if value.tzinfo is None or value.utcoffset() is None: - raise ValueError("generated_at must be timezone-aware") # noqa: TRY003 - return value.astimezone(UTC) + summary: HandoffReportSummary + scopes: tuple[ScopeHandoffReport, ...] + selection_digest: str | None = None + report_digest: str | None = None @model_validator(mode="after") - def validate_selection_projection(self) -> HandoffReport: - _validate_scope_projection(self) - if self.project_revision != self.project.version: - raise ValueError("project_revision must match the projected Project descriptor") # noqa: TRY003 - if self.coverage.selected_workstreams != len(self.workstreams): - raise ValueError("selected_workstreams must match Workstream report count") # noqa: TRY003 - _validate_activity_projection(self) - _validate_coverage_projection(self) - _validate_summary_projection(self) - if self.report_kind == "periodic" and self.normalized_period is None: - raise ValueError("a periodic report must contain a normalized period") # noqa: TRY003 - if self.report_kind == "handoff" and (self.normalized_period is not None or self.period_comparison is not None): - raise ValueError("a point-in-time Handoff report cannot contain period values") # noqa: TRY003 - if self.period_comparison is not None and self.report_kind != "periodic": - raise ValueError("period comparison is only valid for a periodic report") # noqa: TRY003 + def validate_projection(self) -> HandoffReport: + if self.generated_at.tzinfo is None or self.generated_at.utcoffset() is None: + raise ValueError("generated_at must be timezone-aware") # noqa: TRY003 + if self.scope_ids != tuple(entry.scope.scope_id for entry in self.scopes): + raise ValueError("scope_ids must match report entries") # noqa: TRY003 + expected = { + "continuable_count": sum(entry.status == "continuable" for entry in self.scopes), + "blocked_count": sum(entry.status == "blocked" for entry in self.scopes), + "complete_count": sum(entry.status == "complete" for entry in self.scopes), + "no_handoff_count": sum(entry.status == "no_handoff" for entry in self.scopes), + } + if self.summary.model_dump() != expected: + raise ValueError("summary must match report entries") # noqa: TRY003 return self -def _validate_scope_projection(report: HandoffReport) -> None: - selection_scopes = tuple(entry.scope_id for entry in report.end_selection) - report_scopes = tuple(item.workstream.scope_id for item in report.workstreams) - if len(set(selection_scopes)) != len(selection_scopes): - raise ValueError("Handoff Report selection scopes must be unique") # noqa: TRY003 - if len(set(report_scopes)) != len(report_scopes): - raise ValueError("Handoff Report Workstream scopes must be unique") # noqa: TRY003 - if selection_scopes != report_scopes: - raise ValueError("Workstream reports must exactly match selection scope order") # noqa: TRY003 - for entry, item in zip(report.end_selection, report.workstreams, strict=True): - if item.workstream.project_id != report.project.project_id: - raise ValueError("every Workstream report must belong to the Report Project") # noqa: TRY003 - if entry.workstream_revision != item.workstream.version: - raise ValueError("selection Workstream revision must match the projected descriptor") # noqa: TRY003 - if entry.handoff_ref != item.handoff_ref: - raise ValueError("selection Handoff reference must match the Workstream report") # noqa: TRY003 - - -def _validate_activity_projection(report: HandoffReport) -> None: - known_scopes = {item.workstream.scope_id for item in report.workstreams} - assigned_activity: list[ReportActivityEvent] = [] - for item in report.workstreams: - for event in item.activities: - if event.project_id != report.project.project_id: - raise ValueError("every assigned Activity Event must belong to the Report Project") # noqa: TRY003 - if event.scope_id != item.workstream.scope_id: - raise ValueError("assigned Activity Event scope must match its Workstream report") # noqa: TRY003 - assigned_activity.append(event) - for event in report.unassigned_activity: - if event.project_id != report.project.project_id: - raise ValueError("every unassigned Activity Event must belong to the Report Project") # noqa: TRY003 - if event.scope_id in known_scopes: - raise ValueError("Activity Event for a selected scope cannot be unassigned") # noqa: TRY003 - activity_ids = tuple(event.event_id for event in (*assigned_activity, *report.unassigned_activity)) - if len(set(activity_ids)) != len(activity_ids): - raise ValueError("Activity Event ids must be unique within a Handoff Report") # noqa: TRY003 - if report.activity_selection != activity_ids: - raise ValueError("activity_selection must match projected Activity Event order") # noqa: TRY003 - - -def _validate_coverage_projection(report: HandoffReport) -> None: - if report.coverage.total_included_workstreams < len(report.workstreams): - raise ValueError("total_included_workstreams cannot be smaller than the selected report") # noqa: TRY003 - if report.coverage.catalog_matched_workstreams > report.coverage.total_included_workstreams: - raise ValueError("catalog_matched_workstreams cannot exceed total_included_workstreams") # noqa: TRY003 - all_events = tuple(event for item in report.workstreams for event in item.activities) + report.unassigned_activity - expected = { - "missing_handoff_workstreams": sum(item.handoff_ref is None for item in report.workstreams), - "reported_with_omissions": sum( - item.reporting_status == "reported_with_omissions" for item in report.workstreams - ), - "unchecked_evidence_workstreams": sum( - item.handoff_ref is not None and item.evidence_checks == "not_checked" for item in report.workstreams - ), - "unavailable_evidence_workstreams": sum( - item.evidence_unavailable - or ( - item.evidence_checks != "not_checked" - and any(check.status == "unavailable" for check in item.evidence_checks) - ) - for item in report.workstreams - ), - "activity_without_handoff_workstreams": sum( - item.activity_status == "activity_without_handoff" for item in report.workstreams - ), - "activity_after_handoff_workstreams": sum( - item.handoff_activity_relation == "activity_after_handoff" for item in report.workstreams - ), - "unknown_time_events": sum(event.effective_period_time() is None for event in all_events), - "unassigned_activity_count": len(report.unassigned_activity), - "unassigned_activity_events": len(report.unassigned_activity), - } - for field, value in expected.items(): - if getattr(report.coverage, field) != value: - raise ValueError(f"{field} must match the canonical report projection") # noqa: TRY003 - - -def _validate_summary_projection(report: HandoffReport) -> None: - expected = { - "continuable_count": sum(item.work_status == "continuable" for item in report.workstreams), - "blocked_count": sum(item.work_status == "blocked" for item in report.workstreams), - "complete_count": sum(item.work_status == "complete" for item in report.workstreams), - "no_handoff_count": sum(item.work_status == "no_handoff" for item in report.workstreams), - } - for field, value in expected.items(): - if getattr(report.summary, field) != value: - raise ValueError(f"{field} must match the canonical report projection") # noqa: TRY003 - - -__all__ = [ - "MAX_REPORT_ACTIVITIES", - "MAX_REPORT_WORKSTREAMS", - "HandoffReport", - "ReportActivityCoverageStatus", - "ReportActivityStatus", - "ReportCoverage", - "ReportEvidenceChecks", - "ReportFormat", - "ReportHandoffActivityRelation", - "ReportHandoffBoundaryCoverage", - "ReportKind", - "ReportPeriodComparison", - "ReportReportingStatus", - "ReportSummary", - "ReportWorkStatus", - "WorkstreamReport", -] +__all__ = ["HandoffReport", "HandoffReportStatus", "HandoffReportSummary", "ScopeHandoffReport"] diff --git a/src/powercontext/builtin/handoff_report/repository.py b/src/powercontext/builtin/handoff_report/repository.py deleted file mode 100644 index f4eb7a3d7..000000000 --- a/src/powercontext/builtin/handoff_report/repository.py +++ /dev/null @@ -1,149 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Repository boundary for Report-owned activity observations. - -The boundary accepts structural event values and validates their complete payload -against the canonical domain model at the persistence edge. -""" - -from __future__ import annotations - -from collections.abc import Iterable, Mapping -from dataclasses import dataclass -from datetime import datetime -from typing import Literal, Protocol, TypeAlias, runtime_checkable - -from sqlalchemy.ext.asyncio import AsyncConnection - -from powercontext.builtin.handoff_report.errors import HandoffReportError -from powercontext.builtin.handoff_report.models import ReportTimeBasis - -ActivityTimeBasis: TypeAlias = ReportTimeBasis - - -@runtime_checkable -class ActivityEventLike(Protocol): - """Structural input required by an activity repository.""" - - @property - def event_id(self) -> str: ... - - @property - def project_id(self) -> str: ... - - @property - def scope_id(self) -> str | None: ... - - @property - def source(self) -> str: ... - - @property - def source_event_id(self) -> str: ... - - @property - def occurred_at(self) -> datetime | None: ... - - @property - def observed_at(self) -> datetime: ... - - @property - def time_basis(self) -> ActivityTimeBasis: ... - - @property - def trust(self) -> str: ... - - def model_dump(self, *, mode: Literal["json"], by_alias: Literal[True]) -> dict[str, object]: - """Return the complete canonical event payload.""" - - -@dataclass(frozen=True, slots=True) -class StoredActivityEvent: - """One stored observation plus its stable per-Project cursor.""" - - cursor: int - event_id: str - project_id: str - scope_id: str | None - source: str - source_event_id: str - occurred_at: datetime | None - observed_at: datetime - time_basis: ActivityTimeBasis - payload: Mapping[str, object] - - -class ActivityEventConflictError(HandoffReportError, ValueError): - """An idempotency key was reused for a different canonical event.""" - - def __init__(self, source: str, source_event_id: str) -> None: - super().__init__(f"activity event conflict for ({source!r}, {source_event_id!r})") - self.source = source - self.source_event_id = source_event_id - - -class InvalidActivityRepositoryArgumentError(HandoffReportError, ValueError): - """A repository argument is structurally invalid.""" - - def __init__(self, field: str, reason: str) -> None: - super().__init__(f"invalid {field}: {reason}") - - -class InvalidActivityEventError(HandoffReportError, ValueError): - """An activity does not satisfy the persistence boundary.""" - - def __init__(self, field: str, reason: str) -> None: - super().__init__(f"invalid activity event {field}: {reason}") - - -class ActivityEventSerializationError(HandoffReportError, TypeError): - """An activity cannot be converted to its canonical JSON payload.""" - - def __init__(self, operation: str, reason: str) -> None: - super().__init__(f"activity event serialization failed during {operation}: {reason}") - - -class StoredActivityEventError(HandoffReportError, RuntimeError): - """Persisted activity data violates the store schema.""" - - def __init__(self, field: str, reason: str) -> None: - super().__init__(f"invalid stored activity event {field}: {reason}") - - -class ActivityEventRepository(Protocol): - """Persistence operations needed by Activity capture and report assembly.""" - - async def record(self, connection: AsyncConnection, event: ActivityEventLike, /) -> StoredActivityEvent: - """Record an event or return an identical existing capture.""" - - async def list( - self, - connection: AsyncConnection, - project_id: str, - /, - *, - period_start: datetime | None = None, - period_end: datetime | None = None, - sources: Iterable[str] | None = None, - after_cursor: int = 0, - through_cursor: int | None = None, - limit: int | None = 50, - ) -> tuple[StoredActivityEvent, ...]: - """List a stable cursor-ordered Project page.""" - - async def high_watermark(self, connection: AsyncConnection, project_id: str, /) -> int: - """Return the latest allocated cursor without regressing after retention purge.""" - - async def purge(self, connection: AsyncConnection, project_id: str, observed_before: datetime, /) -> int: - """Delete expired Report-owned events and return the deleted row count.""" diff --git a/src/powercontext/builtin/handoff_report/selection.py b/src/powercontext/builtin/handoff_report/selection.py deleted file mode 100644 index 8766ef58f..000000000 --- a/src/powercontext/builtin/handoff_report/selection.py +++ /dev/null @@ -1,91 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Optimistic, read-only selection of exact Handoff heads.""" - -from __future__ import annotations - -from collections.abc import Sequence -from itertools import pairwise - -from powercontext.artifacts import ArtifactRef -from powercontext.builtin.handoff_report.errors import HandoffReportBusyError -from powercontext.builtin.handoff_report.models import ( - ReportSelectionEntry, - WorkstreamDescriptor, -) -from powercontext.builtin.handoff_report.protocols import HandoffReadAdapter - -DEFAULT_HANDOFF_SELECTION_ATTEMPTS = 3 -MAX_HANDOFF_SELECTION_ATTEMPTS = 5 - - -async def select_optimistic_stable_handoffs( - adapter: HandoffReadAdapter, - workstreams: Sequence[WorkstreamDescriptor], - /, - *, - attempts: int = DEFAULT_HANDOFF_SELECTION_ATTEMPTS, -) -> tuple[ReportSelectionEntry, ...]: - """Freeze exact heads after two equal vectors, retrying bounded instability.""" - - _validate_attempts(attempts) - ordered = _ordered_workstreams(workstreams) - for _ in range(attempts): - first = await _read_head_vector(adapter, ordered) - second = await _read_head_vector(adapter, ordered) - if first == second: - return tuple( - ReportSelectionEntry( - scope_id=workstream.scope_id, - workstream_revision=workstream.version, - status="no_handoff" if reference is None else "selected", - handoff_ref=reference, - ) - for workstream, reference in zip(ordered, second, strict=True) - ) - raise HandoffReportBusyError(attempts) - - -async def _read_head_vector( - adapter: HandoffReadAdapter, - workstreams: tuple[WorkstreamDescriptor, ...], -) -> tuple[ArtifactRef | None, ...]: - values: list[ArtifactRef | None] = [] - for workstream in workstreams: - handoff = await adapter.latest(workstream.scope_id) - values.append(None if handoff is None else handoff.as_ref()) - return tuple(values) - - -def _ordered_workstreams(values: Sequence[WorkstreamDescriptor]) -> tuple[WorkstreamDescriptor, ...]: - ordered = tuple(sorted(values, key=lambda value: value.scope_id)) - for previous, current in pairwise(ordered): - if previous.scope_id == current.scope_id: - raise ValueError(f"duplicate Workstream scope_id: {current.scope_id}") # noqa: TRY003 - return ordered - - -def _validate_attempts(value: int) -> None: - if not isinstance(value, int) or isinstance(value, bool) or not 1 <= value <= MAX_HANDOFF_SELECTION_ATTEMPTS: - raise ValueError( # noqa: TRY003 - f"attempts must be between 1 and {MAX_HANDOFF_SELECTION_ATTEMPTS}" - ) - - -__all__ = [ - "DEFAULT_HANDOFF_SELECTION_ATTEMPTS", - "MAX_HANDOFF_SELECTION_ATTEMPTS", - "select_optimistic_stable_handoffs", -] diff --git a/src/powercontext/builtin/handoff_report/service.py b/src/powercontext/builtin/handoff_report/service.py deleted file mode 100644 index 978252dc9..000000000 --- a/src/powercontext/builtin/handoff_report/service.py +++ /dev/null @@ -1,322 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Read-only assembly of canonical Handoff Reports.""" - -from __future__ import annotations - -from collections.abc import Sequence -from datetime import UTC, datetime - -from pydantic import JsonValue - -from powercontext.artifacts import ArtifactRef -from powercontext.builtin.artifacts.handoff import Handoff -from powercontext.builtin.handoff_report.canonical import finalize_digests -from powercontext.builtin.handoff_report.errors import ( - HandoffReportEvidenceCheckUnavailableError, - HandoffReportInconsistentError, -) -from powercontext.builtin.handoff_report.models import ( - ProjectDescriptor, - ReportActivityEvent, - ReportLocale, - WorkstreamDescriptor, - activity_sort_key, -) -from powercontext.builtin.handoff_report.protocols import HandoffReadAdapter, WorkContinuityReadAdapter -from powercontext.builtin.handoff_report.report import ( - MAX_REPORT_ACTIVITIES, - MAX_REPORT_HANDOFF_HISTORY, - MAX_REPORT_HANDOFF_HISTORY_EXCERPT_LENGTH, - MAX_REPORT_WORKSTREAMS, - HandoffReport, - HandoffRevisionSummary, - ReportActivityCoverageStatus, - ReportActivityStatus, - ReportCoverage, - ReportEvidenceChecks, - ReportFormat, - ReportKind, - ReportPeriodComparison, - ReportReportingStatus, - ReportSummary, - WorkstreamReport, -) -from powercontext.builtin.handoff_report.selection import select_optimistic_stable_handoffs -from powercontext.builtin.work import WorkContinuity - - -class HandoffReportService: - """Assemble reports without entering Handoff prepare, commit, or Continue control flow.""" - - def __init__( - self, - handoffs: HandoffReadAdapter, - /, - continuity: WorkContinuityReadAdapter | None = None, - ) -> None: - self._handoffs = handoffs - self._continuity = continuity - - async def generate( - self, - project: ProjectDescriptor, - workstreams: Sequence[WorkstreamDescriptor], - /, - *, - locale: ReportLocale | None = None, - include_evidence_checks: bool = True, - activities: Sequence[ReportActivityEvent] = (), - activity_cursor: int = 0, - activity_coverage: ReportActivityCoverageStatus = "not_configured", - generated_at: datetime | None = None, - selection_attempts: int = 3, - report_format: ReportFormat = "json", - report_kind: ReportKind = "handoff", - normalized_filters: dict[str, JsonValue] | None = None, - normalized_period: dict[str, JsonValue] | None = None, - period_comparison: ReportPeriodComparison | None = None, - ) -> HandoffReport: - """Freeze exact heads and project only exact Handoffs plus explicit Activity Events.""" - - ordered_workstreams = _validate_inputs(project, workstreams, activities, activity_cursor) - selection = await select_optimistic_stable_handoffs( - self._handoffs, - ordered_workstreams, - attempts=selection_attempts, - ) - activities_by_scope, unassigned = _group_activities(activities, ordered_workstreams) - projected: list[WorkstreamReport] = [] - for descriptor, entry in zip(ordered_workstreams, selection, strict=True): - scoped_activity = activities_by_scope.get(descriptor.scope_id, ()) - continuity = ( - WorkContinuity(scope_id=descriptor.scope_id) - if self._continuity is None - else await self._continuity.get(descriptor.scope_id, entry.handoff_ref) - ) - if entry.handoff_ref is None: - projected.append( - WorkstreamReport( - workstream=descriptor, - continuity=continuity, - handoff_ref=None, - content=None, - activities=scoped_activity, - work_status="no_handoff", - reporting_status="no_handoff", - activity_status=("activity_without_handoff" if scoped_activity else "no_observed_activity"), - handoff_activity_relation=None, - observed_activity_count=len(scoped_activity), - ) - ) - continue - - handoff = await self._handoffs.get(descriptor.scope_id, entry.handoff_ref) - if handoff.as_ref() != entry.handoff_ref: - raise HandoffReportInconsistentError(descriptor.scope_id) - revision_count, revision_history = await self._revision_history( - descriptor.scope_id, - entry.handoff_ref, - ) - checks: ReportEvidenceChecks = "not_checked" - evidence_unavailable = False - if include_evidence_checks: - try: - checks = await self._handoffs.check_evidence(descriptor.scope_id, entry.handoff_ref) - except HandoffReportEvidenceCheckUnavailableError: - evidence_unavailable = True - projected.append( - WorkstreamReport( - workstream=descriptor, - continuity=continuity, - handoff_ref=entry.handoff_ref, - content=handoff.content, - handoff_revision_count=revision_count, - handoff_history_truncated=revision_count > len(revision_history), - handoff_history=revision_history, - evidence_checks=checks, - evidence_unavailable=evidence_unavailable, - activities=scoped_activity, - work_status=handoff.content.disposition, - reporting_status=_reporting_status(handoff.content.omissions, checks, evidence_unavailable), - activity_status=_activity_status(scoped_activity), - handoff_activity_relation=(None if not scoped_activity else "unknown"), - observed_activity_count=len(scoped_activity), - ) - ) - - reports = tuple(projected) - activity_selection = tuple(event.event_id for report in reports for event in report.activities) + tuple( - event.event_id for event in unassigned - ) - report = HandoffReport( - locale=project.default_locale if locale is None else locale, - format=report_format, - report_kind=report_kind, - renderer_version="canonical-v1" if report_format == "json" else "markdown-v1", - generated_at=datetime.now(UTC) if generated_at is None else generated_at, - selection_consistency="optimistic_stable", - project=project, - project_revision=project.version, - normalized_filters={} if normalized_filters is None else normalized_filters, - normalized_period=normalized_period, - period_comparison=period_comparison, - end_selection=selection, - activity_cursor=activity_cursor, - activity_selection=activity_selection, - coverage=ReportCoverage( - total_included_workstreams=len(ordered_workstreams), - catalog_matched_workstreams=len(ordered_workstreams), - selected_workstreams=len(reports), - missing_handoff_workstreams=sum(item.handoff_ref is None for item in reports), - reported_with_omissions=sum(item.reporting_status == "reported_with_omissions" for item in reports), - unchecked_evidence_workstreams=sum( - item.handoff_ref is not None and item.evidence_checks == "not_checked" for item in reports - ), - unavailable_evidence_workstreams=sum( - item.reporting_status == "evidence_unavailable" for item in reports - ), - activity_without_handoff_workstreams=sum( - item.activity_status == "activity_without_handoff" for item in reports - ), - activity_after_handoff_workstreams=sum( - item.handoff_activity_relation == "activity_after_handoff" for item in reports - ), - unknown_time_events=sum( - event.effective_period_time() is None for item in reports for event in item.activities - ) - + sum(event.effective_period_time() is None for event in unassigned), - unassigned_activity_count=len(unassigned), - unassigned_activity_events=len(unassigned), - activity_coverage=activity_coverage, - ), - summary=ReportSummary( - continuable_count=sum(item.work_status == "continuable" for item in reports), - blocked_count=sum(item.work_status == "blocked" for item in reports), - complete_count=sum(item.work_status == "complete" for item in reports), - no_handoff_count=sum(item.work_status == "no_handoff" for item in reports), - ), - unassigned_activity=unassigned, - workstreams=reports, - ) - return finalize_digests(report) - - async def _revision_history( - self, - scope_id: str, - selected_ref: ArtifactRef, - /, - ) -> tuple[int, tuple[HandoffRevisionSummary, ...]]: - revisions = await self._handoffs.revisions(scope_id) - lifecycle = tuple( - handoff - for handoff in revisions - if handoff.as_ref().family == selected_ref.family and handoff.artifact_id == selected_ref.artifact_id - ) - references = tuple(handoff.as_ref() for handoff in lifecycle) - if tuple(reference.revision for reference in references) != tuple( - sorted({reference.revision for reference in references}) - ): - raise HandoffReportInconsistentError(scope_id) - try: - selected_index = references.index(selected_ref) - except ValueError as error: - raise HandoffReportInconsistentError(scope_id) from error - selected_history = lifecycle[: selected_index + 1] - recent_history = selected_history[-MAX_REPORT_HANDOFF_HISTORY:] - return len(selected_history), tuple(_revision_summary(handoff) for handoff in recent_history) - - -def _validate_inputs( - project: ProjectDescriptor, - workstreams: Sequence[WorkstreamDescriptor], - activities: Sequence[ReportActivityEvent], - activity_cursor: int, -) -> tuple[WorkstreamDescriptor, ...]: - if not isinstance(activity_cursor, int) or isinstance(activity_cursor, bool) or activity_cursor < 0: - raise ValueError("activity_cursor must be a non-negative integer") # noqa: TRY003 - if len(workstreams) > MAX_REPORT_WORKSTREAMS: - raise ValueError(f"a Handoff Report selects at most {MAX_REPORT_WORKSTREAMS} Workstreams") # noqa: TRY003 - if len(activities) > MAX_REPORT_ACTIVITIES: - raise ValueError(f"a Handoff Report selects at most {MAX_REPORT_ACTIVITIES} Activity Events") # noqa: TRY003 - ordered = tuple(sorted(workstreams, key=lambda value: value.scope_id)) - for workstream in ordered: - if workstream.project_id != project.project_id: - raise ValueError("every Workstream must belong to the requested Project") # noqa: TRY003 - if len({workstream.scope_id for workstream in ordered}) != len(ordered): - raise ValueError("Workstream scope_id values must be unique") # noqa: TRY003 - for event in activities: - if event.project_id != project.project_id: - raise ValueError("every Activity Event must belong to the requested Project") # noqa: TRY003 - return ordered - - -def _revision_summary(handoff: Handoff, /) -> HandoffRevisionSummary: - next_action = handoff.content.next_action - return HandoffRevisionSummary( - reference=handoff.as_ref(), - objective_excerpt=_history_excerpt(handoff.content.objective), - disposition=handoff.content.disposition, - next_action_excerpt=None if next_action is None else _history_excerpt(next_action.text), - state_count=len(handoff.content.state), - omission_count=len(handoff.content.omissions), - ) - - -def _history_excerpt(value: str, /) -> str: - compact = " ".join(value.split()) - if len(compact) <= MAX_REPORT_HANDOFF_HISTORY_EXCERPT_LENGTH: - return compact - return compact[: MAX_REPORT_HANDOFF_HISTORY_EXCERPT_LENGTH - 1].rstrip() + "…" - - -def _group_activities( - activities: Sequence[ReportActivityEvent], - workstreams: Sequence[WorkstreamDescriptor], -) -> tuple[dict[str, tuple[ReportActivityEvent, ...]], tuple[ReportActivityEvent, ...]]: - known_scopes = {workstream.scope_id for workstream in workstreams} - grouped: dict[str, list[ReportActivityEvent]] = {} - unassigned: list[ReportActivityEvent] = [] - for event in sorted(activities, key=activity_sort_key): - if event.scope_id is None or event.scope_id not in known_scopes: - unassigned.append(event) - else: - grouped.setdefault(event.scope_id, []).append(event) - return {scope_id: tuple(values) for scope_id, values in grouped.items()}, tuple(unassigned) - - -def _reporting_status( - omissions: tuple[object, ...], - checks: ReportEvidenceChecks, - evidence_unavailable: bool, -) -> ReportReportingStatus: - if evidence_unavailable or (checks != "not_checked" and any(check.status == "unavailable" for check in checks)): - return "evidence_unavailable" - if omissions: - return "reported_with_omissions" - return "reported" - - -def _activity_status(events: tuple[ReportActivityEvent, ...]) -> ReportActivityStatus: - if not events: - return "no_observed_activity" - if all(event.time_basis == "current_only" for event in events): - return "current_only" - # Existing Handoff has no authoritative commit timestamp, so the first slice - # must not claim that an observation happened after it. - return "unknown" - - -__all__ = ["HandoffReportService"] diff --git a/src/powercontext/builtin/handoff_report/sqlite.py b/src/powercontext/builtin/handoff_report/sqlite.py deleted file mode 100644 index 137ec61ca..000000000 --- a/src/powercontext/builtin/handoff_report/sqlite.py +++ /dev/null @@ -1,454 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""SQLite-compatible relational Activity Event Store owned by Handoff Report.""" - -from __future__ import annotations - -import asyncio -import json -from collections.abc import Iterable, Mapping -from datetime import UTC, datetime -from typing import Any, cast - -from pydantic import ValidationError -from sqlalchemy import ( - BigInteger, - Column, - Index, - MetaData, - Table, - Text, - UniqueConstraint, - delete, - insert, - select, - update, -) -from sqlalchemy.dialects.mysql import insert as mysql_insert -from sqlalchemy.dialects.sqlite import insert as sqlite_insert -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncConnection - -from powercontext.builtin.handoff_report.catalog_store import HANDOFF_REPORT_CATALOG_TABLES -from powercontext.builtin.handoff_report.models import MAX_REPORT_ID_LENGTH, ReportActivityEvent -from powercontext.builtin.handoff_report.report import MAX_REPORT_ACTIVITIES -from powercontext.builtin.handoff_report.repository import ( - ActivityEventConflictError, - ActivityEventLike, - ActivityEventSerializationError, - ActivityTimeBasis, - InvalidActivityEventError, - InvalidActivityRepositoryArgumentError, - StoredActivityEvent, - StoredActivityEventError, -) -from powercontext.builtin.handoff_report.workspace_store import HANDOFF_REPORT_WORKSPACE_TABLES -from powercontext.builtin.persistence.tables import identity_string -from powercontext.limits import MAX_SCOPE_ID_LENGTH - -HANDOFF_REPORT_METADATA = MetaData() - -HANDOFF_REPORT_ACTIVITY_HEADS_TABLE = Table( - "pc_handoff_report_activity_heads", - HANDOFF_REPORT_METADATA, - Column("project_id", identity_string(MAX_REPORT_ID_LENGTH), primary_key=True), - Column("cursor", BigInteger, nullable=False), -) - -HANDOFF_REPORT_ACTIVITIES_TABLE = Table( - "pc_handoff_report_activities", - HANDOFF_REPORT_METADATA, - Column("project_id", identity_string(MAX_REPORT_ID_LENGTH), primary_key=True), - Column("cursor", BigInteger, primary_key=True), - Column("event_id", identity_string(MAX_REPORT_ID_LENGTH), nullable=False, unique=True), - Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH)), - Column("source", identity_string(64), nullable=False), - Column("source_event_id", identity_string(MAX_REPORT_ID_LENGTH), nullable=False), - Column("occurred_at", identity_string(32)), - Column("observed_at", identity_string(32), nullable=False), - Column("period_at", identity_string(32)), - Column("time_basis", identity_string(32), nullable=False), - Column("payload", Text, nullable=False), - UniqueConstraint("source", "source_event_id", name="uq_pc_handoff_report_activities_source_event"), -) -Index( - "ix_pc_handoff_report_activities_project_period", - HANDOFF_REPORT_ACTIVITIES_TABLE.c.project_id, - HANDOFF_REPORT_ACTIVITIES_TABLE.c.period_at, - HANDOFF_REPORT_ACTIVITIES_TABLE.c.cursor, -) -Index( - "ix_pc_handoff_report_activities_project_source_cursor", - HANDOFF_REPORT_ACTIVITIES_TABLE.c.project_id, - HANDOFF_REPORT_ACTIVITIES_TABLE.c.source, - HANDOFF_REPORT_ACTIVITIES_TABLE.c.cursor, -) - -HANDOFF_REPORT_TABLES = ( - *HANDOFF_REPORT_CATALOG_TABLES, - *HANDOFF_REPORT_WORKSPACE_TABLES, - HANDOFF_REPORT_ACTIVITY_HEADS_TABLE, - HANDOFF_REPORT_ACTIVITIES_TABLE, -) - -_TIME_BASES: frozenset[str] = frozenset({"source_reported", "host_observed", "first_seen", "current_only", "unknown"}) -_MAX_ACTIVITY_LIST_LIMIT = MAX_REPORT_ACTIVITIES + 1 - - -class SQLiteActivityEventRepository: - """Persist activity events without reading or writing Handoff/Core tables. - - The coroutine lock serializes ``record`` calls only through this repository - instance. Multiple instances or processes still rely on SQLite's writer lock - and configured busy timeout; deployments must handle lock timeout failures. - """ - - def __init__(self) -> None: - self._record_lock = asyncio.Lock() - - async def record(self, connection: AsyncConnection, event: ActivityEventLike, /) -> StoredActivityEvent: - payload, indexed = _canonical_event(event) - async with self._record_lock: - return await self._record_locked(connection, payload, indexed) - - async def _record_locked( - self, - connection: AsyncConnection, - payload: str, - indexed: Mapping[str, Any], - ) -> StoredActivityEvent: - # Take SQLite's writer reservation before the idempotency read. This - # avoids two DEFERRED/WAL transactions reading the same old snapshot - # and then both attempting to upgrade it to a writer. - await _reserve_project_writer(connection, str(indexed["project_id"])) - existing = await self._find_by_source_identity(connection, indexed["source"], indexed["source_event_id"]) - if existing is not None: - return _idempotent_result(existing, payload) - - cursor = await _allocate_cursor(connection, indexed["project_id"]) - try: - await connection.execute( - insert(HANDOFF_REPORT_ACTIVITIES_TABLE).values( - project_id=indexed["project_id"], - cursor=cursor, - event_id=indexed["event_id"], - scope_id=indexed["scope_id"], - source=indexed["source"], - source_event_id=indexed["source_event_id"], - occurred_at=indexed["occurred_at"], - observed_at=indexed["observed_at"], - period_at=indexed["period_at"], - time_basis=indexed["time_basis"], - payload=payload, - ) - ) - except IntegrityError: - existing = await self._find_by_source_identity(connection, indexed["source"], indexed["source_event_id"]) - if existing is None: - raise - return _idempotent_result(existing, payload) - - row = await self._find_by_project_cursor(connection, indexed["project_id"], cursor) - if row is None: # pragma: no cover - a successful insert must be visible in its transaction - raise StoredActivityEventError("event", "inserted row is not readable") - return _decode_row(row) - - async def list( - self, - connection: AsyncConnection, - project_id: str, - /, - *, - period_start: datetime | None = None, - period_end: datetime | None = None, - sources: Iterable[str] | None = None, - after_cursor: int = 0, - through_cursor: int | None = None, - limit: int | None = 50, - ) -> tuple[StoredActivityEvent, ...]: - project_id, start, end, normalized_sources = _validate_list_arguments( - project_id, - period_start=period_start, - period_end=period_end, - sources=sources, - after_cursor=after_cursor, - through_cursor=through_cursor, - limit=limit, - ) - if normalized_sources == (): - return () - statement = _activity_list_statement( - project_id, - start=start, - end=end, - sources=normalized_sources, - after_cursor=after_cursor, - through_cursor=through_cursor, - limit=limit, - ) - rows = (await connection.execute(statement)).mappings() - return tuple(_decode_row(row) for row in rows) - - async def high_watermark(self, connection: AsyncConnection, project_id: str, /) -> int: - project_id = _identifier("project_id", project_id, maximum=MAX_REPORT_ID_LENGTH) - value = await connection.scalar( - select(HANDOFF_REPORT_ACTIVITY_HEADS_TABLE.c.cursor).where( - HANDOFF_REPORT_ACTIVITY_HEADS_TABLE.c.project_id == project_id - ) - ) - return 0 if value is None else int(value) - - async def purge(self, connection: AsyncConnection, project_id: str, observed_before: datetime, /) -> int: - project_id = _identifier("project_id", project_id, maximum=MAX_REPORT_ID_LENGTH) - boundary = _utc_text("observed_before", observed_before) - result = await connection.execute( - delete(HANDOFF_REPORT_ACTIVITIES_TABLE).where( - HANDOFF_REPORT_ACTIVITIES_TABLE.c.project_id == project_id, - HANDOFF_REPORT_ACTIVITIES_TABLE.c.observed_at < boundary, - ) - ) - return int(result.rowcount or 0) - - async def _find_by_source_identity( - self, connection: AsyncConnection, source: str, source_event_id: str - ) -> Mapping[Any, Any] | None: - return ( - ( - await connection.execute( - select(HANDOFF_REPORT_ACTIVITIES_TABLE).where( - HANDOFF_REPORT_ACTIVITIES_TABLE.c.source == source, - HANDOFF_REPORT_ACTIVITIES_TABLE.c.source_event_id == source_event_id, - ) - ) - ) - .mappings() - .one_or_none() - ) - - async def _find_by_project_cursor( - self, connection: AsyncConnection, project_id: str, cursor: int - ) -> Mapping[Any, Any] | None: - return ( - ( - await connection.execute( - select(HANDOFF_REPORT_ACTIVITIES_TABLE).where( - HANDOFF_REPORT_ACTIVITIES_TABLE.c.project_id == project_id, - HANDOFF_REPORT_ACTIVITIES_TABLE.c.cursor == cursor, - ) - ) - ) - .mappings() - .one_or_none() - ) - - -def _validate_list_arguments( - project_id: str, - *, - period_start: datetime | None, - period_end: datetime | None, - sources: Iterable[str] | None, - after_cursor: int, - through_cursor: int | None, - limit: int | None, -) -> tuple[str, str | None, str | None, tuple[str, ...] | None]: - project_id = _identifier("project_id", project_id, maximum=MAX_REPORT_ID_LENGTH) - _require_integer("after_cursor", after_cursor, minimum=0) - if through_cursor is not None: - _require_integer("through_cursor", through_cursor, minimum=0) - if limit is not None: - _require_integer("limit", limit, minimum=1, maximum=_MAX_ACTIVITY_LIST_LIMIT) - start = None if period_start is None else _utc_text("period_start", period_start) - end = None if period_end is None else _utc_text("period_end", period_end) - if start is not None and end is not None and start >= end: - raise InvalidActivityRepositoryArgumentError("period", "start must be before end") - normalized_sources = None - if sources is not None: - normalized_sources = tuple(dict.fromkeys(_identifier("source", item, maximum=64) for item in sources)) - return project_id, start, end, normalized_sources - - -def _activity_list_statement( - project_id: str, - *, - start: str | None, - end: str | None, - sources: tuple[str, ...] | None, - after_cursor: int, - through_cursor: int | None, - limit: int | None, -): - statement = select(HANDOFF_REPORT_ACTIVITIES_TABLE).where( - HANDOFF_REPORT_ACTIVITIES_TABLE.c.project_id == project_id, - HANDOFF_REPORT_ACTIVITIES_TABLE.c.cursor > after_cursor, - ) - if through_cursor is not None: - statement = statement.where(HANDOFF_REPORT_ACTIVITIES_TABLE.c.cursor <= through_cursor) - if start is not None: - statement = statement.where(HANDOFF_REPORT_ACTIVITIES_TABLE.c.period_at >= start) - if end is not None: - statement = statement.where(HANDOFF_REPORT_ACTIVITIES_TABLE.c.period_at < end) - if sources is not None: - statement = statement.where(HANDOFF_REPORT_ACTIVITIES_TABLE.c.source.in_(sources)) - statement = statement.order_by(HANDOFF_REPORT_ACTIVITIES_TABLE.c.cursor) - return statement if limit is None else statement.limit(limit) - - -async def _reserve_project_writer(connection: AsyncConnection, project_id: str) -> None: - dialect = connection.dialect.name - if dialect == "sqlite": - statement = sqlite_insert(HANDOFF_REPORT_ACTIVITY_HEADS_TABLE).values(project_id=project_id, cursor=0) - statement = statement.on_conflict_do_update( - index_elements=(HANDOFF_REPORT_ACTIVITY_HEADS_TABLE.c.project_id,), - set_={"cursor": HANDOFF_REPORT_ACTIVITY_HEADS_TABLE.c.cursor}, - ) - elif dialect == "mysql": - statement = mysql_insert(HANDOFF_REPORT_ACTIVITY_HEADS_TABLE).values(project_id=project_id, cursor=0) - statement = statement.on_duplicate_key_update(cursor=HANDOFF_REPORT_ACTIVITY_HEADS_TABLE.c.cursor) - else: - raise InvalidActivityRepositoryArgumentError("dialect", f"unsupported database dialect: {dialect}") - await connection.execute(statement) - - -async def _allocate_cursor(connection: AsyncConnection, project_id: str) -> int: - result = await connection.execute( - update(HANDOFF_REPORT_ACTIVITY_HEADS_TABLE) - .where(HANDOFF_REPORT_ACTIVITY_HEADS_TABLE.c.project_id == project_id) - .values(cursor=HANDOFF_REPORT_ACTIVITY_HEADS_TABLE.c.cursor + 1) - ) - if result.rowcount != 1: - raise StoredActivityEventError("cursor", "Project allocator is missing") - value = await connection.scalar( - select(HANDOFF_REPORT_ACTIVITY_HEADS_TABLE.c.cursor).where( - HANDOFF_REPORT_ACTIVITY_HEADS_TABLE.c.project_id == project_id - ) - ) - if not isinstance(value, int) or isinstance(value, bool) or value < 1: - raise StoredActivityEventError("cursor", "must be a positive integer") - return value - - -def _canonical_event(event: ActivityEventLike) -> tuple[str, dict[str, Any]]: - try: - dumped = event.model_dump(mode="json", by_alias=True) - except (AttributeError, TypeError, ValueError) as error: - raise ActivityEventSerializationError("model_dump", "JSON mode failed") from error - if not isinstance(dumped, dict): - raise ActivityEventSerializationError("model_dump", "result must be a dictionary") - try: - candidate_payload = json.dumps( - dumped, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - allow_nan=False, - ) - except (TypeError, ValueError) as error: - raise ActivityEventSerializationError("JSON encoding", "not serializable") from error # noqa: TRY003 - try: - validated = ReportActivityEvent.model_validate_json(candidate_payload) - except ValidationError as error: - raise InvalidActivityEventError("payload", "does not match ReportActivityEvent") from error - - canonical = validated.model_dump(mode="json", by_alias=True) - payload = json.dumps(canonical, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False) - event_id = _identifier("event_id", validated.event_id, maximum=MAX_REPORT_ID_LENGTH) - project_id = _identifier("project_id", validated.project_id, maximum=MAX_REPORT_ID_LENGTH) - scope_id = ( - None if validated.scope_id is None else _identifier("scope_id", validated.scope_id, maximum=MAX_SCOPE_ID_LENGTH) - ) - source = _identifier("source", validated.source, maximum=64) - source_event_id = _identifier("source_event_id", validated.source_event_id, maximum=MAX_REPORT_ID_LENGTH) - observed_at = _utc_text("observed_at", validated.observed_at) - occurred_at = None if validated.occurred_at is None else _utc_text("occurred_at", validated.occurred_at) - period_time = validated.effective_period_time() - period_at = None if period_time is None else _utc_text("period_at", period_time) - return payload, { - "event_id": event_id, - "project_id": project_id, - "scope_id": scope_id, - "source": source, - "source_event_id": source_event_id, - "occurred_at": occurred_at, - "observed_at": observed_at, - "period_at": period_at, - "time_basis": validated.time_basis, - } - - -def _idempotent_result(row: Mapping[Any, Any], payload: str) -> StoredActivityEvent: - if _semantic_payload(str(row["payload"])) != _semantic_payload(payload): - raise ActivityEventConflictError(str(row["source"]), str(row["source_event_id"])) - return _decode_row(row) - - -def _semantic_payload(payload: str) -> str: - try: - value = json.loads(payload) - except (TypeError, ValueError) as error: - raise StoredActivityEventError("payload", "must be valid JSON") from error - if not isinstance(value, dict): - raise StoredActivityEventError("payload", "must be a JSON object") - value.pop("event_id", None) - value.pop("observed_at", None) - return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False) - - -def _decode_row(row: Mapping[Any, Any]) -> StoredActivityEvent: - time_basis = str(row["time_basis"]) - if time_basis not in _TIME_BASES: - raise StoredActivityEventError("time_basis", "unsupported value") - payload = json.loads(str(row["payload"])) - if not isinstance(payload, dict): - raise StoredActivityEventError("payload", "must be a JSON object") - return StoredActivityEvent( - cursor=int(row["cursor"]), - event_id=str(row["event_id"]), - project_id=str(row["project_id"]), - scope_id=None if row["scope_id"] is None else str(row["scope_id"]), - source=str(row["source"]), - source_event_id=str(row["source_event_id"]), - occurred_at=None if row["occurred_at"] is None else _parse_utc(str(row["occurred_at"])), - observed_at=_parse_utc(str(row["observed_at"])), - time_basis=cast(ActivityTimeBasis, time_basis), - payload=payload, - ) - - -def _identifier(field: str, value: object, *, maximum: int) -> str: - if not isinstance(value, str) or not value or value != value.strip(): - raise InvalidActivityRepositoryArgumentError(field, "must be a non-empty trimmed string") - if len(value) > maximum: - raise InvalidActivityRepositoryArgumentError(field, f"must not exceed {maximum} characters") - return value - - -def _require_integer(field: str, value: object, *, minimum: int, maximum: int | None = None) -> None: - if not isinstance(value, int) or isinstance(value, bool): - raise InvalidActivityRepositoryArgumentError(field, "must be an integer") - if value < minimum: - raise InvalidActivityRepositoryArgumentError(field, f"must be at least {minimum}") - if maximum is not None and value > maximum: - raise InvalidActivityRepositoryArgumentError(field, f"must not exceed {maximum}") - - -def _utc_text(field: str, value: datetime) -> str: - if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None: - raise InvalidActivityRepositoryArgumentError(field, "must be timezone-aware") - return value.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z") - - -def _parse_utc(value: str) -> datetime: - return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC) diff --git a/src/powercontext/builtin/handoff_report/workspace.py b/src/powercontext/builtin/handoff_report/workspace.py deleted file mode 100644 index 04e92a5af..000000000 --- a/src/powercontext/builtin/handoff_report/workspace.py +++ /dev/null @@ -1,101 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Explicit WorkspaceBinding application operations.""" - -from __future__ import annotations - -from datetime import UTC, datetime - -from sqlalchemy.ext.asyncio import AsyncConnection - -from powercontext.builtin.handoff_report.catalog import HandoffReportCatalog -from powercontext.builtin.handoff_report.errors import ( - WorkspaceBindingConflictError, - WorkspaceBindingNotFoundError, -) -from powercontext.builtin.handoff_report.models import RepositoryRef, WorkspaceBinding, normalize_repository_ref -from powercontext.builtin.handoff_report.workspace_store import WorkspaceBindingRepository - - -class WorkspaceBindingService: - """Attach and detach a workspace only after exact Project selection.""" - - def __init__( - self, - catalog: HandoffReportCatalog | None = None, - repository: WorkspaceBindingRepository | None = None, - ) -> None: - self._catalog = HandoffReportCatalog() if catalog is None else catalog - self._repository = WorkspaceBindingRepository() if repository is None else repository - - async def get(self, connection: AsyncConnection, workspace_instance_id: str, /) -> WorkspaceBinding: - return await self._repository.get_confirmed(connection, workspace_instance_id) - - async def attach( - self, - connection: AsyncConnection, - *, - workspace_instance_id: str, - project_id: str, - repository_ref: RepositoryRef, - expected_version: int | None, - confirmed_at: datetime | None = None, - ) -> WorkspaceBinding: - await self._catalog.get_project(connection, project_id) - normalized_ref = normalize_repository_ref(repository_ref) - current = None - if expected_version is not None: - try: - current = await self._repository.get(connection, workspace_instance_id) - except WorkspaceBindingNotFoundError as error: - raise WorkspaceBindingConflictError( - workspace_instance_id, - expected_version, - None, - detail="workspace binding record is missing", - ) from error - version = 1 if expected_version is None else expected_version + 1 - binding = WorkspaceBinding( - workspace_instance_id=workspace_instance_id, - project_id=project_id, - repository_ref=normalized_ref, - state="confirmed", - confirmed_at=datetime.now(UTC) if confirmed_at is None else confirmed_at, - version=version, - ) - if current is not None and current.state == "confirmed" and current.project_id != project_id: - raise WorkspaceBindingConflictError( - workspace_instance_id, - expected_version, - current.version, - detail="detach the confirmed binding before attaching another Project", - ) - return await self._repository.attach(connection, binding, expected_version) - - async def detach( - self, - connection: AsyncConnection, - workspace_instance_id: str, - expected_version: int, - ) -> WorkspaceBinding: - return await self._repository.detach(connection, workspace_instance_id, expected_version) - - async def get_record(self, connection: AsyncConnection, workspace_instance_id: str, /) -> WorkspaceBinding: - """Read a detached record for explicit re-attach workflows.""" - - return await self._repository.get(connection, workspace_instance_id) - - -__all__ = ["WorkspaceBindingService"] diff --git a/src/powercontext/builtin/handoff_report/workspace_store.py b/src/powercontext/builtin/handoff_report/workspace_store.py deleted file mode 100644 index 9c8f466fc..000000000 --- a/src/powercontext/builtin/handoff_report/workspace_store.py +++ /dev/null @@ -1,322 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Report-owned persistence for explicit WorkspaceBinding CAS transitions.""" - -from __future__ import annotations - -import json -from collections.abc import Mapping -from datetime import UTC, datetime -from typing import Any - -from pydantic import ValidationError -from sqlalchemy import CheckConstraint, Column, Integer, MetaData, Table, Text, insert, select, update -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncConnection - -from powercontext.builtin.handoff_report.errors import ( - HandoffReportCatalogArgumentError, - InvalidStoredCatalogError, - WorkspaceBindingConflictError, - WorkspaceBindingNotFoundError, -) -from powercontext.builtin.handoff_report.models import ( - MAX_REPORT_ID_LENGTH, - MAX_REPORT_NORMALIZED_REMOTE_LENGTH, - MAX_REPORT_REPOSITORY_ID_LENGTH, - MAX_REPORT_SUBPATH_LENGTH, - MAX_WORKSPACE_INSTANCE_ID_LENGTH, - WorkspaceBinding, -) -from powercontext.builtin.persistence.tables import identity_string - -HANDOFF_REPORT_WORKSPACE_METADATA = MetaData() - -HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE = Table( - "pc_handoff_report_workspace_bindings", - HANDOFF_REPORT_WORKSPACE_METADATA, - Column("workspace_instance_id", identity_string(MAX_WORKSPACE_INSTANCE_ID_LENGTH), primary_key=True), - Column("project_id", identity_string(MAX_REPORT_ID_LENGTH), nullable=False), - Column("provider", identity_string(32), nullable=False), - Column("repository_id", identity_string(MAX_REPORT_REPOSITORY_ID_LENGTH)), - Column("normalized_remote", identity_string(MAX_REPORT_NORMALIZED_REMOTE_LENGTH)), - Column("subpath", identity_string(MAX_REPORT_SUBPATH_LENGTH)), - Column("state", identity_string(16), nullable=False), - Column("confirmed_at", identity_string(32), nullable=False), - Column("version", Integer, nullable=False), - Column("payload", Text, nullable=False), - CheckConstraint("version > 0", name="ck_pc_handoff_report_workspace_bindings_version_positive"), -) - -HANDOFF_REPORT_WORKSPACE_TABLES = (HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE,) - - -class WorkspaceBindingRepository: - """Store one mutable binding record per local workspace instance.""" - - async def get( - self, - connection: AsyncConnection, - workspace_instance_id: str, - /, - ) -> WorkspaceBinding: - workspace_instance_id = _identifier( - "workspace_instance_id", - workspace_instance_id, - MAX_WORKSPACE_INSTANCE_ID_LENGTH, - ) - row = await self._find(connection, workspace_instance_id) - if row is None: - raise WorkspaceBindingNotFoundError(workspace_instance_id) - return _decode_binding(row) - - async def get_confirmed( - self, - connection: AsyncConnection, - workspace_instance_id: str, - /, - ) -> WorkspaceBinding: - binding = await self.get(connection, workspace_instance_id) - if binding.state != "confirmed": - raise WorkspaceBindingNotFoundError(workspace_instance_id) - return binding - - async def attach( - self, - connection: AsyncConnection, - binding: WorkspaceBinding, - expected_version: int | None, - /, - ) -> WorkspaceBinding: - _validate_binding(binding) - if binding.state != "confirmed": - raise HandoffReportCatalogArgumentError("state", "attach requires a confirmed binding") - _optional_version(expected_version) - current = await self._find(connection, binding.workspace_instance_id) - if expected_version is None: - return await self._attach_absent(connection, binding, current) - return await self._attach_existing(connection, binding, expected_version, current) - - async def _attach_absent( - self, - connection: AsyncConnection, - binding: WorkspaceBinding, - current: Mapping[Any, Any] | None, - ) -> WorkspaceBinding: - if binding.version != 1: - raise HandoffReportCatalogArgumentError( - "version", - "an expect-absent attach must create version 1", - ) - if current is not None: - raise WorkspaceBindingConflictError( - binding.workspace_instance_id, - None, - int(current["version"]), - detail="workspace already has a binding record", - ) - try: - await connection.execute(insert(HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE).values(_row(binding))) - except IntegrityError as error: - raise WorkspaceBindingConflictError( - binding.workspace_instance_id, - None, - None, - detail="workspace already has a binding record", - ) from error - return binding - - async def _attach_existing( - self, - connection: AsyncConnection, - binding: WorkspaceBinding, - expected_version: int, - current: Mapping[Any, Any] | None, - ) -> WorkspaceBinding: - if current is None: - raise WorkspaceBindingConflictError( - binding.workspace_instance_id, - expected_version, - None, - detail="workspace binding record is missing", - ) - current_binding = _decode_binding(current) - if current_binding.version != expected_version: - raise WorkspaceBindingConflictError( - binding.workspace_instance_id, - expected_version, - current_binding.version, - ) - if binding.version != expected_version + 1: - raise HandoffReportCatalogArgumentError( - "version", - "updated binding version must equal expected_version + 1", - ) - if current_binding.state == "confirmed" and current_binding.project_id != binding.project_id: - raise WorkspaceBindingConflictError( - binding.workspace_instance_id, - expected_version, - current_binding.version, - detail="detach the confirmed binding before attaching another Project", - ) - result = await connection.execute( - update(HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE) - .where( - HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE.c.workspace_instance_id == binding.workspace_instance_id, - HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE.c.version == expected_version, - ) - .values(_row(binding)) - ) - if result.rowcount != 1: - latest = await self.get(connection, binding.workspace_instance_id) - raise WorkspaceBindingConflictError( - binding.workspace_instance_id, - expected_version, - latest.version, - ) - return binding - - async def detach( - self, - connection: AsyncConnection, - workspace_instance_id: str, - expected_version: int, - /, - ) -> WorkspaceBinding: - workspace_instance_id = _identifier( - "workspace_instance_id", - workspace_instance_id, - MAX_WORKSPACE_INSTANCE_ID_LENGTH, - ) - _required_version(expected_version) - current = await self._find(connection, workspace_instance_id) - if current is None: - raise WorkspaceBindingConflictError( - workspace_instance_id, - expected_version, - None, - detail="workspace binding record is missing", - ) - binding = _decode_binding(current) - if binding.version != expected_version: - raise WorkspaceBindingConflictError(workspace_instance_id, expected_version, binding.version) - if binding.state != "confirmed": - raise WorkspaceBindingConflictError( - workspace_instance_id, - expected_version, - binding.version, - detail="workspace binding is already detached", - ) - detached = binding.model_copy(update={"state": "detached", "version": expected_version + 1}) - result = await connection.execute( - update(HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE) - .where( - HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE.c.workspace_instance_id == workspace_instance_id, - HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE.c.version == expected_version, - ) - .values(_row(detached)) - ) - if result.rowcount != 1: - latest = await self.get(connection, workspace_instance_id) - raise WorkspaceBindingConflictError( - workspace_instance_id, - expected_version, - latest.version, - ) - return detached - - async def _find(self, connection: AsyncConnection, workspace_instance_id: str) -> Mapping[Any, Any] | None: - return ( - ( - await connection.execute( - select(HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE).where( - HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE.c.workspace_instance_id == workspace_instance_id - ) - ) - ) - .mappings() - .one_or_none() - ) - - -def _validate_binding(value: WorkspaceBinding) -> None: - if not isinstance(value, WorkspaceBinding): - raise HandoffReportCatalogArgumentError("binding", "must be a WorkspaceBinding") - - -def _row(binding: WorkspaceBinding) -> dict[str, object]: - reference = binding.repository_ref - return { - "workspace_instance_id": binding.workspace_instance_id, - "project_id": binding.project_id, - "provider": reference.provider, - "repository_id": reference.repository_id, - "normalized_remote": reference.normalized_remote, - "subpath": reference.subpath, - "state": binding.state, - "confirmed_at": _utc_text(binding.confirmed_at), - "version": binding.version, - "payload": json.dumps( - binding.model_dump(mode="json", by_alias=True), - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - allow_nan=False, - ), - } - - -def _decode_binding(row: Mapping[Any, Any]) -> WorkspaceBinding: - try: - value = WorkspaceBinding.model_validate_json(str(row["payload"])) - except ValidationError as error: - raise InvalidStoredCatalogError("WorkspaceBinding", "does not match its schema") from error - if value.workspace_instance_id != str(row["workspace_instance_id"]) or value.version != int(row["version"]): - raise InvalidStoredCatalogError( - "WorkspaceBinding", - "identity does not match indexed columns", - ) - return value - - -def _identifier(field: str, value: object, maximum: int) -> str: - if not isinstance(value, str) or not value or value != value.strip(): - raise HandoffReportCatalogArgumentError(field, "must be a non-empty trimmed string") - if len(value) > maximum: - raise HandoffReportCatalogArgumentError(field, f"must not exceed {maximum} characters") - return value - - -def _required_version(value: object) -> None: - if not isinstance(value, int) or isinstance(value, bool) or value < 1: - raise HandoffReportCatalogArgumentError("expected_version", "must be a positive integer") - - -def _optional_version(value: object) -> None: - if value is not None: - _required_version(value) - - -def _utc_text(value: datetime) -> str: - if value.tzinfo is None or value.utcoffset() is None: - raise HandoffReportCatalogArgumentError("confirmed_at", "must include a UTC offset") - return value.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z") - - -__all__ = [ - "HANDOFF_REPORT_WORKSPACE_BINDINGS_TABLE", - "HANDOFF_REPORT_WORKSPACE_TABLES", - "WorkspaceBindingRepository", -] diff --git a/src/powercontext/builtin/persistence/artifacts.py b/src/powercontext/builtin/persistence/artifacts.py index 0e65140df..5693630a9 100644 --- a/src/powercontext/builtin/persistence/artifacts.py +++ b/src/powercontext/builtin/persistence/artifacts.py @@ -240,6 +240,37 @@ async def revisions( revisions.append(await self._decode_row(connection, row)) return tuple(revisions) + async def copy_exact( + self, + connection: AsyncConnection, + target_scope_id: str, + target_artifact_id: str, + source: Artifact[Any], + /, + ) -> Artifact[Any]: + """Create an independent target lifecycle from one exact source revision.""" + + _require_scope(target_scope_id) + artifact_type = self._artifact_type(source.family) + ref = ArtifactRef(family=source.family, artifact_id=target_artifact_id, revision=1) + copied = await self._insert_revision( + connection, + target_scope_id, + artifact_type, + ref, + source.content, + ArtifactLineage(), + ) + await connection.execute( + insert(ARTIFACT_HEADS_TABLE).values( + scope_id=target_scope_id, + family=ref.family, + artifact_id=ref.artifact_id, + revision=ref.revision, + ) + ) + return copied + async def _insert_revision( self, connection: AsyncConnection, diff --git a/src/powercontext/builtin/persistence/tables.py b/src/powercontext/builtin/persistence/tables.py index 99e1013a6..7055e74fd 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -39,7 +39,14 @@ MAX_EXTERNAL_SKILL_HOST_ID_LENGTH, MAX_EXTERNAL_SKILL_LOCATOR_LENGTH, MAX_EXTERNAL_SKILL_NAME_LENGTH, + MAX_SCOPE_BINDING_EXTERNAL_ID_LENGTH, + MAX_SCOPE_BINDING_INTEGRATION_LENGTH, + MAX_SCOPE_BINDING_KIND_LENGTH, + MAX_SCOPE_EXTERNAL_REFERENCE_KIND_LENGTH, MAX_SCOPE_ID_LENGTH, + MAX_SCOPE_IDEMPOTENCY_KEY_LENGTH, + MAX_SCOPE_SUMMARY_LENGTH, + MAX_SCOPE_TITLE_LENGTH, MAX_SOURCE_ID_LENGTH, MAX_SOURCE_TYPE_LENGTH, ) @@ -75,6 +82,82 @@ def _entry_text_type(): return Text().with_variant(MEDIUMTEXT(), "mysql") +SCOPES_TABLE = Table( + "pc_scopes", + SHARED_METADATA, + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), + Column("title", String(MAX_SCOPE_TITLE_LENGTH), nullable=False), + Column("summary", String(MAX_SCOPE_SUMMARY_LENGTH), nullable=False), + Column("parent_scope_id", identity_string(MAX_SCOPE_ID_LENGTH)), + Column("version", Integer, nullable=False), + ForeignKeyConstraint( + ("parent_scope_id",), + ("pc_scopes.scope_id",), + ondelete="RESTRICT", + ), + CheckConstraint("version > 0", name="ck_pc_scopes_version_positive"), +) + +SCOPE_CONTEXT_REFERENCES_TABLE = Table( + "pc_scope_context_references", + SHARED_METADATA, + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), + Column("referenced_scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), + ForeignKeyConstraint(("scope_id",), ("pc_scopes.scope_id",), ondelete="CASCADE"), + ForeignKeyConstraint(("referenced_scope_id",), ("pc_scopes.scope_id",), ondelete="RESTRICT"), + CheckConstraint("scope_id <> referenced_scope_id", name="ck_pc_scope_context_references_not_self"), +) + +SCOPE_EXTERNAL_REFERENCES_TABLE = Table( + "pc_scope_external_references", + SHARED_METADATA, + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), + Column("ordinal", Integer, primary_key=True), + Column("kind", identity_string(MAX_SCOPE_EXTERNAL_REFERENCE_KIND_LENGTH), nullable=False), + Column("value", String(MAX_SCOPE_SUMMARY_LENGTH), nullable=False), + Column("value_digest", identity_string(64), nullable=False), + ForeignKeyConstraint(("scope_id",), ("pc_scopes.scope_id",), ondelete="CASCADE"), + UniqueConstraint("scope_id", "kind", "value_digest", name="uq_pc_scope_external_references_value"), + CheckConstraint("ordinal >= 0", name="ck_pc_scope_external_references_ordinal_nonnegative"), +) + +SCOPE_CREATION_REQUESTS_TABLE = Table( + "pc_scope_creation_requests", + SHARED_METADATA, + Column("idempotency_key", identity_string(MAX_SCOPE_IDEMPOTENCY_KEY_LENGTH), primary_key=True), + Column("request_digest", identity_string(64), nullable=False), + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), nullable=False), + ForeignKeyConstraint(("scope_id",), ("pc_scopes.scope_id",), ondelete="RESTRICT"), +) + +SCOPE_SETTINGS_TABLE = Table( + "pc_scope_settings", + SHARED_METADATA, + Column("name", identity_string(64), primary_key=True), + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), nullable=False), + ForeignKeyConstraint(("scope_id",), ("pc_scopes.scope_id",), ondelete="RESTRICT"), +) + +SCOPE_BINDINGS_TABLE = Table( + "pc_scope_bindings", + SHARED_METADATA, + Column("integration", identity_string(MAX_SCOPE_BINDING_INTEGRATION_LENGTH), primary_key=True), + Column("kind", identity_string(MAX_SCOPE_BINDING_KIND_LENGTH), primary_key=True), + Column("external_id", identity_string(MAX_SCOPE_BINDING_EXTERNAL_ID_LENGTH), primary_key=True), + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), nullable=False), + ForeignKeyConstraint(("scope_id",), ("pc_scopes.scope_id",), ondelete="RESTRICT"), +) + +SCOPE_TABLES = ( + SCOPES_TABLE, + SCOPE_CONTEXT_REFERENCES_TABLE, + SCOPE_EXTERNAL_REFERENCES_TABLE, + SCOPE_CREATION_REQUESTS_TABLE, + SCOPE_SETTINGS_TABLE, + SCOPE_BINDINGS_TABLE, +) + + SOURCES_TABLE = Table( "pc_sources", SHARED_METADATA, @@ -186,6 +269,42 @@ def _entry_text_type(): ), ) +ARTIFACT_PUBLICATIONS_TABLE = Table( + "pc_artifact_publications", + SHARED_METADATA, + Column("target_scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), + Column("target_family", identity_string(MAX_ARTIFACT_FAMILY_LENGTH), primary_key=True), + Column("target_artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH), primary_key=True), + Column("target_revision", Integer, primary_key=True), + Column("source_scope_id", identity_string(MAX_SCOPE_ID_LENGTH), nullable=False), + Column("source_family", identity_string(MAX_ARTIFACT_FAMILY_LENGTH), nullable=False), + Column("source_artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH), nullable=False), + Column("source_revision", Integer, nullable=False), + Column("content_digest", identity_string(64), nullable=False), + Column("idempotency_key", identity_string(MAX_SCOPE_IDEMPOTENCY_KEY_LENGTH), nullable=False), + ForeignKeyConstraint( + ("target_scope_id", "target_family", "target_artifact_id", "target_revision"), + ( + "pc_artifacts.scope_id", + "pc_artifacts.family", + "pc_artifacts.artifact_id", + "pc_artifacts.revision", + ), + ondelete="RESTRICT", + ), + ForeignKeyConstraint( + ("source_scope_id", "source_family", "source_artifact_id", "source_revision"), + ( + "pc_artifacts.scope_id", + "pc_artifacts.family", + "pc_artifacts.artifact_id", + "pc_artifacts.revision", + ), + ondelete="RESTRICT", + ), + UniqueConstraint("target_scope_id", "idempotency_key", name="uq_pc_artifact_publications_request"), +) + ARTIFACT_CANDIDATE_VERSIONS_TABLE = Table( "pc_artifact_candidate_versions", SHARED_METADATA, @@ -354,6 +473,7 @@ def _entry_text_type(): ARTIFACT_HEADS_TABLE, ARTIFACT_LINEAGE_SOURCES_TABLE, ARTIFACT_LINEAGE_ARTIFACTS_TABLE, + ARTIFACT_PUBLICATIONS_TABLE, ARTIFACT_CANDIDATE_VERSIONS_TABLE, ARTIFACT_CANDIDATE_HEADS_TABLE, SOURCE_CURSORS_TABLE, @@ -452,4 +572,4 @@ def _entry_text_type(): STATISTICS_TABLES = (MODEL_USAGE_DAILY_TABLE, RECALL_TOKEN_DAILY_TABLE) -BUILTIN_TABLES = SHARED_TABLES + MEMORY_TABLES + STATISTICS_TABLES +BUILTIN_TABLES = SCOPE_TABLES + SHARED_TABLES + MEMORY_TABLES + STATISTICS_TABLES diff --git a/src/powercontext/builtin/publication.py b/src/powercontext/builtin/publication.py new file mode 100644 index 000000000..984a15ba4 --- /dev/null +++ b/src/powercontext/builtin/publication.py @@ -0,0 +1,214 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Exact Artifact delivery across Scope ownership boundaries.""" + +from __future__ import annotations + +import hashlib +import secrets +from collections.abc import Callable, Mapping +from typing import Any + +from pydantic import BaseModel, ConfigDict, field_validator, model_validator +from sqlalchemy import insert, select +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.artifacts import ArtifactAddress, ArtifactRef +from powercontext.builtin.persistence.artifacts import ArtifactRepository +from powercontext.builtin.persistence.codec import dump_model +from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.persistence.tables import ARTIFACT_PUBLICATIONS_TABLE +from powercontext.builtin.scope import ScopeApplication +from powercontext.limits import MAX_SCOPE_IDEMPOTENCY_KEY_LENGTH + +PublicationIdFactory = Callable[[], str] +_CROCKFORD = "0123456789abcdefghjkmnpqrstvwxyz" + + +class ArtifactPublicationRequest(BaseModel): + """Select one exact source revision for delivery into a target Scope.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + source: ArtifactAddress + target_scope_id: str + idempotency_key: str + + @field_validator("target_scope_id", "idempotency_key") + @classmethod + def validate_text(cls, value: str, info) -> str: + if not value.strip() or value != value.strip(): + raise ValueError(f"{info.field_name} must be non-empty and trimmed") # noqa: TRY003 + if info.field_name == "idempotency_key" and len(value) > MAX_SCOPE_IDEMPOTENCY_KEY_LENGTH: + raise ValueError( # noqa: TRY003 + f"idempotency_key must not exceed {MAX_SCOPE_IDEMPOTENCY_KEY_LENGTH} characters" + ) + return value + + @model_validator(mode="after") + def require_scope_boundary(self) -> ArtifactPublicationRequest: + if self.source.scope_id == self.target_scope_id: + raise ValueError("publication requires different source and target Scopes") # noqa: TRY003 + return self + + +class ArtifactPublication(BaseModel): + """The immutable source and target addresses created by one publication.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + source: ArtifactAddress + target: ArtifactAddress + content_digest: str + + +class ArtifactPublicationConflictError(RuntimeError): + """Report reuse of an idempotency key for a different publication.""" + + +class ArtifactPublicationApplication: + def __init__( + self, + database: AsyncDatabase, + artifacts: ArtifactRepository, + scopes: ScopeApplication, + *, + id_factory: PublicationIdFactory | None = None, + ) -> None: + self._database = database + self._artifacts = artifacts + self._scopes = scopes + self._id_factory = generate_publication_artifact_id if id_factory is None else id_factory + + async def publish(self, request: ArtifactPublicationRequest, /) -> ArtifactPublication: + await self._scopes.get(request.source.scope_id) + await self._scopes.get(request.target_scope_id) + async with self._database.transaction() as connection: + existing = await self._find_request(connection, request.target_scope_id, request.idempotency_key) + if existing is not None: + if existing.source != request.source: + raise ArtifactPublicationConflictError(request.idempotency_key) + return existing + + source = await self._artifacts.get(connection, request.source.scope_id, request.source.artifact) + target_artifact_id = self._id_factory() + target = await self._artifacts.copy_exact( + connection, + request.target_scope_id, + target_artifact_id, + source, + ) + publication = ArtifactPublication( + source=request.source, + target=ArtifactAddress(scope_id=request.target_scope_id, artifact=target.as_ref()), + content_digest=hashlib.sha256( + dump_model(source.content, kind="artifact", name=source.family) + ).hexdigest(), + ) + await connection.execute( + insert(ARTIFACT_PUBLICATIONS_TABLE).values( + **_publication_row(publication), + idempotency_key=request.idempotency_key, + ) + ) + return publication + + async def get(self, target: ArtifactAddress, /) -> ArtifactPublication | None: + async with self._database.transaction() as connection: + row = ( + ( + await connection.execute( + select(ARTIFACT_PUBLICATIONS_TABLE).where( + ARTIFACT_PUBLICATIONS_TABLE.c.target_scope_id == target.scope_id, + ARTIFACT_PUBLICATIONS_TABLE.c.target_family == target.artifact.family, + ARTIFACT_PUBLICATIONS_TABLE.c.target_artifact_id == target.artifact.artifact_id, + ARTIFACT_PUBLICATIONS_TABLE.c.target_revision == target.artifact.revision, + ) + ) + ) + .mappings() + .one_or_none() + ) + return None if row is None else _decode_publication(row) + + async def _find_request( + self, + connection: AsyncConnection, + target_scope_id: str, + idempotency_key: str, + ) -> ArtifactPublication | None: + row = ( + ( + await connection.execute( + select(ARTIFACT_PUBLICATIONS_TABLE).where( + ARTIFACT_PUBLICATIONS_TABLE.c.target_scope_id == target_scope_id, + ARTIFACT_PUBLICATIONS_TABLE.c.idempotency_key == idempotency_key, + ) + ) + ) + .mappings() + .one_or_none() + ) + return None if row is None else _decode_publication(row) + + +def generate_publication_artifact_id() -> str: + value = int.from_bytes(secrets.token_bytes(16), "big") + encoded = "".join(_CROCKFORD[(value >> shift) & 31] for shift in range(125, -1, -5)) + return f"pub_{encoded}" + + +def _publication_row(publication: ArtifactPublication) -> dict[str, object]: + return { + "target_scope_id": publication.target.scope_id, + "target_family": publication.target.artifact.family, + "target_artifact_id": publication.target.artifact.artifact_id, + "target_revision": publication.target.artifact.revision, + "source_scope_id": publication.source.scope_id, + "source_family": publication.source.artifact.family, + "source_artifact_id": publication.source.artifact.artifact_id, + "source_revision": publication.source.artifact.revision, + "content_digest": publication.content_digest, + } + + +def _decode_publication(row: Mapping[Any, Any]) -> ArtifactPublication: + return ArtifactPublication( + source=ArtifactAddress( + scope_id=str(row["source_scope_id"]), + artifact=ArtifactRef( + family=str(row["source_family"]), + artifact_id=str(row["source_artifact_id"]), + revision=int(row["source_revision"]), + ), + ), + target=ArtifactAddress( + scope_id=str(row["target_scope_id"]), + artifact=ArtifactRef( + family=str(row["target_family"]), + artifact_id=str(row["target_artifact_id"]), + revision=int(row["target_revision"]), + ), + ), + content_digest=str(row["content_digest"]), + ) + + +__all__ = [ + "ArtifactPublication", + "ArtifactPublicationApplication", + "ArtifactPublicationConflictError", + "ArtifactPublicationRequest", +] diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index 750b7d20e..4b0a01dc4 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -71,6 +71,7 @@ from powercontext.builtin.context import BuiltinArtifacts, BuiltinSources from powercontext.builtin.inference.models import InferenceUsage from powercontext.builtin.inference.usage import bind_usage_reporter +from powercontext.builtin.publication import ArtifactPublicationApplication from powercontext.builtin.review.generation import GeneratedCandidateResult, ReviewedGenerationService from powercontext.builtin.review.service import ReviewService from powercontext.builtin.runtime._scope_cache import ( @@ -119,7 +120,12 @@ SkillCandidate, SourceReceipt, ) -from powercontext.builtin.runtime.prepared_context import PreparedContextBuild, PreparedContextBuilder +from powercontext.builtin.runtime.prepared_context import ( + PreparedContextBuild, + PreparedContextBuilder, + PreparedExperienceCandidates, + PreparedMemoryCandidates, +) from powercontext.builtin.runtime.protocols import ( BuiltinTriggers, PowerContextProvider, @@ -133,6 +139,7 @@ RuntimeReadinessChecks, ) from powercontext.builtin.runtime.statistics import RelationalScopedStatistics +from powercontext.builtin.scope import ScopeApplication, ScopeNotFoundError, ScopeSelection from powercontext.builtin.sources import ( ContentCapture, ContentSource, @@ -147,6 +154,7 @@ Statistics, StatisticsPeriod, ) +from powercontext.builtin.statistics.aggregation import aggregate_statistics from powercontext.builtin.work import ( HANDOFF_BOUNDARY_SOURCE_KIND, HANDOFF_RECEIPT_SOURCE_KIND, @@ -322,6 +330,27 @@ def __init__(self, runtime: BuiltinRuntime) -> None: def for_scope(self, scope_id: str, /) -> ScopedStatisticsApplication: return ScopedStatisticsApplication(self._runtime, scope_id) + async def overview( + self, + selection: ScopeSelection, + *, + period: StatisticsPeriod = StatisticsPeriod.THIRTY_DAYS, + ) -> Statistics: + if self._runtime.scopes is None: + raise _RuntimeStateError("statistics") + async with self._runtime._operation(): + resolved = await self._runtime.scopes.resolve_selection(selection) + captured_at = self._runtime._clock() + snapshots = tuple([ + await self._runtime._statistics(scope.scope_id).overview(period, captured_at) for scope in resolved + ]) + return aggregate_statistics( + selection, + tuple(scope.scope_id for scope in resolved), + snapshots, + captured_at, + ) + class ScopedContextApplication: """Prepare final context for one scope using Runtime-owned source policy.""" @@ -336,26 +365,103 @@ async def prepare(self, request: PrepareContextRequest, /) -> PreparedContext: async def _prepare(self, request: PrepareContextRequest, /) -> PreparedContext: builder = PreparedContextBuilder() + scope_ids = [self.scope_id] + if self._runtime.scopes is not None: + try: + scope = await self._runtime.scopes.get(self.scope_id) + except ScopeNotFoundError: + pass + else: + scope_ids.extend(scope.context_references) + + memory_candidates: list[PreparedMemoryCandidates] = [] + experience_candidates: list[PreparedExperienceCandidates] = [] + remaining_memory = builder.memory_candidate_limit + remaining_experience = builder.experience_candidate_limit + for scope_id in scope_ids: + memory, experiences = await self._recall_scope( + scope_id, + request, + memory_limit=remaining_memory, + experience_limit=remaining_experience, + ) + memory_candidates.append(memory) + experience_candidates.append(experiences) + remaining_memory -= len(memory.hits) + remaining_experience -= len(experiences.hits) + + with self._runtime._stage( + "context.build", + attributes={ + "powercontext.context.build.scope_count": len(scope_ids), + "powercontext.context.build.memory_candidate_count": sum( + len(candidates.hits) for candidates in memory_candidates + ), + "powercontext.context.build.experience_candidate_count": sum( + len(candidates.hits) for candidates in experience_candidates + ), + }, + ) as span: + build = builder.build_scopes_result( + request=request, + current_scope_id=self.scope_id, + memory_candidates=memory_candidates, + experience_candidates=experience_candidates, + ) + if span is not None: + span.set_attributes({ + "powercontext.context.build.selected_count": len(build.origins), + "powercontext.context.build.status": build.context.status, + "powercontext.context.build.content_bytes": build.context.content_bytes, + }) + if self._runtime._recall_token_estimator is not None: + try: + measurement = await self._runtime._recall_token_estimator(self.scope_id, build) + except Exception as error: + log_safely( + logger, + logging.ERROR, + "Recall token estimation failed", + exc_info=error, + extra={ + "event": "statistics.recall_tokens.estimation_failed", + "outcome": "failure", + "unit": "statistics", + }, + ) + else: + if measurement is not None: + await self._runtime.statistics.for_scope(self.scope_id).record_recall(measurement) + return build.context + + async def _recall_scope( + self, + scope_id: str, + request: PrepareContextRequest, + *, + memory_limit: int, + experience_limit: int, + ) -> tuple[PreparedMemoryCandidates, PreparedExperienceCandidates]: async with ( - self._runtime._context(self.scope_id, embedding_purpose=ModelUsagePurpose.MEMORY_RECALL) as context, - self._runtime._locked(self.scope_id), + self._runtime._context(scope_id, embedding_purpose=ModelUsagePurpose.MEMORY_RECALL) as context, + self._runtime._locked(scope_id), ): with self._runtime._stage( _MEMORY_SEARCH_STAGE, attributes={ _MEMORY_SEARCH_REQUESTED_MODE: "auto", - _MEMORY_SEARCH_LIMIT: builder.memory_candidate_limit, + _MEMORY_SEARCH_LIMIT: memory_limit, }, ) as span: service = context.artifacts.memory current = await _head_or_none(service, context.artifacts.memory_artifact_id) memory_hits = () search_mode: str | None = None - if current is not None: + if current is not None and memory_limit > 0: result = await service.search( request.query, memories=(current,), - limit=builder.memory_candidate_limit, + limit=memory_limit, mode="auto", ) memory_hits = result.hits @@ -374,59 +480,28 @@ async def _prepare(self, request: PrepareContextRequest, /) -> PreparedContext: "experience.search", attributes={ "powercontext.experience.search.configured": experience_recall is not None, - "powercontext.experience.search.limit": builder.experience_candidate_limit, + "powercontext.experience.search.limit": experience_limit, }, ) as span: experience_hits = ( () - if experience_recall is None + if experience_recall is None or experience_limit == 0 else await experience_recall( - self.scope_id, + scope_id, request.query, - builder.experience_candidate_limit, + experience_limit, ) ) if span is not None: span.set_attributes({"powercontext.experience.search.result_count": len(experience_hits)}) - - with self._runtime._stage( - "context.build", - attributes={ - "powercontext.context.build.memory_candidate_count": len(memory_hits), - "powercontext.context.build.experience_candidate_count": len(experience_hits), - }, - ) as span: - build = builder.build_result( - request=request, - memory_ref=None if current is None else current.as_ref(), - hits=memory_hits, - experience_hits=experience_hits, - ) - if span is not None: - span.set_attributes({ - "powercontext.context.build.selected_count": len(build.origins), - "powercontext.context.build.status": build.context.status, - "powercontext.context.build.content_bytes": build.context.content_bytes, - }) - if self._runtime._recall_token_estimator is not None: - try: - measurement = await self._runtime._recall_token_estimator(self.scope_id, build) - except Exception as error: - log_safely( - logger, - logging.ERROR, - "Recall token estimation failed", - exc_info=error, - extra={ - "event": "statistics.recall_tokens.estimation_failed", - "outcome": "failure", - "unit": "statistics", - }, - ) - else: - if measurement is not None: - await self._runtime.statistics.for_scope(self.scope_id).record_recall(measurement) - return build.context + return ( + PreparedMemoryCandidates( + scope_id=scope_id, + memory_ref=None if current is None else current.as_ref(), + hits=memory_hits, + ), + PreparedExperienceCandidates(scope_id=scope_id, hits=experience_hits), + ) class ContextApplication: @@ -1227,6 +1302,8 @@ def __init__( external_skill_importer: ExternalSkillImporter | None = None, statistics_service: StatisticsServiceFactory | None = None, recall_token_estimator: RecallTokenEstimator | None = None, + publication_application: ArtifactPublicationApplication | None = None, + scope_application: ScopeApplication | None = None, readiness: RuntimeReadinessChecks | None = None, clock: Clock | None = None, tracing: RuntimeTracing | None = None, @@ -1245,6 +1322,8 @@ def __init__( self._external_skill_importer = external_skill_importer self._statistics_service = statistics_service self._recall_token_estimator = recall_token_estimator + self.publications = publication_application + self.scopes = scope_application self._readiness = RuntimeReadinessChecks() if readiness is None else readiness self._clock = _utc_now if clock is None else clock self._tracing = tracing diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index b74668587..b2c03cc8f 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -38,9 +38,8 @@ MemoryReranker, ) from powercontext.builtin.artifacts.skill import AgentSkillProvider, ExternalSkillProvider, SkillGenerator -from powercontext.builtin.handoff_report.adapters import RuntimeHandoffReadAdapter, RuntimeWorkContinuityReadAdapter +from powercontext.builtin.handoff_report.adapters import RuntimeHandoffReadAdapter from powercontext.builtin.handoff_report.application import HandoffReportApplication -from powercontext.builtin.handoff_report.sqlite import HANDOFF_REPORT_TABLES from powercontext.builtin.inference import EmbeddingModel, TokenEstimator, character_token_estimator from powercontext.builtin.inference.usage import ( UsageReportingEmbeddingModel, @@ -279,16 +278,16 @@ async def open_builtin_runtime( external_skill_importer=contexts.import_external_skill if contexts.external_skill_registry else None, statistics_service=contexts.statistics, recall_token_estimator=contexts.estimate_recall_tokens, + publication_application=contexts.publications, + scope_application=contexts.scopes, readiness=RuntimeReadinessChecks(readiness_probes), tracing=tracing, ) ) if config.handoff_report.enabled: runtime.handoff_report = HandoffReportApplication( - contexts.database, + contexts.scopes, RuntimeHandoffReadAdapter(runtime.handoff), - continuity=RuntimeWorkContinuityReadAdapter(runtime.work), - scope_ids=contexts.handoff_scope_ids, ) if config.runtime.schedule_seconds is not None and configured_pipeline is None: raise BuiltinConfigurationError("scheduled-pipeline") @@ -322,7 +321,6 @@ async def open_builtin_contexts( """Open the selected database and expose scope-bound PowerContext providers.""" database = config.database - report_tables = HANDOFF_REPORT_TABLES if config.handoff_report.enabled else () configured_token_estimator = character_token_estimator() if token_estimator is None else token_estimator if isinstance(database, SQLiteConfig): experience_index = SQLiteExperienceFTSIndex() @@ -332,13 +330,13 @@ async def open_builtin_contexts( index = CompositeMemoryIndex(*indexes) async with SQLiteProfile.open( database, - tables=BUILTIN_TABLES + report_tables + index.tables, + tables=BUILTIN_TABLES + index.tables, load_vector_extension=embedding_model is not None, ) as profile: async with profile.database.transaction() as connection: await index.initialize(connection) await experience_index.initialize(connection) - yield RelationalContexts( + contexts = RelationalContexts( database=profile.database, index=index, experience_index=experience_index, @@ -353,13 +351,15 @@ async def open_builtin_contexts( memory_reranker=memory_reranker, memory_rerank_candidate_limit=config.runtime.memory_rerank_candidate_limit, ) + await contexts.scopes.bootstrap_default() + yield contexts return experience_index = OceanBaseExperienceFTSIndex() indexes = [OceanBaseMemoryFTSIndex()] if embedding_model is not None: indexes.append(OceanBaseMemoryVectorIndex(embedding_model.profile)) index = CompositeMemoryIndex(*indexes) - tables = BUILTIN_TABLES + report_tables + index.tables + tables = BUILTIN_TABLES + index.tables if isinstance(database, OceanBaseConfig): profile_context = OceanBaseProfile.open(database, tables=tables) elif isinstance(database, SeekDBConfig): @@ -370,7 +370,7 @@ async def open_builtin_contexts( async with profile.database.transaction() as connection: await index.initialize(connection) await experience_index.initialize(connection) - yield RelationalContexts( + contexts = RelationalContexts( database=profile.database, index=index, experience_index=experience_index, @@ -385,6 +385,8 @@ async def open_builtin_contexts( memory_reranker=memory_reranker, memory_rerank_candidate_limit=config.runtime.memory_rerank_candidate_limit, ) + await contexts.scopes.bootstrap_default() + yield contexts async def _generation_pipelines( diff --git a/src/powercontext/builtin/runtime/prepared_context.py b/src/powercontext/builtin/runtime/prepared_context.py index a1c3d8189..218dbc2cd 100644 --- a/src/powercontext/builtin/runtime/prepared_context.py +++ b/src/powercontext/builtin/runtime/prepared_context.py @@ -20,7 +20,7 @@ from collections.abc import Sequence from dataclasses import dataclass -from powercontext.artifacts import ArtifactRef +from powercontext.artifacts import ArtifactAddress, ArtifactRef from powercontext.builtin.artifacts.experience import Experience, ExperienceSearchHit, render_experience from powercontext.builtin.artifacts.memory.models import MemoryCitation, MemoryHit from powercontext.builtin.runtime.errors import PreparedContextInvariantError @@ -39,7 +39,7 @@ @dataclass(frozen=True) class _PreparedContextEntry: - origin: MemoryCitation | ArtifactRef + origin: PreparedContextOrigin kind: str citation: dict[str, object] content: str @@ -51,7 +51,36 @@ class PreparedContextBuild: """Final public context and the exact origins selected to produce it.""" context: PreparedContext - origins: tuple[MemoryCitation | ArtifactRef, ...] + origins: tuple[PreparedContextOrigin, ...] + + +@dataclass(frozen=True) +class MemoryEntryAddress: + """Identify one exact Memory entry version across Scope boundaries.""" + + memory: ArtifactAddress + entry_id: str + entry_version_id: str + + +PreparedContextOrigin = MemoryCitation | ArtifactRef | MemoryEntryAddress | ArtifactAddress + + +@dataclass(frozen=True) +class PreparedMemoryCandidates: + """Memory candidates read from one Scope.""" + + scope_id: str + memory_ref: ArtifactRef | None = None + hits: tuple[MemoryHit, ...] = () + + +@dataclass(frozen=True) +class PreparedExperienceCandidates: + """Experience candidates read from one Scope.""" + + scope_id: str + hits: tuple[ExperienceSearchHit, ...] = () class PreparedContextBuilder: @@ -90,15 +119,43 @@ def build_result( hits: Sequence[MemoryHit] = (), experience_hits: Sequence[ExperienceSearchHit] = (), ) -> PreparedContextBuild: - if len(hits) > self.memory_candidate_limit: + return self.build_scopes_result( + request=request, + current_scope_id=None, + memory_candidates=(PreparedMemoryCandidates(scope_id="", memory_ref=memory_ref, hits=tuple(hits)),), + experience_candidates=(PreparedExperienceCandidates(scope_id="", hits=tuple(experience_hits)),), + ) + + def build_scopes_result( + self, + *, + request: PrepareContextRequest, + current_scope_id: str | None, + memory_candidates: Sequence[PreparedMemoryCandidates] = (), + experience_candidates: Sequence[PreparedExperienceCandidates] = (), + ) -> PreparedContextBuild: + if sum(len(candidates.hits) for candidates in memory_candidates) > self.memory_candidate_limit: raise PreparedContextInvariantError("memory-candidate-limit") - if len(experience_hits) > self.experience_candidate_limit: + if sum(len(candidates.hits) for candidates in experience_candidates) > self.experience_candidate_limit: raise PreparedContextInvariantError("experience-candidate-limit") - if hits and memory_ref is None: - raise PreparedContextInvariantError("memory-ref-missing") - memory_entries = self._memory_entries(memory_ref, hits) - experience_entries = self._experience_entries(experience_hits) + memory_entries = tuple( + entry + for candidates in memory_candidates + for entry in self._memory_entries( + candidates.memory_ref, + candidates.hits, + scope_id=None if candidates.scope_id == current_scope_id else candidates.scope_id or None, + ) + ) + experience_entries = tuple( + entry + for candidates in experience_candidates + for entry in self._experience_entries( + candidates.hits, + scope_id=None if candidates.scope_id == current_scope_id else candidates.scope_id or None, + ) + ) entries = self._fit_entries(request, memory_entries, experience_entries) if not entries: @@ -116,7 +173,11 @@ def _memory_entries( self, memory_ref: ArtifactRef | None, hits: Sequence[MemoryHit], + *, + scope_id: str | None = None, ) -> tuple[_PreparedContextEntry, ...]: + if hits and memory_ref is None: + raise PreparedContextInvariantError("memory-ref-missing") memory_entries: list[_PreparedContextEntry] = [] seen: set[tuple[str, str]] = set() for hit in hits: @@ -136,11 +197,25 @@ def _memory_entries( entry_id=hit.entry_id, entry_version_id=hit.entry_version_id, ) + origin: PreparedContextOrigin = citation + rendered_citation = citation.model_dump(mode="json") + if scope_id is not None: + memory = ArtifactAddress(scope_id=scope_id, artifact=hit.memory_ref) + origin = MemoryEntryAddress( + memory=memory, + entry_id=hit.entry_id, + entry_version_id=hit.entry_version_id, + ) + rendered_citation = { + "memory": memory.model_dump(mode="json"), + "entry_id": hit.entry_id, + "entry_version_id": hit.entry_version_id, + } memory_entries.append( _PreparedContextEntry( - origin=citation, + origin=origin, kind="memory", - citation=citation.model_dump(mode="json"), + citation=rendered_citation, content=hit.text, truncated=False, ) @@ -150,6 +225,8 @@ def _memory_entries( def _experience_entries( self, hits: Sequence[ExperienceSearchHit], + *, + scope_id: str | None = None, ) -> tuple[_PreparedContextEntry, ...]: experience_entries: list[_PreparedContextEntry] = [] seen_experiences: set[tuple[str, int]] = set() @@ -162,11 +239,16 @@ def _experience_entries( seen_experiences.add(identity) if len(experience_entries) >= self.experience_entry_limit: break + origin: PreparedContextOrigin = hit.artifact_ref + rendered_citation: dict[str, object] = {"artifact_ref": hit.artifact_ref.model_dump(mode="json")} + if scope_id is not None: + origin = ArtifactAddress(scope_id=scope_id, artifact=hit.artifact_ref) + rendered_citation = {"artifact": origin.model_dump(mode="json")} experience_entries.append( _PreparedContextEntry( - origin=hit.artifact_ref, + origin=origin, kind="experience", - citation={"artifact_ref": hit.artifact_ref.model_dump(mode="json")}, + citation=rendered_citation, content=render_experience(hit.content), truncated=False, ) @@ -199,7 +281,7 @@ def _fit_entry( self, entries: Sequence[_PreparedContextEntry], *, - origin: MemoryCitation | ArtifactRef, + origin: PreparedContextOrigin, kind: str, citation: dict[str, object], text: str, diff --git a/src/powercontext/builtin/runtime/recall.py b/src/powercontext/builtin/runtime/recall.py index b5b2d195c..470bcc1e5 100644 --- a/src/powercontext/builtin/runtime/recall.py +++ b/src/powercontext/builtin/runtime/recall.py @@ -20,13 +20,17 @@ from sqlalchemy.ext.asyncio import AsyncConnection -from powercontext.artifacts import ArtifactRef +from powercontext.artifacts import ArtifactAddress, ArtifactRef from powercontext.builtin.artifacts.memory import MemoryCitation, MemoryService from powercontext.builtin.inference import TokenEstimator from powercontext.builtin.persistence.artifacts import ArtifactRepository from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.sources import SourceRepository -from powercontext.builtin.runtime.prepared_context import PreparedContextBuild +from powercontext.builtin.runtime.prepared_context import ( + MemoryEntryAddress, + PreparedContextBuild, + PreparedContextOrigin, +) from powercontext.builtin.sources import ContentSource, ExternalSkillSnapshotSource from powercontext.builtin.statistics import RecallTokenMeasurement from powercontext.sources import Source, SourceRef @@ -39,6 +43,66 @@ def __init__(self, source: Source) -> None: super().__init__(f"no recall token projection is registered for {type(source).__name__}") +SourceAddress = tuple[str, str, str] + + +class _RecallOriginResolver: + def __init__( + self, + *, + connection: AsyncConnection, + current_scope_id: str, + artifacts: ArtifactRepository, + memory_service: Callable[[str, AsyncConnection], MemoryService], + ) -> None: + self._connection = connection + self._current_scope_id = current_scope_id + self._artifacts = artifacts + self._memory_service = memory_service + self._artifact_sources: dict[tuple[str, str, str, int], frozenset[SourceAddress]] = {} + self._resolving_artifacts: set[tuple[str, str, str, int]] = set() + + async def resolve(self, origin: PreparedContextOrigin, /) -> set[SourceAddress]: + if isinstance(origin, MemoryCitation): + return await self._memory(self._current_scope_id, origin) + if isinstance(origin, MemoryEntryAddress): + return await self._memory( + origin.memory.scope_id, + MemoryCitation( + memory_ref=origin.memory.artifact, + entry_id=origin.entry_id, + entry_version_id=origin.entry_version_id, + ), + ) + if isinstance(origin, ArtifactAddress): + return set(await self._artifact(origin.scope_id, origin.artifact)) + return set(await self._artifact(self._current_scope_id, origin)) + + async def _memory(self, scope_id: str, citation: MemoryCitation) -> set[SourceAddress]: + memory = self._memory_service(scope_id, self._connection) + entry = await memory.validate_citation(citation) + sources = _source_identities(scope_id, entry.sources) + for artifact_ref in entry.artifacts: + sources.update(await self._artifact(scope_id, artifact_ref)) + return sources + + async def _artifact(self, scope_id: str, artifact_ref: ArtifactRef) -> frozenset[SourceAddress]: + identity = _artifact_identity(scope_id, artifact_ref) + if identity in self._artifact_sources: + return self._artifact_sources[identity] + if identity in self._resolving_artifacts: + return frozenset() + self._resolving_artifacts.add(identity) + artifact = await self._artifacts.get(self._connection, scope_id, artifact_ref) + resolved = _source_identities(scope_id, artifact.lineage.sources) + for parent in artifact.lineage.artifacts: + resolved.update(await self._artifact(scope_id, parent)) + self._resolving_artifacts.remove(identity) + result = frozenset(resolved) + self._artifact_sources[identity] = result + return result + + class RelationalRecallTokenEstimator: """Resolve exact recall lineage and estimate its token reduction.""" @@ -49,7 +113,7 @@ def __init__( scope_id: str, sources: SourceRepository, artifacts: ArtifactRepository, - memory_service: Callable[[AsyncConnection], MemoryService], + memory_service: Callable[[str, AsyncConnection], MemoryService], estimator: TokenEstimator, ) -> None: self._database = database @@ -60,38 +124,18 @@ def __init__( self._estimator = estimator async def estimate(self, build: PreparedContextBuild, /) -> RecallTokenMeasurement: - source_refs: set[tuple[str, str]] = set() + source_refs: set[tuple[str, str, str]] = set() comparable = build.context.status == "ready" and bool(build.origins) async with self._database.transaction() as connection: - memory = self._memory_service(connection) - artifact_sources: dict[tuple[str, str, int], frozenset[tuple[str, str]]] = {} - resolving_artifacts: set[tuple[str, str, int]] = set() - - async def resolve_artifact(artifact_ref: ArtifactRef) -> frozenset[tuple[str, str]]: - identity = _artifact_identity(artifact_ref) - if identity in artifact_sources: - return artifact_sources[identity] - if identity in resolving_artifacts: - return frozenset() - resolving_artifacts.add(identity) - artifact = await self._artifacts.get(connection, self._scope_id, artifact_ref) - resolved = _source_identities(artifact.lineage.sources) - for parent in artifact.lineage.artifacts: - resolved.update(await resolve_artifact(parent)) - resolving_artifacts.remove(identity) - result = frozenset(resolved) - artifact_sources[identity] = result - return result - + resolver = _RecallOriginResolver( + connection=connection, + current_scope_id=self._scope_id, + artifacts=self._artifacts, + memory_service=self._memory_service, + ) for origin in build.origins: - if isinstance(origin, MemoryCitation): - entry = await memory.validate_citation(origin) - origin_sources = _source_identities(entry.sources) - for artifact_ref in entry.artifacts: - origin_sources.update(await resolve_artifact(artifact_ref)) - else: - origin_sources = set(await resolve_artifact(origin)) + origin_sources = await resolver.resolve(origin) comparable = comparable and bool(origin_sources) source_refs.update(origin_sources) @@ -105,10 +149,13 @@ async def resolve_artifact(artifact_ref: ArtifactRef) -> frozenset[tuple[str, st ) texts = [] - for source_type, source_id in sorted(source_refs, key=lambda ref: (ref[0].encode(), ref[1].encode())): + for scope_id, source_type, source_id in sorted( + source_refs, + key=lambda ref: (ref[0].encode(), ref[1].encode(), ref[2].encode()), + ): stored = await self._sources.get( connection, - self._scope_id, + scope_id, SourceRef(source_type=source_type, source_id=source_id), ) texts.append(_source_text(stored.value)) @@ -123,12 +170,12 @@ async def resolve_artifact(artifact_ref: ArtifactRef) -> frozenset[tuple[str, st ) -def _source_identities(sources: tuple[SourceRef, ...], /) -> set[tuple[str, str]]: - return {(source.source_type, source.source_id) for source in sources} +def _source_identities(scope_id: str, sources: tuple[SourceRef, ...], /) -> set[SourceAddress]: + return {(scope_id, source.source_type, source.source_id) for source in sources} -def _artifact_identity(artifact: ArtifactRef, /) -> tuple[str, str, int]: - return artifact.family, artifact.artifact_id, artifact.revision +def _artifact_identity(scope_id: str, artifact: ArtifactRef, /) -> tuple[str, str, str, int]: + return scope_id, artifact.family, artifact.artifact_id, artifact.revision def _source_text(source: Source, /) -> str: diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index 534a828f5..859981121 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -18,7 +18,7 @@ import asyncio from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Any, cast from uuid import uuid4 @@ -77,6 +77,7 @@ from powercontext.builtin.persistence.sources import SourceRepository, StoredSource from powercontext.builtin.persistence.statistics import StatisticsRepository from powercontext.builtin.persistence.tables import ARTIFACT_HEADS_TABLE, SOURCE_JOURNAL_HEADS_TABLE +from powercontext.builtin.publication import ArtifactPublicationApplication from powercontext.builtin.review.generation import ( GeneratedCandidateResult, GenerationCapabilityUnavailableError, @@ -89,6 +90,7 @@ from powercontext.builtin.runtime.protocols import BuiltinTriggers from powercontext.builtin.runtime.recall import RelationalRecallTokenEstimator from powercontext.builtin.runtime.statistics import RelationalScopedStatistics +from powercontext.builtin.scope import ScopeApplication from powercontext.builtin.sources import ( CONTENT_SOURCE_ADAPTER, EXTERNAL_SKILL_SNAPSHOT_SOURCE_ADAPTER, @@ -267,9 +269,10 @@ def recall_tokens(self) -> RelationalRecallTokenEstimator | None: if self.token_estimator is None: return None - def memory_service(connection: AsyncConnection) -> MemoryService: - _, source_catalog = self.sources(connection) - return self.memory(source_catalog, connection) + def memory_service(scope_id: str, connection: AsyncConnection) -> MemoryService: + services = self if scope_id == self.scope_id else replace(self, scope_id=scope_id) + _, source_catalog = services.sources(connection) + return services.memory(source_catalog, connection) return RelationalRecallTokenEstimator( database=self.database, @@ -305,6 +308,7 @@ def __init__( memory_artifact_id: str = "memory", ) -> None: self.database = database + self.scopes = ScopeApplication(database) self.index = NoMemoryIndex() if index is None else index self.experience_index = NoExperienceIndex() if experience_index is None else experience_index self.repositories = _Repositories( @@ -318,6 +322,11 @@ def __init__( external_skills=ExternalSkillRepository(), statistics=StatisticsRepository(), ) + self.publications = ArtifactPublicationApplication( + database, + self.repositories.artifacts, + self.scopes, + ) self._candidate_pipeline = candidate_pipeline self.memory_extraction = candidate_pipeline is not None self._experience_pipeline = experience_pipeline diff --git a/src/powercontext/builtin/runtime/statistics.py b/src/powercontext/builtin/runtime/statistics.py index 0fe8d5dbf..e640aaa82 100644 --- a/src/powercontext/builtin/runtime/statistics.py +++ b/src/powercontext/builtin/runtime/statistics.py @@ -31,6 +31,7 @@ StoredModelUsage, StoredRecallTokenUsage, ) +from powercontext.builtin.scope import ScopeSelection from powercontext.builtin.statistics import ( ArtifactInventoryStatistics, CandidateFamilyCount, @@ -124,7 +125,8 @@ async def overview(self, period: StatisticsPeriod, as_of: datetime, /) -> Statis artifacts = tuple(FamilyCount(family=family, total=total) for family, total in stored_inventory.artifacts) candidates = _candidate_inventory(stored_inventory.candidates) return Statistics( - scope_id=self._scope_id, + selection=ScopeSelection(mode="exact", scope_ids=(self._scope_id,)), + scope_ids=(self._scope_id,), as_of=captured_at, inventory=InventoryStatistics( sources=SourceInventoryStatistics( diff --git a/src/powercontext/builtin/scope/__init__.py b/src/powercontext/builtin/scope/__init__.py new file mode 100644 index 000000000..04d76b375 --- /dev/null +++ b/src/powercontext/builtin/scope/__init__.py @@ -0,0 +1,52 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Durable Scope organization and binding.""" + +from powercontext.builtin.scope.application import ScopeApplication, generate_scope_id +from powercontext.builtin.scope.errors import ( + ScopeBindingNotFoundError, + ScopeError, + ScopeIdempotencyConflictError, + ScopeNotFoundError, + ScopeRelationshipError, + ScopeVersionConflictError, +) +from powercontext.builtin.scope.models import ( + ScopeBinding, + ScopeBindingKey, + ScopeDescriptor, + ScopeDraft, + ScopeExternalReference, + ScopeMutation, + ScopeSelection, +) + +__all__ = [ + "ScopeApplication", + "ScopeBinding", + "ScopeBindingKey", + "ScopeBindingNotFoundError", + "ScopeDescriptor", + "ScopeDraft", + "ScopeError", + "ScopeExternalReference", + "ScopeIdempotencyConflictError", + "ScopeMutation", + "ScopeNotFoundError", + "ScopeRelationshipError", + "ScopeSelection", + "ScopeVersionConflictError", + "generate_scope_id", +] diff --git a/src/powercontext/builtin/scope/application.py b/src/powercontext/builtin/scope/application.py new file mode 100644 index 000000000..26243b131 --- /dev/null +++ b/src/powercontext/builtin/scope/application.py @@ -0,0 +1,216 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Application service for Scope ownership, organization, and binding.""" + +from __future__ import annotations + +import hashlib +import secrets +from collections.abc import Callable, Sequence + +from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.scope.errors import ( + ScopeBindingNotFoundError, + ScopeIdempotencyConflictError, + ScopeNotFoundError, + ScopeRelationshipError, + ScopeVersionConflictError, +) +from powercontext.builtin.scope.models import ( + ScopeBinding, + ScopeBindingKey, + ScopeDescriptor, + ScopeDraft, + ScopeMutation, + ScopeSelection, +) +from powercontext.builtin.scope.repository import ScopeRepository + +ScopeIdFactory = Callable[[], str] +_CROCKFORD = "0123456789abcdefghjkmnpqrstvwxyz" +_DEFAULT_IDEMPOTENCY_KEY = "powercontext.default-scope.v1" + + +class ScopeApplication: + def __init__( + self, + database: AsyncDatabase, + *, + repository: ScopeRepository | None = None, + id_factory: ScopeIdFactory | None = None, + ) -> None: + self._database = database + self._repository = ScopeRepository() if repository is None else repository + self._id_factory = generate_scope_id if id_factory is None else id_factory + + async def bootstrap_default(self) -> ScopeDescriptor: + existing = await self.default_scope() + if existing is not None: + return existing + created = await self.create( + ScopeDraft( + title="Default", + summary="Default context", + idempotency_key=_DEFAULT_IDEMPOTENCY_KEY, + ) + ) + await self.set_default(created.scope_id) + return created + + async def create(self, draft: ScopeDraft, /) -> ScopeDescriptor: + digest = _draft_digest(draft) + async with self._database.transaction() as connection: + existing = await self._repository.creation(connection, draft.idempotency_key) + if existing is not None: + existing_digest, scope_id = existing + if existing_digest != digest: + raise ScopeIdempotencyConflictError(draft.idempotency_key) + return await self._required(connection, scope_id) + await self._validate_relationships( + connection, + scope_id=None, + parent_scope_id=draft.parent_scope_id, + context_references=draft.context_references, + ) + return await self._repository.add(connection, self._id_factory(), draft, digest) + + async def get(self, scope_id: str, /) -> ScopeDescriptor: + async with self._database.transaction() as connection: + return await self._required(connection, scope_id) + + async def list(self) -> tuple[ScopeDescriptor, ...]: + async with self._database.transaction() as connection: + return await self._repository.list(connection) + + async def update(self, scope_id: str, mutation: ScopeMutation, /) -> ScopeDescriptor: + async with self._database.transaction() as connection: + current = await self._required(connection, scope_id) + if current.version != mutation.expected_version: + raise ScopeVersionConflictError(scope_id, mutation.expected_version, current.version) + await self._validate_relationships( + connection, + scope_id=scope_id, + parent_scope_id=mutation.parent_scope_id, + context_references=mutation.context_references, + ) + if not await self._repository.replace(connection, scope_id, mutation): + refreshed = await self._required(connection, scope_id) + raise ScopeVersionConflictError(scope_id, mutation.expected_version, refreshed.version) + return await self._required(connection, scope_id) + + async def default_scope(self) -> ScopeDescriptor | None: + async with self._database.transaction() as connection: + scope_id = await self._repository.default_scope_id(connection) + return None if scope_id is None else await self._required(connection, scope_id) + + async def set_default(self, scope_id: str, /) -> ScopeDescriptor: + async with self._database.transaction() as connection: + scope = await self._required(connection, scope_id) + await self._repository.set_default(connection, scope_id) + return scope + + async def bind(self, key: ScopeBindingKey, scope_id: str, /) -> ScopeBinding: + async with self._database.transaction() as connection: + await self._required(connection, scope_id) + return await self._repository.set_binding(connection, key, scope_id) + + async def binding(self, key: ScopeBindingKey, /) -> ScopeBinding | None: + async with self._database.transaction() as connection: + return await self._repository.binding(connection, key) + + async def clear_binding(self, key: ScopeBindingKey, /) -> bool: + async with self._database.transaction() as connection: + return await self._repository.clear_binding(connection, key) + + async def resolve_binding( + self, + *, + explicit_scope_id: str | None = None, + binding_keys: Sequence[ScopeBindingKey] = (), + ) -> ScopeDescriptor: + async with self._database.transaction() as connection: + if explicit_scope_id is not None: + return await self._required(connection, explicit_scope_id) + for key in binding_keys: + binding = await self._repository.binding(connection, key) + if binding is not None: + return await self._required(connection, binding.scope_id) + default_scope_id = await self._repository.default_scope_id(connection) + if default_scope_id is None: + raise ScopeBindingNotFoundError + return await self._required(connection, default_scope_id) + + async def resolve_selection(self, selection: ScopeSelection, /) -> tuple[ScopeDescriptor, ...]: + async with self._database.transaction() as connection: + if selection.mode == "all": + return await self._repository.list(connection) + if selection.mode == "exact": + resolved = [await self._required(connection, scope_id) for scope_id in selection.scope_ids] + return tuple(sorted(resolved, key=lambda scope: scope.scope_id)) + root_scope_id = selection.root_scope_id + if root_scope_id is None: + raise AssertionError + root = await self._required(connection, root_scope_id) + resolved = [root] + pending = [root.scope_id] + while pending: + parent_scope_id = pending.pop(0) + for child_scope_id in await self._repository.children(connection, parent_scope_id): + resolved.append(await self._required(connection, child_scope_id)) + pending.append(child_scope_id) + return tuple(resolved) + + async def _required(self, connection, scope_id: str) -> ScopeDescriptor: + scope = await self._repository.get(connection, scope_id) + if scope is None: + raise ScopeNotFoundError(scope_id) + return scope + + async def _validate_relationships( + self, + connection, + *, + scope_id: str | None, + parent_scope_id: str | None, + context_references: tuple[str, ...], + ) -> None: + if scope_id is not None and parent_scope_id == scope_id: + raise ScopeRelationshipError("Parent", "a Scope cannot parent itself") + if parent_scope_id is not None: + parent = await self._required(connection, parent_scope_id) + while parent.parent_scope_id is not None: + if parent.parent_scope_id == scope_id: + raise ScopeRelationshipError("Parent", "relationships must be acyclic") + parent = await self._required(connection, parent.parent_scope_id) + for referenced_scope_id in context_references: + if referenced_scope_id == scope_id: + raise ScopeRelationshipError( # noqa: TRY003 + "Context Reference", + "a Scope cannot reference itself", + ) + await self._required(connection, referenced_scope_id) + + +def generate_scope_id() -> str: + """Generate the RFC-defined 128-bit opaque Scope identity.""" + + value = int.from_bytes(secrets.token_bytes(16), "big") + encoded = "".join(_CROCKFORD[(value >> shift) & 31] for shift in range(125, -1, -5)) + return f"scp_{encoded}" + + +def _draft_digest(draft: ScopeDraft) -> str: + payload = draft.model_dump_json(exclude={"idempotency_key"}) + return hashlib.sha256(payload.encode()).hexdigest() diff --git a/src/powercontext/builtin/scope/errors.py b/src/powercontext/builtin/scope/errors.py new file mode 100644 index 000000000..a55548877 --- /dev/null +++ b/src/powercontext/builtin/scope/errors.py @@ -0,0 +1,57 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Stable failures for Scope organization and binding.""" + +from __future__ import annotations + +from powercontext.errors import PowerContextError + + +class ScopeError(PowerContextError): + """Base failure for Scope operations.""" + + +class ScopeNotFoundError(ScopeError, LookupError): + def __init__(self, scope_id: str) -> None: + self.scope_id = scope_id + super().__init__("scope was not found") + + +class ScopeVersionConflictError(ScopeError, RuntimeError): + def __init__(self, scope_id: str, expected: int, actual: int) -> None: + self.scope_id = scope_id + self.expected = expected + self.actual = actual + super().__init__("scope metadata changed since it was read") + + +class ScopeIdempotencyConflictError(ScopeError, RuntimeError): + def __init__(self, idempotency_key: str) -> None: + self.idempotency_key = idempotency_key + super().__init__("scope creation key was reused with different parameters") + + +class ScopeRelationshipError(ScopeError, ValueError): + def __init__(self, relationship: str, issue: str) -> None: + self.relationship = relationship + self.issue = issue + super().__init__(f"invalid Scope {relationship}: {issue}") + + +class ScopeBindingNotFoundError(ScopeError, LookupError): + """Raised when no explicit, durable, or default binding can be resolved.""" + + def __init__(self) -> None: + super().__init__("no Scope binding is available") diff --git a/src/powercontext/builtin/scope/models.py b/src/powercontext/builtin/scope/models.py new file mode 100644 index 000000000..0c70a9685 --- /dev/null +++ b/src/powercontext/builtin/scope/models.py @@ -0,0 +1,224 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Domain values for Scope organization, observation, and external binding.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, StrictInt, field_validator, model_validator + +from powercontext.builtin.sources import validate_scope_id +from powercontext.limits import ( + MAX_SCOPE_BINDING_EXTERNAL_ID_LENGTH, + MAX_SCOPE_BINDING_INTEGRATION_LENGTH, + MAX_SCOPE_BINDING_KIND_LENGTH, + MAX_SCOPE_EXTERNAL_REFERENCE_KIND_LENGTH, + MAX_SCOPE_EXTERNAL_REFERENCE_VALUE_LENGTH, + MAX_SCOPE_IDEMPOTENCY_KEY_LENGTH, + MAX_SCOPE_SUMMARY_LENGTH, + MAX_SCOPE_TITLE_LENGTH, +) + + +class _ScopeValue(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class ScopeExternalReference(_ScopeValue): + kind: str + value: str + + @field_validator("kind") + @classmethod + def validate_kind(cls, value: str) -> str: + return _required_text("kind", value, MAX_SCOPE_EXTERNAL_REFERENCE_KIND_LENGTH) + + @field_validator("value") + @classmethod + def validate_value(cls, value: str) -> str: + return _required_text("value", value, MAX_SCOPE_EXTERNAL_REFERENCE_VALUE_LENGTH) + + +class ScopeDraft(_ScopeValue): + title: str + summary: str + parent_scope_id: str | None = None + context_references: tuple[str, ...] = () + external_references: tuple[ScopeExternalReference, ...] = () + idempotency_key: str + + @field_validator("title") + @classmethod + def validate_title(cls, value: str) -> str: + return _required_text("title", value, MAX_SCOPE_TITLE_LENGTH) + + @field_validator("summary") + @classmethod + def validate_summary(cls, value: str) -> str: + return _required_text("summary", value, MAX_SCOPE_SUMMARY_LENGTH) + + @field_validator("parent_scope_id") + @classmethod + def validate_parent(cls, value: str | None) -> str | None: + return None if value is None else validate_scope_id(value) + + @field_validator("context_references") + @classmethod + def validate_context_references(cls, values: tuple[str, ...]) -> tuple[str, ...]: + normalized = tuple(validate_scope_id(value) for value in values) + if len(set(normalized)) != len(normalized): + raise ValueError("Context References must be unique") # noqa: TRY003 + return normalized + + @field_validator("external_references") + @classmethod + def validate_external_references( + cls, + values: tuple[ScopeExternalReference, ...], + ) -> tuple[ScopeExternalReference, ...]: + if len(set(values)) != len(values): + raise ValueError("external references must be unique") # noqa: TRY003 + return values + + @field_validator("idempotency_key") + @classmethod + def validate_idempotency_key(cls, value: str) -> str: + return _required_text("idempotency_key", value, MAX_SCOPE_IDEMPOTENCY_KEY_LENGTH) + + +class ScopeMutation(_ScopeValue): + expected_version: StrictInt = Field(ge=1) + title: str + summary: str + parent_scope_id: str | None = None + context_references: tuple[str, ...] = () + external_references: tuple[ScopeExternalReference, ...] = () + + @field_validator("title") + @classmethod + def validate_title(cls, value: str) -> str: + return _required_text("title", value, MAX_SCOPE_TITLE_LENGTH) + + @field_validator("summary") + @classmethod + def validate_summary(cls, value: str) -> str: + return _required_text("summary", value, MAX_SCOPE_SUMMARY_LENGTH) + + @field_validator("parent_scope_id") + @classmethod + def validate_parent(cls, value: str | None) -> str | None: + return None if value is None else validate_scope_id(value) + + @field_validator("context_references") + @classmethod + def validate_context_references(cls, values: tuple[str, ...]) -> tuple[str, ...]: + normalized = tuple(validate_scope_id(value) for value in values) + if len(set(normalized)) != len(normalized): + raise ValueError("Context References must be unique") # noqa: TRY003 + return normalized + + @field_validator("external_references") + @classmethod + def validate_external_references( + cls, + values: tuple[ScopeExternalReference, ...], + ) -> tuple[ScopeExternalReference, ...]: + if len(set(values)) != len(values): + raise ValueError("external references must be unique") # noqa: TRY003 + return values + + +class ScopeDescriptor(_ScopeValue): + scope_id: str + title: str + summary: str + parent_scope_id: str | None = None + context_references: tuple[str, ...] = () + external_references: tuple[ScopeExternalReference, ...] = () + version: StrictInt = Field(ge=1) + + @field_validator("scope_id") + @classmethod + def validate_scope_id(cls, value: str) -> str: + return validate_scope_id(value) + + +class ScopeBindingKey(_ScopeValue): + integration: str + kind: str + external_id: str + + @field_validator("integration") + @classmethod + def validate_integration(cls, value: str) -> str: + return _required_text("integration", value, MAX_SCOPE_BINDING_INTEGRATION_LENGTH) + + @field_validator("kind") + @classmethod + def validate_kind(cls, value: str) -> str: + return _required_text("kind", value, MAX_SCOPE_BINDING_KIND_LENGTH) + + @field_validator("external_id") + @classmethod + def validate_external_id(cls, value: str) -> str: + return _required_text("external_id", value, MAX_SCOPE_BINDING_EXTERNAL_ID_LENGTH) + + +class ScopeBinding(_ScopeValue): + key: ScopeBindingKey + scope_id: str + + @field_validator("scope_id") + @classmethod + def validate_scope_id(cls, value: str) -> str: + return validate_scope_id(value) + + +class ScopeSelection(_ScopeValue): + mode: Literal["all", "exact", "subtree"] + scope_ids: tuple[str, ...] = () + root_scope_id: str | None = None + + @field_validator("scope_ids") + @classmethod + def validate_scope_ids(cls, values: tuple[str, ...]) -> tuple[str, ...]: + normalized = tuple(validate_scope_id(value) for value in values) + if len(set(normalized)) != len(normalized): + raise ValueError("exact Scope selection must be unique") # noqa: TRY003 + return normalized + + @field_validator("root_scope_id") + @classmethod + def validate_root_scope_id(cls, value: str | None) -> str | None: + return None if value is None else validate_scope_id(value) + + @model_validator(mode="after") + def validate_shape(self) -> ScopeSelection: + if self.mode == "all" and (self.scope_ids or self.root_scope_id is not None): + raise ValueError("all selection accepts no Scope arguments") # noqa: TRY003 + if self.mode == "exact" and (not self.scope_ids or self.root_scope_id is not None): + raise ValueError("exact selection requires scope_ids only") # noqa: TRY003 + if self.mode == "subtree" and (self.root_scope_id is None or self.scope_ids): + raise ValueError("subtree selection requires root_scope_id only") # noqa: TRY003 + return self + + +def _required_text(field: str, value: str, maximum: int) -> str: + if not value.strip() or value != value.strip(): + raise ValueError(f"{field} must be non-empty without surrounding whitespace") # noqa: TRY003 + if len(value) > maximum: + raise ValueError(f"{field} must not exceed {maximum} characters") # noqa: TRY003 + return value diff --git a/src/powercontext/builtin/scope/repository.py b/src/powercontext/builtin/scope/repository.py new file mode 100644 index 000000000..846954e6c --- /dev/null +++ b/src/powercontext/builtin/scope/repository.py @@ -0,0 +1,289 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Relational persistence for durable Scope organization.""" + +from __future__ import annotations + +from hashlib import sha256 + +from sqlalchemy import delete, insert, select, update +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.builtin.persistence.tables import ( + SCOPE_BINDINGS_TABLE, + SCOPE_CONTEXT_REFERENCES_TABLE, + SCOPE_CREATION_REQUESTS_TABLE, + SCOPE_EXTERNAL_REFERENCES_TABLE, + SCOPE_SETTINGS_TABLE, + SCOPES_TABLE, +) +from powercontext.builtin.scope.models import ( + ScopeBinding, + ScopeBindingKey, + ScopeDescriptor, + ScopeDraft, + ScopeExternalReference, + ScopeMutation, +) + +_DEFAULT_SETTING = "default" + + +class ScopeRepository: + async def get(self, connection: AsyncConnection, scope_id: str, /) -> ScopeDescriptor | None: + row = ( + (await connection.execute(select(SCOPES_TABLE).where(SCOPES_TABLE.c.scope_id == scope_id))) + .mappings() + .one_or_none() + ) + if row is None: + return None + context_references = tuple( + str(value) + for value in ( + await connection.execute( + select(SCOPE_CONTEXT_REFERENCES_TABLE.c.referenced_scope_id) + .where(SCOPE_CONTEXT_REFERENCES_TABLE.c.scope_id == scope_id) + .order_by(SCOPE_CONTEXT_REFERENCES_TABLE.c.referenced_scope_id) + ) + ).scalars() + ) + external_references = tuple( + ScopeExternalReference(kind=str(value.kind), value=str(value.value)) + for value in ( + await connection.execute( + select( + SCOPE_EXTERNAL_REFERENCES_TABLE.c.kind, + SCOPE_EXTERNAL_REFERENCES_TABLE.c.value, + ) + .where(SCOPE_EXTERNAL_REFERENCES_TABLE.c.scope_id == scope_id) + .order_by(SCOPE_EXTERNAL_REFERENCES_TABLE.c.ordinal) + ) + ) + ) + return ScopeDescriptor( + scope_id=str(row["scope_id"]), + title=str(row["title"]), + summary=str(row["summary"]), + parent_scope_id=None if row["parent_scope_id"] is None else str(row["parent_scope_id"]), + context_references=context_references, + external_references=external_references, + version=int(row["version"]), + ) + + async def list(self, connection: AsyncConnection, /) -> tuple[ScopeDescriptor, ...]: + scope_ids = tuple( + str(value) + for value in ( + await connection.execute(select(SCOPES_TABLE.c.scope_id).order_by(SCOPES_TABLE.c.scope_id)) + ).scalars() + ) + scopes: list[ScopeDescriptor] = [] + for scope_id in scope_ids: + scope = await self.get(connection, scope_id) + if scope is None: + raise AssertionError + scopes.append(scope) + return tuple(scopes) + + async def children(self, connection: AsyncConnection, parent_scope_id: str, /) -> tuple[str, ...]: + return tuple( + str(value) + for value in ( + await connection.execute( + select(SCOPES_TABLE.c.scope_id) + .where(SCOPES_TABLE.c.parent_scope_id == parent_scope_id) + .order_by(SCOPES_TABLE.c.scope_id) + ) + ).scalars() + ) + + async def creation(self, connection: AsyncConnection, idempotency_key: str, /) -> tuple[str, str] | None: + row = ( + await connection.execute( + select( + SCOPE_CREATION_REQUESTS_TABLE.c.request_digest, + SCOPE_CREATION_REQUESTS_TABLE.c.scope_id, + ).where(SCOPE_CREATION_REQUESTS_TABLE.c.idempotency_key == idempotency_key) + ) + ).one_or_none() + return None if row is None else (str(row.request_digest), str(row.scope_id)) + + async def add( + self, + connection: AsyncConnection, + scope_id: str, + draft: ScopeDraft, + request_digest: str, + /, + ) -> ScopeDescriptor: + await connection.execute( + insert(SCOPES_TABLE).values( + scope_id=scope_id, + title=draft.title, + summary=draft.summary, + parent_scope_id=draft.parent_scope_id, + version=1, + ) + ) + await self._replace_relationships( + connection, + scope_id, + draft.context_references, + draft.external_references, + ) + await connection.execute( + insert(SCOPE_CREATION_REQUESTS_TABLE).values( + idempotency_key=draft.idempotency_key, + request_digest=request_digest, + scope_id=scope_id, + ) + ) + created = await self.get(connection, scope_id) + if created is None: + raise AssertionError + return created + + async def replace( + self, + connection: AsyncConnection, + scope_id: str, + mutation: ScopeMutation, + /, + ) -> bool: + result = await connection.execute( + update(SCOPES_TABLE) + .where( + SCOPES_TABLE.c.scope_id == scope_id, + SCOPES_TABLE.c.version == mutation.expected_version, + ) + .values( + title=mutation.title, + summary=mutation.summary, + parent_scope_id=mutation.parent_scope_id, + version=mutation.expected_version + 1, + ) + ) + if result.rowcount != 1: + return False + await self._replace_relationships( + connection, + scope_id, + mutation.context_references, + mutation.external_references, + ) + return True + + async def default_scope_id(self, connection: AsyncConnection, /) -> str | None: + value = ( + await connection.execute( + select(SCOPE_SETTINGS_TABLE.c.scope_id).where(SCOPE_SETTINGS_TABLE.c.name == _DEFAULT_SETTING) + ) + ).scalar_one_or_none() + return None if value is None else str(value) + + async def set_default(self, connection: AsyncConnection, scope_id: str, /) -> None: + result = await connection.execute( + update(SCOPE_SETTINGS_TABLE) + .where(SCOPE_SETTINGS_TABLE.c.name == _DEFAULT_SETTING) + .values(scope_id=scope_id) + ) + if result.rowcount == 0: + await connection.execute(insert(SCOPE_SETTINGS_TABLE).values(name=_DEFAULT_SETTING, scope_id=scope_id)) + + async def binding(self, connection: AsyncConnection, key: ScopeBindingKey, /) -> ScopeBinding | None: + value = ( + await connection.execute( + select(SCOPE_BINDINGS_TABLE.c.scope_id).where( + SCOPE_BINDINGS_TABLE.c.integration == key.integration, + SCOPE_BINDINGS_TABLE.c.kind == key.kind, + SCOPE_BINDINGS_TABLE.c.external_id == key.external_id, + ) + ) + ).scalar_one_or_none() + return None if value is None else ScopeBinding(key=key, scope_id=str(value)) + + async def set_binding( + self, + connection: AsyncConnection, + key: ScopeBindingKey, + scope_id: str, + /, + ) -> ScopeBinding: + result = await connection.execute( + update(SCOPE_BINDINGS_TABLE) + .where( + SCOPE_BINDINGS_TABLE.c.integration == key.integration, + SCOPE_BINDINGS_TABLE.c.kind == key.kind, + SCOPE_BINDINGS_TABLE.c.external_id == key.external_id, + ) + .values(scope_id=scope_id) + ) + if result.rowcount == 0: + await connection.execute( + insert(SCOPE_BINDINGS_TABLE).values( + integration=key.integration, + kind=key.kind, + external_id=key.external_id, + scope_id=scope_id, + ) + ) + return ScopeBinding(key=key, scope_id=scope_id) + + async def clear_binding(self, connection: AsyncConnection, key: ScopeBindingKey, /) -> bool: + result = await connection.execute( + delete(SCOPE_BINDINGS_TABLE).where( + SCOPE_BINDINGS_TABLE.c.integration == key.integration, + SCOPE_BINDINGS_TABLE.c.kind == key.kind, + SCOPE_BINDINGS_TABLE.c.external_id == key.external_id, + ) + ) + return result.rowcount == 1 + + async def _replace_relationships( + self, + connection: AsyncConnection, + scope_id: str, + context_references: tuple[str, ...], + external_references: tuple[ScopeExternalReference, ...], + ) -> None: + await connection.execute( + delete(SCOPE_CONTEXT_REFERENCES_TABLE).where(SCOPE_CONTEXT_REFERENCES_TABLE.c.scope_id == scope_id) + ) + if context_references: + await connection.execute( + insert(SCOPE_CONTEXT_REFERENCES_TABLE), + [ + {"scope_id": scope_id, "referenced_scope_id": referenced_scope_id} + for referenced_scope_id in context_references + ], + ) + await connection.execute( + delete(SCOPE_EXTERNAL_REFERENCES_TABLE).where(SCOPE_EXTERNAL_REFERENCES_TABLE.c.scope_id == scope_id) + ) + if external_references: + await connection.execute( + insert(SCOPE_EXTERNAL_REFERENCES_TABLE), + [ + { + "scope_id": scope_id, + "ordinal": ordinal, + "kind": reference.kind, + "value": reference.value, + "value_digest": sha256(reference.value.encode()).hexdigest(), + } + for ordinal, reference in enumerate(external_references) + ], + ) diff --git a/src/powercontext/builtin/statistics/aggregation.py b/src/powercontext/builtin/statistics/aggregation.py new file mode 100644 index 000000000..f203d65e7 --- /dev/null +++ b/src/powercontext/builtin/statistics/aggregation.py @@ -0,0 +1,250 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Additive Statistics projection over a frozen Scope selection.""" + +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Callable, Iterable +from datetime import datetime + +from powercontext.builtin.scope.models import ScopeSelection +from powercontext.builtin.statistics.models import ( + ArtifactInventoryStatistics, + CandidateFamilyCount, + CandidateInventoryStatistics, + FamilyCount, + InventoryStatistics, + MemoryEntryInventoryStatistics, + MemoryInventoryStatistics, + MemoryKindCount, + ModelUsageDay, + ModelUsagePurpose, + ModelUsagePurposeBreakdown, + ModelUsageStatistics, + ModelUsageValue, + RecallTokenDay, + RecallTokenStatistics, + RecallTokenValue, + SourceInventoryStatistics, + Statistics, + UsageStatistics, +) + + +def aggregate_statistics( + selection: ScopeSelection, + scope_ids: tuple[str, ...], + snapshots: tuple[Statistics, ...], + as_of: datetime, +) -> Statistics: + """Aggregate values that are already bounded to the same reporting period.""" + + if len(scope_ids) != len(snapshots): + raise ValueError("every selected Scope must have one Statistics snapshot") # noqa: TRY003 + if not snapshots: + raise ValueError("Statistics selection must resolve at least one Scope") # noqa: TRY003 + period = snapshots[0].usage.period + if any(snapshot.usage.period != period or snapshot.recall.period != period for snapshot in snapshots): + raise ValueError("Statistics snapshots must use the same period") # noqa: TRY003 + + return Statistics( + selection=selection, + scope_ids=scope_ids, + as_of=as_of, + inventory=_inventory(snapshots), + usage=_usage(snapshots), + recall=_recall(snapshots), + ) + + +def _inventory(snapshots: tuple[Statistics, ...]) -> InventoryStatistics: + family_counts: dict[str, int] = defaultdict(int) + candidate_counts: dict[str, list[int]] = defaultdict(lambda: [0, 0, 0]) + kind_counts: dict[str, list[int]] = defaultdict(lambda: [0, 0]) + for snapshot in snapshots: + for item in snapshot.inventory.artifacts.by_family: + family_counts[item.family] += item.total + for item in snapshot.inventory.candidates.by_family: + values = candidate_counts[item.family] + values[0] += item.pending + values[1] += item.approved + values[2] += item.rejected + for item in snapshot.inventory.memory.entries.by_kind: + values = kind_counts[item.kind] + values[0] += item.active + values[1] += item.inactive + candidates = tuple( + CandidateFamilyCount( + family=family, + total=sum(values), + pending=values[0], + approved=values[1], + rejected=values[2], + ) + for family, values in sorted(candidate_counts.items()) + ) + kinds = tuple( + MemoryKindCount(kind=kind, total=sum(values), active=values[0], inactive=values[1]) + for kind, values in sorted(kind_counts.items()) + ) + return InventoryStatistics( + sources=SourceInventoryStatistics( + total=sum(snapshot.inventory.sources.total for snapshot in snapshots), + memory_processed=sum(snapshot.inventory.sources.memory_processed for snapshot in snapshots), + memory_pending=sum(snapshot.inventory.sources.memory_pending for snapshot in snapshots), + ), + artifacts=ArtifactInventoryStatistics( + total=sum(family_counts.values()), + by_family=tuple(FamilyCount(family=family, total=total) for family, total in sorted(family_counts.items())), + ), + candidates=CandidateInventoryStatistics( + total=sum(item.total for item in candidates), + pending=sum(item.pending for item in candidates), + approved=sum(item.approved for item in candidates), + rejected=sum(item.rejected for item in candidates), + by_family=candidates, + ), + memory=MemoryInventoryStatistics( + entries=MemoryEntryInventoryStatistics( + total=sum(item.total for item in kinds), + active=sum(item.active for item in kinds), + inactive=sum(item.inactive for item in kinds), + by_kind=kinds, + ) + ), + ) + + +def _usage(snapshots: tuple[Statistics, ...]) -> UsageStatistics: + period = snapshots[0].usage.period + purposes = tuple( + purpose + for purpose in ModelUsagePurpose + if any(any(item.purpose is purpose for item in snapshot.usage.by_purpose) for snapshot in snapshots) + ) + return UsageStatistics( + period=period, + totals=_model_usage(snapshot.usage.totals for snapshot in snapshots), + by_purpose=tuple( + ModelUsagePurposeBreakdown( + purpose=purpose, + generation=_usage_value( + _purpose(snapshot.usage.by_purpose, purpose).generation for snapshot in snapshots + ), + embedding=_usage_value( + _purpose(snapshot.usage.by_purpose, purpose).embedding for snapshot in snapshots + ), + ) + for purpose in purposes + ), + daily=tuple( + ModelUsageDay( + date=day.date, + generation=_usage_value(snapshot.usage.daily[index].generation for snapshot in snapshots), + embedding=_usage_value(snapshot.usage.daily[index].embedding for snapshot in snapshots), + by_purpose=tuple( + ModelUsagePurposeBreakdown( + purpose=purpose, + generation=_usage_value( + _purpose(snapshot.usage.daily[index].by_purpose, purpose).generation + for snapshot in snapshots + ), + embedding=_usage_value( + _purpose(snapshot.usage.daily[index].by_purpose, purpose).embedding + for snapshot in snapshots + ), + ) + for purpose in ModelUsagePurpose + if any( + any(item.purpose is purpose for item in snapshot.usage.daily[index].by_purpose) + for snapshot in snapshots + ) + ), + ) + for index, day in enumerate(snapshots[0].usage.daily) + ), + ) + + +def _recall(snapshots: tuple[Statistics, ...]) -> RecallTokenStatistics: + first = snapshots[0].recall + if any(snapshot.recall.estimator != first.estimator for snapshot in snapshots): + raise ValueError("Statistics snapshots must use the same recall estimator") # noqa: TRY003 + return RecallTokenStatistics( + period=first.period, + estimator=first.estimator, + totals=_recall_value(snapshot.recall.totals for snapshot in snapshots), + daily=tuple( + RecallTokenDay( + date=day.date, + **_recall_value(snapshot.recall.daily[index] for snapshot in snapshots).model_dump(), + ) + for index, day in enumerate(first.daily) + ), + ) + + +def _model_usage(values: Iterable[ModelUsageStatistics]) -> ModelUsageStatistics: + items = tuple(values) + return ModelUsageStatistics( + generation=_usage_value(item.generation for item in items), + embedding=_usage_value(item.embedding for item in items), + ) + + +def _usage_value(values: Iterable[ModelUsageValue]) -> ModelUsageValue: + items = tuple(values) + return ModelUsageValue( + requests=sum(item.requests for item in items), + input_tokens=_optional_sum(items, lambda item: item.input_tokens), + output_tokens=_optional_sum(items, lambda item: item.output_tokens), + ) + + +def _optional_sum(values: tuple[ModelUsageValue, ...], getter: Callable[[ModelUsageValue], int | None]) -> int | None: + selected = tuple(getter(value) for value in values) + return None if any(value is None for value in selected) else sum(value for value in selected if value is not None) + + +def _purpose( + values: tuple[ModelUsagePurposeBreakdown, ...], + purpose: ModelUsagePurpose, +) -> ModelUsagePurposeBreakdown: + return next( + (value for value in values if value.purpose is purpose), + ModelUsagePurposeBreakdown( + purpose=purpose, + generation=ModelUsageValue(requests=0, input_tokens=0, output_tokens=0), + embedding=ModelUsageValue(requests=0, input_tokens=0, output_tokens=0), + ), + ) + + +def _recall_value(values: Iterable[RecallTokenValue]) -> RecallTokenValue: + items = tuple(values) + baseline = sum(item.baseline_tokens for item in items) + recalled = sum(item.recalled_tokens for item in items) + return RecallTokenValue( + preparations=sum(item.preparations for item in items), + ready_preparations=sum(item.ready_preparations for item in items), + comparable_preparations=sum(item.comparable_preparations for item in items), + baseline_tokens=baseline, + recalled_tokens=recalled, + token_reduction=baseline - recalled, + ) + + +__all__ = ["aggregate_statistics"] diff --git a/src/powercontext/builtin/statistics/models.py b/src/powercontext/builtin/statistics/models.py index 428c457a0..64990ca88 100644 --- a/src/powercontext/builtin/statistics/models.py +++ b/src/powercontext/builtin/statistics/models.py @@ -23,6 +23,7 @@ from pydantic import BaseModel, Field, model_validator from powercontext.builtin.inference import TokenEstimatorProfile +from powercontext.builtin.scope.models import ScopeSelection class StatisticsPeriod(StrEnum): @@ -229,9 +230,10 @@ class RecallTokenStatistics(BaseModel): class Statistics(BaseModel): - """Current inventory and bounded model usage for one scope.""" + """Current inventory and bounded model usage for one frozen Scope selection.""" - scope_id: str + selection: ScopeSelection + scope_ids: tuple[str, ...] as_of: datetime inventory: InventoryStatistics usage: UsageStatistics diff --git a/src/powercontext/cli/config.py b/src/powercontext/cli/config.py index 166b84edb..752baa7a2 100644 --- a/src/powercontext/cli/config.py +++ b/src/powercontext/cli/config.py @@ -17,7 +17,6 @@ from __future__ import annotations import asyncio -import json import os import re import shlex @@ -70,8 +69,6 @@ class GeneratedConfiguration: """Canonical state rendered into one managed environment block.""" config_version: int - scope_id: str - display_name: str generation: ModelSelection embedding: ModelSelection embedding_profile_id: str @@ -193,7 +190,7 @@ class ApiProtocol: "POWERCONTEXT_OPENCODE_CAPTURE_PROMPTS": "true", "POWERCONTEXT_PI_CAPTURE_PROMPTS": "true", } -_SCOPE_NAMES = ( +_EXPLICIT_SCOPE_NAMES = ( "POWERCONTEXT_CODEX_SCOPE_ID", "POWERCONTEXT_CLAUDE_SCOPE_ID", "POWERCONTEXT_DSH_SCOPE_ID", @@ -208,11 +205,10 @@ class ApiProtocol: } _ALL_FIXED_MANAGED_NAMES = ( set(_BASE_ENVIRONMENT) - | set(_SCOPE_NAMES) + | set(_EXPLICIT_SCOPE_NAMES) | _KNOWN_PROVIDER_NAMES | _OPTIONAL_MANAGED_NAMES | { - "POWERCONTEXT_SERVER_DASHBOARD_SCOPES", "POWERCONTEXT_SERVER_DATABASE_KIND", "POWERCONTEXT_SERVER_RUNTIME_SCHEDULE_SECONDS", "POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL", @@ -313,7 +309,7 @@ def _print_summary(configuration: GeneratedConfiguration) -> None: "provider default", ) typer.secho("\nConfiguration", bold=True, fg=typer.colors.CYAN) - typer.echo(f" Scope {configuration.scope_id}") + typer.echo(" Scope Server default; integrations may bind a Session or workspace") typer.echo(f" Generation {configuration.generation.model.partition(':')[2]} ({generation_url})") typer.echo(f" Embedding {configuration.embedding.model.partition(':')[2]} ({embedding_url})") typer.echo(f" Database {configuration.database_kind}") @@ -327,8 +323,6 @@ def collect_configuration( typer.secho("\nPowerContext configuration", bold=True, fg=typer.colors.CYAN) typer.echo("Press Enter to accept a default. Provider details are derived from the API protocol.\n") - scope_id = typer.prompt("Scope ID", default="project:quickstart").strip() - display_name = typer.prompt("Dashboard display name", default="Quick Start").strip() generation, generation_credentials = _collect_connection("generation") generation_protocol = _PROTOCOL_BY_ID.get(generation.protocol_id or "") can_reuse = generation_protocol is not None and generation_protocol.embedding_adapter is not None @@ -355,8 +349,6 @@ def collect_configuration( schedule = 60 return GeneratedConfiguration( config_version=CONFIG_VERSION, - scope_id=scope_id, - display_name=display_name, generation=generation, embedding=embedding, embedding_profile_id=profile, @@ -374,11 +366,6 @@ def render_environment(configuration: GeneratedConfiguration) -> dict[str, str]: values = dict(_BASE_ENVIRONMENT) values.update({ - "POWERCONTEXT_SERVER_DASHBOARD_SCOPES": json.dumps( - [{"scope_id": configuration.scope_id, "display_name": configuration.display_name}], - separators=(",", ":"), - ensure_ascii=False, - ), "POWERCONTEXT_SERVER_DATABASE_KIND": configuration.database_kind, "POWERCONTEXT_SERVER_RUNTIME_SCHEDULE_SECONDS": str(configuration.schedule_seconds), "POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL": configuration.generation.model, @@ -386,7 +373,6 @@ def render_environment(configuration: GeneratedConfiguration) -> dict[str, str]: "POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_PROFILE_ID": configuration.embedding_profile_id, "POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_DIMENSION": str(configuration.embedding_dimension), }) - values.update(dict.fromkeys(_SCOPE_NAMES, configuration.scope_id)) if configuration.database_url is not None: values["POWERCONTEXT_SERVER_DATABASE_URL"] = configuration.database_url if configuration.database_path is not None: @@ -446,11 +432,8 @@ def configuration_from_document(content: str) -> GeneratedConfiguration: metadata = _managed_metadata(content) generation_model = _required(values, "POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL") embedding_model = _required(values, "POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_MODEL") - scope_id, display_name = _scope(values) return GeneratedConfiguration( config_version=_parse_integer(str(metadata.get("config-version", CONFIG_VERSION)), "config-version"), - scope_id=scope_id, - display_name=display_name, generation=ModelSelection( generation_model, _provider_variables("generation", generation_model, metadata, values) ), @@ -472,16 +455,10 @@ def configuration_from_document(content: str) -> GeneratedConfiguration: def validate_configuration(configuration: GeneratedConfiguration) -> None: - """Validate model, environment, database, Scope, and runtime contracts.""" + """Validate model, environment, database, and runtime contracts.""" if configuration.config_version != CONFIG_VERSION: raise ConfigError(f"unsupported config version: {configuration.config_version}") # noqa: TRY003 - if not configuration.scope_id.strip() or not configuration.display_name.strip(): - raise ConfigError("Scope ID and Dashboard display name are required") # noqa: TRY003 - if len(configuration.scope_id) > 255: - raise ConfigError("Scope ID must contain at most 255 characters") # noqa: TRY003 - if len(configuration.display_name) > 80: - raise ConfigError("Dashboard display name must contain at most 80 characters") # noqa: TRY003 if configuration.embedding_dimension < 1 or configuration.schedule_seconds < 1: raise ConfigError("Embedding dimension and Source interval must be positive") # noqa: TRY003 _validate_model_selection("Generation", configuration.generation) @@ -837,17 +814,6 @@ def _environment_names(selection: ModelSelection) -> str: return ",".join(variable.name for variable in selection.environment) -def _scope(values: Mapping[str, str]) -> tuple[str, str]: - raw = _required(values, "POWERCONTEXT_SERVER_DASHBOARD_SCOPES") - try: - first = json.loads(raw)[0] - return str(first["scope_id"]), str(first["display_name"]) - except (IndexError, KeyError, TypeError, json.JSONDecodeError) as error: - raise ConfigError( # noqa: TRY003 - "Dashboard scopes must contain one scope_id and display_name" - ) from error - - def _required(values: Mapping[str, str], name: str) -> str: value = values.get(name) if value is None or not value.strip(): diff --git a/src/powercontext/cli/workbuddy.py b/src/powercontext/cli/workbuddy.py index c2d0b296c..d4d0b6187 100644 --- a/src/powercontext/cli/workbuddy.py +++ b/src/powercontext/cli/workbuddy.py @@ -52,15 +52,15 @@ WORKBUDDY_SKILL_NAME = "project-context" WORKBUDDY_SKILL_MANIFEST = ".powercontext.json" WORKBUDDY_PYTHON_PLACEHOLDER = "${POWERCONTEXT_PYTHON}" -WORKBUDDY_PROJECT_SCOPE_PLACEHOLDER = "${POWERCONTEXT_PROJECT_SCOPE_SCRIPT}" +WORKBUDDY_SCOPE_BINDING_PLACEHOLDER = "${POWERCONTEXT_SCOPE_BINDING_SCRIPT}" WORKBUDDY_HOOK_DRIVER = "workbuddy_powercontext_hook.py" -WORKBUDDY_SCOPE_RESOLVER = "powercontext_project_scope.py" +WORKBUDDY_SCOPE_RESOLVER = "powercontext_scope_binding.py" WORKBUDDY_HOOK_MODULES = ( "workbuddy_powercontext_hook.py", "workbuddy_settings.py", "prepared_context.py", ) -WORKBUDDY_SCRIPT_MODULES = ("__init__.py", "project_scope.py") +WORKBUDDY_SCRIPT_MODULES = ("__init__.py", "workspace_scope.py") WORKBUDDY_SERVER_URL_ENV = "POWERCONTEXT_WORKBUDDY_SERVER_URL" WORKBUDDY_AUTHORIZATION_ENV = "POWERCONTEXT_WORKBUDDY_AUTHORIZATION" WORKBUDDY_MCP_URL = f"${{{WORKBUDDY_SERVER_URL_ENV}:-http://127.0.0.1:8000}}/mcp" @@ -220,7 +220,7 @@ def _install_hook_files(plugin_dir: Path, hooks_dir: Path) -> None: source_hooks = plugin_dir / WORKBUDDY_HOOKS_DIRNAME for name in WORKBUDDY_HOOK_MODULES: shutil.copy2(source_hooks / name, hooks_dir / name) - shutil.copy2(plugin_dir / "scripts" / "project_scope.py", hooks_dir / WORKBUDDY_SCOPE_RESOLVER) + shutil.copy2(plugin_dir / "scripts" / "workspace_scope.py", hooks_dir / WORKBUDDY_SCOPE_RESOLVER) except OSError as error: raise SetupError.workbuddy_hooks_write(hooks_dir, error) from error @@ -330,7 +330,7 @@ def _install_workbuddy_skill(plugin_dir: Path, skills_dir: Path, hooks_dir: Path content = skill_markdown.read_text(encoding="utf-8") content = content.replace(WORKBUDDY_PYTHON_PLACEHOLDER, _shell_argument(_python_executable())) content = content.replace( - WORKBUDDY_PROJECT_SCOPE_PLACEHOLDER, + WORKBUDDY_SCOPE_BINDING_PLACEHOLDER, _shell_argument((hooks_dir / WORKBUDDY_SCOPE_RESOLVER).as_posix()), ) skill_markdown.write_text(content, encoding="utf-8") @@ -438,7 +438,7 @@ def _skill_diagnostic(skill_file: Path) -> Diagnostic: content = skill_file.read_text(encoding="utf-8") except OSError: return Diagnostic(status=DiagnosticStatus.FAILED, detail=f"cannot read {skill_file}") - if WORKBUDDY_PROJECT_SCOPE_PLACEHOLDER in content or WORKBUDDY_PYTHON_PLACEHOLDER in content: + if WORKBUDDY_SCOPE_BINDING_PLACEHOLDER in content or WORKBUDDY_PYTHON_PLACEHOLDER in content: return Diagnostic( status=DiagnosticStatus.FAILED, detail="PowerContext WorkBuddy skill still contains an unresolved command placeholder", diff --git a/src/powercontext/client/cli.py b/src/powercontext/client/cli.py index 811f4e25b..784a0c87b 100644 --- a/src/powercontext/client/cli.py +++ b/src/powercontext/client/cli.py @@ -61,6 +61,9 @@ ScanExternalSkillsRequest, ScanExternalSkillsResponse, ScopedStats, + ScopeId, + ScopeSelection, + ScopeSelectionMode, SkillArtifact, SkillGenerationOrigin, SkillProposal, @@ -157,12 +160,21 @@ def capabilities(context: typer.Context) -> None: def stats( context: typer.Context, - scope_id: Annotated[str, typer.Option(help="Application scope to inspect.")], + scope_id: Annotated[list[str] | None, typer.Option(help="Exact application scope to include.")] = None, + root_scope_id: Annotated[str | None, typer.Option(help="Organization subtree root to include.")] = None, period: Annotated[StatsPeriod, typer.Option(help="Bounded UTC statistics period.")] = StatsPeriod.FIELD_30D, ) -> None: - """Show current inventory and bounded usage for one scope.""" - - request = GetStatsRequest(scope_id=scope_id, period=period) + """Show current inventory and bounded usage for a Scope selection.""" + + if scope_id and root_scope_id is not None: + raise typer.BadParameter("--scope-id and --root-scope-id are mutually exclusive") # noqa: TRY003 + if root_scope_id is not None: + selection = ScopeSelection(mode=ScopeSelectionMode.SUBTREE, root_scope_id=root_scope_id) + elif scope_id: + selection = ScopeSelection(mode=ScopeSelectionMode.EXACT, scope_ids=[ScopeId(value) for value in scope_id]) + else: + selection = ScopeSelection(mode=ScopeSelectionMode.ALL) + request = GetStatsRequest(selection=selection, period=period) asyncio.run(_execute(context, lambda client: client.get_stats(request))) @@ -762,7 +774,7 @@ def _print_human_response(response: _ClientResponse) -> None: def _print_stats(response: ScopedStats) -> None: inventory = response.inventory - typer.echo(f"Scope: {response.scope_id}") + typer.echo(f"Selection: {response.selection.mode.value} ({len(response.scope_ids)} Scopes)") typer.echo(f"As of: {response.as_of.isoformat()}") typer.echo( "Sources: " diff --git a/src/powercontext/client/client.py b/src/powercontext/client/client.py index 2c1618f2f..42e887e46 100644 --- a/src/powercontext/client/client.py +++ b/src/powercontext/client/client.py @@ -31,16 +31,17 @@ ApproveArtifactCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, - AttachHandoffReportWorkspaceRequest, + ArtifactPublication, Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, + ClearScopeBindingRequest, + ClearScopeBindingResponse, CommitHandoffRequest, CommittedHandoff, ContinueHandoffRequest, - CreateHandoffReportProjectRequest, + CreateScopeRequest, CreateWorkContractRequest, - DetachHandoffReportWorkspaceRequest, ErrorResponse, ExperienceArtifact, ExternalSkillResolution, @@ -52,30 +53,22 @@ GenerateSkillRequest, GetArtifactCandidateRequest, GetExperienceRequest, - GetHandoffReportProjectRequest, GetHandoffReportRequest, - GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, + GetScopeRequest, GetSkillRequest, GetStatsRequest, HandoffAcknowledgement, HandoffActivation, HandoffCurrentWorkRequest, HandoffDraft, - HandoffReportActivityPage, HandoffReportResponse, - HandoffReportWorkspaceBinding, HandoffResolution, HealthResponse, ImportExternalSkillRequest, - KnownHandoffScopePage, ListArtifactCandidatesRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, - ListHandoffReportActivitiesRequest, - ListHandoffReportKnownScopesRequest, - ListHandoffReportProjectsRequest, - ListHandoffReportWorkstreamsRequest, ListMemoryChangesRequest, ListMemoryChangesResponse, ListMemoryEntriesRequest, @@ -87,89 +80,84 @@ PreparedHandoff, PreparedWorkHandoff, PrepareHandoffRequest, - ProjectDescriptor, - ProjectPage, ProposeExperienceRequest, ProposeSkillRequest, - PurgeHandoffReportActivitiesRequest, - PurgeHandoffReportActivitiesResponse, + PublishArtifactRequest, ReadinessResponse, - RecordHandoffReportActivityRequest, RecordTaskOutcomeRequest, - RegisterHandoffReportWorkstreamRequest, RejectArtifactCandidateRequest, RememberMemoryRequest, ResolveExternalSkillRequest, + ResolveScopeBindingRequest, + ResolveScopeSelectionRequest, RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, ReviseMemoryEntryRequest, ScanExternalSkillsRequest, ScanExternalSkillsResponse, + ScopeBinding, + ScopeDescriptor, ScopedStats, + ScopePage, SearchMemoryRequest, SearchMemoryResponse, + SetDefaultScopeRequest, + SetScopeBindingRequest, SkillArtifact, - StoredHandoffReportActivity, - UpdateHandoffReportProjectRequest, - UpdateHandoffReportWorkstreamRequest, + UpdateScopeRequest, WorkSourceReceipt, - WorkstreamDescriptor, - WorkstreamPage, ) from powercontext.http._generated.operations import ( ACKNOWLEDGE_HANDOFF, ACTIVATE_HANDOFF, APPROVE_ARTIFACT_CANDIDATE, - ATTACH_HANDOFF_REPORT_WORKSPACE, CAPTURE_CONTENT_SOURCE, + CLEAR_SCOPE_BINDING, COMMIT_HANDOFF, CONTINUE_HANDOFF, - CREATE_HANDOFF_REPORT_PROJECT, + CREATE_SCOPE, CREATE_WORK_CONTRACT, - DETACH_HANDOFF_REPORT_WORKSPACE, FINALIZE_HANDOFF, FLUSH_MEMORY, GENERATE_EXPERIENCE, GENERATE_SKILL, GET_ARTIFACT_CANDIDATE, GET_CAPABILITIES, + GET_DEFAULT_SCOPE, GET_EXPERIENCE, GET_HANDOFF_REPORT, - GET_HANDOFF_REPORT_PROJECT, - GET_HANDOFF_REPORT_WORKSPACE, GET_LIVENESS, GET_MEMORY_ENTRY, GET_READINESS, + GET_SCOPE, GET_SKILL, GET_STATS, HANDOFF_CURRENT_WORK, IMPORT_EXTERNAL_SKILL, LIST_ARTIFACT_CANDIDATES, LIST_EXTERNAL_SKILLS, - LIST_HANDOFF_REPORT_ACTIVITIES, - LIST_HANDOFF_REPORT_KNOWN_SCOPES, - LIST_HANDOFF_REPORT_PROJECTS, - LIST_HANDOFF_REPORT_WORKSTREAMS, LIST_MEMORY_CHANGES, LIST_MEMORY_ENTRIES, + LIST_SCOPES, PREPARE_CONTEXT, PREPARE_HANDOFF, PROPOSE_EXPERIENCE, PROPOSE_SKILL, - PURGE_HANDOFF_REPORT_ACTIVITIES, - RECORD_HANDOFF_REPORT_ACTIVITY, + PUBLISH_ARTIFACT, RECORD_TASK_OUTCOME, - REGISTER_HANDOFF_REPORT_WORKSTREAM, REJECT_ARTIFACT_CANDIDATE, REMEMBER_MEMORY, RESOLVE_EXTERNAL_SKILL, + RESOLVE_SCOPE_BINDING, + RESOLVE_SCOPE_SELECTION, RETIRE_MEMORY_ENTRY, REVISE_ARTIFACT_CANDIDATE, REVISE_MEMORY_ENTRY, SCAN_EXTERNAL_SKILLS, SEARCH_MEMORY, - UPDATE_HANDOFF_REPORT_PROJECT, - UPDATE_HANDOFF_REPORT_WORKSTREAM, + SET_DEFAULT_SCOPE, + SET_SCOPE_BINDING, + UPDATE_SCOPE, Operation, ) from powercontext.transport import is_plaintext_non_loopback @@ -246,122 +234,65 @@ async def get_capabilities(self) -> Capabilities: return await self._request(GET_CAPABILITIES) - async def get_stats(self, request: GetStatsRequest) -> ScopedStats: - """Read current inventory and bounded usage for one scope.""" + async def list_scopes(self) -> ScopePage: + """List durable Scope descriptors.""" - return await self._request(GET_STATS, request) + return await self._request(LIST_SCOPES) - async def create_handoff_report_project( - self, - request: CreateHandoffReportProjectRequest, - ) -> ProjectDescriptor: - """Create one explicit Report Project.""" - - return await self._request(CREATE_HANDOFF_REPORT_PROJECT, request) - - async def get_handoff_report_project( - self, - request: GetHandoffReportProjectRequest, - ) -> ProjectDescriptor: - """Read one current Report Project descriptor.""" - - return await self._request(GET_HANDOFF_REPORT_PROJECT, request) - - async def update_handoff_report_project( - self, - request: UpdateHandoffReportProjectRequest, - ) -> ProjectDescriptor: - """CAS-update one Report Project descriptor.""" - - return await self._request(UPDATE_HANDOFF_REPORT_PROJECT, request) - - async def list_handoff_report_projects( - self, - request: ListHandoffReportProjectsRequest, - ) -> ProjectPage: - """List Report Projects with cursor pagination.""" + async def create_scope(self, request: CreateScopeRequest) -> ScopeDescriptor: + """Create one independent Scope boundary.""" - return await self._request(LIST_HANDOFF_REPORT_PROJECTS, request) + return await self._request(CREATE_SCOPE, request) - async def list_handoff_report_known_scopes( - self, - request: ListHandoffReportKnownScopesRequest, - ) -> KnownHandoffScopePage: - """List scopes that contain a committed Handoff.""" + async def get_scope(self, request: GetScopeRequest) -> ScopeDescriptor: + """Read one exact Scope descriptor.""" - return await self._request(LIST_HANDOFF_REPORT_KNOWN_SCOPES, request) + return await self._request(GET_SCOPE, request) - async def register_handoff_report_workstream( - self, - request: RegisterHandoffReportWorkstreamRequest, - ) -> WorkstreamDescriptor: - """Register one existing scope as a Report Workstream.""" + async def update_scope(self, request: UpdateScopeRequest) -> ScopeDescriptor: + """Replace mutable Scope metadata and relationships.""" - return await self._request(REGISTER_HANDOFF_REPORT_WORKSTREAM, request) + return await self._request(UPDATE_SCOPE, request) - async def list_handoff_report_workstreams( - self, - request: ListHandoffReportWorkstreamsRequest, - ) -> WorkstreamPage: - """List Workstreams belonging to one Report Project.""" + async def get_default_scope(self) -> ScopeDescriptor: + """Read the host's default Scope target.""" - return await self._request(LIST_HANDOFF_REPORT_WORKSTREAMS, request) + return await self._request(GET_DEFAULT_SCOPE) - async def update_handoff_report_workstream( - self, - request: UpdateHandoffReportWorkstreamRequest, - ) -> WorkstreamDescriptor: - """CAS-update one Report Workstream descriptor.""" + async def set_default_scope(self, request: SetDefaultScopeRequest) -> ScopeDescriptor: + """Change the host's default Scope target.""" - return await self._request(UPDATE_HANDOFF_REPORT_WORKSTREAM, request) + return await self._request(SET_DEFAULT_SCOPE, request) - async def record_handoff_report_activity( - self, - request: RecordHandoffReportActivityRequest, - ) -> StoredHandoffReportActivity: - """Record one explicit Report-owned Activity observation.""" + async def resolve_scope_selection(self, request: ResolveScopeSelectionRequest) -> ScopePage: + """Resolve all, exact, or subtree into exact Scope descriptors.""" - return await self._request(RECORD_HANDOFF_REPORT_ACTIVITY, request) + return await self._request(RESOLVE_SCOPE_SELECTION, request) - async def list_handoff_report_activities( - self, - request: ListHandoffReportActivitiesRequest, - ) -> HandoffReportActivityPage: - """List one frozen cursor page of Report-owned Activities.""" + async def resolve_scope_binding(self, request: ResolveScopeBindingRequest) -> ScopeDescriptor: + """Resolve explicit and external host bindings to one Scope.""" - return await self._request(LIST_HANDOFF_REPORT_ACTIVITIES, request) + return await self._request(RESOLVE_SCOPE_BINDING, request) - async def purge_handoff_report_activities( - self, - request: PurgeHandoffReportActivitiesRequest, - ) -> PurgeHandoffReportActivitiesResponse: - """Purge Report-owned Activities before an observation boundary.""" + async def set_scope_binding(self, request: SetScopeBindingRequest) -> ScopeBinding: + """Bind one external integration identity to a Scope.""" - return await self._request(PURGE_HANDOFF_REPORT_ACTIVITIES, request) + return await self._request(SET_SCOPE_BINDING, request) - async def get_handoff_report_workspace( - self, - request: GetHandoffReportWorkspaceRequest, - ) -> HandoffReportWorkspaceBinding: - """Read one confirmed Workspace-to-Project binding.""" + async def clear_scope_binding(self, request: ClearScopeBindingRequest) -> ClearScopeBindingResponse: + """Clear one external integration binding.""" - return await self._request(GET_HANDOFF_REPORT_WORKSPACE, request) + return await self._request(CLEAR_SCOPE_BINDING, request) - async def attach_handoff_report_workspace( - self, - request: AttachHandoffReportWorkspaceRequest, - ) -> HandoffReportWorkspaceBinding: - """Attach a Workspace to an exact Report Project using CAS.""" + async def publish_artifact(self, request: PublishArtifactRequest) -> ArtifactPublication: + """Deliver one exact Artifact revision into another Scope.""" - return await self._request(ATTACH_HANDOFF_REPORT_WORKSPACE, request) + return await self._request(PUBLISH_ARTIFACT, request) - async def detach_handoff_report_workspace( - self, - request: DetachHandoffReportWorkspaceRequest, - ) -> HandoffReportWorkspaceBinding: - """Detach a Workspace binding using its exact version.""" + async def get_stats(self, request: GetStatsRequest) -> ScopedStats: + """Read current inventory and bounded usage for one scope.""" - return await self._request(DETACH_HANDOFF_REPORT_WORKSPACE, request) + return await self._request(GET_STATS, request) async def get_handoff_report(self, request: GetHandoffReportRequest) -> HandoffReportResponse | str: """Generate the current canonical Handoff Report projection.""" diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index 56c05bf89..6a16412b0 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -17,12 +17,15 @@ from powercontext.http._generated.models import ( AcknowledgeHandoffRequest, ActivateHandoffRequest, + AgentKind, ApproveArtifactCandidateRequest, + ArtifactAddress, ArtifactCandidate, ArtifactCandidatePage, ArtifactInventoryStatistics, + ArtifactPublication, ArtifactReference, - AttachHandoffReportWorkspaceRequest, + AuthorizationNote, CandidateFamily, CandidateFamilyCount, CandidateInventoryStatistics, @@ -31,17 +34,21 @@ CaptureContentSourceRequest, CaptureContentSourceResponse, CaptureStatus, + ClearScopeBindingRequest, + ClearScopeBindingResponse, CommitHandoffRequest, CommittedHandoff, + CompletionCriterion, + ContextReference, ContinueHandoffRequest, - CreateHandoffReportProjectRequest, + CreateScopeRequest, CreateWorkContractRequest, CurrentWorkHandoff, - DetachHandoffReportWorkspaceRequest, EntryChange, EntryChangeOperation, ErrorDetail, ErrorResponse, + Exclusion, ExperienceArtifact, ExperienceProposal, ExternalSkillImportMode, @@ -60,13 +67,13 @@ GenerateSkillRequest, GetArtifactCandidateRequest, GetExperienceRequest, - GetHandoffReportProjectRequest, GetHandoffReportRequest, - GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, + GetScopeRequest, GetSkillRequest, GetStatsRequest, HandoffAcknowledgement, + HandoffAcknowledgementSelection, HandoffActivation, HandoffActivationStatus, HandoffArtifactCitation, @@ -81,15 +88,7 @@ HandoffMemoryCitation, HandoffOmission, HandoffReceiptStatus, - HandoffReportActivity, - HandoffReportActivityAgent, - HandoffReportActivityPage, - HandoffReportActivityVcsContext, - HandoffReportExternalReference, - HandoffReportPeriodRequest, - HandoffReportRepositoryRef, HandoffReportResponse, - HandoffReportWorkspaceBinding, HandoffResolution, HandoffResolutionStatus, HandoffSchema, @@ -98,20 +97,19 @@ HandoffStatement, HealthResponse, ImportExternalSkillRequest, + InScopeItem, InventoryStatistics, - KnownHandoffScope, - KnownHandoffScopePage, + Kind, + Kind1, + Kind2, ListArtifactCandidatesRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, - ListHandoffReportActivitiesRequest, - ListHandoffReportKnownScopesRequest, - ListHandoffReportProjectsRequest, - ListHandoffReportWorkstreamsRequest, ListMemoryChangesRequest, ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, + LiveStateCheckStatus, MemoryCitation, MemoryEntry, MemoryEntryInventoryStatistics, @@ -127,6 +125,8 @@ ModelUsagePurposeBreakdown, ModelUsageStatistics, ModelUsageValue, + Omission, + OpenQuestion, PrepareContextRequest, PreparedContext, PreparedContextSchema, @@ -135,38 +135,48 @@ PreparedHandoffSchema, PreparedWorkHandoff, PrepareHandoffRequest, - ProjectDescriptor, - ProjectPage, ProposeExperienceRequest, ProposeSkillRequest, - PurgeHandoffReportActivitiesRequest, - PurgeHandoffReportActivitiesResponse, + Provider, + PublishArtifactRequest, ReadinessResponse, ReadinessStatus, RecallTokenDay, RecallTokenStatistics, RecallTokenValue, - RecordHandoffReportActivityRequest, + ReceiverChecks, + ReceiverReadinessCheckStatus, RecordTaskOutcomeRequest, - RegisterHandoffReportWorkstreamRequest, RejectArtifactCandidateRequest, + RemainingWorkItem, RememberMemoryRequest, - ReportActivitySource, - ReportCatalogState, ReportFormat, - ReportLocale, - ReportTimeBasis, ResolvedUsagePeriod, ResolveExternalSkillRequest, + ResolveScopeBindingRequest, + ResolveScopeSelectionRequest, RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, ReviseMemoryEntryRequest, ScanExternalSkillsRequest, ScanExternalSkillsResponse, + Schema, + Schema1, + Schema2, + ScopeBinding, + ScopeBindingKey, + ScopeDescriptor, ScopedStats, + ScopeExternalReference, + ScopeId, + ScopePage, + ScopeSelection, + ScopeSelectionMode, SearchMemoryHit, SearchMemoryRequest, SearchMemoryResponse, + SetDefaultScopeRequest, + SetScopeBindingRequest, SkillArtifact, SkillGenerationOrigin, SkillProposal, @@ -174,34 +184,36 @@ SourceInventoryStatistics, SourceReference, StatsPeriod, - StoredHandoffReportActivity, TaskCheck, TaskCheckStatus, TaskOutcome, TaskOutcomeStatus, + Timezone, TokenEstimatorProfile, - UpdateHandoffReportProjectRequest, - UpdateHandoffReportWorkstreamRequest, + Trust, + Trust2, + Trust3, + UpdateScopeRequest, UsageStatistics, WorkClaim, WorkClaimBasis, WorkContract, WorkSourceKind, WorkSourceReceipt, - WorkstreamDescriptor, - WorkstreamKind, - WorkstreamPage, ) __all__ = [ "AcknowledgeHandoffRequest", "ActivateHandoffRequest", + "AgentKind", "ApproveArtifactCandidateRequest", + "ArtifactAddress", "ArtifactCandidate", "ArtifactCandidatePage", "ArtifactInventoryStatistics", + "ArtifactPublication", "ArtifactReference", - "AttachHandoffReportWorkspaceRequest", + "AuthorizationNote", "CandidateFamily", "CandidateFamilyCount", "CandidateInventoryStatistics", @@ -210,17 +222,21 @@ "CaptureContentSourceRequest", "CaptureContentSourceResponse", "CaptureStatus", + "ClearScopeBindingRequest", + "ClearScopeBindingResponse", "CommitHandoffRequest", "CommittedHandoff", + "CompletionCriterion", + "ContextReference", "ContinueHandoffRequest", - "CreateHandoffReportProjectRequest", + "CreateScopeRequest", "CreateWorkContractRequest", "CurrentWorkHandoff", - "DetachHandoffReportWorkspaceRequest", "EntryChange", "EntryChangeOperation", "ErrorDetail", "ErrorResponse", + "Exclusion", "ExperienceArtifact", "ExperienceProposal", "ExternalSkillImportMode", @@ -239,13 +255,13 @@ "GeneratedCandidateStatus", "GetArtifactCandidateRequest", "GetExperienceRequest", - "GetHandoffReportProjectRequest", "GetHandoffReportRequest", - "GetHandoffReportWorkspaceRequest", "GetMemoryEntryRequest", + "GetScopeRequest", "GetSkillRequest", "GetStatsRequest", "HandoffAcknowledgement", + "HandoffAcknowledgementSelection", "HandoffActivation", "HandoffActivationStatus", "HandoffArtifactCitation", @@ -260,15 +276,7 @@ "HandoffMemoryCitation", "HandoffOmission", "HandoffReceiptStatus", - "HandoffReportActivity", - "HandoffReportActivityAgent", - "HandoffReportActivityPage", - "HandoffReportActivityVcsContext", - "HandoffReportExternalReference", - "HandoffReportPeriodRequest", - "HandoffReportRepositoryRef", "HandoffReportResponse", - "HandoffReportWorkspaceBinding", "HandoffResolution", "HandoffResolutionStatus", "HandoffSchema", @@ -277,20 +285,19 @@ "HandoffStatement", "HealthResponse", "ImportExternalSkillRequest", + "InScopeItem", "InventoryStatistics", - "KnownHandoffScope", - "KnownHandoffScopePage", + "Kind", + "Kind1", + "Kind2", "ListArtifactCandidatesRequest", "ListExternalSkillsRequest", "ListExternalSkillsResponse", - "ListHandoffReportActivitiesRequest", - "ListHandoffReportKnownScopesRequest", - "ListHandoffReportProjectsRequest", - "ListHandoffReportWorkstreamsRequest", "ListMemoryChangesRequest", "ListMemoryChangesResponse", "ListMemoryEntriesRequest", "ListMemoryEntriesResponse", + "LiveStateCheckStatus", "MemoryCitation", "MemoryEntry", "MemoryEntryInventoryStatistics", @@ -306,6 +313,8 @@ "ModelUsagePurposeBreakdown", "ModelUsageStatistics", "ModelUsageValue", + "Omission", + "OpenQuestion", "PrepareContextRequest", "PrepareHandoffRequest", "PreparedContext", @@ -314,38 +323,48 @@ "PreparedHandoff", "PreparedHandoffSchema", "PreparedWorkHandoff", - "ProjectDescriptor", - "ProjectPage", "ProposeExperienceRequest", "ProposeSkillRequest", - "PurgeHandoffReportActivitiesRequest", - "PurgeHandoffReportActivitiesResponse", + "Provider", + "PublishArtifactRequest", "ReadinessResponse", "ReadinessStatus", "RecallTokenDay", "RecallTokenStatistics", "RecallTokenValue", - "RecordHandoffReportActivityRequest", + "ReceiverChecks", + "ReceiverReadinessCheckStatus", "RecordTaskOutcomeRequest", - "RegisterHandoffReportWorkstreamRequest", "RejectArtifactCandidateRequest", + "RemainingWorkItem", "RememberMemoryRequest", - "ReportActivitySource", - "ReportCatalogState", "ReportFormat", - "ReportLocale", - "ReportTimeBasis", "ResolveExternalSkillRequest", + "ResolveScopeBindingRequest", + "ResolveScopeSelectionRequest", "ResolvedUsagePeriod", "RetireMemoryEntryRequest", "ReviseArtifactCandidateRequest", "ReviseMemoryEntryRequest", "ScanExternalSkillsRequest", "ScanExternalSkillsResponse", + "Schema", + "Schema1", + "Schema2", + "ScopeBinding", + "ScopeBindingKey", + "ScopeDescriptor", + "ScopeExternalReference", + "ScopeId", + "ScopePage", + "ScopeSelection", + "ScopeSelectionMode", "ScopedStats", "SearchMemoryHit", "SearchMemoryRequest", "SearchMemoryResponse", + "SetDefaultScopeRequest", + "SetScopeBindingRequest", "SkillArtifact", "SkillGenerationOrigin", "SkillProposal", @@ -353,21 +372,20 @@ "SourceInventoryStatistics", "SourceReference", "StatsPeriod", - "StoredHandoffReportActivity", "TaskCheck", "TaskCheckStatus", "TaskOutcome", "TaskOutcomeStatus", + "Timezone", "TokenEstimatorProfile", - "UpdateHandoffReportProjectRequest", - "UpdateHandoffReportWorkstreamRequest", + "Trust", + "Trust2", + "Trust3", + "UpdateScopeRequest", "UsageStatistics", "WorkClaim", "WorkClaimBasis", "WorkContract", "WorkSourceKind", "WorkSourceReceipt", - "WorkstreamDescriptor", - "WorkstreamKind", - "WorkstreamPage", ] diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index ad2a598c1..a4320d4ed 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -30,6 +30,169 @@ class ArtifactReference(BaseModel): revision: Annotated[StrictInt, Field(ge=1)] +class ArtifactAddress(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + artifact: ArtifactReference + + +class PublishArtifactRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + source: ArtifactAddress + target_scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + idempotency_key: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + + +class ArtifactPublication(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + source: ArtifactAddress + target: ArtifactAddress + content_digest: Annotated[StrictStr, Field(pattern="^[0-9a-f]{64}$")] + + +class ScopeExternalReference(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + kind: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern=".*\\S.*")] + value: Annotated[StrictStr, Field(max_length=2000, min_length=1, pattern=".*\\S.*")] + + +class ContextReference(RootModel[StrictStr]): + root: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + + +class ScopeDescriptor(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + title: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + summary: Annotated[StrictStr, Field(max_length=2000, min_length=1, pattern=".*\\S.*")] + parent_scope_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1, pattern=".*\\S.*")] = None + context_references: list[ContextReference] + external_references: list[ScopeExternalReference] + version: Annotated[StrictInt, Field(ge=1)] + + +class ScopePage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + items: list[ScopeDescriptor] + + +class CreateScopeRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + title: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + summary: Annotated[StrictStr, Field(max_length=2000, min_length=1, pattern=".*\\S.*")] + parent_scope_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1, pattern=".*\\S.*")] = None + context_references: Annotated[list[ContextReference], Field(validate_default=True)] = [] + external_references: Annotated[list[ScopeExternalReference], Field(validate_default=True)] = [] + idempotency_key: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + + +class GetScopeRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + + +class UpdateScopeRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + expected_version: Annotated[StrictInt, Field(ge=1)] + title: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + summary: Annotated[StrictStr, Field(max_length=2000, min_length=1, pattern=".*\\S.*")] + parent_scope_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1, pattern=".*\\S.*")] = None + context_references: Annotated[list[ContextReference], Field(validate_default=True)] = [] + external_references: Annotated[list[ScopeExternalReference], Field(validate_default=True)] = [] + + +class SetDefaultScopeRequest(RootModel[GetScopeRequest]): + root: GetScopeRequest + + +class ScopeSelectionMode(StrEnum): + ALL = "all" + EXACT = "exact" + SUBTREE = "subtree" + + +class ScopeId(RootModel[StrictStr]): + root: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + + +class ScopeSelection(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + mode: ScopeSelectionMode + scope_ids: Annotated[list[ScopeId], Field(validate_default=True)] = [] + root_scope_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1, pattern=".*\\S.*")] = None + + +class ResolveScopeSelectionRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + selection: ScopeSelection + + +class ScopeBindingKey(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + integration: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern=".*\\S.*")] + kind: Annotated[StrictStr, Field(max_length=64, min_length=1, pattern=".*\\S.*")] + external_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + + +class ScopeBinding(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + key: ScopeBindingKey + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + + +class SetScopeBindingRequest(RootModel[ScopeBinding]): + root: ScopeBinding + + +class ClearScopeBindingRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + key: ScopeBindingKey + + +class ClearScopeBindingResponse(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + cleared: StrictBool + + +class ResolveScopeBindingRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + explicit_scope_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1, pattern=".*\\S.*")] = None + binding_keys: Annotated[list[ScopeBindingKey], Field(validate_default=True)] = [] + + class ApproveArtifactCandidateRequest(BaseModel): model_config = ConfigDict( extra="forbid", @@ -380,254 +543,11 @@ class GetSkillRequest(BaseModel): artifact: ArtifactReference -class ListHandoffReportProjectsRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - cursor: StrictStr | None = None - limit: Annotated[StrictInt, Field(ge=1, le=100)] = 50 - include_archived: StrictBool = False - - -class GetHandoffReportProjectRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - project_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - - -class Label(RootModel[StrictStr]): - root: Annotated[StrictStr, Field(max_length=128, min_length=1)] - - -class ListHandoffReportWorkstreamsRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - project_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - cursor: StrictStr | None = None - limit: Annotated[StrictInt, Field(ge=1, le=100)] = 50 - include_archived: StrictBool = False - - -class ListHandoffReportKnownScopesRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - cursor: StrictStr | None = None - limit: Annotated[StrictInt, Field(ge=1, le=100)] = 50 - - -class KnownHandoffScope(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - - -class KnownHandoffScopePage(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - items: list[KnownHandoffScope] - next_cursor: StrictStr | None = None - - -class HandoffReportPeriodRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - start: AwareDatetime - end: AwareDatetime - timezone: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - compare_to_previous_period: StrictBool = False - - -class ReportActivitySource(StrEnum): - HANDOFF_OBSERVATION = "handoff_observation" - GIT_COMMIT = "git_commit" - GIT_WORKTREE = "git_worktree" - CODING_SESSION = "coding_session" - OTHER = "other" - - -class ReportTimeBasis(StrEnum): - SOURCE_REPORTED = "source_reported" - HOST_OBSERVED = "host_observed" - FIRST_SEEN = "first_seen" - CURRENT_ONLY = "current_only" - UNKNOWN = "unknown" - - -class HandoffReportActivityAgent(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - provider: Annotated[StrictStr | None, Field(max_length=64, min_length=1)] = None - label: Annotated[StrictStr | None, Field(max_length=128, min_length=1)] = None - - -class HandoffReportActivityVcsContext(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - branch: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - head_revision: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - - -class Schema3(StrEnum): - POWERCONTEXT_HANDOFF_REPORT_ACTIVITY_V1 = "powercontext.handoff-report-activity.v1" - - -class Trust4(StrEnum): - UNTRUSTED_OBSERVATION = "untrusted_observation" - - -class ListHandoffReportActivitiesRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - project_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - period_start: AwareDatetime | None = None - period_end: AwareDatetime | None = None - sources: Annotated[list[ReportActivitySource] | None, Field(max_length=5)] = None - after_cursor: Annotated[StrictInt, Field(ge=0)] = 0 - through_cursor: Annotated[StrictInt | None, Field(ge=0)] = None - limit: Annotated[StrictInt, Field(ge=1, le=100)] = 50 - - -class PurgeHandoffReportActivitiesRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - project_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - observed_before: AwareDatetime - - -class PurgeHandoffReportActivitiesResponse(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - deleted_count: Annotated[StrictInt, Field(ge=0)] - - -class Provider1(StrEnum): - GITHUB = "github" - GITLAB = "gitlab" - LOCAL = "local" - OTHER = "other" - - -class HandoffReportRepositoryRef(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - provider: Provider1 - repository_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] - normalized_remote: Annotated[StrictStr | None, Field(max_length=2048, min_length=1)] - subpath: Annotated[StrictStr | None, Field(max_length=1024, min_length=1)] - - -class Schema4(StrEnum): - POWERCONTEXT_WORKSPACE_BINDING_V1 = "powercontext.workspace-binding.v1" - - -class State(StrEnum): - CONFIRMED = "confirmed" - DETACHED = "detached" - - -class HandoffReportWorkspaceBinding(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - schema_: Annotated[Schema4, Field(alias="schema")] - workspace_instance_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - project_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - repository_ref: HandoffReportRepositoryRef - state: State - confirmed_at: AwareDatetime - version: Annotated[StrictInt, Field(ge=1)] - - -class GetHandoffReportWorkspaceRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - workspace_instance_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - - -class AttachHandoffReportWorkspaceRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - workspace_instance_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - project_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - repository_ref: HandoffReportRepositoryRef - expected_version: Annotated[StrictInt | None, Field(ge=1)] - - -class DetachHandoffReportWorkspaceRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - workspace_instance_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - expected_version: Annotated[StrictInt, Field(ge=1)] - - -class Schema5(StrEnum): - POWERCONTEXT_PROJECT_V1 = "powercontext.project.v1" - - -class Schema6(StrEnum): - POWERCONTEXT_WORKSTREAM_V1 = "powercontext.workstream.v1" - - -class Kind3(StrEnum): - ISSUE = "issue" - TASK = "task" - PULL_REQUEST = "pull_request" - BRANCH = "branch" - FEATURE = "feature" - RELEASE = "release" - PROGRAM = "program" - OTHER = "other" - - -class HandoffReportExternalReference(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - kind: Kind3 - provider: Annotated[StrictStr, Field(max_length=64, min_length=1)] - external_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - url: Annotated[StrictStr | None, Field(max_length=2048)] - - -class ReportLocale(StrEnum): - ZH_CN = "zh-CN" - EN = "en" - - class ReportFormat(StrEnum): JSON = "json" MARKDOWN = "markdown" -class ReportCatalogState(StrEnum): - INCLUDED = "included" - ARCHIVED = "archived" - - -class WorkstreamKind(StrEnum): - FEATURE = "feature" - BUG = "bug" - REFACTOR = "refactor" - OPERATIONS = "operations" - RESEARCH = "research" - OTHER = "other" - - class HealthResponse(BaseModel): model_config = ConfigDict( extra="forbid", @@ -992,7 +912,8 @@ class ScopedStats(BaseModel): model_config = ConfigDict( extra="forbid", ) - scope_id: StrictStr + selection: ScopeSelection + scope_ids: list[StrictStr] as_of: AwareDatetime inventory: InventoryStatistics usage: UsageStatistics @@ -1003,7 +924,7 @@ class GetStatsRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + selection: ScopeSelection period: StatsPeriod = StatsPeriod.FIELD_30D @@ -1153,51 +1074,13 @@ class GetMemoryEntryRequest(BaseModel): citation: MemoryCitation -class CreateHandoffReportProjectRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - project_key: Annotated[StrictStr, Field(max_length=64, min_length=1)] - title: Annotated[StrictStr, Field(max_length=256, min_length=1)] - description: Annotated[StrictStr | None, Field(max_length=2000)] = None - default_locale: ReportLocale = ReportLocale.ZH_CN - timezone: Annotated[StrictStr, Field(max_length=256, min_length=1)] = "UTC" - - -class RegisterHandoffReportWorkstreamRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - project_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - key: Annotated[StrictStr | None, Field(max_length=64, min_length=1)] = None - title: Annotated[StrictStr, Field(max_length=256, min_length=1)] - kind: WorkstreamKind - catalog_state: ReportCatalogState = ReportCatalogState.INCLUDED - external_refs: Annotated[list[HandoffReportExternalReference], Field(max_length=32, validate_default=True)] = [] - labels: Annotated[list[Label], Field(max_length=32, validate_default=True)] = [] - - class GetHandoffReportRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - project_id: Annotated[ - StrictStr | None, - Field( - deprecated=True, - description="Retained for wire compatibility and ignored when generating a scope report.", - max_length=256, - min_length=1, - ), - ] = None - locale: ReportLocale | None = None - include_evidence_checks: StrictBool = True - format: ReportFormat = ReportFormat.MARKDOWN - include_archived: StrictBool = False + selection: ScopeSelection + format: ReportFormat = ReportFormat.JSON download: StrictBool = False - period: HandoffReportPeriodRequest | None = None class HandoffReportResponse(BaseModel): @@ -1211,112 +1094,6 @@ class HandoffReportResponse(BaseModel): report_digest: Annotated[StrictStr, Field(pattern="^sha256:[0-9a-f]{64}$")] -class RecordHandoffReportActivityRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - project_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - scope_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - source: ReportActivitySource - source_event_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - source_ref: HandoffReportExternalReference | None = None - occurred_at: AwareDatetime | None = None - time_basis: ReportTimeBasis - title: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - summary: Annotated[StrictStr | None, Field(max_length=2000, min_length=1)] = None - agent: HandoffReportActivityAgent | None = None - session_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - vcs_context: HandoffReportActivityVcsContext | None = None - evidence_refs: Annotated[list[HandoffReportExternalReference], Field(max_length=32, validate_default=True)] = [] - - -class HandoffReportActivity(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - schema_: Annotated[Schema3, Field(alias="schema")] - event_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - project_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - scope_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] - source: ReportActivitySource - source_event_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - source_ref: Annotated[HandoffReportExternalReference | None, Field(...)] - occurred_at: Annotated[AwareDatetime | None, Field(...)] - observed_at: AwareDatetime - time_basis: ReportTimeBasis - title: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] - summary: Annotated[StrictStr | None, Field(max_length=2000, min_length=1)] - agent: Annotated[HandoffReportActivityAgent | None, Field(...)] - session_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] - vcs_context: Annotated[HandoffReportActivityVcsContext | None, Field(...)] - evidence_refs: Annotated[list[HandoffReportExternalReference], Field(max_length=32)] - trust: Trust4 - - -class StoredHandoffReportActivity(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - cursor: Annotated[StrictInt, Field(ge=1)] - event: HandoffReportActivity - - -class HandoffReportActivityPage(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - items: Annotated[list[HandoffReportActivity], Field(max_length=100)] - next_cursor: Annotated[StrictInt | None, Field(ge=1)] - high_watermark: Annotated[StrictInt, Field(ge=0)] - - -class ProjectDescriptor(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - schema_: Annotated[Schema5, Field(alias="schema")] - project_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - project_key: Annotated[StrictStr, Field(max_length=64, min_length=1)] - title: Annotated[StrictStr, Field(max_length=256, min_length=1)] - description: Annotated[StrictStr | None, Field(max_length=2000)] - default_locale: ReportLocale - timezone: Annotated[StrictStr, Field(max_length=256, min_length=1)] - catalog_state: ReportCatalogState - version: Annotated[StrictInt, Field(ge=1)] - - -class ProjectPage(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - items: Annotated[list[ProjectDescriptor], Field(max_length=100)] - next_cursor: Annotated[StrictStr | None, Field(...)] - - -class WorkstreamDescriptor(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - schema_: Annotated[Schema6, Field(alias="schema")] - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - project_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - key: Annotated[StrictStr | None, Field(max_length=64)] - title: Annotated[StrictStr, Field(max_length=256, min_length=1)] - kind: WorkstreamKind - catalog_state: ReportCatalogState - external_refs: Annotated[list[HandoffReportExternalReference], Field(max_length=32)] - labels: Annotated[list[Label], Field(max_length=32)] - version: Annotated[StrictInt, Field(ge=1)] - - -class WorkstreamPage(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - items: Annotated[list[WorkstreamDescriptor], Field(max_length=100)] - next_cursor: Annotated[StrictStr | None, Field(...)] - - class ListArtifactCandidatesRequest(BaseModel): model_config = ConfigDict( extra="forbid", @@ -1652,22 +1429,6 @@ class SkillArtifact(BaseModel): artifact_refs: list[ArtifactReference] -class UpdateHandoffReportProjectRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - project: ProjectDescriptor - expected_version: Annotated[StrictInt, Field(ge=1)] - - -class UpdateHandoffReportWorkstreamRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - workstream: WorkstreamDescriptor - expected_version: Annotated[StrictInt, Field(ge=1)] - - class ListMemoryChangesResponse(BaseModel): model_config = ConfigDict( extra="forbid", diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index d344d87bd..dc15d9010 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -12,16 +12,17 @@ ApproveArtifactCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, - AttachHandoffReportWorkspaceRequest, + ArtifactPublication, Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, + ClearScopeBindingRequest, + ClearScopeBindingResponse, CommitHandoffRequest, CommittedHandoff, ContinueHandoffRequest, - CreateHandoffReportProjectRequest, + CreateScopeRequest, CreateWorkContractRequest, - DetachHandoffReportWorkspaceRequest, ExperienceArtifact, ExternalSkillResolution, FinalizeHandoffRequest, @@ -32,30 +33,22 @@ GenerateSkillRequest, GetArtifactCandidateRequest, GetExperienceRequest, - GetHandoffReportProjectRequest, GetHandoffReportRequest, - GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, + GetScopeRequest, GetSkillRequest, GetStatsRequest, HandoffAcknowledgement, HandoffActivation, HandoffCurrentWorkRequest, HandoffDraft, - HandoffReportActivityPage, HandoffReportResponse, - HandoffReportWorkspaceBinding, HandoffResolution, HealthResponse, ImportExternalSkillRequest, - KnownHandoffScopePage, ListArtifactCandidatesRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, - ListHandoffReportActivitiesRequest, - ListHandoffReportKnownScopesRequest, - ListHandoffReportProjectsRequest, - ListHandoffReportWorkstreamsRequest, ListMemoryChangesRequest, ListMemoryChangesResponse, ListMemoryEntriesRequest, @@ -67,34 +60,32 @@ PreparedHandoff, PreparedWorkHandoff, PrepareHandoffRequest, - ProjectDescriptor, - ProjectPage, ProposeExperienceRequest, ProposeSkillRequest, - PurgeHandoffReportActivitiesRequest, - PurgeHandoffReportActivitiesResponse, + PublishArtifactRequest, ReadinessResponse, - RecordHandoffReportActivityRequest, RecordTaskOutcomeRequest, - RegisterHandoffReportWorkstreamRequest, RejectArtifactCandidateRequest, RememberMemoryRequest, ResolveExternalSkillRequest, + ResolveScopeBindingRequest, + ResolveScopeSelectionRequest, RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, ReviseMemoryEntryRequest, ScanExternalSkillsRequest, ScanExternalSkillsResponse, + ScopeBinding, + ScopeDescriptor, ScopedStats, + ScopePage, SearchMemoryRequest, SearchMemoryResponse, + SetDefaultScopeRequest, + SetScopeBindingRequest, SkillArtifact, - StoredHandoffReportActivity, - UpdateHandoffReportProjectRequest, - UpdateHandoffReportWorkstreamRequest, + UpdateScopeRequest, WorkSourceReceipt, - WorkstreamDescriptor, - WorkstreamPage, ) OPENAPI_VERSION = "3.0.3" @@ -178,6 +169,202 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, ) +LIST_SCOPES = Operation[None, ScopePage]( + method="GET", + path="/v1/scopes", + operation_id="list_scopes", + request_type=None, + request_location=None, + response_type=ScopePage, + success_status=200, + summary="List observable Scopes", + tags=("scopes",), + responses={ + 200: {"description": "Durable Scope metadata in deterministic identity order."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, +) + +CREATE_SCOPE = Operation[CreateScopeRequest, ScopeDescriptor]( + method="POST", + path="/v1/scopes", + operation_id="create_scope", + request_type=CreateScopeRequest, + request_location="body", + response_type=ScopeDescriptor, + success_status=201, + summary="Create an independent Scope boundary", + tags=("scopes",), + responses={ + 201: {"description": "The durable Scope descriptor."}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + }, +) + +PUBLISH_ARTIFACT = Operation[PublishArtifactRequest, ArtifactPublication]( + method="POST", + path="/v1/artifact-publications", + operation_id="publish_artifact", + request_type=PublishArtifactRequest, + request_location="body", + response_type=ArtifactPublication, + success_status=201, + summary="Publish one exact Artifact revision into another Scope", + tags=("scopes",), + responses={ + 201: {"description": "Independent target Artifact and its exact source provenance."}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + }, +) + +GET_SCOPE = Operation[GetScopeRequest, ScopeDescriptor]( + method="POST", + path="/v1/scopes/get", + operation_id="get_scope", + request_type=GetScopeRequest, + request_location="body", + response_type=ScopeDescriptor, + success_status=200, + summary="Get one Scope descriptor", + tags=("scopes",), + responses={ + 200: {"description": "The exact Scope descriptor."}, + 404: {"$ref": "#/components/responses/NotFound"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + }, +) + +UPDATE_SCOPE = Operation[UpdateScopeRequest, ScopeDescriptor]( + method="POST", + path="/v1/scopes/update", + operation_id="update_scope", + request_type=UpdateScopeRequest, + request_location="body", + response_type=ScopeDescriptor, + success_status=200, + summary="Replace mutable Scope metadata and relationships", + tags=("scopes",), + responses={ + 200: {"description": "The updated Scope descriptor."}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + }, +) + +GET_DEFAULT_SCOPE = Operation[None, ScopeDescriptor]( + method="GET", + path="/v1/scopes/default", + operation_id="get_default_scope", + request_type=None, + request_location=None, + response_type=ScopeDescriptor, + success_status=200, + summary="Get the default Scope binding target", + tags=("scopes",), + responses={ + 200: {"description": "The ordinary Scope selected by the host default pointer."}, + 404: {"$ref": "#/components/responses/NotFound"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + }, +) + +SET_DEFAULT_SCOPE = Operation[SetDefaultScopeRequest, ScopeDescriptor]( + method="PUT", + path="/v1/scopes/default", + operation_id="set_default_scope", + request_type=SetDefaultScopeRequest, + request_location="body", + response_type=ScopeDescriptor, + success_status=200, + summary="Change the default Scope binding target", + tags=("scopes",), + responses={ + 200: {"description": "The selected ordinary Scope."}, + 404: {"$ref": "#/components/responses/NotFound"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + }, +) + +RESOLVE_SCOPE_SELECTION = Operation[ResolveScopeSelectionRequest, ScopePage]( + method="POST", + path="/v1/scopes/selection/resolve", + operation_id="resolve_scope_selection", + request_type=ResolveScopeSelectionRequest, + request_location="body", + response_type=ScopePage, + success_status=200, + summary="Resolve an observation selection to a frozen Scope set", + tags=("scopes",), + responses={ + 200: {"description": "The selected Scope descriptors in deterministic order."}, + 404: {"$ref": "#/components/responses/NotFound"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + }, +) + +RESOLVE_SCOPE_BINDING = Operation[ResolveScopeBindingRequest, ScopeDescriptor]( + method="POST", + path="/v1/scope-bindings/resolve", + operation_id="resolve_scope_binding", + request_type=ResolveScopeBindingRequest, + request_location="body", + response_type=ScopeDescriptor, + success_status=200, + summary="Resolve an explicit durable or default Scope binding", + tags=("scope-bindings",), + responses={ + 200: {"description": "The resolved Scope descriptor."}, + 404: {"$ref": "#/components/responses/NotFound"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + }, +) + +SET_SCOPE_BINDING = Operation[SetScopeBindingRequest, ScopeBinding]( + method="PUT", + path="/v1/scope-bindings", + operation_id="set_scope_binding", + request_type=SetScopeBindingRequest, + request_location="body", + response_type=ScopeBinding, + success_status=200, + summary="Persist an external identity to Scope binding", + tags=("scope-bindings",), + responses={ + 200: {"description": "The durable external binding."}, + 404: {"$ref": "#/components/responses/NotFound"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + }, +) + +CLEAR_SCOPE_BINDING = Operation[ClearScopeBindingRequest, ClearScopeBindingResponse]( + method="POST", + path="/v1/scope-bindings/clear", + operation_id="clear_scope_binding", + request_type=ClearScopeBindingRequest, + request_location="body", + response_type=ClearScopeBindingResponse, + success_status=200, + summary="Remove one durable external Scope binding", + tags=("scope-bindings",), + responses={ + 200: {"description": "Whether a durable binding was removed."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + }, +) + CAPTURE_CONTENT_SOURCE = Operation[CaptureContentSourceRequest, CaptureContentSourceResponse]( method="POST", path="/v1/sources/content", @@ -968,18 +1155,18 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): ) GET_STATS = Operation[GetStatsRequest, ScopedStats]( - method="GET", + method="POST", path="/v1/stats", operation_id="get_stats", request_type=GetStatsRequest, - request_location="query", + request_location="body", response_type=ScopedStats, success_status=200, - summary="Get scoped product statistics", + summary="Aggregate product statistics over a Scope selection", tags=("stats",), responses={ 200: { - "description": "Current inventory, model usage, and recall token estimates for the scope.", + "description": "Current inventory, model usage, and recall token estimates for the frozen Scope set.", "headers": { "X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}, "Cache-Control": { @@ -995,183 +1182,6 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, ) -CREATE_HANDOFF_REPORT_PROJECT = Operation[CreateHandoffReportProjectRequest, ProjectDescriptor]( - method="POST", - path="/v1/handoff-reports/projects/create", - operation_id="create_handoff_report_project", - request_type=CreateHandoffReportProjectRequest, - request_location="body", - response_type=ProjectDescriptor, - success_status=201, - summary="Create a Handoff Report Project", - tags=("handoff-reports",), - responses={ - 201: { - "description": "The created Report Project.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 409: {"$ref": "#/components/responses/Conflict"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -LIST_HANDOFF_REPORT_PROJECTS = Operation[ListHandoffReportProjectsRequest, ProjectPage]( - method="POST", - path="/v1/handoff-reports/projects/list", - operation_id="list_handoff_report_projects", - request_type=ListHandoffReportProjectsRequest, - request_location="body", - response_type=ProjectPage, - success_status=200, - summary="List Handoff Report Projects", - tags=("handoff-reports",), - responses={ - 200: { - "description": "A cursor-paginated page of Report Projects.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -LIST_HANDOFF_REPORT_KNOWN_SCOPES = Operation[ListHandoffReportKnownScopesRequest, KnownHandoffScopePage]( - method="POST", - path="/v1/handoff-reports/scopes/list-known", - operation_id="list_handoff_report_known_scopes", - request_type=ListHandoffReportKnownScopesRequest, - request_location="body", - response_type=KnownHandoffScopePage, - success_status=200, - summary="List scopes that contain a committed Handoff", - tags=("handoff-reports",), - responses={ - 200: { - "description": "A cursor-paginated page of scopes that can be rendered as Handoff Reports.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -GET_HANDOFF_REPORT_PROJECT = Operation[GetHandoffReportProjectRequest, ProjectDescriptor]( - method="POST", - path="/v1/handoff-reports/projects/get", - operation_id="get_handoff_report_project", - request_type=GetHandoffReportProjectRequest, - request_location="body", - response_type=ProjectDescriptor, - success_status=200, - summary="Get a Handoff Report Project", - tags=("handoff-reports",), - responses={ - 200: { - "description": "The exact current Report Project descriptor.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 404: {"$ref": "#/components/responses/NotFound"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -UPDATE_HANDOFF_REPORT_PROJECT = Operation[UpdateHandoffReportProjectRequest, ProjectDescriptor]( - method="POST", - path="/v1/handoff-reports/projects/update", - operation_id="update_handoff_report_project", - request_type=UpdateHandoffReportProjectRequest, - request_location="body", - response_type=ProjectDescriptor, - success_status=200, - summary="Update a Handoff Report Project", - tags=("handoff-reports",), - responses={ - 200: { - "description": "The updated Report Project descriptor.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 404: {"$ref": "#/components/responses/NotFound"}, - 409: {"$ref": "#/components/responses/Conflict"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -REGISTER_HANDOFF_REPORT_WORKSTREAM = Operation[RegisterHandoffReportWorkstreamRequest, WorkstreamDescriptor]( - method="POST", - path="/v1/handoff-reports/workstreams/register", - operation_id="register_handoff_report_workstream", - request_type=RegisterHandoffReportWorkstreamRequest, - request_location="body", - response_type=WorkstreamDescriptor, - success_status=201, - summary="Register a Handoff Report Workstream", - tags=("handoff-reports",), - responses={ - 201: { - "description": "The registered Report Workstream.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 404: {"$ref": "#/components/responses/NotFound"}, - 409: {"$ref": "#/components/responses/Conflict"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -LIST_HANDOFF_REPORT_WORKSTREAMS = Operation[ListHandoffReportWorkstreamsRequest, WorkstreamPage]( - method="POST", - path="/v1/handoff-reports/workstreams/list", - operation_id="list_handoff_report_workstreams", - request_type=ListHandoffReportWorkstreamsRequest, - request_location="body", - response_type=WorkstreamPage, - success_status=200, - summary="List Handoff Report Workstreams", - tags=("handoff-reports",), - responses={ - 200: { - "description": "A cursor-paginated page of Report Workstreams.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 404: {"$ref": "#/components/responses/NotFound"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -UPDATE_HANDOFF_REPORT_WORKSTREAM = Operation[UpdateHandoffReportWorkstreamRequest, WorkstreamDescriptor]( - method="POST", - path="/v1/handoff-reports/workstreams/update", - operation_id="update_handoff_report_workstream", - request_type=UpdateHandoffReportWorkstreamRequest, - request_location="body", - response_type=WorkstreamDescriptor, - success_status=200, - summary="Update a Handoff Report Workstream", - tags=("handoff-reports",), - responses={ - 200: { - "description": "The updated Report Workstream descriptor.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 404: {"$ref": "#/components/responses/NotFound"}, - 409: {"$ref": "#/components/responses/Conflict"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - GET_HANDOFF_REPORT = Operation[GetHandoffReportRequest, HandoffReportResponse]( method="POST", path="/v1/handoff-reports/get", @@ -1213,138 +1223,3 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): 500: {"$ref": "#/components/responses/InternalError"}, }, ) - -RECORD_HANDOFF_REPORT_ACTIVITY = Operation[RecordHandoffReportActivityRequest, StoredHandoffReportActivity]( - method="POST", - path="/v1/handoff-reports/activities/record", - operation_id="record_handoff_report_activity", - request_type=RecordHandoffReportActivityRequest, - request_location="body", - response_type=StoredHandoffReportActivity, - success_status=201, - summary="Record a Handoff Report Activity", - tags=("handoff-reports",), - responses={ - 201: { - "description": "The idempotently recorded Report Activity.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 404: {"$ref": "#/components/responses/NotFound"}, - 409: {"$ref": "#/components/responses/Conflict"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -LIST_HANDOFF_REPORT_ACTIVITIES = Operation[ListHandoffReportActivitiesRequest, HandoffReportActivityPage]( - method="POST", - path="/v1/handoff-reports/activities/list", - operation_id="list_handoff_report_activities", - request_type=ListHandoffReportActivitiesRequest, - request_location="body", - response_type=HandoffReportActivityPage, - success_status=200, - summary="List Handoff Report Activities", - tags=("handoff-reports",), - responses={ - 200: { - "description": "A frozen cursor page of Report Activities.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 404: {"$ref": "#/components/responses/NotFound"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -PURGE_HANDOFF_REPORT_ACTIVITIES = Operation[PurgeHandoffReportActivitiesRequest, PurgeHandoffReportActivitiesResponse]( - method="POST", - path="/v1/handoff-reports/activities/purge", - operation_id="purge_handoff_report_activities", - request_type=PurgeHandoffReportActivitiesRequest, - request_location="body", - response_type=PurgeHandoffReportActivitiesResponse, - success_status=200, - summary="Purge Handoff Report Activities", - tags=("handoff-reports",), - responses={ - 200: { - "description": "The number of deleted Report-owned Activity rows.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 404: {"$ref": "#/components/responses/NotFound"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -GET_HANDOFF_REPORT_WORKSPACE = Operation[GetHandoffReportWorkspaceRequest, HandoffReportWorkspaceBinding]( - method="POST", - path="/v1/handoff-reports/workspace-bindings/get", - operation_id="get_handoff_report_workspace", - request_type=GetHandoffReportWorkspaceRequest, - request_location="body", - response_type=HandoffReportWorkspaceBinding, - success_status=200, - summary="Get a Handoff Report Workspace Binding", - tags=("handoff-reports",), - responses={ - 200: { - "description": "The confirmed Workspace binding.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 404: {"$ref": "#/components/responses/NotFound"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -ATTACH_HANDOFF_REPORT_WORKSPACE = Operation[AttachHandoffReportWorkspaceRequest, HandoffReportWorkspaceBinding]( - method="POST", - path="/v1/handoff-reports/workspace-bindings/attach", - operation_id="attach_handoff_report_workspace", - request_type=AttachHandoffReportWorkspaceRequest, - request_location="body", - response_type=HandoffReportWorkspaceBinding, - success_status=200, - summary="Attach a Handoff Report Workspace Binding", - tags=("handoff-reports",), - responses={ - 200: { - "description": "The confirmed Workspace binding.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 404: {"$ref": "#/components/responses/NotFound"}, - 409: {"$ref": "#/components/responses/Conflict"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -DETACH_HANDOFF_REPORT_WORKSPACE = Operation[DetachHandoffReportWorkspaceRequest, HandoffReportWorkspaceBinding]( - method="POST", - path="/v1/handoff-reports/workspace-bindings/detach", - operation_id="detach_handoff_report_workspace", - request_type=DetachHandoffReportWorkspaceRequest, - request_location="body", - response_type=HandoffReportWorkspaceBinding, - success_status=200, - summary="Detach a Handoff Report Workspace Binding", - tags=("handoff-reports",), - responses={ - 200: { - "description": "The detached Workspace binding record.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 404: {"$ref": "#/components/responses/NotFound"}, - 409: {"$ref": "#/components/responses/Conflict"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 6be425400..59f41e53a 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -60,6 +60,228 @@ }, } }, + "/v1/scopes": { + "get": { + "tags": ["scopes"], + "summary": "List observable Scopes", + "operationId": "list_scopes", + "responses": { + "200": { + "description": "Durable Scope metadata in deterministic identity order.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScopePage"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + }, + "post": { + "tags": ["scopes"], + "summary": "Create an independent Scope boundary", + "operationId": "create_scope", + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateScopeRequest"}}}, + "required": True, + }, + "responses": { + "201": { + "description": "The durable Scope descriptor.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScopeDescriptor"}}}, + }, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + }, + }, + }, + "/v1/artifact-publications": { + "post": { + "tags": ["scopes"], + "summary": "Publish one exact Artifact revision into another Scope", + "operationId": "publish_artifact", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/PublishArtifactRequest"}} + }, + "required": True, + }, + "responses": { + "201": { + "description": "Independent target Artifact and its exact source provenance.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ArtifactPublication"}} + }, + }, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + }, + } + }, + "/v1/scopes/get": { + "post": { + "tags": ["scopes"], + "summary": "Get one Scope descriptor", + "operationId": "get_scope", + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GetScopeRequest"}}}, + "required": True, + }, + "responses": { + "200": { + "description": "The exact Scope descriptor.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScopeDescriptor"}}}, + }, + "404": {"$ref": "#/components/responses/NotFound"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + }, + } + }, + "/v1/scopes/update": { + "post": { + "tags": ["scopes"], + "summary": "Replace mutable Scope metadata and relationships", + "operationId": "update_scope", + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateScopeRequest"}}}, + "required": True, + }, + "responses": { + "200": { + "description": "The updated Scope descriptor.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScopeDescriptor"}}}, + }, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + }, + } + }, + "/v1/scopes/default": { + "get": { + "tags": ["scopes"], + "summary": "Get the default Scope binding target", + "operationId": "get_default_scope", + "responses": { + "200": { + "description": "The ordinary Scope selected by the host default pointer.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScopeDescriptor"}}}, + }, + "404": {"$ref": "#/components/responses/NotFound"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + }, + }, + "put": { + "tags": ["scopes"], + "summary": "Change the default Scope binding target", + "operationId": "set_default_scope", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SetDefaultScopeRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "The selected ordinary Scope.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScopeDescriptor"}}}, + }, + "404": {"$ref": "#/components/responses/NotFound"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + }, + }, + }, + "/v1/scopes/selection/resolve": { + "post": { + "tags": ["scopes"], + "summary": "Resolve an observation selection to a frozen Scope set", + "operationId": "resolve_scope_selection", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ResolveScopeSelectionRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "The selected Scope descriptors in deterministic order.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScopePage"}}}, + }, + "404": {"$ref": "#/components/responses/NotFound"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + }, + } + }, + "/v1/scope-bindings/resolve": { + "post": { + "tags": ["scope-bindings"], + "summary": "Resolve an explicit durable or default Scope binding", + "operationId": "resolve_scope_binding", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ResolveScopeBindingRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "The resolved Scope descriptor.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScopeDescriptor"}}}, + }, + "404": {"$ref": "#/components/responses/NotFound"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + }, + } + }, + "/v1/scope-bindings": { + "put": { + "tags": ["scope-bindings"], + "summary": "Persist an external identity to Scope binding", + "operationId": "set_scope_binding", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SetScopeBindingRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "The durable external binding.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScopeBinding"}}}, + }, + "404": {"$ref": "#/components/responses/NotFound"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + }, + } + }, + "/v1/scope-bindings/clear": { + "post": { + "tags": ["scope-bindings"], + "summary": "Remove one durable external Scope binding", + "operationId": "clear_scope_binding", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ClearScopeBindingRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "Whether a durable binding was removed.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ClearScopeBindingResponse"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + }, + } + }, "/v1/sources/content": { "post": { "tags": ["sources"], @@ -1029,27 +1251,20 @@ } }, "/v1/stats": { - "get": { + "post": { "tags": ["stats"], - "summary": "Get scoped product statistics", + "summary": "Aggregate product statistics over a Scope selection", "operationId": "get_stats", - "parameters": [ - { - "name": "scope_id", - "in": "query", - "required": True, - "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, - }, - { - "name": "period", - "in": "query", - "required": False, - "schema": {"$ref": "#/components/schemas/StatsPeriod"}, - }, - ], + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GetStatsRequest"}}}, + "required": True, + }, "responses": { "200": { - "description": "Current inventory, model usage, and recall token estimates for the scope.", + "description": "Current inventory, model " + "usage, and recall token " + "estimates for the frozen " + "Scope set.", "headers": { "X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}, "Cache-Control": { @@ -1066,474 +1281,298 @@ }, } }, - "/v1/handoff-reports/projects/create": { - "post": { - "tags": ["handoff-reports"], - "summary": "Create a Handoff Report Project", - "operationId": "create_handoff_report_project", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/CreateHandoffReportProjectRequest"} - } - }, - "required": True, - }, - "responses": { - "201": { - "description": "The created Report Project.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectDescriptor"}}}, - }, - "409": {"$ref": "#/components/responses/Conflict"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - } - }, - "/v1/handoff-reports/projects/list": { - "post": { - "tags": ["handoff-reports"], - "summary": "List Handoff Report Projects", - "operationId": "list_handoff_report_projects", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/ListHandoffReportProjectsRequest"} - } - }, - "required": True, - }, - "responses": { - "200": { - "description": "A cursor-paginated page of Report Projects.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectPage"}}}, - }, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - } - }, - "/v1/handoff-reports/scopes/list-known": { + "/v1/handoff-reports/get": { "post": { "tags": ["handoff-reports"], - "summary": "List scopes that contain a committed Handoff", - "operationId": "list_handoff_report_known_scopes", + "summary": "Generate a Handoff Report", + "operationId": "get_handoff_report", "requestBody": { "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/ListHandoffReportKnownScopesRequest"} - } + "application/json": {"schema": {"$ref": "#/components/schemas/GetHandoffReportRequest"}} }, "required": True, }, "responses": { "200": { - "description": "A cursor-paginated page of scopes that can be rendered as Handoff Reports.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "description": "A canonical JSON report, optionally accompanied by Markdown.", + "headers": { + "X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}, + "Cache-Control": { + "description": "Prevent caches from retaining scoped report data.", + "schema": {"type": "string", "enum": ["no-store"]}, + }, + "X-PowerContext-Selection-Digest": { + "description": "Digest of the exact report selection.", + "schema": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + }, + "X-PowerContext-Report-Digest": { + "description": "Digest of the selected output projection.", + "schema": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + }, + "Content-Disposition": { + "description": "Safe attachment filename when download is true.", + "schema": {"type": "string"}, + }, + }, "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/KnownHandoffScopePage"}} + "application/json": {"schema": {"$ref": "#/components/schemas/HandoffReportResponse"}}, + "text/markdown": {"schema": {"type": "string"}}, }, }, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - } - }, - "/v1/handoff-reports/projects/get": { - "post": { - "tags": ["handoff-reports"], - "summary": "Get a Handoff Report Project", - "operationId": "get_handoff_report_project", - "requestBody": { - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/GetHandoffReportProjectRequest"}} - }, - "required": True, - }, - "responses": { - "200": { - "description": "The exact current Report Project descriptor.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectDescriptor"}}}, - }, "404": {"$ref": "#/components/responses/NotFound"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "413": {"$ref": "#/components/responses/ReportTooLarge"}, + "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, } }, - "/v1/handoff-reports/projects/update": { - "post": { - "tags": ["handoff-reports"], - "summary": "Update a Handoff Report Project", - "operationId": "update_handoff_report_project", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/UpdateHandoffReportProjectRequest"} - } + }, + "components": { + "schemas": { + "ActivateHandoffRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "boundary_source": {"$ref": "#/components/schemas/SourceReference"}, + "objective": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, + "evidence": { + "items": {"$ref": "#/components/schemas/HandoffCitation"}, + "type": "array", + "maxItems": 32, + "default": [], }, - "required": True, + "max_bytes": {"type": "integer", "maximum": 32768.0, "minimum": 512.0, "default": 8000}, }, - "responses": { - "200": { - "description": "The updated Report Project descriptor.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectDescriptor"}}}, - }, - "404": {"$ref": "#/components/responses/NotFound"}, - "409": {"$ref": "#/components/responses/Conflict"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - } - }, - "/v1/handoff-reports/workstreams/register": { - "post": { - "tags": ["handoff-reports"], - "summary": "Register a Handoff Report Workstream", - "operationId": "register_handoff_report_workstream", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/RegisterHandoffReportWorkstreamRequest"} - } - }, - "required": True, - }, - "responses": { - "201": { - "description": "The registered Report Workstream.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/WorkstreamDescriptor"}} - }, - }, - "404": {"$ref": "#/components/responses/NotFound"}, - "409": {"$ref": "#/components/responses/Conflict"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - } - }, - "/v1/handoff-reports/workstreams/list": { - "post": { - "tags": ["handoff-reports"], - "summary": "List Handoff Report Workstreams", - "operationId": "list_handoff_report_workstreams", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/ListHandoffReportWorkstreamsRequest"} - } - }, - "required": True, - }, - "responses": { - "200": { - "description": "A cursor-paginated page of Report Workstreams.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WorkstreamPage"}}}, - }, - "404": {"$ref": "#/components/responses/NotFound"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "boundary_source", "objective"], + }, + "ArtifactReference": { + "properties": { + "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "artifact_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "revision": {"type": "integer", "minimum": 1.0}, }, - } - }, - "/v1/handoff-reports/workstreams/update": { - "post": { - "tags": ["handoff-reports"], - "summary": "Update a Handoff Report Workstream", - "operationId": "update_handoff_report_workstream", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/UpdateHandoffReportWorkstreamRequest"} - } - }, - "required": True, + "additionalProperties": False, + "type": "object", + "required": ["family", "artifact_id", "revision"], + }, + "ArtifactAddress": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, }, - "responses": { - "200": { - "description": "The updated Report Workstream descriptor.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/WorkstreamDescriptor"}} - }, - }, - "404": {"$ref": "#/components/responses/NotFound"}, - "409": {"$ref": "#/components/responses/Conflict"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "artifact"], + }, + "PublishArtifactRequest": { + "properties": { + "source": {"$ref": "#/components/schemas/ArtifactAddress"}, + "target_scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "idempotency_key": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, }, - } - }, - "/v1/handoff-reports/get": { - "post": { - "tags": ["handoff-reports"], - "summary": "Generate a Handoff Report", - "operationId": "get_handoff_report", - "requestBody": { - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/GetHandoffReportRequest"}} - }, - "required": True, + "additionalProperties": False, + "type": "object", + "required": ["source", "target_scope_id", "idempotency_key"], + }, + "ArtifactPublication": { + "properties": { + "source": {"$ref": "#/components/schemas/ArtifactAddress"}, + "target": {"$ref": "#/components/schemas/ArtifactAddress"}, + "content_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, }, - "responses": { - "200": { - "description": "A canonical JSON report, optionally accompanied by Markdown.", - "headers": { - "X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}, - "Cache-Control": { - "description": "Prevent caches from retaining scoped report data.", - "schema": {"type": "string", "enum": ["no-store"]}, - }, - "X-PowerContext-Selection-Digest": { - "description": "Digest of the exact report selection.", - "schema": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, - }, - "X-PowerContext-Report-Digest": { - "description": "Digest of the selected output projection.", - "schema": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, - }, - "Content-Disposition": { - "description": "Safe attachment filename when download is true.", - "schema": {"type": "string"}, - }, - }, - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/HandoffReportResponse"}}, - "text/markdown": {"schema": {"type": "string"}}, - }, - }, - "404": {"$ref": "#/components/responses/NotFound"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "413": {"$ref": "#/components/responses/ReportTooLarge"}, - "503": {"$ref": "#/components/responses/Unavailable"}, - "500": {"$ref": "#/components/responses/InternalError"}, + "additionalProperties": False, + "type": "object", + "required": ["source", "target", "content_digest"], + }, + "ScopeExternalReference": { + "properties": { + "kind": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": ".*\\S.*"}, + "value": {"type": "string", "maxLength": 2000, "minLength": 1, "pattern": ".*\\S.*"}, }, - } - }, - "/v1/handoff-reports/activities/record": { - "post": { - "tags": ["handoff-reports"], - "summary": "Record a Handoff Report Activity", - "operationId": "record_handoff_report_activity", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/RecordHandoffReportActivityRequest"} - } + "additionalProperties": False, + "type": "object", + "required": ["kind", "value"], + }, + "ScopeDescriptor": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "title": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "summary": {"type": "string", "maxLength": 2000, "minLength": 1, "pattern": ".*\\S.*"}, + "parent_scope_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": ".*\\S.*", + "nullable": True, }, - "required": True, - }, - "responses": { - "201": { - "description": "The idempotently recorded Report Activity.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/StoredHandoffReportActivity"}} - }, + "context_references": { + "items": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "type": "array", + "uniqueItems": True, }, - "404": {"$ref": "#/components/responses/NotFound"}, - "409": {"$ref": "#/components/responses/Conflict"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - } - }, - "/v1/handoff-reports/activities/list": { - "post": { - "tags": ["handoff-reports"], - "summary": "List Handoff Report Activities", - "operationId": "list_handoff_report_activities", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/ListHandoffReportActivitiesRequest"} - } + "external_references": { + "items": {"$ref": "#/components/schemas/ScopeExternalReference"}, + "type": "array", + "uniqueItems": True, }, - "required": True, + "version": {"type": "integer", "minimum": 1.0}, }, - "responses": { - "200": { - "description": "A frozen cursor page of Report Activities.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/HandoffReportActivityPage"}} - }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "title", "summary", "context_references", "external_references", "version"], + }, + "ScopePage": { + "properties": {"items": {"items": {"$ref": "#/components/schemas/ScopeDescriptor"}, "type": "array"}}, + "additionalProperties": False, + "type": "object", + "required": ["items"], + }, + "CreateScopeRequest": { + "properties": { + "title": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "summary": {"type": "string", "maxLength": 2000, "minLength": 1, "pattern": ".*\\S.*"}, + "parent_scope_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": ".*\\S.*", + "nullable": True, }, - "404": {"$ref": "#/components/responses/NotFound"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - } - }, - "/v1/handoff-reports/activities/purge": { - "post": { - "tags": ["handoff-reports"], - "summary": "Purge Handoff Report Activities", - "operationId": "purge_handoff_report_activities", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/PurgeHandoffReportActivitiesRequest"} - } + "context_references": { + "items": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "type": "array", + "uniqueItems": True, + "default": [], }, - "required": True, - }, - "responses": { - "200": { - "description": "The number of deleted Report-owned Activity rows.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/PurgeHandoffReportActivitiesResponse"} - } - }, + "external_references": { + "items": {"$ref": "#/components/schemas/ScopeExternalReference"}, + "type": "array", + "uniqueItems": True, + "default": [], }, - "404": {"$ref": "#/components/responses/NotFound"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, + "idempotency_key": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, }, - } - }, - "/v1/handoff-reports/workspace-bindings/get": { - "post": { - "tags": ["handoff-reports"], - "summary": "Get a Handoff Report Workspace Binding", - "operationId": "get_handoff_report_workspace", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/GetHandoffReportWorkspaceRequest"} - } + "additionalProperties": False, + "type": "object", + "required": ["title", "summary", "idempotency_key"], + }, + "GetScopeRequest": { + "properties": {"scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}}, + "additionalProperties": False, + "type": "object", + "required": ["scope_id"], + }, + "UpdateScopeRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "expected_version": {"type": "integer", "minimum": 1.0}, + "title": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "summary": {"type": "string", "maxLength": 2000, "minLength": 1, "pattern": ".*\\S.*"}, + "parent_scope_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": ".*\\S.*", + "nullable": True, }, - "required": True, - }, - "responses": { - "200": { - "description": "The confirmed Workspace binding.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/HandoffReportWorkspaceBinding"} - } - }, + "context_references": { + "items": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "type": "array", + "uniqueItems": True, + "default": [], }, - "404": {"$ref": "#/components/responses/NotFound"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - } - }, - "/v1/handoff-reports/workspace-bindings/attach": { - "post": { - "tags": ["handoff-reports"], - "summary": "Attach a Handoff Report Workspace Binding", - "operationId": "attach_handoff_report_workspace", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/AttachHandoffReportWorkspaceRequest"} - } + "external_references": { + "items": {"$ref": "#/components/schemas/ScopeExternalReference"}, + "type": "array", + "uniqueItems": True, + "default": [], }, - "required": True, }, - "responses": { - "200": { - "description": "The confirmed Workspace binding.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/HandoffReportWorkspaceBinding"} - } - }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "expected_version", "title", "summary"], + }, + "SetDefaultScopeRequest": {"$ref": "#/components/schemas/GetScopeRequest"}, + "ScopeSelectionMode": {"type": "string", "enum": ["all", "exact", "subtree"]}, + "ScopeSelection": { + "properties": { + "mode": {"$ref": "#/components/schemas/ScopeSelectionMode"}, + "scope_ids": { + "items": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "type": "array", + "uniqueItems": True, + "default": [], }, - "404": {"$ref": "#/components/responses/NotFound"}, - "409": {"$ref": "#/components/responses/Conflict"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - } - }, - "/v1/handoff-reports/workspace-bindings/detach": { - "post": { - "tags": ["handoff-reports"], - "summary": "Detach a Handoff Report Workspace Binding", - "operationId": "detach_handoff_report_workspace", - "requestBody": { - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/DetachHandoffReportWorkspaceRequest"} - } + "root_scope_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": ".*\\S.*", + "nullable": True, }, - "required": True, }, - "responses": { - "200": { - "description": "The detached Workspace binding record.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/HandoffReportWorkspaceBinding"} - } - }, - }, - "404": {"$ref": "#/components/responses/NotFound"}, - "409": {"$ref": "#/components/responses/Conflict"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "500": {"$ref": "#/components/responses/InternalError"}, + "additionalProperties": False, + "type": "object", + "required": ["mode"], + }, + "ResolveScopeSelectionRequest": { + "properties": {"selection": {"$ref": "#/components/schemas/ScopeSelection"}}, + "additionalProperties": False, + "type": "object", + "required": ["selection"], + }, + "ScopeBindingKey": { + "properties": { + "integration": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": ".*\\S.*"}, + "kind": {"type": "string", "maxLength": 64, "minLength": 1, "pattern": ".*\\S.*"}, + "external_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, }, - } - }, - }, - "components": { - "schemas": { - "ActivateHandoffRequest": { + "additionalProperties": False, + "type": "object", + "required": ["integration", "kind", "external_id"], + }, + "ScopeBinding": { "properties": { + "key": {"$ref": "#/components/schemas/ScopeBindingKey"}, "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "boundary_source": {"$ref": "#/components/schemas/SourceReference"}, - "objective": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, - "evidence": { - "items": {"$ref": "#/components/schemas/HandoffCitation"}, - "type": "array", - "maxItems": 32, - "default": [], - }, - "max_bytes": {"type": "integer", "maximum": 32768.0, "minimum": 512.0, "default": 8000}, }, "additionalProperties": False, "type": "object", - "required": ["scope_id", "boundary_source", "objective"], + "required": ["key", "scope_id"], }, - "ArtifactReference": { + "SetScopeBindingRequest": {"$ref": "#/components/schemas/ScopeBinding"}, + "ClearScopeBindingRequest": { + "properties": {"key": {"$ref": "#/components/schemas/ScopeBindingKey"}}, + "additionalProperties": False, + "type": "object", + "required": ["key"], + }, + "ClearScopeBindingResponse": { + "properties": {"cleared": {"type": "boolean"}}, + "additionalProperties": False, + "type": "object", + "required": ["cleared"], + }, + "ResolveScopeBindingRequest": { "properties": { - "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, - "artifact_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, - "revision": {"type": "integer", "minimum": 1.0}, + "explicit_scope_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": ".*\\S.*", + "nullable": True, + }, + "binding_keys": { + "items": {"$ref": "#/components/schemas/ScopeBindingKey"}, + "type": "array", + "default": [], + }, }, "additionalProperties": False, "type": "object", - "required": ["family", "artifact_id", "revision"], }, "ArtifactCandidate": { "properties": { @@ -1905,7 +1944,8 @@ }, "ScopedStats": { "properties": { - "scope_id": {"type": "string"}, + "selection": {"$ref": "#/components/schemas/ScopeSelection"}, + "scope_ids": {"items": {"type": "string"}, "type": "array", "uniqueItems": True}, "as_of": {"type": "string", "format": "date-time"}, "inventory": {"$ref": "#/components/schemas/InventoryStatistics"}, "usage": {"$ref": "#/components/schemas/UsageStatistics"}, @@ -1913,16 +1953,16 @@ }, "additionalProperties": False, "type": "object", - "required": ["scope_id", "as_of", "inventory", "usage", "recall"], + "required": ["selection", "scope_ids", "as_of", "inventory", "usage", "recall"], }, "GetStatsRequest": { "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "selection": {"$ref": "#/components/schemas/ScopeSelection"}, "period": {"$ref": "#/components/schemas/StatsPeriod", "default": "30d"}, }, "additionalProperties": False, "type": "object", - "required": ["scope_id"], + "required": ["selection"], }, "WorkClaimBasis": {"type": "string", "enum": ["declared", "verified"]}, "WorkClaim": { @@ -2677,142 +2717,15 @@ "type": "object", "required": ["scope_id", "artifact"], }, - "CreateHandoffReportProjectRequest": { - "properties": { - "project_key": {"type": "string", "maxLength": 64, "minLength": 1}, - "title": {"type": "string", "maxLength": 256, "minLength": 1}, - "description": {"type": "string", "maxLength": 2000, "nullable": True}, - "default_locale": {"$ref": "#/components/schemas/ReportLocale", "default": "zh-CN"}, - "timezone": {"type": "string", "maxLength": 256, "minLength": 1, "default": "UTC"}, - }, - "additionalProperties": False, - "type": "object", - "required": ["project_key", "title"], - }, - "ListHandoffReportProjectsRequest": { - "properties": { - "cursor": {"type": "string", "nullable": True}, - "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "default": 50}, - "include_archived": {"type": "boolean", "default": False}, - }, - "additionalProperties": False, - "type": "object", - }, - "GetHandoffReportProjectRequest": { - "properties": {"project_id": {"type": "string", "maxLength": 256, "minLength": 1}}, - "additionalProperties": False, - "type": "object", - "required": ["project_id"], - }, - "UpdateHandoffReportProjectRequest": { - "properties": { - "project": {"$ref": "#/components/schemas/ProjectDescriptor"}, - "expected_version": {"type": "integer", "minimum": 1.0}, - }, - "additionalProperties": False, - "type": "object", - "required": ["project", "expected_version"], - }, - "RegisterHandoffReportWorkstreamRequest": { - "properties": { - "project_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "key": {"type": "string", "maxLength": 64, "minLength": 1, "nullable": True}, - "title": {"type": "string", "maxLength": 256, "minLength": 1}, - "kind": {"$ref": "#/components/schemas/WorkstreamKind"}, - "catalog_state": {"$ref": "#/components/schemas/ReportCatalogState", "default": "included"}, - "external_refs": { - "items": {"$ref": "#/components/schemas/HandoffReportExternalReference"}, - "type": "array", - "maxItems": 32, - "default": [], - }, - "labels": { - "items": {"type": "string", "maxLength": 128, "minLength": 1}, - "type": "array", - "maxItems": 32, - "default": [], - }, - }, - "additionalProperties": False, - "type": "object", - "required": ["project_id", "scope_id", "title", "kind"], - }, - "ListHandoffReportWorkstreamsRequest": { - "properties": { - "project_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "cursor": {"type": "string", "nullable": True}, - "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "default": 50}, - "include_archived": {"type": "boolean", "default": False}, - }, - "additionalProperties": False, - "type": "object", - "required": ["project_id"], - }, - "UpdateHandoffReportWorkstreamRequest": { - "properties": { - "workstream": {"$ref": "#/components/schemas/WorkstreamDescriptor"}, - "expected_version": {"type": "integer", "minimum": 1.0}, - }, - "additionalProperties": False, - "type": "object", - "required": ["workstream", "expected_version"], - }, "GetHandoffReportRequest": { "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "project_id": { - "type": "string", - "maxLength": 256, - "minLength": 1, - "description": "Retained for wire compatibility and ignored when generating a scope report.", - "deprecated": True, - "nullable": True, - }, - "locale": {"$ref": "#/components/schemas/ReportLocale", "nullable": True}, - "include_evidence_checks": {"type": "boolean", "default": True}, - "format": {"$ref": "#/components/schemas/ReportFormat", "default": "markdown"}, - "include_archived": {"type": "boolean", "default": False}, + "selection": {"$ref": "#/components/schemas/ScopeSelection"}, + "format": {"$ref": "#/components/schemas/ReportFormat", "default": "json"}, "download": {"type": "boolean", "default": False}, - "period": {"$ref": "#/components/schemas/HandoffReportPeriodRequest", "nullable": True}, - }, - "additionalProperties": False, - "type": "object", - "required": ["scope_id"], - }, - "ListHandoffReportKnownScopesRequest": { - "properties": { - "cursor": {"type": "string", "nullable": True}, - "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "default": 50}, - }, - "additionalProperties": False, - "type": "object", - }, - "KnownHandoffScope": { - "properties": {"scope_id": {"type": "string", "maxLength": 256, "minLength": 1}}, - "additionalProperties": False, - "type": "object", - "required": ["scope_id"], - }, - "KnownHandoffScopePage": { - "properties": { - "items": {"items": {"$ref": "#/components/schemas/KnownHandoffScope"}, "type": "array"}, - "next_cursor": {"type": "string", "nullable": True}, - }, - "additionalProperties": False, - "type": "object", - "required": ["items"], - }, - "HandoffReportPeriodRequest": { - "properties": { - "start": {"type": "string", "format": "date-time"}, - "end": {"type": "string", "format": "date-time"}, - "timezone": {"type": "string", "maxLength": 256, "minLength": 1, "nullable": True}, - "compare_to_previous_period": {"type": "boolean", "default": False}, }, "additionalProperties": False, "type": "object", - "required": ["start", "end"], + "required": ["selection"], }, "HandoffReportResponse": { "properties": { @@ -2826,326 +2739,7 @@ "type": "object", "required": ["format", "report", "markdown", "selection_digest", "report_digest"], }, - "ReportActivitySource": { - "type": "string", - "enum": ["handoff_observation", "git_commit", "git_worktree", "coding_session", "other"], - }, - "ReportTimeBasis": { - "type": "string", - "enum": ["source_reported", "host_observed", "first_seen", "current_only", "unknown"], - }, - "HandoffReportActivityAgent": { - "properties": { - "provider": {"type": "string", "maxLength": 64, "minLength": 1, "nullable": True}, - "label": {"type": "string", "maxLength": 128, "minLength": 1, "nullable": True}, - }, - "additionalProperties": False, - "type": "object", - }, - "HandoffReportActivityVcsContext": { - "properties": { - "branch": {"type": "string", "maxLength": 256, "minLength": 1, "nullable": True}, - "head_revision": {"type": "string", "maxLength": 256, "minLength": 1, "nullable": True}, - }, - "additionalProperties": False, - "type": "object", - }, - "RecordHandoffReportActivityRequest": { - "properties": { - "project_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "nullable": True}, - "source": {"$ref": "#/components/schemas/ReportActivitySource"}, - "source_event_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "source_ref": {"$ref": "#/components/schemas/HandoffReportExternalReference", "nullable": True}, - "occurred_at": {"type": "string", "format": "date-time", "nullable": True}, - "time_basis": {"$ref": "#/components/schemas/ReportTimeBasis"}, - "title": {"type": "string", "maxLength": 256, "minLength": 1, "nullable": True}, - "summary": {"type": "string", "maxLength": 2000, "minLength": 1, "nullable": True}, - "agent": {"$ref": "#/components/schemas/HandoffReportActivityAgent", "nullable": True}, - "session_id": {"type": "string", "maxLength": 256, "minLength": 1, "nullable": True}, - "vcs_context": {"$ref": "#/components/schemas/HandoffReportActivityVcsContext", "nullable": True}, - "evidence_refs": { - "items": {"$ref": "#/components/schemas/HandoffReportExternalReference"}, - "type": "array", - "maxItems": 32, - "default": [], - }, - }, - "additionalProperties": False, - "type": "object", - "required": ["project_id", "source", "source_event_id", "time_basis"], - }, - "HandoffReportActivity": { - "properties": { - "schema": {"type": "string", "enum": ["powercontext.handoff-report-activity.v1"]}, - "event_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "project_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "nullable": True}, - "source": {"$ref": "#/components/schemas/ReportActivitySource"}, - "source_event_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "source_ref": {"$ref": "#/components/schemas/HandoffReportExternalReference", "nullable": True}, - "occurred_at": {"type": "string", "format": "date-time", "nullable": True}, - "observed_at": {"type": "string", "format": "date-time"}, - "time_basis": {"$ref": "#/components/schemas/ReportTimeBasis"}, - "title": {"type": "string", "maxLength": 256, "minLength": 1, "nullable": True}, - "summary": {"type": "string", "maxLength": 2000, "minLength": 1, "nullable": True}, - "agent": {"$ref": "#/components/schemas/HandoffReportActivityAgent", "nullable": True}, - "session_id": {"type": "string", "maxLength": 256, "minLength": 1, "nullable": True}, - "vcs_context": {"$ref": "#/components/schemas/HandoffReportActivityVcsContext", "nullable": True}, - "evidence_refs": { - "items": {"$ref": "#/components/schemas/HandoffReportExternalReference"}, - "type": "array", - "maxItems": 32, - }, - "trust": {"type": "string", "enum": ["untrusted_observation"]}, - }, - "additionalProperties": False, - "type": "object", - "required": [ - "schema", - "event_id", - "project_id", - "scope_id", - "source", - "source_event_id", - "source_ref", - "occurred_at", - "observed_at", - "time_basis", - "title", - "summary", - "agent", - "session_id", - "vcs_context", - "evidence_refs", - "trust", - ], - }, - "StoredHandoffReportActivity": { - "properties": { - "cursor": {"type": "integer", "minimum": 1.0}, - "event": {"$ref": "#/components/schemas/HandoffReportActivity"}, - }, - "additionalProperties": False, - "type": "object", - "required": ["cursor", "event"], - }, - "ListHandoffReportActivitiesRequest": { - "properties": { - "project_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "period_start": {"type": "string", "format": "date-time", "nullable": True}, - "period_end": {"type": "string", "format": "date-time", "nullable": True}, - "sources": { - "items": {"$ref": "#/components/schemas/ReportActivitySource"}, - "type": "array", - "maxItems": 5, - "nullable": True, - }, - "after_cursor": {"type": "integer", "minimum": 0.0, "default": 0}, - "through_cursor": {"type": "integer", "minimum": 0.0, "nullable": True}, - "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "default": 50}, - }, - "additionalProperties": False, - "type": "object", - "required": ["project_id"], - }, - "HandoffReportActivityPage": { - "properties": { - "items": { - "items": {"$ref": "#/components/schemas/HandoffReportActivity"}, - "type": "array", - "maxItems": 100, - }, - "next_cursor": {"type": "integer", "minimum": 1.0, "nullable": True}, - "high_watermark": {"type": "integer", "minimum": 0.0}, - }, - "additionalProperties": False, - "type": "object", - "required": ["items", "next_cursor", "high_watermark"], - }, - "PurgeHandoffReportActivitiesRequest": { - "properties": { - "project_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "observed_before": {"type": "string", "format": "date-time"}, - }, - "additionalProperties": False, - "type": "object", - "required": ["project_id", "observed_before"], - }, - "PurgeHandoffReportActivitiesResponse": { - "properties": {"deleted_count": {"type": "integer", "minimum": 0.0}}, - "additionalProperties": False, - "type": "object", - "required": ["deleted_count"], - }, - "HandoffReportRepositoryRef": { - "properties": { - "provider": {"type": "string", "enum": ["github", "gitlab", "local", "other"]}, - "repository_id": {"type": "string", "maxLength": 256, "minLength": 1, "nullable": True}, - "normalized_remote": {"type": "string", "maxLength": 2048, "minLength": 1, "nullable": True}, - "subpath": {"type": "string", "maxLength": 1024, "minLength": 1, "nullable": True}, - }, - "additionalProperties": False, - "type": "object", - "required": ["provider", "repository_id", "normalized_remote", "subpath"], - }, - "HandoffReportWorkspaceBinding": { - "properties": { - "schema": {"type": "string", "enum": ["powercontext.workspace-binding.v1"]}, - "workspace_instance_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "project_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "repository_ref": {"$ref": "#/components/schemas/HandoffReportRepositoryRef"}, - "state": {"type": "string", "enum": ["confirmed", "detached"]}, - "confirmed_at": {"type": "string", "format": "date-time"}, - "version": {"type": "integer", "minimum": 1.0}, - }, - "additionalProperties": False, - "type": "object", - "required": [ - "schema", - "workspace_instance_id", - "project_id", - "repository_ref", - "state", - "confirmed_at", - "version", - ], - }, - "GetHandoffReportWorkspaceRequest": { - "properties": {"workspace_instance_id": {"type": "string", "maxLength": 256, "minLength": 1}}, - "additionalProperties": False, - "type": "object", - "required": ["workspace_instance_id"], - }, - "AttachHandoffReportWorkspaceRequest": { - "properties": { - "workspace_instance_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "project_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "repository_ref": {"$ref": "#/components/schemas/HandoffReportRepositoryRef"}, - "expected_version": {"type": "integer", "minimum": 1.0, "nullable": True}, - }, - "additionalProperties": False, - "type": "object", - "required": ["workspace_instance_id", "project_id", "repository_ref", "expected_version"], - }, - "DetachHandoffReportWorkspaceRequest": { - "properties": { - "workspace_instance_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "expected_version": {"type": "integer", "minimum": 1.0}, - }, - "additionalProperties": False, - "type": "object", - "required": ["workspace_instance_id", "expected_version"], - }, - "ProjectDescriptor": { - "properties": { - "schema": {"type": "string", "enum": ["powercontext.project.v1"]}, - "project_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "project_key": {"type": "string", "maxLength": 64, "minLength": 1}, - "title": {"type": "string", "maxLength": 256, "minLength": 1}, - "description": {"type": "string", "maxLength": 2000, "nullable": True}, - "default_locale": {"$ref": "#/components/schemas/ReportLocale"}, - "timezone": {"type": "string", "maxLength": 256, "minLength": 1}, - "catalog_state": {"$ref": "#/components/schemas/ReportCatalogState"}, - "version": {"type": "integer", "minimum": 1.0}, - }, - "additionalProperties": False, - "type": "object", - "required": [ - "schema", - "project_id", - "project_key", - "title", - "description", - "default_locale", - "timezone", - "catalog_state", - "version", - ], - }, - "ProjectPage": { - "properties": { - "items": { - "items": {"$ref": "#/components/schemas/ProjectDescriptor"}, - "type": "array", - "maxItems": 100, - }, - "next_cursor": {"type": "string", "nullable": True}, - }, - "additionalProperties": False, - "type": "object", - "required": ["items", "next_cursor"], - }, - "WorkstreamDescriptor": { - "properties": { - "schema": {"type": "string", "enum": ["powercontext.workstream.v1"]}, - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "project_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "key": {"type": "string", "maxLength": 64, "nullable": True}, - "title": {"type": "string", "maxLength": 256, "minLength": 1}, - "kind": {"$ref": "#/components/schemas/WorkstreamKind"}, - "catalog_state": {"$ref": "#/components/schemas/ReportCatalogState"}, - "external_refs": { - "items": {"$ref": "#/components/schemas/HandoffReportExternalReference"}, - "type": "array", - "maxItems": 32, - }, - "labels": { - "items": {"type": "string", "maxLength": 128, "minLength": 1}, - "type": "array", - "maxItems": 32, - }, - "version": {"type": "integer", "minimum": 1.0}, - }, - "additionalProperties": False, - "type": "object", - "required": [ - "schema", - "scope_id", - "project_id", - "key", - "title", - "kind", - "catalog_state", - "external_refs", - "labels", - "version", - ], - }, - "WorkstreamPage": { - "properties": { - "items": { - "items": {"$ref": "#/components/schemas/WorkstreamDescriptor"}, - "type": "array", - "maxItems": 100, - }, - "next_cursor": {"type": "string", "nullable": True}, - }, - "additionalProperties": False, - "type": "object", - "required": ["items", "next_cursor"], - }, - "HandoffReportExternalReference": { - "properties": { - "kind": { - "type": "string", - "enum": ["issue", "task", "pull_request", "branch", "feature", "release", "program", "other"], - }, - "provider": {"type": "string", "maxLength": 64, "minLength": 1}, - "external_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "url": {"type": "string", "maxLength": 2048, "nullable": True}, - }, - "additionalProperties": False, - "type": "object", - "required": ["kind", "provider", "external_id", "url"], - }, - "ReportLocale": {"type": "string", "enum": ["zh-CN", "en"]}, "ReportFormat": {"type": "string", "enum": ["json", "markdown"]}, - "ReportCatalogState": {"type": "string", "enum": ["included", "archived"]}, - "WorkstreamKind": { - "type": "string", - "enum": ["feature", "bug", "refactor", "operations", "research", "other"], - }, "HealthResponse": { "properties": {"status": {"type": "string"}}, "additionalProperties": False, diff --git a/src/powercontext/limits.py b/src/powercontext/limits.py index f71ed0fe2..5550c3507 100644 --- a/src/powercontext/limits.py +++ b/src/powercontext/limits.py @@ -15,6 +15,14 @@ """Shared identity limits that remain safe for utf8mb4 relational indexes.""" MAX_SCOPE_ID_LENGTH = 256 +MAX_SCOPE_TITLE_LENGTH = 256 +MAX_SCOPE_SUMMARY_LENGTH = 2_000 +MAX_SCOPE_EXTERNAL_REFERENCE_KIND_LENGTH = 128 +MAX_SCOPE_EXTERNAL_REFERENCE_VALUE_LENGTH = 2_000 +MAX_SCOPE_IDEMPOTENCY_KEY_LENGTH = 256 +MAX_SCOPE_BINDING_INTEGRATION_LENGTH = 128 +MAX_SCOPE_BINDING_KIND_LENGTH = 64 +MAX_SCOPE_BINDING_EXTERNAL_ID_LENGTH = 256 MAX_SOURCE_ID_LENGTH = 256 MAX_SOURCE_TYPE_LENGTH = 128 MAX_ARTIFACT_FAMILY_LENGTH = 128 diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 8cfd96edd..c689b5864 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -22,22 +22,21 @@ from collections.abc import Awaitable, Callable, Sequence from contextlib import suppress from copy import deepcopy -from datetime import UTC, datetime from functools import wraps from time import perf_counter from typing import TYPE_CHECKING, Annotated, Any, Protocol, TypeVar, cast -from uuid import uuid4 -from fastapi import Depends, FastAPI, Query, Request, Response, status +from fastapi import Depends, FastAPI, Request, Response, status from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from opentelemetry.trace import SpanKind +from pydantic import ValidationError as PydanticValidationError from starlette.middleware import Middleware from starlette.middleware.base import RequestResponseEndpoint from starlette.types import Lifespan from powercontext._logging import log_safely -from powercontext.artifacts import ArtifactRef +from powercontext.artifacts import ArtifactAddress, ArtifactRef from powercontext.builtin.artifacts.experience import Experience from powercontext.builtin.artifacts.handoff import ( HandoffEvidenceUnavailableError, @@ -65,37 +64,18 @@ ) from powercontext.builtin.handoff_report import ( HandoffReportApplication, - HandoffReportBusyError, - HandoffReportCatalogArgumentError, HandoffReportError, HandoffReportInconsistentError, HandoffReportTooLargeError, - ProjectConflictError, - ProjectNotFoundError, - ReportPeriodInput, - ScopeAlreadyGroupedError, - WorkspaceBindingConflictError, - WorkspaceBindingNotFoundError, - WorkstreamConflictError, - WorkstreamNotFoundError, ) -from powercontext.builtin.handoff_report.models import ( - ExternalReference as ReportExternalReference, -) -from powercontext.builtin.handoff_report.models import ( - ProjectDescriptor as DomainProjectDescriptor, -) -from powercontext.builtin.handoff_report.models import ReportActivityEvent as DomainReportActivityEvent -from powercontext.builtin.handoff_report.models import RepositoryRef as DomainRepositoryRef -from powercontext.builtin.handoff_report.models import ( - WorkstreamDescriptor as DomainWorkstreamDescriptor, +from powercontext.builtin.inference.errors import InferenceTimeoutError, InferenceUnavailableError +from powercontext.builtin.publication import ( + ArtifactPublicationApplication, + ArtifactPublicationConflictError, ) -from powercontext.builtin.handoff_report.repository import ( - ActivityEventConflictError, - InvalidActivityEventError, - InvalidActivityRepositoryArgumentError, +from powercontext.builtin.publication import ( + ArtifactPublicationRequest as DomainArtifactPublicationRequest, ) -from powercontext.builtin.inference.errors import InferenceTimeoutError, InferenceUnavailableError from powercontext.builtin.review import ( ArtifactTargetConflictError, CandidateConflictError, @@ -192,6 +172,28 @@ from powercontext.builtin.runtime import ( StatisticsPeriod as RuntimeStatisticsPeriod, ) +from powercontext.builtin.scope import ( + ScopeApplication, + ScopeBindingNotFoundError, + ScopeDraft, + ScopeIdempotencyConflictError, + ScopeMutation, + ScopeNotFoundError, + ScopeRelationshipError, + ScopeVersionConflictError, +) +from powercontext.builtin.scope import ( + ScopeBindingKey as DomainScopeBindingKey, +) +from powercontext.builtin.scope import ( + ScopeDescriptor as DomainScopeDescriptor, +) +from powercontext.builtin.scope import ( + ScopeExternalReference as DomainScopeExternalReference, +) +from powercontext.builtin.scope import ( + ScopeSelection as DomainScopeSelection, +) from powercontext.builtin.work import ( AcknowledgeHandoff as RuntimeAcknowledgeHandoff, ) @@ -219,16 +221,16 @@ ApproveArtifactCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, - AttachHandoffReportWorkspaceRequest, Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, + ClearScopeBindingRequest, + ClearScopeBindingResponse, CommitHandoffRequest, CommittedHandoff, ContinueHandoffRequest, - CreateHandoffReportProjectRequest, + CreateScopeRequest, CreateWorkContractRequest, - DetachHandoffReportWorkspaceRequest, ErrorDetail, ErrorResponse, ExperienceArtifact, @@ -241,30 +243,20 @@ GenerateSkillRequest, GetArtifactCandidateRequest, GetExperienceRequest, - GetHandoffReportProjectRequest, GetHandoffReportRequest, - GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, + GetScopeRequest, GetSkillRequest, GetStatsRequest, HandoffAcknowledgement, HandoffCurrentWorkRequest, - HandoffReportActivity, - HandoffReportActivityPage, HandoffReportResponse, - HandoffReportWorkspaceBinding, HandoffSelection, HealthResponse, ImportExternalSkillRequest, - KnownHandoffScope, - KnownHandoffScopePage, ListArtifactCandidatesRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, - ListHandoffReportActivitiesRequest, - ListHandoffReportKnownScopesRequest, - ListHandoffReportProjectsRequest, - ListHandoffReportWorkstreamsRequest, ListMemoryChangesRequest, ListMemoryChangesResponse, ListMemoryEntriesRequest, @@ -275,35 +267,38 @@ PreparedContext, PreparedWorkHandoff, PrepareHandoffRequest, - ProjectDescriptor, - ProjectPage, ProposeExperienceRequest, ProposeSkillRequest, - PurgeHandoffReportActivitiesRequest, - PurgeHandoffReportActivitiesResponse, + PublishArtifactRequest, ReadinessResponse, ReadinessStatus, - RecordHandoffReportActivityRequest, RecordTaskOutcomeRequest, - RegisterHandoffReportWorkstreamRequest, RejectArtifactCandidateRequest, RememberMemoryRequest, ResolveExternalSkillRequest, + ResolveScopeBindingRequest, + ResolveScopeSelectionRequest, RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, ReviseMemoryEntryRequest, ScanExternalSkillsRequest, ScanExternalSkillsResponse, + ScopeBinding, + ScopeBindingKey, + ScopeDescriptor, ScopedStats, + ScopePage, + ScopeSelection, SearchMemoryRequest, SearchMemoryResponse, + SetDefaultScopeRequest, + SetScopeBindingRequest, SkillArtifact, - StoredHandoffReportActivity, - UpdateHandoffReportProjectRequest, - UpdateHandoffReportWorkstreamRequest, + UpdateScopeRequest, WorkSourceReceipt, - WorkstreamDescriptor, - WorkstreamPage, +) +from powercontext.http import ( + ArtifactPublication as TransportArtifactPublication, ) from powercontext.http import ( HandoffActivation as TransportHandoffActivation, @@ -324,57 +319,54 @@ API_TITLE, API_VERSION, APPROVE_ARTIFACT_CANDIDATE, - ATTACH_HANDOFF_REPORT_WORKSPACE, CAPTURE_CONTENT_SOURCE, + CLEAR_SCOPE_BINDING, COMMIT_HANDOFF, CONTINUE_HANDOFF, - CREATE_HANDOFF_REPORT_PROJECT, + CREATE_SCOPE, CREATE_WORK_CONTRACT, - DETACH_HANDOFF_REPORT_WORKSPACE, FINALIZE_HANDOFF, FLUSH_MEMORY, GENERATE_EXPERIENCE, GENERATE_SKILL, GET_ARTIFACT_CANDIDATE, GET_CAPABILITIES, + GET_DEFAULT_SCOPE, GET_EXPERIENCE, GET_HANDOFF_REPORT, - GET_HANDOFF_REPORT_PROJECT, - GET_HANDOFF_REPORT_WORKSPACE, GET_LIVENESS, GET_MEMORY_ENTRY, GET_READINESS, + GET_SCOPE, GET_SKILL, GET_STATS, HANDOFF_CURRENT_WORK, IMPORT_EXTERNAL_SKILL, LIST_ARTIFACT_CANDIDATES, LIST_EXTERNAL_SKILLS, - LIST_HANDOFF_REPORT_ACTIVITIES, - LIST_HANDOFF_REPORT_KNOWN_SCOPES, - LIST_HANDOFF_REPORT_PROJECTS, - LIST_HANDOFF_REPORT_WORKSTREAMS, LIST_MEMORY_CHANGES, LIST_MEMORY_ENTRIES, + LIST_SCOPES, OPENAPI_VERSION, PREPARE_CONTEXT, PREPARE_HANDOFF, PROPOSE_EXPERIENCE, PROPOSE_SKILL, - PURGE_HANDOFF_REPORT_ACTIVITIES, - RECORD_HANDOFF_REPORT_ACTIVITY, + PUBLISH_ARTIFACT, RECORD_TASK_OUTCOME, - REGISTER_HANDOFF_REPORT_WORKSTREAM, REJECT_ARTIFACT_CANDIDATE, REMEMBER_MEMORY, RESOLVE_EXTERNAL_SKILL, + RESOLVE_SCOPE_BINDING, + RESOLVE_SCOPE_SELECTION, RETIRE_MEMORY_ENTRY, REVISE_ARTIFACT_CANDIDATE, REVISE_MEMORY_ENTRY, SCAN_EXTERNAL_SKILLS, SEARCH_MEMORY, - UPDATE_HANDOFF_REPORT_PROJECT, - UPDATE_HANDOFF_REPORT_WORKSTREAM, + SET_DEFAULT_SCOPE, + SET_SCOPE_BINDING, + UPDATE_SCOPE, Operation, ) from powercontext.http._generated.schema import OPENAPI_SCHEMA @@ -535,8 +527,17 @@ async def overview(self, *, period: RuntimeStatisticsPeriod) -> RuntimeStatistic class _StatisticsApplication(Protocol): def for_scope(self, scope_id: str, /) -> _ScopedStatisticsApplication: ... + async def overview( + self, + selection: DomainScopeSelection, + *, + period: RuntimeStatisticsPeriod, + ) -> RuntimeStatistics: ... + class ServerApplication(Protocol): + scopes: ScopeApplication | None + publications: ArtifactPublicationApplication | None sources: _SourceApplication context: _ContextApplication experience: _ExperienceApplication @@ -606,7 +607,11 @@ async def attach_request_id(request: Request, call_next: RequestResponseEndpoint return response @app.exception_handler(RequestValidationError) - async def invalid_request(request: Request, error: RequestValidationError) -> JSONResponse: + @app.exception_handler(PydanticValidationError) + async def invalid_request( + request: Request, + error: RequestValidationError | PydanticValidationError, + ) -> JSONResponse: return _error_response( status.HTTP_422_UNPROCESSABLE_CONTENT, code="invalid_request", @@ -635,22 +640,19 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, GET_LIVENESS, get_liveness) _add_route(app, GET_READINESS, get_readiness) _add_route(app, GET_CAPABILITIES, get_capabilities) + _add_route(app, LIST_SCOPES, list_scopes) + _add_route(app, CREATE_SCOPE, create_scope) + _add_route(app, GET_SCOPE, get_scope) + _add_route(app, UPDATE_SCOPE, update_scope) + _add_route(app, GET_DEFAULT_SCOPE, get_default_scope) + _add_route(app, SET_DEFAULT_SCOPE, set_default_scope) + _add_route(app, RESOLVE_SCOPE_SELECTION, resolve_scope_selection) + _add_route(app, RESOLVE_SCOPE_BINDING, resolve_scope_binding) + _add_route(app, SET_SCOPE_BINDING, set_scope_binding) + _add_route(app, CLEAR_SCOPE_BINDING, clear_scope_binding) + _add_route(app, PUBLISH_ARTIFACT, publish_artifact) _add_route(app, GET_STATS, get_stats) if handoff_report_enabled: - _add_route(app, CREATE_HANDOFF_REPORT_PROJECT, create_handoff_report_project) - _add_route(app, GET_HANDOFF_REPORT_PROJECT, get_handoff_report_project) - _add_route(app, UPDATE_HANDOFF_REPORT_PROJECT, update_handoff_report_project) - _add_route(app, LIST_HANDOFF_REPORT_PROJECTS, list_handoff_report_projects) - _add_route(app, LIST_HANDOFF_REPORT_KNOWN_SCOPES, list_handoff_report_known_scopes) - _add_route(app, REGISTER_HANDOFF_REPORT_WORKSTREAM, register_handoff_report_workstream) - _add_route(app, LIST_HANDOFF_REPORT_WORKSTREAMS, list_handoff_report_workstreams) - _add_route(app, UPDATE_HANDOFF_REPORT_WORKSTREAM, update_handoff_report_workstream) - _add_route(app, RECORD_HANDOFF_REPORT_ACTIVITY, record_handoff_report_activity) - _add_route(app, LIST_HANDOFF_REPORT_ACTIVITIES, list_handoff_report_activities) - _add_route(app, PURGE_HANDOFF_REPORT_ACTIVITIES, purge_handoff_report_activities) - _add_route(app, GET_HANDOFF_REPORT_WORKSPACE, get_handoff_report_workspace) - _add_route(app, ATTACH_HANDOFF_REPORT_WORKSPACE, attach_handoff_report_workspace) - _add_route(app, DETACH_HANDOFF_REPORT_WORKSPACE, detach_handoff_report_workspace) _add_route(app, GET_HANDOFF_REPORT, get_handoff_report) _add_route(app, CAPTURE_CONTENT_SOURCE, capture_content_source) _add_route(app, FLUSH_MEMORY, flush_memory) @@ -723,192 +725,140 @@ async def get_capabilities(request: Request) -> Capabilities: return request.app.state.capabilities -async def get_stats( - request: Annotated[GetStatsRequest, Query()], - response: Response, - application: Annotated[ServerApplication, Depends(_require_application)], -) -> ScopedStats: - response.headers["Cache-Control"] = "no-store" - result = await application.statistics.for_scope(request.scope_id).overview( - period=RuntimeStatisticsPeriod(request.period.value) - ) - return mapping.statistics_response(result) - - -async def create_handoff_report_project( - request: CreateHandoffReportProjectRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> ProjectDescriptor: - result = await report.create_project( - project_key=request.project_key, - title=request.title, - description=request.description, - default_locale=request.default_locale.value, - timezone=request.timezone, +async def list_scopes( + scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], +) -> ScopePage: + return ScopePage(items=[_scope_descriptor_response(scope) for scope in await scopes.list()]) + + +async def create_scope( + request: CreateScopeRequest, + scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], +) -> ScopeDescriptor: + created = await scopes.create( + ScopeDraft( + title=request.title, + summary=request.summary, + parent_scope_id=request.parent_scope_id, + context_references=tuple(reference.root for reference in request.context_references), + external_references=tuple( + DomainScopeExternalReference(kind=reference.kind, value=reference.value) + for reference in request.external_references + ), + idempotency_key=request.idempotency_key, + ) ) - return _project_descriptor_response(result) - + return _scope_descriptor_response(created) -async def get_handoff_report_project( - request: GetHandoffReportProjectRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> ProjectDescriptor: - return _project_descriptor_response(await report.get_project(request.project_id)) +async def get_scope( + request: GetScopeRequest, + scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], +) -> ScopeDescriptor: + return _scope_descriptor_response(await scopes.get(request.scope_id)) -async def update_handoff_report_project( - request: UpdateHandoffReportProjectRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> ProjectDescriptor: - descriptor = DomainProjectDescriptor.model_validate_json(request.project.model_dump_json(by_alias=True)) - return _project_descriptor_response(await report.update_project(descriptor, request.expected_version)) - -async def list_handoff_report_projects( - request: ListHandoffReportProjectsRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> ProjectPage: - result = await report.list_projects( - cursor=request.cursor, - limit=request.limit, - include_archived=request.include_archived, - ) - return ProjectPage( - items=[_project_descriptor_response(item) for item in result.items], - next_cursor=result.next_cursor, - ) - - -async def list_handoff_report_known_scopes( - request: ListHandoffReportKnownScopesRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> KnownHandoffScopePage: - result = await report.list_known_scopes(cursor=request.cursor, limit=request.limit) - return KnownHandoffScopePage( - items=[KnownHandoffScope(scope_id=scope_id) for scope_id in result.items], - next_cursor=result.next_cursor, +async def update_scope( + request: UpdateScopeRequest, + scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], +) -> ScopeDescriptor: + updated = await scopes.update( + request.scope_id, + ScopeMutation( + expected_version=request.expected_version, + title=request.title, + summary=request.summary, + parent_scope_id=request.parent_scope_id, + context_references=tuple(reference.root for reference in request.context_references), + external_references=tuple( + DomainScopeExternalReference(kind=reference.kind, value=reference.value) + for reference in request.external_references + ), + ), ) + return _scope_descriptor_response(updated) -async def register_handoff_report_workstream( - request: RegisterHandoffReportWorkstreamRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> WorkstreamDescriptor: - values = request.model_dump(mode="json") - result = await report.register_workstream( - project_id=request.project_id, - scope_id=request.scope_id, - title=request.title, - kind=request.kind.value, - key=request.key, - catalog_state=request.catalog_state.value, - external_refs=tuple(ReportExternalReference.model_validate(value) for value in values["external_refs"]), - labels=tuple(str(value) for value in values["labels"]), - ) - return _workstream_descriptor_response(result) +async def get_default_scope( + scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], +) -> ScopeDescriptor: + current = await scopes.default_scope() + if current is None: + raise ScopeBindingNotFoundError + return _scope_descriptor_response(current) -async def list_handoff_report_workstreams( - request: ListHandoffReportWorkstreamsRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> WorkstreamPage: - result = await report.list_workstreams( - request.project_id, - cursor=request.cursor, - limit=request.limit, - include_archived=request.include_archived, - ) - return WorkstreamPage( - items=[_workstream_descriptor_response(item) for item in result.items], - next_cursor=result.next_cursor, - ) +async def set_default_scope( + request: SetDefaultScopeRequest, + scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], +) -> ScopeDescriptor: + return _scope_descriptor_response(await scopes.set_default(request.root.scope_id)) -async def update_handoff_report_workstream( - request: UpdateHandoffReportWorkstreamRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> WorkstreamDescriptor: - descriptor = DomainWorkstreamDescriptor.model_validate_json(request.workstream.model_dump_json(by_alias=True)) - return _workstream_descriptor_response(await report.update_workstream(descriptor, request.expected_version)) +async def resolve_scope_selection( + request: ResolveScopeSelectionRequest, + scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], +) -> ScopePage: + selection = _domain_scope_selection(request.selection) + return ScopePage(items=[_scope_descriptor_response(scope) for scope in await scopes.resolve_selection(selection)]) -async def record_handoff_report_activity( - request: RecordHandoffReportActivityRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> StoredHandoffReportActivity: - values = request.model_dump(mode="json") - event = DomainReportActivityEvent.model_validate_json( - json.dumps({ - **values, - "event_id": f"evt_{uuid4().hex}", - "observed_at": datetime.now(UTC).isoformat(), - "trust": "untrusted_observation", - }) - ) - stored = await report.record_activity(event) - return StoredHandoffReportActivity( - cursor=stored.cursor, - event=HandoffReportActivity.model_validate(stored.payload), +async def resolve_scope_binding( + request: ResolveScopeBindingRequest, + scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], +) -> ScopeDescriptor: + resolved = await scopes.resolve_binding( + explicit_scope_id=request.explicit_scope_id, + binding_keys=tuple(_domain_binding_key(key) for key in request.binding_keys), ) + return _scope_descriptor_response(resolved) -async def list_handoff_report_activities( - request: ListHandoffReportActivitiesRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> HandoffReportActivityPage: - page = await report.list_activities( - request.project_id, - period_start=request.period_start, - period_end=request.period_end, - sources=None if request.sources is None else tuple(value.value for value in request.sources), - after_cursor=request.after_cursor, - through_cursor=request.through_cursor, - limit=request.limit, - ) - return HandoffReportActivityPage( - items=[ - HandoffReportActivity.model_validate(item.model_dump(mode="json", by_alias=True)) for item in page.items - ], - next_cursor=page.next_cursor, - high_watermark=page.high_watermark, +async def set_scope_binding( + request: SetScopeBindingRequest, + scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], +) -> ScopeBinding: + binding = await scopes.bind(_domain_binding_key(request.root.key), request.root.scope_id) + return ScopeBinding( + key=_transport_binding_key(binding.key), + scope_id=binding.scope_id, ) -async def purge_handoff_report_activities( - request: PurgeHandoffReportActivitiesRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> PurgeHandoffReportActivitiesResponse: - deleted = await report.purge_activities(request.project_id, request.observed_before) - return PurgeHandoffReportActivitiesResponse(deleted_count=deleted) - - -async def get_handoff_report_workspace( - request: GetHandoffReportWorkspaceRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> HandoffReportWorkspaceBinding: - binding = await report.get_workspace_binding(request.workspace_instance_id) - return HandoffReportWorkspaceBinding.model_validate(binding.model_dump(mode="json", by_alias=True)) - - -async def attach_handoff_report_workspace( - request: AttachHandoffReportWorkspaceRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> HandoffReportWorkspaceBinding: - binding = await report.attach_workspace_binding( - workspace_instance_id=request.workspace_instance_id, - project_id=request.project_id, - repository_ref=DomainRepositoryRef.model_validate(request.repository_ref.model_dump(mode="json")), - expected_version=request.expected_version, +async def clear_scope_binding( + request: ClearScopeBindingRequest, + scopes: Annotated[ScopeApplication, Depends(_require_scope_application)], +) -> ClearScopeBindingResponse: + return ClearScopeBindingResponse(cleared=await scopes.clear_binding(_domain_binding_key(request.key))) + + +async def publish_artifact( + request: PublishArtifactRequest, + publications: Annotated[ArtifactPublicationApplication, Depends(_require_publication_application)], +) -> TransportArtifactPublication: + result = await publications.publish( + DomainArtifactPublicationRequest( + source=ArtifactAddress( + scope_id=request.source.scope_id, + artifact=ArtifactRef.model_validate(request.source.artifact.model_dump(mode="json")), + ), + target_scope_id=request.target_scope_id, + idempotency_key=request.idempotency_key, + ) ) - return HandoffReportWorkspaceBinding.model_validate(binding.model_dump(mode="json", by_alias=True)) + return TransportArtifactPublication.model_validate(result.model_dump(mode="json")) -async def detach_handoff_report_workspace( - request: DetachHandoffReportWorkspaceRequest, - report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], -) -> HandoffReportWorkspaceBinding: - binding = await report.detach_workspace_binding(request.workspace_instance_id, request.expected_version) - return HandoffReportWorkspaceBinding.model_validate(binding.model_dump(mode="json", by_alias=True)) +async def get_stats( + request: GetStatsRequest, + response: Response, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> ScopedStats: + response.headers["Cache-Control"] = "no-store" + result = await application.statistics.overview( + _domain_scope_selection(request.selection), period=RuntimeStatisticsPeriod(request.period.value) + ) + return mapping.statistics_response(result) async def get_handoff_report( @@ -916,23 +866,7 @@ async def get_handoff_report( response: Response, report: Annotated[HandoffReportApplication, Depends(_require_handoff_report_application)], ) -> HandoffReportResponse | Response: - result = await report.get_report( - request.scope_id, - locale=None if request.locale is None else request.locale.value, - include_evidence_checks=request.include_evidence_checks, - report_format=request.format.value, - include_archived=request.include_archived, - period=( - None - if request.period is None - else ReportPeriodInput( - start=request.period.start, - end=request.period.end, - timezone=request.period.timezone, - compare_to_previous_period=request.period.compare_to_previous_period, - ) - ), - ) + result = await report.get_report(_domain_scope_selection(request.selection)) selection_digest = cast(str, result.selection_digest) report_digest = cast(str, result.report_digest) response.headers["Cache-Control"] = "no-store" @@ -982,8 +916,7 @@ def _require_report_size(estimated_bytes: int, report: Any) -> None: return raise HandoffReportTooLargeError( estimated_bytes=estimated_bytes, - selected_workstreams=report.coverage.selected_workstreams, - selected_activities=len(report.activity_selection), + selected_scopes=len(report.scopes), ) @@ -1327,6 +1260,20 @@ def _require_application(request: Request) -> ServerApplication: return application +def _require_scope_application(request: Request) -> ScopeApplication: + application = _require_application(request) + if application.scopes is None: + raise _RuntimeNotReadyError + return application.scopes + + +def _require_publication_application(request: Request) -> ArtifactPublicationApplication: + application = _require_application(request) + if application.publications is None: + raise _RuntimeNotReadyError + return application.publications + + def _require_handoff_report_application(request: Request) -> HandoffReportApplication: application = _require_application(request) if application.handoff_report is None: @@ -1334,12 +1281,32 @@ def _require_handoff_report_application(request: Request) -> HandoffReportApplic return application.handoff_report -def _project_descriptor_response(value: DomainProjectDescriptor) -> ProjectDescriptor: - return ProjectDescriptor.model_validate(value.model_dump(mode="json", by_alias=True)) +def _scope_descriptor_response(value: DomainScopeDescriptor) -> ScopeDescriptor: + return ScopeDescriptor.model_validate(value.model_dump(mode="json")) + + +def _domain_binding_key(value: ScopeBindingKey) -> DomainScopeBindingKey: + return DomainScopeBindingKey( + integration=value.integration, + kind=value.kind, + external_id=value.external_id, + ) + + +def _domain_scope_selection(value: ScopeSelection) -> DomainScopeSelection: + return DomainScopeSelection( + mode=value.mode.value, + scope_ids=tuple(scope_id.root for scope_id in value.scope_ids), + root_scope_id=value.root_scope_id, + ) -def _workstream_descriptor_response(value: DomainWorkstreamDescriptor) -> WorkstreamDescriptor: - return WorkstreamDescriptor.model_validate(value.model_dump(mode="json", by_alias=True)) +def _transport_binding_key(value: DomainScopeBindingKey) -> ScopeBindingKey: + return ScopeBindingKey( + integration=value.integration, + kind=value.kind, + external_id=value.external_id, + ) def _add_route( @@ -1481,11 +1448,11 @@ def _error_response( return JSONResponse(status_code=response_status, content=error.model_dump(mode="json")) -def _validation_error_details(error: RequestValidationError) -> list[Any]: +def _validation_error_details(error: RequestValidationError | PydanticValidationError) -> list[Any]: details: list[Any] = [] for item in error.errors(): if isinstance(item, dict): - details.append({key: value for key, value in item.items() if key != "input"}) + details.append({key: value for key, value in item.items() if key not in {"ctx", "input", "url"}}) else: details.append(item) return details @@ -1517,6 +1484,9 @@ def _map_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None]: "Artifact generation is not configured.", {"family": error.family}, ) + scope_error = _map_scope_error(error) + if scope_error is not None: + return scope_error candidate_error = _map_candidate_error(error) if candidate_error is not None: return candidate_error @@ -1529,6 +1499,40 @@ def _map_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None]: return _map_domain_error(error) +def _map_scope_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: + if isinstance(error, ArtifactPublicationConflictError): + return ( + status.HTTP_409_CONFLICT, + "artifact_publication_conflict", + "The publication key identifies a different source Artifact.", + None, + ) + if isinstance(error, (ScopeNotFoundError, ScopeBindingNotFoundError)): + return status.HTTP_404_NOT_FOUND, "scope_not_found", "The requested Scope was not found.", None + if isinstance(error, ScopeVersionConflictError): + return ( + status.HTTP_409_CONFLICT, + "scope_version_conflict", + "The Scope metadata version is stale.", + {"expected_version": error.expected, "current_version": error.actual}, + ) + if isinstance(error, ScopeIdempotencyConflictError): + return ( + status.HTTP_409_CONFLICT, + "scope_idempotency_conflict", + "The Scope creation key identifies different parameters.", + None, + ) + if isinstance(error, ScopeRelationshipError): + return ( + status.HTTP_422_UNPROCESSABLE_CONTENT, + "invalid_scope_relationship", + "The Scope relationship is invalid.", + {"relationship": error.relationship, "issue": error.issue}, + ) + return None + + def _map_candidate_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: if isinstance(error, CandidateNotFoundError): return status.HTTP_404_NOT_FOUND, "candidate_not_found", "The requested Candidate was not found.", None @@ -1559,46 +1563,14 @@ def _map_candidate_error(error: Exception) -> tuple[int, str, str, dict[str, Any def _map_report_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: - if isinstance(error, (ProjectNotFoundError, WorkstreamNotFoundError, WorkspaceBindingNotFoundError)): - return status.HTTP_404_NOT_FOUND, error.code, "The requested Handoff Report catalog value was not found.", None - if isinstance( - error, - (ProjectConflictError, WorkstreamConflictError, ScopeAlreadyGroupedError, WorkspaceBindingConflictError), - ): - details = { - name: getattr(error, name) - for name in ("expected_version", "current_version", "project_id", "scope_id", "workspace_instance_id") - if hasattr(error, name) - } - return ( - status.HTTP_409_CONFLICT, - error.code, - "The Handoff Report catalog value is stale or conflicting.", - details, - ) - if isinstance(error, ActivityEventConflictError): - return ( - status.HTTP_409_CONFLICT, - "activity_event_conflict", - "The Activity idempotency key already identifies different content.", - {"source": error.source, "source_event_id": error.source_event_id}, - ) - if isinstance(error, HandoffReportBusyError): - return ( - status.HTTP_409_CONFLICT, - "handoff_report_busy", - "Handoff heads changed while the report was being assembled.", - {"attempts": error.attempts}, - ) if isinstance(error, HandoffReportTooLargeError): return ( status.HTTP_413_CONTENT_TOO_LARGE, "handoff_report_too_large", - "The Handoff Report is too large; narrow the Workstream or Activity selection.", + "The Handoff Report is too large; narrow the Scope selection.", { "estimated_bytes": error.estimated_bytes, - "selected_workstreams": error.selected_workstreams, - "selected_activities": error.selected_activities, + "selected_scopes": error.selected_scopes, }, ) if isinstance(error, HandoffReportInconsistentError): @@ -1608,11 +1580,6 @@ def _map_report_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | "The frozen Handoff selection could not be read consistently.", {"scope_id": error.scope_id}, ) - if isinstance( - error, - (HandoffReportCatalogArgumentError, InvalidActivityEventError, InvalidActivityRepositoryArgumentError), - ): - return status.HTTP_422_UNPROCESSABLE_CONTENT, "invalid_request", "The request is invalid.", None if isinstance(error, HandoffReportError): return status.HTTP_503_SERVICE_UNAVAILABLE, "handoff_report_unavailable", "Handoff Report is unavailable.", None return None diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index 563b0ab4c..cec366f2d 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -183,7 +183,6 @@ def _mount_optional_web_ui(app: FastAPI, settings: ServerSettings) -> None: try: mount_web_ui( app, - scopes={scope.scope_id: scope.display_name for scope in settings.dashboard.scopes}, dashboard_enabled=settings.dashboard.enabled, handoff_report_enabled=settings.handoff_report.enabled, authentication_required=settings.auth.enabled, diff --git a/src/powercontext/server/handoff_picker.py b/src/powercontext/server/handoff_picker.py deleted file mode 100644 index 9cebcbd02..000000000 --- a/src/powercontext/server/handoff_picker.py +++ /dev/null @@ -1,505 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# ruff: noqa: RUF001 -"""Interactive MCP selection for one Handoff Report Workstream.""" - -from __future__ import annotations - -from collections.abc import Callable, Sequence -from dataclasses import dataclass -from typing import Annotated, Generic, Literal, TypeVar, cast - -import httpx -from fastmcp import Context, FastMCP -from fastmcp.server.elicitation import AcceptedElicitation -from mcp.types import ClientCapabilities, ElicitationCapability, ToolAnnotations -from pydantic import BaseModel, ConfigDict, Field - -from powercontext.client import PowerContextClient -from powercontext.http import ( - ListHandoffReportProjectsRequest, - ListHandoffReportWorkstreamsRequest, - ProjectDescriptor, - WorkstreamDescriptor, -) - -PickerLocale = Literal["zh-CN", "en"] -PickerStatus = Literal["selected", "needs_selection", "empty", "cancelled", "declined"] -PickerStage = Literal["project", "workstream"] - -_MAX_PICKER_CHOICES = 100 -_FORM_ELICITATION_CAPABILITY = ClientCapabilities(elicitation=ElicitationCapability()) -_ChoiceT = TypeVar("_ChoiceT") - - -class HandoffProjectChoice(BaseModel): - """One project that can be used to narrow the Workstream picker.""" - - model_config = ConfigDict(extra="forbid", frozen=True) - - project_id: str - project_key: str - title: str - - -class HandoffWorkstreamChoice(BaseModel): - """One validated Workstream selection returned to an Agent.""" - - model_config = ConfigDict(extra="forbid", frozen=True) - - work_id: str - scope_id: str - project_id: str - project_key: str - title: str - kind: str - catalog_version: int - - -class HandoffWorkstreamSelection(BaseModel): - """Stable result for native and text-fallback selection flows.""" - - model_config = ConfigDict(extra="forbid", frozen=True) - - status: PickerStatus - message: str - stage: PickerStage | None = None - selected: HandoffWorkstreamChoice | None = None - project_choices: list[HandoffProjectChoice] = Field(default_factory=list, max_length=_MAX_PICKER_CHOICES) - workstream_choices: list[HandoffWorkstreamChoice] = Field(default_factory=list, max_length=_MAX_PICKER_CHOICES) - truncated: bool = False - - -def register_handoff_workstream_picker(server: FastMCP, http_client: httpx.AsyncClient) -> None: - """Register the Report-backed Workstream picker on an existing MCP server.""" - - # ``http_client`` is the Server's own in-process ASGI transport; ``http://fastapi`` is only a - # routing label, so vouch for it explicitly rather than have the loopback guard reject it. - picker = _HandoffWorkstreamPicker( - PowerContextClient("http://fastapi", http_client=http_client, trust_transport_security=True) - ) - server.tool( - picker.select, - name="select_handoff_workstream", - title="Select Handoff Workstream", - description=( - "Select one Report Workstream before handoff_current_work or continue_handoff. " - "Uses a native MCP picker when supported and returns validated structured choices otherwise." - ), - annotations=ToolAnnotations( - readOnlyHint=True, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - ), - ) - - -class _HandoffWorkstreamPicker: - def __init__(self, client: PowerContextClient) -> None: - self._client = client - - async def select( - self, - ctx: Context, - project_id: Annotated[ - str | None, - Field( - default=None, - min_length=1, - max_length=256, - pattern=r".*\S.*", - description="Exact Report Project ID. Omit it to choose a Project interactively.", - ), - ] = None, - work_id: Annotated[ - str | None, - Field( - default=None, - min_length=1, - max_length=256, - pattern=r".*\S.*", - description="Workstream key or scope ID returned by an earlier picker result.", - ), - ] = None, - query: Annotated[ - str | None, - Field( - default=None, - min_length=1, - max_length=256, - pattern=r".*\S.*", - description="Optional case-insensitive filter over Workstream title, key, scope, kind, and labels.", - ), - ] = None, - include_archived: Annotated[ - bool, - Field(description="Include archived Projects and Workstreams in the choices."), - ] = False, - locale: Annotated[ - PickerLocale, - Field(description="Language used for picker prompts and result messages."), - ] = "zh-CN", - ) -> HandoffWorkstreamSelection: - """Select a catalog Workstream before handing off or continuing work. - - A client with MCP form elicitation gets a native picker. Other clients - receive structured choices and can call this tool again with project_id - and work_id. Selecting a Workstream never creates or commits a Handoff. - """ - - projects, projects_truncated = await _list_projects(self._client, include_archived=include_archived) - if not projects: - return _selection(status="empty", locale=locale, message_key="no_projects") - - project_result = await _choose_project( - ctx, - projects=projects, - project_id=project_id, - locale=locale, - truncated=projects_truncated, - ) - if isinstance(project_result, HandoffWorkstreamSelection): - return project_result - project = project_result - - workstreams, workstreams_truncated = await _list_workstreams( - self._client, - project.project_id, - include_archived=include_archived, - ) - filtered_workstreams = _filter_workstreams(workstreams, query) - choices = [_workstream_choice(item, project) for item in filtered_workstreams] - truncated = projects_truncated or workstreams_truncated - if not choices: - return _selection( - status="empty", - locale=locale, - message_key="no_matching_workstreams" if query else "no_workstreams", - stage="workstream", - truncated=truncated, - ) - - return await _choose_workstream( - ctx, - choices=choices, - work_id=work_id, - locale=locale, - truncated=truncated, - ) - - -@dataclass(frozen=True, slots=True) -class _ElicitedChoice(Generic[_ChoiceT]): - status: Literal["selected", "cancelled", "declined"] - value: _ChoiceT | None - - -async def _elicit_choice( - ctx: Context, - *, - message: str, - response_title: str, - values: Sequence[_ChoiceT], - title: Callable[[_ChoiceT], str], -) -> _ElicitedChoice[_ChoiceT]: - option_values = {f"option-{index + 1}": value for index, value in enumerate(values)} - options = {token: {"title": title(value)} for token, value in option_values.items()} - result = await ctx.elicit( - message, - options, - response_title=response_title, - ) - if not isinstance(result, AcceptedElicitation): - status: Literal["cancelled", "declined"] = "cancelled" if result.action == "cancel" else "declined" - return _ElicitedChoice(status=status, value=None) - selected_token = str(result.data) - return _ElicitedChoice(status="selected", value=option_values[selected_token]) - - -async def _choose_project( - ctx: Context, - *, - projects: Sequence[ProjectDescriptor], - project_id: str | None, - locale: PickerLocale, - truncated: bool, -) -> ProjectDescriptor | HandoffWorkstreamSelection: - project = _project_by_id(projects, project_id) - if project_id is not None and project is None: - return _selection( - status="needs_selection", - locale=locale, - message_key="project_not_found", - stage="project", - project_choices=[_project_choice(item) for item in projects], - truncated=truncated, - ) - if project is not None: - return project - if len(projects) == 1: - return projects[0] - if not _supports_form_elicitation(ctx): - return _selection( - status="needs_selection", - locale=locale, - message_key="choose_project_fallback", - stage="project", - project_choices=[_project_choice(item) for item in projects], - truncated=truncated, - ) - result = await _elicit_choice( - ctx, - message=_copy(locale, "choose_project"), - response_title=_copy(locale, "project_field"), - values=projects, - title=_project_title, - ) - if result.status != "selected": - return _selection( - status=result.status, - locale=locale, - message_key=result.status, - stage="project", - ) - return cast(ProjectDescriptor, result.value) - - -async def _choose_workstream( - ctx: Context, - *, - choices: Sequence[HandoffWorkstreamChoice], - work_id: str | None, - locale: PickerLocale, - truncated: bool, -) -> HandoffWorkstreamSelection: - selected = _workstream_by_id(choices, work_id) - if work_id is not None and selected is None: - return _selection( - status="needs_selection", - locale=locale, - message_key="workstream_not_found", - stage="workstream", - workstream_choices=list(choices), - truncated=truncated, - ) - if selected is None and len(choices) == 1: - selected = choices[0] - if selected is None and not _supports_form_elicitation(ctx): - return _selection( - status="needs_selection", - locale=locale, - message_key="choose_workstream_fallback", - stage="workstream", - workstream_choices=list(choices), - truncated=truncated, - ) - if selected is None: - result = await _elicit_choice( - ctx, - message=_copy(locale, "choose_workstream"), - response_title=_copy(locale, "workstream_field"), - values=choices, - title=_workstream_title, - ) - if result.status != "selected": - return _selection( - status=result.status, - locale=locale, - message_key=result.status, - stage="workstream", - ) - selected = cast(HandoffWorkstreamChoice, result.value) - return _selection( - status="selected", - locale=locale, - message_key="selected", - selected=selected, - truncated=truncated, - ) - - -async def _list_projects( - client: PowerContextClient, - *, - include_archived: bool, -) -> tuple[list[ProjectDescriptor], bool]: - page = await client.list_handoff_report_projects( - ListHandoffReportProjectsRequest( - limit=_MAX_PICKER_CHOICES, - include_archived=include_archived, - ) - ) - return page.items, page.next_cursor is not None - - -async def _list_workstreams( - client: PowerContextClient, - project_id: str, - *, - include_archived: bool, -) -> tuple[list[WorkstreamDescriptor], bool]: - page = await client.list_handoff_report_workstreams( - ListHandoffReportWorkstreamsRequest( - project_id=project_id, - limit=_MAX_PICKER_CHOICES, - include_archived=include_archived, - ) - ) - return page.items, page.next_cursor is not None - - -def _supports_form_elicitation(ctx: Context) -> bool: - return ctx.session.check_client_capability(_FORM_ELICITATION_CAPABILITY) - - -def _project_by_id(projects: Sequence[ProjectDescriptor], project_id: str | None) -> ProjectDescriptor | None: - if project_id is None: - return None - return next((project for project in projects if project.project_id == project_id), None) - - -def _workstream_by_id( - choices: Sequence[HandoffWorkstreamChoice], - work_id: str | None, -) -> HandoffWorkstreamChoice | None: - if work_id is None: - return None - matches = [choice for choice in choices if choice.work_id == work_id or choice.scope_id == work_id] - return matches[0] if len(matches) == 1 else None - - -def _filter_workstreams( - workstreams: Sequence[WorkstreamDescriptor], - query: str | None, -) -> list[WorkstreamDescriptor]: - normalized_query = "" if query is None else query.strip().casefold() - if not normalized_query: - return list(workstreams) - return [workstream for workstream in workstreams if normalized_query in _workstream_search_text(workstream)] - - -def _workstream_search_text(workstream: WorkstreamDescriptor) -> str: - values = ( - workstream.title, - workstream.key or "", - workstream.scope_id, - str(workstream.kind), - *(label.root for label in workstream.labels), - ) - return "\n".join(values).casefold() - - -def _project_choice(project: ProjectDescriptor) -> HandoffProjectChoice: - return HandoffProjectChoice( - project_id=project.project_id, - project_key=project.project_key, - title=project.title, - ) - - -def _workstream_choice( - workstream: WorkstreamDescriptor, - project: ProjectDescriptor, -) -> HandoffWorkstreamChoice: - return HandoffWorkstreamChoice( - work_id=workstream.key or workstream.scope_id, - scope_id=workstream.scope_id, - project_id=project.project_id, - project_key=project.project_key, - title=workstream.title, - kind=str(workstream.kind), - catalog_version=workstream.version, - ) - - -def _project_title(project: ProjectDescriptor) -> str: - return f"{project.title} · {project.project_key}" - - -def _workstream_title(workstream: HandoffWorkstreamChoice) -> str: - return f"{workstream.title} · {workstream.work_id} · {workstream.kind}" - - -def _selection( - *, - status: PickerStatus, - locale: PickerLocale, - message_key: str, - stage: PickerStage | None = None, - selected: HandoffWorkstreamChoice | None = None, - project_choices: list[HandoffProjectChoice] | None = None, - workstream_choices: list[HandoffWorkstreamChoice] | None = None, - truncated: bool = False, -) -> HandoffWorkstreamSelection: - return HandoffWorkstreamSelection( - status=status, - message=_copy(locale, message_key), - stage=stage, - selected=selected, - project_choices=[] if project_choices is None else project_choices, - workstream_choices=[] if workstream_choices is None else workstream_choices, - truncated=truncated, - ) - - -def _copy(locale: PickerLocale, key: str) -> str: - return _COPY[locale][key] - - -_COPY: dict[PickerLocale, dict[str, str]] = { - "zh-CN": { - "cancelled": "已取消工作选择,未产生任何交接写入。", - "choose_project": "选择这次交接所属的项目。", - "choose_project_fallback": "当前客户端不支持原生选择框,请从 project_choices 选择并重新调用。", - "choose_workstream": "选择要交接或继续的工作。", - "choose_workstream_fallback": "当前客户端不支持原生选择框,请从 workstream_choices 选择并重新调用。", - "declined": "已拒绝工作选择,未产生任何交接写入。", - "no_matching_workstreams": "没有与查询条件匹配的工作。", - "no_projects": "没有可供选择的交接项目。", - "no_workstreams": "所选项目中没有可供选择的工作。", - "project_field": "项目", - "project_not_found": "找不到指定项目,请从 project_choices 重新选择。", - "selected": "已选择工作;此操作尚未创建或提交交接。", - "workstream_field": "工作", - "workstream_not_found": "找不到指定工作,请从 workstream_choices 重新选择。", - }, - "en": { - "cancelled": "Work selection was cancelled; no Handoff data was written.", - "choose_project": "Choose the Project that owns this Handoff.", - "choose_project_fallback": "This client has no native picker; choose from project_choices and call again.", - "choose_workstream": "Choose the work to hand off or continue.", - "choose_workstream_fallback": ( - "This client has no native picker; choose from workstream_choices and call again." - ), - "declined": "Work selection was declined; no Handoff data was written.", - "no_matching_workstreams": "No work matches the query.", - "no_projects": "No Handoff Projects are available.", - "no_workstreams": "The selected Project has no available work.", - "project_field": "Project", - "project_not_found": "The requested Project was not found; choose from project_choices.", - "selected": "Work selected; this operation has not created or committed a Handoff.", - "workstream_field": "Work", - "workstream_not_found": "The requested work was not found; choose from workstream_choices.", - }, -} - - -__all__ = [ - "HandoffProjectChoice", - "HandoffWorkstreamChoice", - "HandoffWorkstreamSelection", - "register_handoff_workstream_picker", -] diff --git a/src/powercontext/server/mcp.py b/src/powercontext/server/mcp.py index 9b053db83..6a4047592 100644 --- a/src/powercontext/server/mcp.py +++ b/src/powercontext/server/mcp.py @@ -36,20 +36,21 @@ ACTIVATE_HANDOFF, APPROVE_ARTIFACT_CANDIDATE, CAPTURE_CONTENT_SOURCE, + CLEAR_SCOPE_BINDING, COMMIT_HANDOFF, CONTINUE_HANDOFF, + CREATE_SCOPE, CREATE_WORK_CONTRACT, FINALIZE_HANDOFF, GET_ARTIFACT_CANDIDATE, GET_HANDOFF_REPORT, - GET_HANDOFF_REPORT_WORKSPACE, GET_MEMORY_ENTRY, + GET_SCOPE, HANDOFF_CURRENT_WORK, LIST_ARTIFACT_CANDIDATES, - LIST_HANDOFF_REPORT_KNOWN_SCOPES, - LIST_HANDOFF_REPORT_PROJECTS, - LIST_HANDOFF_REPORT_WORKSTREAMS, LIST_MEMORY_ENTRIES, + LIST_SCOPES, + PUBLISH_ARTIFACT, RECORD_TASK_OUTCOME, REJECT_ARTIFACT_CANDIDATE, REMEMBER_MEMORY, @@ -57,6 +58,7 @@ REVISE_ARTIFACT_CANDIDATE, REVISE_MEMORY_ENTRY, SEARCH_MEMORY, + SET_SCOPE_BINDING, ) from powercontext.server.access import McpAccessLogMiddleware from powercontext.server.app import REQUEST_ID_HEADER @@ -65,7 +67,6 @@ current_request_id, reset_internal_bridge, ) -from powercontext.server.handoff_picker import register_handoff_workstream_picker from powercontext.server.metrics import McpMetricsMiddleware, ServerMetrics from powercontext.server.tracing import McpTracingMiddleware, ServerTracing @@ -87,14 +88,18 @@ REMEMBER_MEMORY.operation_id, REVISE_MEMORY_ENTRY.operation_id, GET_HANDOFF_REPORT.operation_id, - LIST_HANDOFF_REPORT_KNOWN_SCOPES.operation_id, - GET_HANDOFF_REPORT_WORKSPACE.operation_id, RETIRE_MEMORY_ENTRY.operation_id, LIST_ARTIFACT_CANDIDATES.operation_id, GET_ARTIFACT_CANDIDATE.operation_id, APPROVE_ARTIFACT_CANDIDATE.operation_id, REJECT_ARTIFACT_CANDIDATE.operation_id, REVISE_ARTIFACT_CANDIDATE.operation_id, + CREATE_SCOPE.operation_id, + LIST_SCOPES.operation_id, + GET_SCOPE.operation_id, + SET_SCOPE_BINDING.operation_id, + CLEAR_SCOPE_BINDING.operation_id, + PUBLISH_ARTIFACT.operation_id, }) _MCP_READ_ONLY_OPERATION_IDS = frozenset({ CONTINUE_HANDOFF.operation_id, @@ -102,10 +107,10 @@ LIST_MEMORY_ENTRIES.operation_id, GET_MEMORY_ENTRY.operation_id, GET_HANDOFF_REPORT.operation_id, - LIST_HANDOFF_REPORT_KNOWN_SCOPES.operation_id, - GET_HANDOFF_REPORT_WORKSPACE.operation_id, LIST_ARTIFACT_CANDIDATES.operation_id, GET_ARTIFACT_CANDIDATE.operation_id, + LIST_SCOPES.operation_id, + GET_SCOPE.operation_id, }) _MCP_REVIEW_WRITE_OPERATION_IDS = frozenset({ APPROVE_ARTIFACT_CANDIDATE.operation_id, @@ -132,6 +137,7 @@ def _annotate_mcp_component( component.annotations = ToolAnnotations( readOnlyHint=True, destructiveHint=False, + idempotentHint=True, openWorldHint=False, ) elif route.operation_id == HANDOFF_CURRENT_WORK.operation_id: @@ -186,11 +192,6 @@ def create_mcp_server( validate_output=False, ) server = FastMCP(name=MCP_SERVER_NAME, providers=[provider]) - if { - LIST_HANDOFF_REPORT_PROJECTS.path, - LIST_HANDOFF_REPORT_WORKSTREAMS.path, - }.issubset(server_app.openapi()["paths"]): - register_handoff_workstream_picker(server, client) server.add_middleware(McpTracingMiddleware(resolved_tracing)) if access_log: server.add_middleware(McpAccessLogMiddleware()) diff --git a/src/powercontext/server/settings.py b/src/powercontext/server/settings.py index 060730d89..bc744db81 100644 --- a/src/powercontext/server/settings.py +++ b/src/powercontext/server/settings.py @@ -108,33 +108,10 @@ def require_token_when_enabled(self) -> BearerAuthConfig: return self -class DashboardScopeConfig(BaseModel): - """One scope exposed by the personal Dashboard.""" - - scope_id: str = Field(min_length=1, max_length=255) - display_name: str = Field(min_length=1, max_length=80) - - @field_validator("scope_id", "display_name") - @classmethod - def strip_non_empty_text(cls, value: str) -> str: - stripped = value.strip() - if not stripped: - raise ValueError("Dashboard scope values must not be empty") # noqa: TRY003 - return stripped - - class DashboardConfig(BaseModel): """Personal Dashboard served by the local Server.""" enabled: bool = True - scopes: list[DashboardScopeConfig] = Field(default_factory=list, max_length=100) - - @model_validator(mode="after") - def validate_scopes(self) -> DashboardConfig: - scope_ids = [scope.scope_id for scope in self.scopes] - if len(scope_ids) != len(set(scope_ids)): - raise ValueError("Dashboard scope IDs must be unique") # noqa: TRY003 - return self class ServerLoggingConfig(BaseModel): @@ -222,7 +199,6 @@ def reject_unauthenticated_non_loopback_bind(self) -> ServerSettings: __all__ = [ "BearerAuthConfig", "DashboardConfig", - "DashboardScopeConfig", "HandoffReportConfig", "HttpConfig", "McpConfig", diff --git a/src/powercontext/server/static/dashboard.js b/src/powercontext/server/static/dashboard.js index b4e9f34e9..5eacf73dd 100644 --- a/src/powercontext/server/static/dashboard.js +++ b/src/powercontext/server/static/dashboard.js @@ -23,6 +23,7 @@ import { storeServerToken } from "./auth.js?v=optional-auth"; import {createPageUi, createRequestGate} from "./page-ui.js?v=locale-complete"; +import {buildScopeSelectionChoices} from "./scope-selection.js?v=selection-v1"; const translations = { en: { @@ -40,6 +41,9 @@ const translations = { tokenLabel: "Server token", continue: "Continue", selectScope: "Scope", + allScopes: "All", + subtreeView: "{title} and descendants", + exactFocus: "Focus: {title}", period30: "Last 30 days", estimatedReduction: "Estimated token reduction", sources: "Sources", @@ -82,7 +86,7 @@ const translations = { requestFailed: "The Dashboard request failed with HTTP {status}.", serverUnavailable: "The Server is unavailable.", retry: "Retry", - noScopes: "No Dashboard scopes are configured.", + noScopes: "No Scopes are available.", scopeUnavailable: "The selected scope is not available.", scopeOverview: "Scope overview" }, @@ -101,6 +105,9 @@ const translations = { tokenLabel: "服务器访问令牌", continue: "继续", selectScope: "作用域", + allScopes: "全部", + subtreeView: "{title}及其下级", + exactFocus: "聚焦:{title}", period30: "过去 30 天", estimatedReduction: "预估令牌减少量", sources: "数据源", @@ -143,7 +150,7 @@ const translations = { requestFailed: "仪表盘请求失败(HTTP {status})。", serverUnavailable: "服务器无法访问。", retry: "重试", - noScopes: "未配置仪表盘作用域。", + noScopes: "当前没有可用作用域。", scopeUnavailable: "选中的作用域不可用。", scopeOverview: "作用域概览" } @@ -230,11 +237,10 @@ async function authenticate(token, scopeId = "") { showPageStatus("noScopes", {}, true); return; } - const selectedScopeId = currentScopes.some((scope) => scope.scope_id === scopeId) - ? scopeId - : currentScopes[0].scope_id; - currentScopeId = selectedScopeId; - await loadStatistics(token, selectedScopeId, request); + const choices = buildScopeSelectionChoices(currentScopes, translate); + const selectedKey = choices.some((choice) => choice.key === scopeId) ? scopeId : "all"; + currentScopeId = selectedKey; + await loadStatistics(token, selectedKey, request); } catch (error) { if (request.isCurrent()) { showPageStatus("serverUnavailable", {}, true); @@ -256,10 +262,16 @@ async function loadStatistics(token, scopeId, request = null) { currentScopeId = scopeId; scopeSelect.disabled = true; try { - const url = new URL("/v1/stats", window.location.origin); - url.searchParams.set("scope_id", scopeId); - url.searchParams.set("period", "30d"); - const response = await fetchWithBearer(url, token); + const choice = buildScopeSelectionChoices(currentScopes, translate).find((item) => item.key === scopeId); + if (!choice) { + showPageStatus("scopeUnavailable", {}, true); + return; + } + const response = await fetchWithBearer("/v1/stats", token, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({selection: choice.selection, period: "30d"}) + }); if (!activeRequest.isCurrent()) { return; } @@ -276,12 +288,7 @@ async function loadStatistics(token, scopeId, request = null) { if (!activeRequest.isCurrent()) { return; } - const selectedScope = currentScopes.find((scope) => scope.scope_id === statistics.scope_id); - if (!selectedScope) { - showPageStatus("scopeUnavailable", {}, true); - return; - } - renderDashboard({scopes: currentScopes, selectedScope, statistics}); + renderDashboard({scopes: currentScopes, choice, statistics}); } catch (error) { if (activeRequest.isCurrent()) { showPageStatus("serverUnavailable", {}, true); @@ -349,8 +356,8 @@ function renderDashboard(view) { dashboard.hidden = false; signOut.hidden = !authenticationRequired; - renderScopes(view.scopes, statistics.scope_id); - setText("dashboard-name", view.selectedScope.display_name); + renderScopes(view.scopes, view.choice.key); + setText("dashboard-name", view.choice.label); setText("as-of", translate("updated", {value: formatDateTime(statistics.as_of)})); setText("sources", formatNumber(inventory.sources.total)); setText("memory-entries", formatNumber(inventory.memory.entries.total)); @@ -368,13 +375,13 @@ function renderDashboard(view) { renderTrend(recall.daily); } -function renderScopes(scopes, selectedScopeId) { +function renderScopes(scopes, selectedKey) { scopeSelect.replaceChildren(); - for (const scope of scopes) { + for (const choice of buildScopeSelectionChoices(scopes, translate)) { const option = document.createElement("option"); - option.value = scope.scope_id; - option.textContent = `${scope.display_name} (${scope.scope_id})`; - option.selected = scope.scope_id === selectedScopeId; + option.value = choice.key; + option.textContent = choice.label; + option.selected = choice.key === selectedKey; scopeSelect.appendChild(option); } } diff --git a/src/powercontext/server/static/handoff-report.js b/src/powercontext/server/static/handoff-report.js index 8d0cac06b..ca9cd3815 100644 --- a/src/powercontext/server/static/handoff-report.js +++ b/src/powercontext/server/static/handoff-report.js @@ -22,16 +22,9 @@ import { readServerToken, storeServerToken } from "./auth.js?v=optional-auth"; -import {formatDateRange, resolvePeriodSelection, validateDateRange} from "./handoff-period.js"; import {createPageUi, createRequestGate} from "./page-ui.js?v=locale-complete"; +import {buildScopeSelectionChoices} from "./scope-selection.js?v=selection-v1"; -const selectedProjectKey = "powercontext.handoff-report.project"; -const selectedWorkKey = "powercontext.handoff-report.work"; -const autoRefreshIntervalMilliseconds = 5_000; -const continuityTimelineRecentLimit = 6; -const workstreamSearchThreshold = 8; -const projectOptionRenderLimit = 50; -const authenticationRequired = document.documentElement.dataset.serverAuthRequired === "true"; const translations = { en: { pageTitle: "PowerContext Handoff Report", @@ -41,7 +34,6 @@ const translations = { handoffReportTitle: "Handoff Report", brandHomeLabel: "PowerContext Dashboard", primaryNavigation: "Primary navigation", - handoffSummary: "Handoff summary", maintainedBy: "Maintained by OceanBase.", signOut: "Sign out", authTitle: "Connect to PowerContext", @@ -50,200 +42,52 @@ const translations = { continue: "Continue", refresh: "Refresh", downloadMarkdown: "Download Markdown", - projects: "Scopes", - searchProjectsPlaceholder: "Search by scope ID", - projectSearchCount: "{count} scopes", - projectSearchMatches: "{count} matching scopes", - projectSearchLimited: "Showing the first {shown} of {total} matches. Keep typing to narrow the list.", - noMatchingProjects: "No scopes match this search.", - reportPeriod: "Report period", - activity: "Activity", - activitySubtitle: "Period controls affect Activity counts only. Handoff status stays on the current exact selection.", - activityPeriod: "Activity period", - activityByWorkstream: "Activity by Workstream", - day: "Day", - week: "Week", - month: "Month", - custom: "Custom", - periodStart: "Start date", - periodEnd: "End date", - apply: "Apply", - periodSummary: "{preset} / {range} / {timezone}", - periodComparison: "Activity: {current} current / {previous} previous / {delta} change", - periodBoundaryUnavailable: "Handoff status uses the current exact selection; this period filters Activity but cannot reconstruct historical Handoff boundaries.", - periodDatesRequired: "Select both a start date and an end date.", - periodInvalidRange: "The start date must not be later than the end date.", + scopeHandoffState: "Scope Handoff state", + reportDescription: "An exact Handoff projection over the selected Scope view.", + scopeView: "Scope view", + scopeViewDescription: "All, subtree, and exact use the same selection semantics as Dashboard.", + selectScope: "Scope", + allScopes: "All", + subtreeView: "{title} and descendants", + exactFocus: "Focus: {title}", + handoffSummary: "Handoff summary", continuable: "Continuable", blocked: "Blocked", complete: "Complete", noHandoff: "No Handoff", - coverage: "Report coverage", - workstreams: "Workstreams", - activities: "Activities", - evidenceUnavailable: "Evidence unavailable", - blockers: "Blockers", - blockersSubtitle: "Workstreams that cannot be continued without intervention", - workstreamsSubtitle: "Exact Handoff state and the next action for each scope", - workstreamNavigation: "Workstream navigation", - workstreamPagination: "Workstream pagination", - searchWorkstreams: "Search Workstreams", - searchWorkstreamsPlaceholder: "Search by name or scope", - previousWorkstream: "Previous", - nextWorkstream: "Next", - workstreamPosition: "{current} / {total}", - workstreamMatchCount: "{count} matches", - noMatchingWorkstreams: "No Workstreams match this search.", - workstream: "Workstream", + no_handoff: "No Handoff", + selectedScopes: "Selected Scopes", + selectedScopesDescription: "Parent describes organization; each row reports only that Scope's exact latest Handoff.", + scope: "Scope", + parent: "Parent", status: "Status", - reporting: "Reporting", + objective: "Objective", nextAction: "Next action", - state: "Current state", - next_action: "Next action", - available: "Available", - unavailable: "Unavailable", - noWorkstreams: "No Workstreams are registered for this Project.", - handoffContents: "Handoff content", - handoffContentsSubtitle: "Edit the Handoff as one document. Every save creates a new Handoff Revision.", - currentSnapshot: "Current Handoff snapshot", exactRevision: "Exact Revision", - editHandoffContent: "Edit", - editHandoffContentLabel: "Edit all Handoff content", - cancelEdit: "Cancel", - saveRevision: "Save Revision", - createFirstRevision: "Create first Revision", - firstRevisionNote: "The first Revision needs an Objective and at least one Current state item.", - emptyField: "Not provided.", - savingRevision: "Saving a new Handoff Revision...", - revisionSaved: "Saved as Handoff Revision @{revision}.", - revisionSaveFailed: "The Handoff Revision could not be saved (HTTP {status}).", - objective: "Objective", - currentState: "Current state", - omissions: "Known omissions", - currentStateLines: "Current state, one item per line", - disposition: "Disposition", - omissionLines: "Known omissions, one item per line", - liveStateCheck: "Live workspace", - capabilityCheck: "Capability", - authorizationCheck: "Authorization", - notChecked: "Not checked", - confirmed: "Confirmed", - mismatch: "Mismatch", - insufficient: "Insufficient", - receiverIdentity: "Receiver identity", - continuityTimeline: "Continuity timeline", - continuityOrderNote: "Ordered by Source journal position, not wall-clock time.", - revisionHistory: "Handoff Revision history", - revisionHistorySummary: "{total} Revisions. Latest first.", - revisionHistoryTruncated: "Showing the latest {shown} of {total} Revisions.", - revisionHistoryEmpty: "No committed Handoff Revisions are available.", - revisionCurrent: "Current", - revisionNextAction: "Next: {value}", - revisionCounts: "{state} state items / {omissions} omissions", - transferState: "Transfer", - outcomeState: "Outcome", - editorRequired: "Objective and at least one current-state item are required.", - timelineEmpty: "No high-level Work continuity records are available for this scope.", - timelineTruncated: "Only the latest {count} of {total} continuity events are shown.", - timelineInvalid: "{count} Work record(s) could not be read and were excluded.", - timelineShowEarlier: "Show {count} earlier events", - timelineShowRecent: "Show latest {count}", - eventSource: "Source", - eventRevision: "Handoff Revision", - eventReceipt: "Receipt", - eventReceiverChecks: "Receiver checks", - eventSchema: "Record schema", - eventNoDetails: "No additional details were recorded.", - autoRefreshActive: "Auto-refresh every 5 seconds", - autoRefreshEditing: "Auto-refresh paused while Handoff content is being edited.", - autoRefreshBusy: "Auto-refresh paused during this action.", - autoRefreshing: "Refreshing report...", - autoRefreshUpdated: "Auto-refreshed just now", - autoRefreshFailed: "Auto-refresh failed. Use Refresh to retry.", - eventActor: "Receiver: {actor}", - "work-contract": "Delegation contract", - "handoff-boundary": "Handoff sent", - "handoff-receipt": "Receiver decision", - "task-outcome": "Task outcome", - delegated: "Delegated", - accepted: "Accepted", - needs_clarification: "Needs clarification", - declined: "Declined", - succeeded: "Succeeded", - partial: "Partial", - failed: "Failed", - cancelled: "Cancelled", - awaiting_receipt: "Awaiting receipt", - awaiting_outcome: "Awaiting outcome", - not_expected: "Not expected", - not_applicable: "Not applicable", - covered: "Covered", metadata: "Report metadata", - selectionConsistency: "Selection consistency", - activityCoverage: "Activity coverage", + generatedAt: "Generated at", selectionDigest: "Selection digest", reportDigest: "Report digest", - dark: "Dark", - light: "Light", + noScopes: "No Scopes are available.", + requestFailed: "The Handoff Report request failed with HTTP {status}.", + serverUnavailable: "The Server is unavailable.", + authRejected: "The Server rejected this token.", + retry: "Retry", switchDark: "Switch to dark mode", switchLight: "Switch to light mode", switchChinese: "Switch to Chinese", switchEnglish: "Switch to English", languageChinese: "中文", - languageEnglish: "EN", - updated: "Updated {value}", - projectOption: "{projectId}", - coverageCaptured: "Captured Activity is included through cursor {cursor}. Counts describe observed events, not completion percentage.", - coverageNotConfigured: "Activity adapters are not configured. Missing Activity must not be read as no work occurring.", - coverageUnavailable: "Activity coverage is unavailable for this report.", - noProjects: "No scope contains a committed Handoff.", - previewReportTitle: "Handoff Report template", - preview: "Preview", - previewNotice: "This data-free preview shows the report's main Handoff sections. Values shown as \u201c\u2014\u201d do not represent real scope status.", - previewRetryHint: "Commit a Handoff for a scope, then retry to load its report.", - previewPlaceholder: "\u2014", - previewProjectSummary: "Project summary and scope", - previewProjectSummarySubtitle: "Project identity, selected scope, and reporting period", - previewProject: "Project", - previewScope: "Scope", - previewWorkstreamsTitle: "Workstreams and Handoff", - previewWorkstreamsSubtitle: "Current Handoff state and continuation content for each scope", - previewHandoffContentsSubtitle: "Objective, current state, disposition, next action, and known omissions", - previewActivitySubtitle: "Coverage and period comparison become available with a real Project report.", - previewCoverageDescription: "Coverage values appear here after a Project report is available.", - previewActivityComparison: "Activity and period comparison", - previewActivityComparisonSubtitle: "Observed Activity in the selected and previous periods", - previewCurrentPeriod: "Current period", - previewPreviousPeriod: "Previous period", - previewChange: "Change", - authRejected: "The Server rejected this token.", - requestFailed: "The Handoff Report request failed with HTTP {status}.", - serverUnavailable: "The Server is unavailable.", - retry: "Retry", - reportUnavailable: "The selected scope report is unavailable.", - downloadFailed: "The Markdown download failed with HTTP {status}.", - reported: "Reported", - reported_with_omissions: "Reported with omissions", - evidence_unavailable: "Evidence unavailable", - no_handoff: "No Handoff", - activity_after_handoff: "Activity after Handoff", - activity_without_handoff: "Activity without Handoff", - no_observed_activity: "No observed Activity", - current_only: "Current only", - exact_input: "Exact input", - optimistic_stable: "Optimistically stable", - captured: "Captured", - not_configured: "Not configured", - unknown: "Unknown" + languageEnglish: "EN" }, zh: { - pageTitle: "PowerContext 项目交接报告", + pageTitle: "PowerContext 交接报告", dashboardTitle: "仪表盘", skillsTitle: "技能", reviewTitle: "审核", handoffReportTitle: "交接报告", brandHomeLabel: "PowerContext 仪表盘", primaryNavigation: "主导航", - handoffSummary: "交接摘要", maintainedBy: "由 OceanBase 维护。", signOut: "退出", authTitle: "连接 PowerContext", @@ -252,193 +96,47 @@ const translations = { continue: "继续", refresh: "刷新", downloadMarkdown: "下载 Markdown", - projects: "范围", - searchProjectsPlaceholder: "按范围标识搜索", - projectSearchCount: "共 {count} 个范围", - projectSearchMatches: "匹配 {count} 个范围", - projectSearchLimited: "显示前 {shown} 个,共匹配 {total} 个。继续输入可缩小范围。", - noMatchingProjects: "没有匹配的范围。", - reportPeriod: "报告周期", - activity: "活动", - activitySubtitle: "周期控件只影响活动数量,交接状态始终采用当前精确选择。", - activityPeriod: "活动周期", - activityByWorkstream: "各工作项活动", - day: "日", - week: "周", - month: "月", - custom: "自定义", - periodStart: "开始日期", - periodEnd: "结束日期", - apply: "应用", - periodSummary: "{preset} / {range} / {timezone}", - periodComparison: "活动:本期 {current} / 上期 {previous} / 变化 {delta}", - periodBoundaryUnavailable: "交接状态采用当前精确选择;该周期只筛选活动,不能还原历史交接边界。", - periodDatesRequired: "请选择开始日期和结束日期。", - periodInvalidRange: "开始日期不能晚于结束日期。", + scopeHandoffState: "Scope 交接状态", + reportDescription: "按所选 Scope 视图汇总各 Scope 的精确交接状态。", + scopeView: "Scope 视图", + scopeViewDescription: "全部、下级范围和精确聚焦与仪表盘使用相同的选择语义。", + selectScope: "作用域", + allScopes: "全部", + subtreeView: "{title}及其下级", + exactFocus: "聚焦:{title}", + handoffSummary: "交接摘要", continuable: "可继续", blocked: "阻塞", complete: "已完成", noHandoff: "无交接", - coverage: "报告覆盖范围", - workstreams: "工作项", - activities: "活动", - evidenceUnavailable: "证据不可用", - blockers: "阻塞事项", - blockersSubtitle: "需要人工处理后才能继续的工作项", - workstreamsSubtitle: "每个工作范围的精确交接状态与下一步", - workstreamNavigation: "工作项导航", - workstreamPagination: "工作项翻页", - searchWorkstreams: "搜索工作项", - searchWorkstreamsPlaceholder: "按名称或范围标识搜索", - previousWorkstream: "上一项", - nextWorkstream: "下一项", - workstreamPosition: "{current} / {total}", - workstreamMatchCount: "匹配 {count} 项", - noMatchingWorkstreams: "没有匹配的工作项。", - workstream: "工作项", + no_handoff: "无交接", + selectedScopes: "所选 Scope", + selectedScopesDescription: "Parent 只表达组织关系;每一行只报告该 Scope 自身最新的精确 Handoff。", + scope: "Scope", + parent: "上级", status: "状态", - reporting: "汇报状态", + objective: "目标", nextAction: "下一步", - state: "当前状态", - next_action: "下一步", - available: "可用", - unavailable: "不可用", - noWorkstreams: "该项目尚未登记工作项。", - handoffContents: "交接内容", - handoffContentsSubtitle: "统一编辑整份交接内容,每次保存都会生成新的交接版本。", - currentSnapshot: "当前交接快照", exactRevision: "精确版本", - editHandoffContent: "编辑", - editHandoffContentLabel: "编辑全部交接内容", - cancelEdit: "取消", - saveRevision: "保存新版本", - createFirstRevision: "创建首个版本", - firstRevisionNote: "首个版本必须包含目标和至少一项当前状态。", - emptyField: "未填写。", - savingRevision: "正在保存新的交接版本...", - revisionSaved: "已保存为交接版本 @{revision}。", - revisionSaveFailed: "交接版本保存失败(HTTP {status})。", - objective: "目标", - currentState: "当前状态", - omissions: "已知缺失", - currentStateLines: "当前状态,每行一项", - disposition: "处置状态", - omissionLines: "已知缺失,每行一项", - liveStateCheck: "实时工作区", - capabilityCheck: "能力", - authorizationCheck: "授权", - notChecked: "未检查", - confirmed: "已确认", - mismatch: "不匹配", - insufficient: "不足", - receiverIdentity: "接手方身份", - continuityTimeline: "连续性时间线", - continuityOrderNote: "按来源日志位置排序,不代表实际发生时间。", - revisionHistory: "交接版本历史", - revisionHistorySummary: "共 {total} 个版本,按最新优先显示。", - revisionHistoryTruncated: "共 {total} 个版本,显示最近 {shown} 个。", - revisionHistoryEmpty: "该工作项尚无已提交的交接版本。", - revisionCurrent: "当前版本", - revisionNextAction: "下一步:{value}", - revisionCounts: "状态 {state} 项 / 缺失 {omissions} 项", - transferState: "交接状态", - outcomeState: "结果状态", - editorRequired: "目标和至少一项当前状态不能为空。", - timelineEmpty: "该工作范围尚无高层工作连续性记录。", - timelineTruncated: "仅显示最近 {count} 条,共有 {total} 条连续性事件。", - timelineInvalid: "有 {count} 条工作记录无法读取,已明确排除。", - timelineShowEarlier: "查看更早的 {count} 条记录", - timelineShowRecent: "收起,仅看最近 {count} 条", - eventSource: "来源", - eventRevision: "交接版本", - eventReceipt: "接手回执", - eventReceiverChecks: "接手检查", - eventSchema: "记录格式", - eventNoDetails: "该事件没有记录更多详情。", - autoRefreshActive: "每 5 秒自动刷新", - autoRefreshEditing: "正在编辑交接内容,自动刷新已暂停。", - autoRefreshBusy: "当前操作进行中,自动刷新已暂停。", - autoRefreshing: "正在刷新报告...", - autoRefreshUpdated: "刚刚已自动刷新", - autoRefreshFailed: "自动刷新失败,请使用刷新按钮重试。", - eventActor: "接手方:{actor}", - "work-contract": "委派契约", - "handoff-boundary": "发送交接", - "handoff-receipt": "接手选择", - "task-outcome": "任务结果", - delegated: "已委派", - accepted: "已接手", - needs_clarification: "需要补充", - declined: "无法接手", - succeeded: "成功", - partial: "部分完成", - failed: "失败", - cancelled: "已取消", - awaiting_receipt: "等待接手选择", - awaiting_outcome: "等待任务结果", - not_expected: "暂不需要", - not_applicable: "暂不适用", - covered: "已覆盖", metadata: "报告元数据", - selectionConsistency: "选择一致性", - activityCoverage: "活动覆盖范围", - selectionDigest: "选择摘要哈希", - reportDigest: "报告摘要哈希", - dark: "深色", - light: "浅色", + generatedAt: "生成时间", + selectionDigest: "选择摘要", + reportDigest: "报告摘要", + noScopes: "当前没有可用 Scope。", + requestFailed: "交接报告请求失败(HTTP {status})。", + serverUnavailable: "服务器无法访问。", + authRejected: "服务器拒绝了该访问令牌。", + retry: "重试", switchDark: "切换至深色模式", switchLight: "切换至浅色模式", switchChinese: "切换至中文", switchEnglish: "切换至英文", languageChinese: "中文", - languageEnglish: "EN", - updated: "更新于 {value}", - projectOption: "{projectId}", - coverageCaptured: "已纳入游标 {cursor} 之前捕获的活动。数量表示已观察事件,不代表完成百分比。", - coverageNotConfigured: "活动适配器尚未配置;缺少活动不能解释为没有发生工作。", - coverageUnavailable: "当前报告无法取得活动覆盖信息。", - noProjects: "尚无包含已提交交接的范围。", - previewReportTitle: "交接报告模板", - preview: "预览", - previewNotice: "此无数据预览展示报告的主要交接部分。以“—”显示的值不代表真实范围状态。", - previewRetryHint: "为某个范围提交交接后,点击重试以加载真实报告。", - previewPlaceholder: "—", - previewProjectSummary: "项目摘要与范围", - previewProjectSummarySubtitle: "项目身份、所选范围和报告周期", - previewProject: "项目", - previewScope: "范围", - previewWorkstreamsTitle: "工作项与交接", - previewWorkstreamsSubtitle: "每个范围的当前交接状态与继续工作所需内容", - previewHandoffContentsSubtitle: "目标、当前状态、处置状态、下一步和已知缺失", - previewActivitySubtitle: "配置真实项目后,将显示覆盖范围和周期对比。", - previewCoverageDescription: "项目报告可用后,此处将显示覆盖数据。", - previewActivityComparison: "活动与周期对比", - previewActivityComparisonSubtitle: "所选周期与上一周期内观察到的活动", - previewCurrentPeriod: "本期", - previewPreviousPeriod: "上期", - previewChange: "变化", - authRejected: "服务器拒绝了该访问令牌。", - requestFailed: "交接报告请求失败(HTTP {status})。", - serverUnavailable: "服务器无法访问。", - retry: "重试", - reportUnavailable: "当前范围的交接报告不可用。", - downloadFailed: "Markdown 下载失败(HTTP {status})。", - reported: "已汇报", - reported_with_omissions: "已汇报但有缺失", - evidence_unavailable: "证据不可用", - no_handoff: "无交接记录", - activity_after_handoff: "交接后有活动", - activity_without_handoff: "有活动但无交接记录", - no_observed_activity: "未观察到活动", - current_only: "仅当前状态", - exact_input: "精确输入", - optimistic_stable: "乐观稳定", - captured: "已捕获", - not_configured: "未配置", - unknown: "未知" + languageEnglish: "EN" } }; +const authenticationRequired = document.documentElement.dataset.serverAuthRequired === "true"; const authShell = document.getElementById("auth-shell"); const authForm = document.getElementById("auth-form"); const authError = document.getElementById("auth-error"); @@ -446,240 +144,41 @@ const tokenInput = document.getElementById("token"); const pageStatus = document.getElementById("page-status"); const pageStatusMessage = document.getElementById("page-status-message"); const pageStatusRetry = document.getElementById("page-status-retry"); -const previewShell = document.getElementById("handoff-report-preview"); -const previewRetryButton = document.getElementById("preview-retry"); const reportShell = document.getElementById("handoff-report"); -const reportError = document.getElementById("report-error"); -const projectCombobox = document.getElementById("project-combobox"); -const projectSearchInput = document.getElementById("project-search"); -const projectOptions = document.getElementById("project-options"); -const projectSearchStatus = document.getElementById("project-search-status"); +const signOut = document.getElementById("sign-out"); +const scopeSelect = document.getElementById("scope-select"); const refreshButton = document.getElementById("refresh-report"); const downloadButton = document.getElementById("download-report"); -const periodButtons = Array.from(document.querySelectorAll("[data-period-mode]")); -const customPeriodForm = document.getElementById("custom-period-form"); -const periodStartInput = document.getElementById("period-start"); -const periodEndInput = document.getElementById("period-end"); -const applyCustomPeriodButton = document.getElementById("apply-custom-period"); -const periodError = document.getElementById("period-error"); -const autoRefreshStatus = document.getElementById("auto-refresh-status"); -const signOut = document.getElementById("sign-out"); -const handoffSaveStatus = document.getElementById("handoff-save-status"); -const handoffEditorActions = document.getElementById("handoff-editor-actions"); -const editHandoffContentButton = document.getElementById("edit-handoff-content"); -const saveHandoffRevisionButton = document.getElementById("save-handoff-revision"); -const cancelHandoffEditButton = document.getElementById("cancel-handoff-edit"); -const continuityTimelineToggle = document.getElementById("continuity-timeline-toggle"); -const workstreamSwitcherToolbar = document.getElementById("workstream-switcher-toolbar"); -const workstreamSearchField = document.getElementById("workstream-search-field"); -const workstreamSearchInput = document.getElementById("workstream-search"); -const workstreamSwitcherNavigation = document.getElementById("workstream-switcher-navigation"); -const previousWorkstreamButton = document.getElementById("previous-workstream"); -const nextWorkstreamButton = document.getElementById("next-workstream"); -const workstreamPosition = document.getElementById("workstream-position"); -const workstreamListPanel = document.querySelector(".workstream-list-panel"); -const workstreamList = document.getElementById("workstream-list"); -const workstreamFilterEmpty = document.getElementById("workstream-filter-empty"); -let currentProjects = []; -let currentHandoffWorks = []; -let currentProject = null; +const requests = createRequestGate(); +let scopes = []; +let selectedKey = "all"; let currentReport = null; -let currentAuthError = null; -let currentPageStatus = null; -let currentPeriodMode = "day"; -let currentPeriodSelection = null; -let appliedCustomRange = null; -let currentWorkstreamScope = null; -let revisionSaving = false; -let reportLoading = false; -let editorDirty = false; -let autoRefreshTimer = null; -let currentWorkstreamQuery = ""; -let projectActiveIndex = -1; -let lastCenteredWorkstreamKey = null; -let pendingWorkstreamLayoutFrame = null; -const handoffDrafts = new Map(); -const pendingHandoffAttempts = new Map(); -const expandedContinuityScopes = new Set(); -const openContinuityEvents = new Map(); -const ui = createPageUi(translations, ({userInitiated = false} = {}) => { - renderAuthError(); - renderPageStatus(); - if (currentProject !== null) { - renderProjectCombobox(currentProjects, currentProject.project_id); - } +let currentStatus = null; + +const ui = createPageUi(translations, () => { + renderChoices(); if (currentReport !== null) { renderReport(currentReport); - } else { - renderPeriodControls(); - } - updateAutoRefreshStatus(); - if (userInitiated && currentProject !== null && readServerToken()) { - void loadReport(readServerToken(), currentProject.project_id, { - background: true, - selectedScopeId: currentWorkstreamScope - }); } + renderStatus(); }); -const {formatDateTime, formatNumber, translate} = ui; -const reportRequests = createRequestGate(); +const {formatDateTime, translate} = ui; authForm.addEventListener("submit", async (event) => { event.preventDefault(); await authenticate(tokenInput.value); }); - signOut.addEventListener("click", () => { - stopAutoRefresh(); clearServerToken(); - tokenInput.value = ""; showLogin(); }); - -pageStatusRetry.addEventListener("click", async () => { - const token = readServerToken(); - if (currentProject === null) { - await authenticate(token); - } else { - await loadReport(token, currentProject.project_id); - } -}); - -previewRetryButton.addEventListener("click", async () => { - await authenticate(readServerToken()); -}); - -refreshButton.addEventListener("click", async () => { - if (currentProject !== null) { - await loadReport(readServerToken(), currentProject.project_id); - } -}); - -editHandoffContentButton.addEventListener("click", () => { - const item = currentReport?.workstreams.find( - (candidate) => candidate.workstream.scope_id === currentWorkstreamScope - ) || null; - if (item !== null) { - startHandoffEdit(item); - } -}); - -cancelHandoffEditButton.addEventListener("click", () => { - cancelHandoffEdit(); -}); - -downloadButton.addEventListener("click", async () => { - await downloadMarkdown(); -}); - -projectSearchInput.addEventListener("focus", () => { - if (projectOptions.hidden) { - projectSearchInput.value = ""; - } - openProjectOptions(); -}); - -projectSearchInput.addEventListener("input", () => { - projectActiveIndex = -1; - renderProjectOptionsList(); - openProjectOptions(); -}); - -projectSearchInput.addEventListener("keydown", (event) => { - handleProjectSearchKeydown(event); -}); - -projectCombobox.addEventListener("focusout", (event) => { - if (!projectCombobox.contains(event.relatedTarget)) { - closeProjectOptions({restoreSelection: true}); - } -}); - -workstreamSearchInput.addEventListener("input", () => { - currentWorkstreamQuery = normalizeWorkstreamQuery(workstreamSearchInput.value); - lastCenteredWorkstreamKey = null; - applyWorkstreamFilter(); -}); - -workstreamSearchInput.addEventListener("keydown", (event) => { - if (event.key === "Escape" && workstreamSearchInput.value) { - event.preventDefault(); - resetWorkstreamSearch(); - applyWorkstreamFilter(); - workstreamSearchInput.focus(); - return; - } - if (event.key === "Enter") { - const visibleButtons = visibleWorkstreamButtons(); - const selected = visibleButtons.find((button) => button.getAttribute("aria-current") === "true"); - const target = selected || visibleButtons[0]; - if (target !== undefined) { - event.preventDefault(); - activateWorkstream(target.dataset.scopeId); - } - } -}); - -previousWorkstreamButton.addEventListener("click", () => { - activateAdjacentWorkstream(-1); -}); - -nextWorkstreamButton.addEventListener("click", () => { - activateAdjacentWorkstream(1); -}); - -new ResizeObserver(() => { - scheduleWorkstreamLayoutUpdate(); -}).observe(workstreamListPanel); - -continuityTimelineToggle.addEventListener("click", () => { - if (currentWorkstreamScope === null) { - return; - } - if (expandedContinuityScopes.has(currentWorkstreamScope)) { - expandedContinuityScopes.delete(currentWorkstreamScope); - } else { - expandedContinuityScopes.add(currentWorkstreamScope); - } - const item = currentReport?.workstreams.find( - (candidate) => candidate.workstream.scope_id === currentWorkstreamScope - ) || null; - renderContinuity(item?.continuity || null); -}); - -for (const button of periodButtons) { - button.addEventListener("click", async () => { - currentPeriodMode = button.dataset.periodMode; - clearPeriodError(); - if (currentProject !== null) { - await loadReport(readServerToken(), currentProject.project_id); - } - }); -} - -customPeriodForm.addEventListener("submit", async (event) => { - event.preventDefault(); - try { - validateDateRange(periodStartInput.value, periodEndInput.value); - } catch (error) { - showPeriodError(error.message); - return; - } - appliedCustomRange = {startDate: periodStartInput.value, endDate: periodEndInput.value}; - currentPeriodMode = "custom"; - clearPeriodError(); - if (currentProject !== null) { - await loadReport(readServerToken(), currentProject.project_id); - } -}); - -periodStartInput.addEventListener("change", updatePeriodInputBounds); -periodEndInput.addEventListener("change", updatePeriodInputBounds); -document.addEventListener("visibilitychange", () => { - if (!document.hidden) { - void autoRefreshReport(); - } +scopeSelect.addEventListener("change", async () => { + selectedKey = scopeSelect.value; + await loadReport(readServerToken()); }); +refreshButton.addEventListener("click", async () => loadReport(readServerToken())); +pageStatusRetry.addEventListener("click", async () => authenticate(readServerToken())); +downloadButton.addEventListener("click", async () => downloadMarkdown(readServerToken())); async function authenticate(token) { if (authenticationRequired && !token) { @@ -690,1552 +189,187 @@ async function authenticate(token) { storeServerToken(token); } tokenInput.value = ""; - currentAuthError = null; - const request = beginReportRequest(); + const request = requests.start(); try { - const projects = await listProjects(token); - if (!request.isCurrent()) { + const response = await fetchWithBearer("/dashboard/scopes", token); + if (!request.isCurrent()) return; + if (response.status === 401) { + clearServerToken(); + showLogin("authRejected"); return; } - currentProjects = projects; - if (currentProjects.length === 0) { - stopAutoRefresh(); - currentHandoffWorks = []; - currentProject = null; - currentReport = null; - currentWorkstreamScope = null; - showReportPreview(); + if (!response.ok) { + showStatus("requestFailed", {status: response.status}); return; } - const rememberedProjectId = readSelectedProject(); - const rememberedWork = readSelectedWorkLocation(); - const selectedProject = currentProjects.find( - (project) => project.project_id === rememberedWork?.projectId - ) || currentProjects.find( - (project) => project.project_id === rememberedProjectId - ) || currentProjects[0]; - currentHandoffWorks = await listHandoffWorks(token, selectedProject); - if (!request.isCurrent()) { + scopes = await response.json(); + if (scopes.length === 0) { + showStatus("noScopes"); return; } - const selectedScopeId = rememberedWork?.projectId === selectedProject.project_id - && currentHandoffWorks.some((item) => item.workstream.scope_id === rememberedWork.scopeId) - ? rememberedWork.scopeId - : currentHandoffWorks[0]?.workstream.scope_id || null; - await loadReportData(token, selectedProject.project_id, request, { - selectedScopeId - }); - if (request.isCurrent()) { - startAutoRefresh(); - } + const choices = buildScopeSelectionChoices(scopes, translate); + if (!choices.some((choice) => choice.key === selectedKey)) selectedKey = "all"; + renderChoices(); + await loadReport(token, request); } catch (error) { - if (request.isCurrent()) { - handleRequestError(error); - } - } finally { - request.finish(); + if (request.isCurrent()) showStatus("serverUnavailable"); } } -async function listProjects(token) { - const projects = []; - let cursor = null; - do { - const payload = {limit: 100}; - if (cursor !== null) { - payload.cursor = cursor; - } - const page = await requestJson("/v1/handoff-reports/scopes/list-known", token, payload); - projects.push(...page.items.map(({scope_id: scopeId}) => ({ - project_id: scopeId, - project_key: scopeId, - title: scopeId, - default_locale: null, - timezone: "UTC" - }))); - cursor = page.next_cursor; - } while (cursor !== null); - return projects.sort((left, right) => ( - left.title.localeCompare(right.title, ui.localeTag(), {numeric: true, sensitivity: "base"}) - || left.project_id.localeCompare(right.project_id) - )); -} - -async function listHandoffWorks(token, project) { - void token; - return [{project, workstream: {scope_id: project.project_id}}]; -} - -async function loadReport(token, projectId, {background = false, selectedScopeId = null} = {}) { - if (reportLoading) { - return false; - } - if (authenticationRequired && !token) { - showLogin(); - return false; - } - reportLoading = true; - if (background) { - setAutoRefreshStatus("refreshing"); - } else { - clearReportError(); - } - const request = beginReportRequest({busy: !background}); +async function loadReport(token, request = requests.start()) { + const choice = selectedChoice(); + if (choice === null) return; + setBusy(true); try { - if (currentProject?.project_id !== projectId) { - const project = currentProjects.find((item) => item.project_id === projectId); - if (project === undefined) { - throw new Error("reportUnavailable"); - } - currentHandoffWorks = await listHandoffWorks(token, project); - if (!request.isCurrent()) { - return false; - } - } - await loadReportData(token, projectId, request, {selectedScopeId}); - if (!request.isCurrent()) { - return false; - } - if (background) { - setAutoRefreshStatus("updated"); - } - return true; - } catch (error) { - if (!request.isCurrent()) { - return false; - } - if (currentProject !== null) { - renderProjectCombobox(currentProjects, currentProject.project_id); - } - if (background) { - if (error.status === 401) { - handleRequestError(error); - } else { - setAutoRefreshStatus("failed"); - } - return false; - } - handleRequestError(error); - return false; - } finally { - reportLoading = false; - request.finish(); - syncHandoffEditingState(); - if (!background && request.isCurrent()) { - updateAutoRefreshStatus(); - } - } -} - -async function loadReportData(token, projectId, request, {selectedScopeId = null} = {}) { - const projectChanged = currentProject?.project_id !== projectId; - const project = currentProjects.find((item) => item.project_id === projectId) || currentProject; - const defaultLocale = projectUiLocale(project); - if (!ui.hasLocalePreference() && defaultLocale !== null && defaultLocale !== ui.locale()) { - ui.applyLocale(defaultLocale, false); - } - const periodSelection = resolveSelectedPeriod(project); - const response = await requestJson("/v1/handoff-reports/get", token, { - scope_id: projectId, - locale: ui.locale() === "zh" ? "zh-CN" : "en", - include_evidence_checks: false, - format: "json", - include_archived: false, - download: false, - period: periodSelection.period - }); - if (!request.isCurrent()) { - return; - } - if (response.report === null) { - throw new Error("reportUnavailable"); - } - if (projectChanged) { - resetWorkstreamSearch(); - lastCenteredWorkstreamKey = null; - } - currentProject = currentProjects.find((item) => item.project_id === projectId) || response.report.project; - currentReport = response.report; - currentPeriodSelection = periodSelection; - if (selectedScopeId !== null) { - currentWorkstreamScope = selectedScopeId; - } - rememberSelectedProject(projectId); - renderProjectCombobox(currentProjects, projectId); - renderReport(currentReport); -} - -function projectUiLocale(project) { - if (typeof project?.default_locale !== "string") { - return null; - } - return project.default_locale.toLowerCase().startsWith("zh") ? "zh" : "en"; -} - -function beginReportRequest({busy = true} = {}) { - if (busy) { - setBusy(true); - } - const request = reportRequests.start(); - return { - finish() { - if (busy && request.isCurrent()) { - setBusy(false); - } - }, - isCurrent: request.isCurrent - }; -} - -async function requestJson(path, token, payload) { - const response = await fetchWithBearer(path, token, { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify(payload) - }); - if (response.status === 401) { - const error = new Error("authRejected"); - error.status = 401; - throw error; - } - if (!response.ok) { - const error = new Error("requestFailed"); - error.status = response.status; - throw error; - } - return response.json(); -} - -function handleRequestError(error) { - if (error.status === 401) { - clearServerToken(); - showLogin("authRejected"); - return; - } - const key = error.message === "reportUnavailable" ? "reportUnavailable" : "serverUnavailable"; - if (typeof error.status === "number") { - showReportFailure("requestFailed", {status: error.status}); - return; - } - showReportFailure(key); -} - -function showReportFailure(key, values = {}) { - if (currentReport === null) { - showPageStatus(key, values, true); - return; - } - currentPageStatus = null; - pageStatus.hidden = true; - previewShell.hidden = true; - reportShell.hidden = false; - signOut.hidden = !authenticationRequired; - showReportError(key, values); -} - -function showLogin(messageKey = "", values = {}) { - stopAutoRefresh(); - reportRequests.cancel(); - setBusy(false); - reportLoading = false; - revisionSaving = false; - handoffDrafts.clear(); - pendingHandoffAttempts.clear(); - editorDirty = false; - currentProjects = []; - currentHandoffWorks = []; - currentProject = null; - currentReport = null; - currentWorkstreamScope = null; - currentPeriodSelection = null; - currentPageStatus = null; - currentAuthError = messageKey ? {key: messageKey, values} : null; - closeProjectOptions(); - projectSearchInput.value = ""; - projectSearchInput.disabled = false; - projectSearchStatus.textContent = ""; - handoffSaveStatus.textContent = ""; - renderAuthError(); - clearReport(); - authShell.hidden = false; - pageStatus.hidden = true; - previewShell.hidden = true; - reportShell.hidden = true; - signOut.hidden = true; - tokenInput.focus(); -} - -function showPageStatus(messageKey, values = {}, retryable = false) { - currentPageStatus = {key: messageKey, values, retryable}; - renderPageStatus(); - authShell.hidden = true; - pageStatus.hidden = false; - previewShell.hidden = true; - reportShell.hidden = true; - signOut.hidden = !authenticationRequired; -} - -function showReportPreview() { - currentPageStatus = null; - clearReport(); - authShell.hidden = true; - pageStatus.hidden = true; - previewShell.hidden = false; - reportShell.hidden = true; - signOut.hidden = !authenticationRequired; -} - -function renderPageStatus() { - if (currentPageStatus === null) { - pageStatusMessage.textContent = ""; - pageStatusRetry.hidden = true; - return; - } - pageStatusMessage.textContent = translate(currentPageStatus.key, currentPageStatus.values); - pageStatusRetry.hidden = !currentPageStatus.retryable; -} - -function renderAuthError() { - authError.textContent = currentAuthError === null - ? "" - : translate(currentAuthError.key, currentAuthError.values); -} - -function renderProjectCombobox(projects, selectedProjectId) { - const selected = projects.find((project) => project.project_id === selectedProjectId) || null; - if (projectOptions.hidden) { - projectSearchInput.value = selected === null ? "" : projectOptionLabel(selected); - projectSearchStatus.textContent = translate("projectSearchCount", {count: formatNumber(projects.length)}); - return; - } - renderProjectOptionsList(); -} - -function projectOptionLabel(project) { - return translate("projectOption", {title: project.title, projectId: project.project_id}); -} - -function normalizedProjectQuery(value) { - return value.trim().toLocaleLowerCase(); -} - -function matchingProjects() { - const query = normalizedProjectQuery(projectSearchInput.value); - if (!query) { - return currentProjects; - } - return currentProjects.filter((project) => ( - `${project.title}\n${project.project_id}\n${project.project_key}`.toLocaleLowerCase().includes(query) - )); -} - -function renderProjectOptionsList() { - const matches = matchingProjects(); - const visible = matches.slice(0, projectOptionRenderLimit); - projectOptions.replaceChildren(); - projectActiveIndex = Math.min(projectActiveIndex, visible.length - 1); - for (const [index, project] of visible.entries()) { - const option = document.createElement("button"); - option.className = "project-option"; - option.id = `project-option-${index}`; - option.type = "button"; - option.role = "option"; - option.dataset.projectId = project.project_id; - option.setAttribute("aria-selected", String(project.project_id === currentProject?.project_id)); - - const title = document.createElement("strong"); - title.textContent = project.title; - const identity = document.createElement("code"); - identity.textContent = project.project_id; - option.append(title, identity); - option.addEventListener("click", () => { - void selectProject(project.project_id); - }); - projectOptions.appendChild(option); - } - if (matches.length === 0) { - const empty = document.createElement("p"); - empty.className = "project-options-empty"; - empty.textContent = translate("noMatchingProjects"); - projectOptions.appendChild(empty); - } - if (matches.length > visible.length) { - projectSearchStatus.textContent = translate("projectSearchLimited", { - shown: formatNumber(visible.length), - total: formatNumber(matches.length) - }); - } else { - projectSearchStatus.textContent = translate( - projectSearchInput.value ? "projectSearchMatches" : "projectSearchCount", - {count: formatNumber(matches.length)} - ); - } - updateActiveProjectOption(); -} - -function openProjectOptions() { - projectOptions.hidden = false; - projectSearchInput.setAttribute("aria-expanded", "true"); - renderProjectOptionsList(); -} - -function closeProjectOptions({restoreSelection = false} = {}) { - projectOptions.hidden = true; - projectSearchInput.setAttribute("aria-expanded", "false"); - projectSearchInput.removeAttribute("aria-activedescendant"); - projectActiveIndex = -1; - if (restoreSelection) { - const selected = currentProjects.find((project) => project.project_id === currentProject?.project_id); - projectSearchInput.value = selected === undefined ? "" : projectOptionLabel(selected); - projectSearchStatus.textContent = translate("projectSearchCount", {count: formatNumber(currentProjects.length)}); - } -} - -function handleProjectSearchKeydown(event) { - if (event.key === "Escape") { - event.preventDefault(); - closeProjectOptions({restoreSelection: true}); - return; - } - if (!["ArrowDown", "ArrowUp", "Enter", "Home", "End"].includes(event.key)) { - return; - } - const options = Array.from(projectOptions.querySelectorAll(".project-option")); - if (projectOptions.hidden) { - openProjectOptions(); - } - if (options.length === 0) { - return; - } - event.preventDefault(); - if (event.key === "Enter") { - const target = options[projectActiveIndex] || options[0]; - void selectProject(target.dataset.projectId); - return; - } - if (event.key === "Home") { - projectActiveIndex = 0; - } else if (event.key === "End") { - projectActiveIndex = options.length - 1; - } else if (event.key === "ArrowDown") { - projectActiveIndex = Math.min(projectActiveIndex + 1, options.length - 1); - } else { - projectActiveIndex = projectActiveIndex <= 0 ? options.length - 1 : projectActiveIndex - 1; - } - updateActiveProjectOption(); -} - -function updateActiveProjectOption() { - const options = Array.from(projectOptions.querySelectorAll(".project-option")); - options.forEach((option, index) => { - option.dataset.active = String(index === projectActiveIndex); - }); - const active = options[projectActiveIndex]; - if (active === undefined) { - projectSearchInput.removeAttribute("aria-activedescendant"); - return; - } - projectSearchInput.setAttribute("aria-activedescendant", active.id); - active.scrollIntoView({block: "nearest"}); -} - -async function selectProject(projectId) { - const selected = currentProjects.find((project) => project.project_id === projectId); - if (selected === undefined) { - return; - } - projectSearchInput.value = projectOptionLabel(selected); - closeProjectOptions(); - if (projectId !== currentProject?.project_id) { - currentWorkstreamScope = null; - await loadReport(readServerToken(), projectId); - } -} - -function renderReport(report) { - currentPageStatus = null; - authShell.hidden = true; - pageStatus.hidden = true; - previewShell.hidden = true; - reportShell.hidden = false; - signOut.hidden = !authenticationRequired; - clearReportError(); - setText("project-name", currentWorkstreamScope || report.workstreams[0]?.workstream.scope_id || translate("handoffReportTitle")); - setText("report-updated", translate("updated", {value: formatDateTime(report.generated_at)})); - setText("continuable-count", formatNumber(report.summary.continuable_count)); - setText("blocked-count", formatNumber(report.summary.blocked_count)); - setText("complete-count", formatNumber(report.summary.complete_count)); - setText("no-handoff-count", formatNumber(report.summary.no_handoff_count)); - setText("selected-workstreams", formatNumber(report.coverage.selected_workstreams)); - setText("activity-count", formatNumber(report.activity_selection.length)); - setText("evidence-unavailable", formatNumber(report.coverage.unavailable_evidence_workstreams)); - setText("coverage-description", coverageDescription(report)); - setText("selection-consistency", statusLabel(report.selection_consistency)); - setText("activity-coverage", statusLabel(report.coverage.activity_coverage)); - setText("selection-digest", report.selection_digest || "-"); - setText("report-digest", report.report_digest || "-"); - renderPeriodControls(report); - renderBlockers(report.workstreams.filter((item) => item.work_status === "blocked")); - renderHandoffWorkstreams(report.workstreams); - renderActivityBreakdown(report.workstreams); -} - -function clearReport() { - setText("project-name", translate("handoffReportTitle")); - setText("report-updated", ""); - for (const id of [ - "continuable-count", - "blocked-count", - "complete-count", - "no-handoff-count", - "selected-workstreams", - "activity-count", - "evidence-unavailable" - ]) { - setText(id, "0"); - } - setText("coverage-description", ""); - setText("selection-consistency", "-"); - setText("activity-coverage", "-"); - setText("selection-digest", "-"); - setText("report-digest", "-"); - currentPeriodSelection = null; - renderPeriodControls(); - renderBlockers([]); - renderHandoffWorkstreams([]); - renderActivityBreakdown([]); -} - -function coverageDescription(report) { - const status = report.coverage.activity_coverage; - if (status === "captured") { - return translate("coverageCaptured", {cursor: formatNumber(report.activity_cursor)}); - } - if (status === "not_configured") { - return translate("coverageNotConfigured"); - } - return translate("coverageUnavailable"); -} - -function renderBlockers(blockers) { - const section = document.getElementById("blockers-section"); - const list = document.getElementById("blocker-list"); - list.replaceChildren(); - section.hidden = blockers.length === 0; - for (const item of blockers) { - const card = document.createElement("article"); - card.className = "blocker-card"; - const heading = document.createElement("h3"); - heading.textContent = item.workstream.title; - const scope = document.createElement("code"); - scope.textContent = item.workstream.scope_id; - const detail = document.createElement("p"); - detail.textContent = item.content?.next_action?.text || item.content?.objective || translate("blocked"); - card.append(heading, scope, detail); - list.appendChild(card); - } -} - -function renderWorkstreams(workstreams) { - const empty = document.getElementById("workstream-empty"); - const existingButtons = new Map( - Array.from(workstreamList.querySelectorAll(".workstream-list-item")) - .map((button) => [button.dataset.scopeId, button]) - ); - empty.hidden = workstreams.length !== 0; - workstreamSearchField.hidden = workstreams.length <= workstreamSearchThreshold; - if (workstreamSearchField.hidden && currentWorkstreamQuery) { - resetWorkstreamSearch(); - } - for (const item of workstreams) { - const selected = item.workstream.scope_id === currentWorkstreamScope; - const button = existingButtons.get(item.workstream.scope_id) || document.createElement("button"); - if (!button.classList.contains("workstream-list-item")) { - button.className = "workstream-list-item"; - button.type = "button"; - button.addEventListener("click", () => { - activateWorkstream(button.dataset.scopeId); - }); - } - button.dataset.scopeId = item.workstream.scope_id; - button.dataset.searchText = normalizeWorkstreamQuery(`${item.workstream.title}\n${item.workstream.scope_id}`); - button.setAttribute("aria-current", String(selected)); - - const header = document.createElement("span"); - header.className = "workstream-list-item-header"; - const title = document.createElement("strong"); - title.textContent = item.workstream.title; - header.append(title, statusBadge(item.work_status)); - - const scope = document.createElement("code"); - scope.textContent = item.workstream.scope_id; - button.replaceChildren(header, scope); - workstreamList.appendChild(button); - existingButtons.delete(item.workstream.scope_id); - } - for (const button of existingButtons.values()) { - button.remove(); - } - applyWorkstreamFilter(); -} - -function normalizeWorkstreamQuery(value) { - return value.trim().toLocaleLowerCase(); -} - -function resetWorkstreamSearch() { - currentWorkstreamQuery = ""; - workstreamSearchInput.value = ""; -} - -function visibleWorkstreamButtons() { - return Array.from(workstreamList.querySelectorAll(".workstream-list-item:not([hidden])")); -} - -function applyWorkstreamFilter() { - const buttons = Array.from(workstreamList.querySelectorAll(".workstream-list-item")); - for (const button of buttons) { - button.hidden = currentWorkstreamQuery !== "" && !button.dataset.searchText.includes(currentWorkstreamQuery); - } - workstreamFilterEmpty.hidden = buttons.length === 0 || visibleWorkstreamButtons().length !== 0; - scheduleWorkstreamLayoutUpdate(); -} - -function scheduleWorkstreamLayoutUpdate() { - if (pendingWorkstreamLayoutFrame !== null) { - window.cancelAnimationFrame(pendingWorkstreamLayoutFrame); - } - pendingWorkstreamLayoutFrame = window.requestAnimationFrame(() => { - pendingWorkstreamLayoutFrame = null; - updateWorkstreamSwitcherControls(); - centerSelectedWorkstream(); - }); -} - -function updateWorkstreamSwitcherControls() { - const buttons = visibleWorkstreamButtons(); - const selectedIndex = buttons.findIndex((button) => button.getAttribute("aria-current") === "true"); - const overflowing = workstreamList.scrollWidth > workstreamListPanel.clientWidth + 1; - workstreamSwitcherNavigation.hidden = !overflowing; - workstreamSwitcherToolbar.hidden = workstreamSearchField.hidden && workstreamSwitcherNavigation.hidden; - if (buttons.length === 0) { - workstreamPosition.textContent = translate("noMatchingWorkstreams"); - } else if (selectedIndex === -1) { - workstreamPosition.textContent = translate("workstreamMatchCount", {count: formatNumber(buttons.length)}); - } else { - workstreamPosition.textContent = translate("workstreamPosition", { - current: formatNumber(selectedIndex + 1), - total: formatNumber(buttons.length) + const response = await fetchWithBearer("/v1/handoff-reports/get", token, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({selection: choice.selection, format: "json"}) }); - } - previousWorkstreamButton.disabled = editorDirty || revisionSaving || buttons.length === 0 || selectedIndex === 0; - nextWorkstreamButton.disabled = editorDirty - || revisionSaving - || buttons.length === 0 - || selectedIndex === buttons.length - 1; -} - -function activateAdjacentWorkstream(direction) { - const buttons = visibleWorkstreamButtons(); - if (buttons.length === 0) { - return; - } - const selectedIndex = buttons.findIndex((button) => button.getAttribute("aria-current") === "true"); - const targetIndex = selectedIndex === -1 - ? (direction < 0 ? buttons.length - 1 : 0) - : selectedIndex + direction; - if (targetIndex < 0 || targetIndex >= buttons.length) { - return; - } - activateWorkstream(buttons[targetIndex].dataset.scopeId); -} - -function centerSelectedWorkstream() { - const selected = workstreamList.querySelector('.workstream-list-item[aria-current="true"]:not([hidden])'); - if (selected === null || currentProject === null) { - return; - } - const selectionKey = `${currentProject.project_id}:${selected.dataset.scopeId}`; - if (selectionKey === lastCenteredWorkstreamKey) { - return; - } - selected.scrollIntoView({block: "nearest", inline: "center"}); - lastCenteredWorkstreamKey = selectionKey; -} - -function renderHandoffContents(workstreams) { - const list = document.getElementById("handoff-content-list"); - list.replaceChildren(); - const item = workstreams.find((candidate) => candidate.workstream.scope_id === currentWorkstreamScope) - || workstreams[0] - || null; - if (item === null) { - const empty = document.createElement("p"); - empty.className = "handoff-content-empty"; - empty.textContent = translate("noWorkstreams"); - list.appendChild(empty); - renderHandoffEditorActions(null, null); - return; - } - - const card = document.createElement("div"); - card.className = "handoff-content-card"; - const header = document.createElement("header"); - const identity = document.createElement("div"); - const title = document.createElement("h4"); - title.className = "handoff-content-title"; - title.textContent = item.workstream.title; - const scope = document.createElement("code"); - scope.textContent = item.workstream.scope_id; - identity.append(title, scope); - const state = document.createElement("div"); - state.className = "handoff-snapshot-state"; - state.appendChild(statusBadge(item.work_status)); - const reference = document.createElement("code"); - reference.textContent = `${translate("exactRevision")}: ${formatArtifactRef(item.handoff_ref)}`; - state.appendChild(reference); - header.append(identity, state); - card.appendChild(header); - - const draft = handoffDrafts.get(item.workstream.scope_id) || null; - if (draft !== null) { - card.appendChild(createHandoffEditor(item, draft)); - } else { - if (item.content === null) { - const note = document.createElement("p"); - note.className = "handoff-content-empty"; - note.textContent = translate("firstRevisionNote"); - card.appendChild(note); - } - for (const field of handoffFieldDefinitions(item)) { - appendHandoffBlock(card, field); - } - } - list.appendChild(card); - renderHandoffEditorActions(item, draft); - syncHandoffEditingState(); -} - -function handoffFieldDefinitions(item, values = draftFromWorkstream(item)) { - return [ - {name: "objective", label: translate("objective"), kind: "text", rows: 3, value: values.objective}, - {name: "state", label: translate("currentState"), kind: "lines", rows: 5, value: values.state}, - {name: "disposition", label: translate("disposition"), kind: "disposition", value: values.disposition}, - {name: "nextAction", label: translate("nextAction"), kind: "text", rows: 3, value: values.nextAction}, - {name: "omissions", label: translate("omissions"), kind: "lines", rows: 3, value: values.omissions} - ]; -} - -function appendHandoffBlock(card, field) { - const section = document.createElement("section"); - section.className = "handoff-content-block"; - section.dataset.field = field.name; - const header = document.createElement("header"); - const heading = document.createElement("h4"); - heading.textContent = field.label; - header.appendChild(heading); - section.appendChild(header); - appendHandoffFieldValue(section, field); - card.appendChild(section); -} - -function appendHandoffFieldValue(section, field) { - const value = field.value.trim(); - if (field.kind === "disposition") { - section.appendChild(statusBadge(field.value)); - return; - } - const entries = field.kind === "lines" ? normalizedLines(value) : [value]; - if (!value || entries.length === 0) { - const empty = document.createElement("p"); - empty.className = "handoff-block-empty"; - empty.textContent = translate("emptyField"); - section.appendChild(empty); - return; - } - if (field.kind === "lines") { - const list = document.createElement("ul"); - for (const entry of entries) { - const row = document.createElement("li"); - row.textContent = entry; - list.appendChild(row); - } - section.appendChild(list); - return; - } - const paragraph = document.createElement("p"); - paragraph.textContent = value; - section.appendChild(paragraph); -} - -function createHandoffEditor(item, draft) { - const form = document.createElement("form"); - form.className = "handoff-content-editor"; - form.id = "handoff-content-editor"; - if (item.content === null) { - const note = document.createElement("p"); - note.className = "handoff-content-empty"; - note.textContent = translate("firstRevisionNote"); - form.appendChild(note); - } - for (const field of handoffFieldDefinitions(item)) { - const section = document.createElement("section"); - section.className = "handoff-content-block is-editing"; - section.dataset.field = field.name; - const label = document.createElement("label"); - const text = document.createElement("span"); - text.textContent = field.kind === "lines" && field.name === "state" - ? translate("currentStateLines") - : field.kind === "lines" && field.name === "omissions" - ? translate("omissionLines") - : field.label; - const control = createHandoffControl( - field, - draft.values[field.name], - `${item.workstream.scope_id}-${field.name}` - ); - label.htmlFor = control.id; - const update = () => { - draft.values[field.name] = control.value; - draft.dirty = handoffDraftChanged(draft); - syncHandoffEditingState(); - }; - control.addEventListener("input", update); - control.addEventListener("change", update); - label.appendChild(text); - section.append(label, control); - form.appendChild(section); - } - form.addEventListener("submit", (event) => { - event.preventDefault(); - void saveHandoffRevision(item, draft); - }); - return form; -} - -function createHandoffControl(field, value, id) { - let control; - if (field.kind === "disposition") { - control = document.createElement("select"); - for (const status of ["continuable", "blocked", "complete"]) { - const option = document.createElement("option"); - option.value = status; - option.textContent = statusLabel(status); - option.selected = status === value; - control.appendChild(option); + if (!request.isCurrent()) return; + if (response.status === 401) { + clearServerToken(); + showLogin("authRejected"); + return; } - } else { - control = document.createElement("textarea"); - control.rows = field.rows; - control.maxLength = 8192; - control.value = value; - } - control.id = id.replaceAll(/[^a-zA-Z0-9_-]/g, "-"); - control.setAttribute("aria-label", field.label); - return control; -} - -function startHandoffEdit(item) { - const values = draftFromWorkstream(item); - handoffDrafts.clear(); - handoffDrafts.set(item.workstream.scope_id, { - initialValues: {...values}, - values: {...values}, - dirty: false - }); - syncHandoffEditingState(); - handoffSaveStatus.textContent = ""; - handoffSaveStatus.classList.remove("is-error"); - renderHandoffContents(currentReport?.workstreams || []); - document.querySelector(".handoff-content-editor :is(textarea, select)")?.focus(); -} - -function cancelHandoffEdit() { - if (currentWorkstreamScope === null || revisionSaving) { - return; - } - handoffDrafts.delete(currentWorkstreamScope); - pendingHandoffAttempts.delete(currentWorkstreamScope); - syncHandoffEditingState(); - renderHandoffContents(currentReport?.workstreams || []); - editHandoffContentButton.focus(); -} - -function handoffDraftChanged(draft) { - return Object.keys(draft.values).some((name) => draft.values[name] !== draft.initialValues[name]); -} - -function renderHandoffEditorActions(item, draft) { - const available = item !== null; - const editing = draft !== null; - handoffEditorActions.hidden = !available; - editHandoffContentButton.hidden = !available || editing; - saveHandoffRevisionButton.hidden = !editing; - cancelHandoffEditButton.hidden = !editing; - if (available) { - const editKey = item.content === null ? "createFirstRevision" : "editHandoffContent"; - editHandoffContentButton.textContent = translate(editKey); - editHandoffContentButton.setAttribute( - "aria-label", - translate(item.content === null ? "createFirstRevision" : "editHandoffContentLabel") - ); - } - editHandoffContentButton.disabled = revisionSaving || reportLoading; - saveHandoffRevisionButton.disabled = revisionSaving || !draft?.dirty; - cancelHandoffEditButton.disabled = revisionSaving; -} - -function syncHandoffEditingState() { - editorDirty = handoffDrafts.size > 0; - projectSearchInput.disabled = reportLoading || revisionSaving || editorDirty; - workstreamSearchInput.disabled = revisionSaving || editorDirty; - document.querySelectorAll(".workstream-list-item").forEach((button) => { - button.disabled = revisionSaving || editorDirty; - }); - const activeDraft = currentWorkstreamScope === null ? null : handoffDrafts.get(currentWorkstreamScope) || null; - const item = currentReport?.workstreams.find( - (candidate) => candidate.workstream.scope_id === currentWorkstreamScope - ) || null; - renderHandoffEditorActions(item, activeDraft); - updateWorkstreamSwitcherControls(); - updateAutoRefreshStatus(); -} - -function renderActivityBreakdown(workstreams) { - const list = document.getElementById("activity-breakdown-list"); - list.replaceChildren(); - if (workstreams.length === 0) { - const empty = document.createElement("p"); - empty.className = "empty-state"; - empty.textContent = translate("noWorkstreams"); - list.appendChild(empty); - return; - } - for (const item of workstreams) { - const row = document.createElement("div"); - row.className = "activity-breakdown-item"; - const identity = document.createElement("div"); - const title = document.createElement("strong"); - title.textContent = item.workstream.title; - const scope = document.createElement("code"); - scope.textContent = item.workstream.scope_id; - identity.append(title, scope); - const reporting = document.createElement("span"); - reporting.textContent = statusLabel(item.reporting_status); - const count = document.createElement("strong"); - count.textContent = formatNumber(item.observed_activity_count); - row.append(identity, reporting, count); - list.appendChild(row); - } -} - -function renderHandoffWorkstreams(workstreams) { - const selected = workstreams.some((item) => item.workstream.scope_id === currentWorkstreamScope) - ? currentWorkstreamScope - : workstreams[0]?.workstream.scope_id || null; - if (workstreams.length === 0) { - currentWorkstreamScope = null; - renderWorkstreams([]); - renderHandoffContents([]); - renderRevisionHistory(null); - renderContinuity(null); - return; - } - - activateWorkstream(selected); -} - -function activateWorkstream(scopeId) { - const item = currentReport?.workstreams.find((candidate) => candidate.workstream.scope_id === scopeId) || null; - if (item === null) { - return; - } - const scopeChanged = currentWorkstreamScope !== scopeId; - currentWorkstreamScope = scopeId; - rememberSelectedWork(currentProject.project_id, scopeId); - if (scopeChanged) { - handoffSaveStatus.textContent = ""; - handoffSaveStatus.classList.remove("is-error"); - } - renderWorkstreams(currentReport.workstreams); - renderHandoffContents(currentReport.workstreams); - renderRevisionHistory(item); - renderContinuity(item.continuity || null); -} - -function artifactRefsEqual(left, right) { - return left !== null - && right !== null - && left.family === right.family - && left.artifact_id === right.artifact_id - && left.revision === right.revision; -} - -function formatArtifactRef(reference) { - return reference === null ? "-" : `${reference.family}/${reference.artifact_id}@${reference.revision}`; -} - -function draftFromWorkstream(item) { - const content = item.content; - if (content === null) { - return {objective: "", state: "", disposition: "continuable", nextAction: "", omissions: ""}; - } - return { - objective: content.objective, - state: content.state.map((statement) => statement.text).join("\n"), - disposition: content.disposition, - nextAction: content.next_action?.text || "", - omissions: content.omissions.map((omission) => omission.text).join("\n") - }; -} - -function normalizedLines(value) { - return [...new Set(value.split("\n").map((line) => line.trim()).filter(Boolean))]; -} - -async function saveHandoffRevision(item, draft) { - if (currentWorkstreamScope === null || revisionSaving || currentWorkstreamScope !== item.workstream.scope_id) { - return; - } - const values = {...draft.values}; - const objective = values.objective.trim(); - const state = normalizedLines(values.state); - if (!objective || state.length === 0) { - setHandoffSaveStatus("editorRequired", {}, true); - return; - } - - const scopeId = currentWorkstreamScope; - const handoff = { - schema: "powercontext.current-work-handoff.v1", - trust: "untrusted_input", - objective, - state: state.map(declaredClaim), - disposition: values.disposition, - next_action: values.nextAction.trim() ? declaredClaim(values.nextAction.trim()) : null, - omissions: normalizedLines(values.omissions) - }; - const attempt = pendingHandoffAttempt(scopeId, handoff); - setRevisionSaving(true); - setHandoffSaveStatus("savingRevision"); - try { - const prepared = attempt.prepared || await requestJson( - "/v1/work/handoffs/prepare-current", - readServerToken(), - {scope_id: scopeId, source_id: attempt.sourceId, handoff} - ); - attempt.prepared = prepared; - const committed = await requestJson("/v1/handoff/commit", readServerToken(), { - scope_id: scopeId, - handoff: prepared.handoff - }); - pendingHandoffAttempts.delete(scopeId); - handoffDrafts.delete(scopeId); - syncHandoffEditingState(); - await loadReport(readServerToken(), currentProject.project_id, {selectedScopeId: scopeId}); - setHandoffSaveStatus("revisionSaved", {revision: committed.reference.revision}); - } catch (error) { - if (error.status === 401) { - handleRequestError(error); + if (!response.ok) { + showStatus("requestFailed", {status: response.status}); return; } - setHandoffSaveStatus("revisionSaveFailed", {status: error.status || "network"}, true); + const payload = await response.json(); + currentReport = payload.report; + currentStatus = null; + authShell.hidden = true; + pageStatus.hidden = true; + reportShell.hidden = false; + signOut.hidden = !authenticationRequired; + renderReport(currentReport); + } catch (error) { + if (request.isCurrent()) showStatus("serverUnavailable"); } finally { - setRevisionSaving(false); + if (request.isCurrent()) setBusy(false); } } -function setHandoffSaveStatus(key, values = {}, isError = false) { - handoffSaveStatus.textContent = translate(key, values); - handoffSaveStatus.classList.toggle("is-error", isError); -} - -function pendingHandoffAttempt(scopeId, handoff) { - const fingerprint = JSON.stringify(handoff); - const existing = pendingHandoffAttempts.get(scopeId); - if (existing?.fingerprint === fingerprint) { - return existing; - } - const attempt = { - fingerprint, - sourceId: revisionSourceId("handoff-boundary"), - prepared: null - }; - pendingHandoffAttempts.set(scopeId, attempt); - return attempt; -} - -function declaredClaim(text) { - return {text, basis: "declared", evidence: []}; -} - -function setRevisionSaving(saving) { - revisionSaving = saving; - document.querySelectorAll( - ".handoff-content-card :is(button, input, select, textarea), .handoff-editor-actions button" - ).forEach((element) => { - element.disabled = saving; - }); - projectSearchInput.disabled = saving || reportLoading || editorDirty; - syncHandoffEditingState(); -} - -function revisionSourceId(kind) { - const unique = typeof window.crypto?.randomUUID === "function" - ? window.crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(16).slice(2)}`; - return `handoff-report:${kind}:${unique}`; -} - -function renderRevisionHistory(item) { - const list = document.getElementById("handoff-revision-history"); - const summary = document.getElementById("revision-history-summary"); - list.replaceChildren(); - const history = Array.isArray(item?.handoff_history) ? item.handoff_history : []; - if (history.length === 0) { - summary.textContent = translate("revisionHistoryEmpty"); - return; - } - const total = Number(item.handoff_revision_count) || history.length; - summary.textContent = translate( - item.handoff_history_truncated ? "revisionHistoryTruncated" : "revisionHistorySummary", - {shown: history.length, total} - ); - for (const revision of [...history].reverse()) { - const current = artifactRefsEqual(revision.reference, item.handoff_ref); - const row = document.createElement("li"); - row.className = "revision-history-item"; - row.dataset.current = String(current); - if (current) { - row.setAttribute("aria-current", "true"); - } - - const header = document.createElement("div"); - header.className = "revision-history-item-header"; - const reference = document.createElement("code"); - reference.textContent = `@${revision.reference.revision}`; - const disposition = document.createElement("span"); - disposition.className = "revision-history-disposition"; - disposition.textContent = statusLabel(revision.disposition); - header.append(reference, disposition); - if (current) { - const currentLabel = document.createElement("strong"); - currentLabel.textContent = translate("revisionCurrent"); - header.appendChild(currentLabel); - } - - const objective = document.createElement("p"); - objective.className = "revision-history-objective"; - objective.textContent = revision.objective_excerpt; - row.append(header, objective); - if (revision.next_action_excerpt) { - const nextAction = document.createElement("p"); - nextAction.className = "revision-history-next"; - nextAction.textContent = translate("revisionNextAction", {value: revision.next_action_excerpt}); - row.appendChild(nextAction); - } - const counts = document.createElement("p"); - counts.className = "revision-history-counts"; - counts.textContent = translate("revisionCounts", { - state: formatNumber(revision.state_count), - omissions: formatNumber(revision.omission_count) - }); - row.appendChild(counts); - list.appendChild(row); - } -} - -function renderContinuity(continuity) { - const timeline = document.getElementById("continuity-timeline"); - const note = document.getElementById("continuity-note"); - const transferState = document.getElementById("transfer-state-status"); - const outcomeState = document.getElementById("outcome-state-status"); - timeline.replaceChildren(); - continuityTimelineToggle.hidden = true; - if (continuity === null) { - transferState.textContent = "-"; - outcomeState.textContent = "-"; - transferState.removeAttribute("data-state"); - outcomeState.removeAttribute("data-state"); - note.textContent = translate("timelineEmpty"); - return; - } - transferState.textContent = statusLabel(continuity.coverage.transfer_state); - transferState.dataset.state = continuity.coverage.transfer_state; - outcomeState.textContent = statusLabel(continuity.coverage.outcome_state); - outcomeState.dataset.state = continuity.coverage.outcome_state; - const expanded = expandedContinuityScopes.has(continuity.scope_id); - const hiddenEventCount = Math.max(0, continuity.events.length - continuityTimelineRecentLimit); - const visibleEvents = expanded - ? continuity.events - : continuity.events.slice(-continuityTimelineRecentLimit); - for (const event of visibleEvents) { - timeline.appendChild(renderContinuityEvent(continuity.scope_id, event, timeline)); - } - if (hiddenEventCount > 0) { - continuityTimelineToggle.hidden = false; - continuityTimelineToggle.setAttribute("aria-expanded", String(expanded)); - continuityTimelineToggle.textContent = translate( - expanded ? "timelineShowRecent" : "timelineShowEarlier", - expanded ? {count: continuityTimelineRecentLimit} : {count: hiddenEventCount} - ); - } - const notes = []; - if (continuity.events.length === 0) { - notes.push(translate("timelineEmpty")); - } - if (continuity.truncated) { - notes.push(translate("timelineTruncated", { - count: continuity.events.length, - total: continuity.total_event_count - })); - } - if (continuity.invalid_record_count > 0) { - notes.push(translate("timelineInvalid", {count: continuity.invalid_record_count})); - } - note.textContent = notes.join(" "); -} - -function renderContinuityEvent(scopeId, event, timeline) { - const item = document.createElement("li"); - item.dataset.kind = event.kind; - item.dataset.status = event.status; - - const disclosure = document.createElement("details"); - disclosure.className = "continuity-event"; - disclosure.open = openContinuityEvents.get(scopeId) === event.position; - item.dataset.open = String(disclosure.open); - - const toggle = document.createElement("summary"); - const position = document.createElement("span"); - position.className = "continuity-position"; - position.textContent = `#${event.position}`; - const heading = document.createElement("span"); - heading.className = "continuity-event-heading"; - const title = document.createElement("strong"); - title.className = "continuity-event-title"; - title.textContent = statusLabel(event.kind); - const detail = event.summary || (event.actor ? translate("eventActor", {actor: event.actor}) : ""); - const preview = document.createElement("span"); - preview.className = "continuity-event-preview"; - preview.textContent = detail || translate("eventNoDetails"); - heading.append(title, preview); - const status = document.createElement("span"); - status.className = "continuity-event-status"; - status.textContent = statusLabel(event.status); - const arrow = document.createElement("span"); - arrow.className = "continuity-event-arrow"; - arrow.setAttribute("aria-hidden", "true"); - arrow.textContent = "↘"; - toggle.append(position, heading, status, arrow); - - const body = document.createElement("div"); - body.className = "continuity-event-body"; - const metadata = document.createElement("dl"); - metadata.className = "continuity-event-meta"; - if (event.actor && event.summary) { - appendContinuityMeta(metadata, translate("receiverIdentity"), event.actor); - } - if (event.selected_revision !== null) { - appendContinuityMeta(metadata, translate("eventRevision"), formatArtifactRef(event.selected_revision), {code: true}); - } - if (event.handoff_receipt_ref !== null) { - appendContinuityMeta(metadata, translate("eventReceipt"), formatSourceRef(event.handoff_receipt_ref), {code: true}); - } - if (event.receiver_checks !== null) { - appendContinuityMeta(metadata, translate("eventReceiverChecks"), formatReceiverChecks(event.receiver_checks)); - } - appendContinuityMeta(metadata, translate("eventSchema"), event.record_schema, {code: true}); - appendContinuityMeta(metadata, translate("eventSource"), formatSourceRef(event.source_ref), {code: true}); - body.appendChild(metadata); - - disclosure.append(toggle, body); - disclosure.addEventListener("toggle", () => { - item.dataset.open = String(disclosure.open); - if (disclosure.open) { - openContinuityEvents.set(scopeId, event.position); - for (const other of timeline.querySelectorAll("details[open]")) { - if (other !== disclosure) { - other.open = false; - } - } - } else if (openContinuityEvents.get(scopeId) === event.position) { - openContinuityEvents.delete(scopeId); - } - }); - item.appendChild(disclosure); - return item; -} - -function appendContinuityMeta(metadata, labelText, value, {code = false} = {}) { - const item = document.createElement("div"); - const label = document.createElement("dt"); - label.textContent = labelText; - const detail = document.createElement("dd"); - const content = document.createElement(code ? "code" : "span"); - content.textContent = value; - detail.appendChild(content); - item.append(label, detail); - metadata.appendChild(item); -} - -function formatSourceRef(reference) { - const sourceType = reference.source_type || reference.name; - return `${sourceType}/${reference.source_id}`; -} - -function formatReceiverChecks(checks) { - return [ - `${translate("liveStateCheck")}: ${statusLabel(checks.live_state)}`, - `${translate("capabilityCheck")}: ${statusLabel(checks.capability)}`, - `${translate("authorizationCheck")}: ${statusLabel(checks.authorization)}` - ].join(" / "); -} - -function statusBadge(status) { - const badge = document.createElement("span"); - badge.className = `status-badge status-${status.replaceAll("_", "-")}`; - badge.textContent = statusLabel(status); - return badge; -} - -function statusLabel(status) { - return translate(status); -} - -async function downloadMarkdown() { - const token = readServerToken(); - if (!token || currentProject === null) { - showLogin(); - return; - } +async function downloadMarkdown(token) { + const choice = selectedChoice(); + if (choice === null) return; setBusy(true); - clearReportError(); try { - const periodSelection = resolveSelectedPeriod(currentProject); const response = await fetchWithBearer("/v1/handoff-reports/get", token, { method: "POST", headers: {"Content-Type": "application/json"}, - body: JSON.stringify({ - scope_id: currentProject.project_id, - locale: ui.locale() === "zh" ? "zh-CN" : "en", - include_evidence_checks: true, - format: "markdown", - include_archived: false, - download: true, - period: periodSelection.period - }) + body: JSON.stringify({selection: choice.selection, format: "markdown", download: true}) }); - if (response.status === 401) { - clearServerToken(); - showLogin("authRejected"); - return; - } if (!response.ok) { - showReportError("downloadFailed", {status: response.status}); + showStatus("requestFailed", {status: response.status}); return; } - const blob = await response.blob(); - const url = URL.createObjectURL(blob); const link = document.createElement("a"); - link.href = url; + link.href = URL.createObjectURL(await response.blob()); link.download = "handoff-report.md"; link.click(); - URL.revokeObjectURL(url); + URL.revokeObjectURL(link.href); } catch (error) { - showReportError("serverUnavailable"); + showStatus("serverUnavailable"); } finally { setBusy(false); } } -function setBusy(busy) { - previewRetryButton.disabled = busy; - refreshButton.disabled = busy; - downloadButton.disabled = busy; - applyCustomPeriodButton.disabled = busy; - periodStartInput.disabled = busy; - periodEndInput.disabled = busy; - periodButtons.forEach((button) => { - button.disabled = busy; - }); - projectSearchInput.disabled = busy || revisionSaving || editorDirty; - if (busy) { - closeProjectOptions({restoreSelection: true}); - } -} - -function startAutoRefresh() { - if (autoRefreshTimer === null) { - autoRefreshTimer = window.setInterval(() => { - void autoRefreshReport(); - }, autoRefreshIntervalMilliseconds); - } - updateAutoRefreshStatus(); -} - -function stopAutoRefresh() { - if (autoRefreshTimer !== null) { - window.clearInterval(autoRefreshTimer); - autoRefreshTimer = null; - } - autoRefreshStatus.textContent = ""; - autoRefreshStatus.dataset.state = "inactive"; -} - -async function autoRefreshReport() { - const token = readServerToken(); - if (document.hidden || reportLoading || token === null || currentProject === null) { - return; - } - if (editorDirty || revisionSaving) { - updateAutoRefreshStatus(); - return; - } - await loadReport(token, currentProject.project_id, {background: true}); -} - -function updateAutoRefreshStatus() { - if (autoRefreshTimer === null) { - return; - } - if (editorDirty) { - setAutoRefreshStatus("editing"); - } else if (revisionSaving) { - setAutoRefreshStatus("busy"); - } else { - setAutoRefreshStatus("active"); - } +function selectedChoice() { + return buildScopeSelectionChoices(scopes, translate).find((choice) => choice.key === selectedKey) || null; } -function setAutoRefreshStatus(state) { - const translationKeys = { - active: "autoRefreshActive", - busy: "autoRefreshBusy", - editing: "autoRefreshEditing", - failed: "autoRefreshFailed", - refreshing: "autoRefreshing", - updated: "autoRefreshUpdated" - }; - autoRefreshStatus.dataset.state = state; - autoRefreshStatus.textContent = translate(translationKeys[state]); -} - -function resolveSelectedPeriod(project) { - if (project === null || project === undefined) { - throw new Error("reportUnavailable"); - } - return resolvePeriodSelection( - currentPeriodMode, - project.timezone, - appliedCustomRange || {startDate: periodStartInput.value, endDate: periodEndInput.value} - ); -} - -function renderPeriodControls(report = null) { - periodButtons.forEach((button) => { - button.setAttribute("aria-pressed", String(button.dataset.periodMode === currentPeriodMode)); - }); - customPeriodForm.classList.toggle("is-active", currentPeriodMode === "custom"); - const project = currentProject || currentProjects[0] || null; - if (currentPeriodSelection === null && project !== null && currentPeriodMode !== "custom") { - currentPeriodSelection = resolveSelectedPeriod(project); - } - const selection = currentPeriodSelection; - if (selection === null) { - setText("period-summary-label", ""); - setText("period-comparison", ""); - setText("period-boundary-note", ""); - return; - } - if (currentPeriodMode !== "custom") { - periodStartInput.value = selection.startDate; - periodEndInput.value = selection.endDate; +function renderChoices() { + if (scopeSelect === null) return; + scopeSelect.replaceChildren(); + for (const choice of buildScopeSelectionChoices(scopes, translate)) { + const option = document.createElement("option"); + option.value = choice.key; + option.textContent = choice.label; + option.selected = choice.key === selectedKey; + scopeSelect.appendChild(option); } - updatePeriodInputBounds(); - setText("period-summary-label", translate("periodSummary", { - preset: translate(currentPeriodMode), - range: formatDateRange(selection.startDate, selection.endDate, ui.localeTag()), - timezone: selection.period.timezone - })); - const comparison = report?.period_comparison; - setText("period-comparison", comparison === null || comparison === undefined - ? "" - : translate("periodComparison", { - current: formatNumber(comparison.current_activity_count), - previous: formatNumber(comparison.previous_activity_count), - delta: formatSignedNumber(comparison.activity_delta) - })); - setText("period-boundary-note", comparison?.handoff_boundary_coverage === "unavailable" - ? translate("periodBoundaryUnavailable") - : ""); -} - -function updatePeriodInputBounds() { - periodStartInput.setAttribute("aria-invalid", "false"); - periodEndInput.setAttribute("aria-invalid", "false"); -} - -function showPeriodError(key) { - periodError.textContent = translate(key); - periodStartInput.setAttribute("aria-invalid", "true"); - periodEndInput.setAttribute("aria-invalid", "true"); -} - -function clearPeriodError() { - periodError.textContent = ""; - updatePeriodInputBounds(); -} - -function showReportError(key, values = {}) { - reportError.textContent = translate(key, values); -} - -function clearReportError() { - reportError.textContent = ""; } -function rememberSelectedProject(projectId) { - try { - sessionStorage.setItem(selectedProjectKey, projectId); - } catch (error) { - // Project selection remains valid for the current render. - } +function renderReport(report) { + setText("continuable-count", report.summary.continuable_count); + setText("blocked-count", report.summary.blocked_count); + setText("complete-count", report.summary.complete_count); + setText("no-handoff-count", report.summary.no_handoff_count); + setText("generated-at", formatDateTime(report.generated_at)); + setText("selection-digest", report.selection_digest); + setText("report-digest", report.report_digest); + const rows = document.getElementById("scope-report-rows"); + rows.replaceChildren(); + for (const entry of report.scopes) { + const row = document.createElement("tr"); + appendCell(row, entry.scope.title, entry.scope.scope_id); + appendCell(row, entry.scope.parent_scope_id || "—"); + appendCell(row, translate(entry.status)); + appendCell(row, entry.content?.objective || "—"); + appendCell(row, entry.content?.next_action?.text || "—"); + appendCell(row, formatAddress(entry.handoff), null, true); + rows.appendChild(row); + } +} + +function appendCell(row, value, detail = null, code = false) { + const cell = document.createElement("td"); + const primary = document.createElement(code ? "code" : "span"); + primary.textContent = value; + cell.appendChild(primary); + if (detail !== null) { + const secondary = document.createElement("code"); + secondary.textContent = detail; + cell.appendChild(document.createElement("br")); + cell.appendChild(secondary); + } + row.appendChild(cell); +} + +function formatAddress(address) { + if (address === null) return "—"; + const artifact = address.artifact; + return `${address.scope_id}/${artifact.family}/${artifact.artifact_id}@${artifact.revision}`; +} + +function showLogin(messageKey = "") { + requests.cancel(); + currentReport = null; + reportShell.hidden = true; + pageStatus.hidden = true; + authShell.hidden = false; + signOut.hidden = true; + authError.textContent = messageKey ? translate(messageKey) : ""; + tokenInput.focus(); } -function readSelectedProject() { - try { - return sessionStorage.getItem(selectedProjectKey); - } catch (error) { - return null; - } +function showStatus(key, values = {}) { + currentStatus = {key, values}; + currentReport = null; + authShell.hidden = true; + reportShell.hidden = true; + pageStatus.hidden = false; + pageStatusRetry.hidden = false; + renderStatus(); } -function rememberSelectedWork(projectId, scopeId) { - try { - sessionStorage.setItem(selectedWorkKey, JSON.stringify([projectId, scopeId])); - } catch (error) { - // Work selection remains valid for the current render. +function renderStatus() { + if (currentStatus !== null) { + pageStatusMessage.textContent = translate(currentStatus.key, currentStatus.values); } } -function readSelectedWorkLocation() { - try { - const value = JSON.parse(sessionStorage.getItem(selectedWorkKey)); - return Array.isArray(value) && value.length === 2 && value.every((part) => typeof part === "string") - ? {projectId: value[0], scopeId: value[1]} - : null; - } catch (error) { - return null; - } +function setBusy(busy) { + scopeSelect.disabled = busy; + refreshButton.disabled = busy; + downloadButton.disabled = busy; } function setText(id, value) { - document.getElementById(id).textContent = value; -} - -function formatSignedNumber(value) { - return new Intl.NumberFormat(ui.localeTag(), {signDisplay: "always"}).format(value); + document.getElementById(id).textContent = String(value); } ui.initialize(); diff --git a/src/powercontext/server/static/scope-selection.js b/src/powercontext/server/static/scope-selection.js new file mode 100644 index 000000000..2f60c0471 --- /dev/null +++ b/src/powercontext/server/static/scope-selection.js @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use strict"; + +export function buildScopeSelectionChoices(scopes, translate) { + const choices = [{key: "all", label: translate("allScopes"), selection: {mode: "all"}}]; + for (const scope of scopes.filter((item) => item.parent_scope_id === null)) { + choices.push({ + key: `subtree:${scope.scope_id}`, + label: translate("subtreeView", {title: scope.display_name || scope.title}), + selection: {mode: "subtree", root_scope_id: scope.scope_id} + }); + } + for (const scope of scopes) { + choices.push({ + key: `exact:${scope.scope_id}`, + label: translate("exactFocus", {title: scope.display_name || scope.title}), + selection: {mode: "exact", scope_ids: [scope.scope_id]} + }); + } + return choices; +} diff --git a/src/powercontext/server/static/site.css b/src/powercontext/server/static/site.css index 1c8709edf..fda380bd0 100644 --- a/src/powercontext/server/static/site.css +++ b/src/powercontext/server/static/site.css @@ -470,12 +470,10 @@ button:disabled { border-color: var(--pc-accent); } -.project-combobox, .scope-combobox { position: relative; } -.project-combobox > input, .scope-combobox > input { width: 100%; min-height: 40px; @@ -486,14 +484,11 @@ button:disabled { padding: 8px 10px; } -.project-combobox > input:hover, -.project-combobox > input:focus, .scope-combobox > input:hover, .scope-combobox > input:focus { border-color: var(--pc-accent); } -.project-options, .scope-options { position: absolute; z-index: 10; @@ -508,7 +503,6 @@ button:disabled { overflow-y: auto; } -.project-option, .scope-option { display: grid; width: 100%; @@ -522,37 +516,30 @@ button:disabled { text-align: left; } -.project-option:last-child, .scope-option:last-child { border-bottom: 0; } -.project-option:hover, -.project-option[data-active="true"], .scope-option:hover, .scope-option[data-active="true"] { background: var(--pc-accent-soft); } -.project-option[aria-selected="true"], .scope-option[aria-selected="true"] { box-shadow: inset 3px 0 0 var(--pc-accent); } -.project-option strong, .scope-option strong { font-size: 13px; overflow-wrap: anywhere; } -.project-option code, .scope-option code { color: var(--pc-muted); font: 10px/1.4 var(--pc-font-code); overflow-wrap: anywhere; } -.project-options-empty, .scope-options-empty { margin: 0; color: var(--pc-muted); @@ -560,7 +547,6 @@ button:disabled { text-align: center; } -.project-search-status, .scope-search-status { min-height: 17px; color: var(--pc-muted); @@ -950,10 +936,6 @@ button:disabled { background: var(--pc-surface-secondary); } -.report-preview-workstream-item { - cursor: default; -} - .report-hero { align-items: flex-start; margin-bottom: 24px; @@ -965,10 +947,6 @@ button:disabled { gap: 10px; } -.report-project-picker { - min-width: min(360px, 70vw); -} - .report-actions { display: flex; flex-wrap: wrap; @@ -1229,78 +1207,6 @@ button:disabled { font-weight: 650; } -.workstream-browser-layout { - display: grid; - gap: 12px; -} - -.workstream-switcher-toolbar { - display: flex; - align-items: end; - justify-content: space-between; - gap: 12px; -} - -.workstream-search { - display: grid; - width: min(360px, 100%); -} - -.workstream-search input { - width: 100%; - min-height: 38px; - border: 1px solid var(--pc-rule-strong); - border-radius: var(--pc-radius-control); - background: var(--pc-surface); - color: var(--pc-ink); - padding: 8px 10px; -} - -.workstream-search input:hover, -.workstream-search input:focus { - border-color: var(--pc-accent); -} - -.workstream-switcher-navigation { - display: flex; - align-items: center; - gap: 8px; - margin-left: auto; -} - -.workstream-navigation-button { - min-height: 38px; - border: 1px solid var(--pc-rule-strong); - border-radius: var(--pc-radius-control); - background: var(--pc-surface); - color: var(--pc-ink); - padding: 0 12px; - white-space: nowrap; -} - -.workstream-navigation-button:hover:not(:disabled) { - border-color: var(--pc-accent); - color: var(--pc-accent); -} - -.workstream-navigation-button:active:not(:disabled) { - transform: translateY(1px); -} - -.workstream-navigation-button:disabled { - color: var(--pc-muted); - cursor: not-allowed; - opacity: 0.55; -} - -.workstream-position { - min-width: 72px; - color: var(--pc-muted); - font: 11px/1.4 var(--pc-font-code); - text-align: center; -} - -.workstream-list-panel, .handoff-snapshot { min-width: 0; border: 1px solid var(--pc-rule-strong); @@ -1308,70 +1214,6 @@ button:disabled { background: var(--pc-surface); } -.workstream-list-panel { - overflow-x: auto; - overflow-y: hidden; - scroll-snap-type: x proximity; - scrollbar-gutter: stable; -} - -.workstream-list { - display: flex; - width: max-content; - min-width: 100%; -} - -.workstream-list-item { - display: grid; - flex: 1 0 260px; - gap: 5px; - border: 0; - border-right: 1px solid var(--pc-rule); - border-radius: 0; - background: transparent; - color: var(--pc-ink); - padding: 12px 15px 14px; - scroll-snap-align: start; - text-align: left; - transition: background-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; -} - -.workstream-list-item:last-child { - border-right: 0; -} - -.workstream-list-item:hover { - background: var(--pc-surface-secondary); -} - -.workstream-list-item:active { - transform: translateY(1px); -} - -.workstream-list-item[aria-current="true"] { - background: var(--pc-accent-soft); - box-shadow: inset 0 -3px 0 var(--pc-accent); -} - -.workstream-list-item-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} - -.workstream-list-item-header strong { - font-size: 13px; - line-height: 1.45; - overflow-wrap: anywhere; -} - -.workstream-list-item code { - color: var(--pc-muted); - font: 10px/1.45 var(--pc-font-code); - overflow-wrap: anywhere; -} - .handoff-snapshot { padding: 20px 22px 22px; } @@ -4129,12 +3971,6 @@ button:disabled { justify-items: start; } - .report-project-picker { - width: 100%; - min-width: 0; - } - - .project-options, .scope-options { max-height: min(320px, 52vh); } @@ -4230,20 +4066,6 @@ button:disabled { flex-direction: column; } - .workstream-switcher-toolbar { - align-items: stretch; - flex-direction: column; - } - - .workstream-search { - width: 100%; - } - - .workstream-switcher-navigation { - justify-content: space-between; - margin-left: 0; - } - .workbench-selection { width: 100%; align-items: flex-start; diff --git a/src/powercontext/server/templates/pages/handoff_report.html b/src/powercontext/server/templates/pages/handoff_report.html index 3a9cc941a..505f6f28d 100644 --- a/src/powercontext/server/templates/pages/handoff_report.html +++ b/src/powercontext/server/templates/pages/handoff_report.html @@ -25,485 +25,65 @@ {% set status_title = "Handoff Report" %} {% include "components/status.html" %} - -