diff --git a/.env.example b/.env.example index 4cff4556a..dace3741e 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,15 @@ # Server transport ------------------------------------------------------------ POWERCONTEXT_SERVER_HTTP_HOST=127.0.0.1 POWERCONTEXT_SERVER_HTTP_PORT=8000 +# Defaults to the directory where the Server starts. Set this explicitly for services and containers. +# POWERCONTEXT_SERVER_WORKSPACE=/srv/project +# Set once when remote Skill Receivers must connect through a remotely reachable endpoint. +# POWERCONTEXT_SERVER_PUBLIC_URL=https://powercontext.example.com +# Development/PoC escape hatch only: permit direct cleartext HTTP for remote Skill Receiver endpoints. +# The Receiver must also enroll with --allow-insecure-http. Keep this false on public or untrusted networks. +# POWERCONTEXT_SERVER_ALLOW_INSECURE_HTTP=false +# A direct non-loopback bind without Server-wide bearer authentication also requires this independent opt-in. +# POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK=false POWERCONTEXT_SERVER_MCP_ENABLED=true POWERCONTEXT_SERVER_MCP_PATH=/mcp diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml index bbc2124bb..076a35b96 100644 --- a/.github/workflows/build-artifacts.yml +++ b/.github/workflows/build-artifacts.yml @@ -377,6 +377,15 @@ jobs: build/smoke-venv/bin/powercontext --help build/smoke-venv/bin/powercontext server --help + cli_wheel="$(find "$BUNDLE_ROOT/distributions" -maxdepth 1 -type f -name 'powercontext-*.whl' -print -quit)" + test -n "$cli_wheel" + python -m venv build/cli-smoke-venv + build/cli-smoke-venv/bin/python -m pip install \ + --no-index \ + --find-links "$BUNDLE_ROOT/wheelhouse" \ + "$cli_wheel[cli]" + build/cli-smoke-venv/bin/powercontext skill remote-sync --help + plugin_root="$BUNDLE_ROOT/integrations/plugins/powercontext" ( cd "$plugin_root" diff --git a/docs/en/development/server-web-ui.md b/docs/en/development/server-web-ui.md index d30083dbe..146edde06 100644 --- a/docs/en/development/server-web-ui.md +++ b/docs/en/development/server-web-ui.md @@ -81,6 +81,7 @@ The browser authenticates against `/dashboard/scopes`, then requests `/v1/stats` | 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 | +| Skill origin | Immutable lineage for managed Skills; registration for external Skills | | Model usage | Persisted daily generation and embedding usage | | Recall hits, token reduction, and savings trend | Persisted daily recall measurements for the configured estimator | @@ -90,6 +91,13 @@ signed daily `token_reduction` as the savings trend. Each heatmap cell combines 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. +The Skills page makes the origin of every item visible with the same compact badge treatment as lifecycle state. An +ordinary managed Skill is labeled Generated, an exact import is labeled Imported, a fork is labeled Forked, and an +Agent-native package that has not entered Review is labeled Local. Import, fork, and Agent-native +details also show the registration's source machine, Agent, external Skill ID, installation scope, and original location. +Later managed Revisions trace through upstream Skill lineage to the first external snapshot, so a revision does not lose +the takeover machine. + ## Share only stable page structure Put document-level structure in `base.html`. Put a fragment in `templates/components/` when it is reused or represents diff --git a/docs/en/docs/reference/configuration.md b/docs/en/docs/reference/configuration.md index c0ee374e0..f309cde0f 100644 --- a/docs/en/docs/reference/configuration.md +++ b/docs/en/docs/reference/configuration.md @@ -51,10 +51,13 @@ Server settings use the `POWERCONTEXT_SERVER_` prefix. | --- | --- | --- | | `POWERCONTEXT_SERVER_HTTP_HOST` | `127.0.0.1` | Listener address | | `POWERCONTEXT_SERVER_HTTP_PORT` | `8000` | Listener port | +| `POWERCONTEXT_SERVER_WORKSPACE` | Server startup directory | Resolution root for local project Agent Skill folders | | `POWERCONTEXT_SERVER_MCP_ENABLED` | `true` | Enable Streamable HTTP MCP | | `POWERCONTEXT_SERVER_MCP_PATH` | `/mcp` | MCP path | | `POWERCONTEXT_SERVER_AUTH_ENABLED` | `false` | Require one static bearer token for HTTP and MCP | | `POWERCONTEXT_SERVER_AUTH_TOKEN` | unset | Static bearer token; required when authentication is enabled | +| `POWERCONTEXT_SERVER_PUBLIC_URL` | unset | Remotely reachable base URL used by remote Skill enrollment guidance; HTTPS is required by default | +| `POWERCONTEXT_SERVER_ALLOW_INSECURE_HTTP` | `false` | Explicitly allow cleartext HTTP for remote Skill Receiver endpoints and guidance | | `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 | @@ -84,7 +87,7 @@ Server settings use the `POWERCONTEXT_SERVER_` prefix. | `POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_TIMEOUT_SECONDS` | `30` | Timeout in seconds for one embedding request | | `POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_BATCH_SIZE` | `10` | Maximum texts sent in one embedding request | | `POWERCONTEXT_SERVER_RUNTIME_EXPERIENCE_SCHEDULE_SECONDS` | unset | Experience incubation interval; unset disables that job | -| `POWERCONTEXT_SERVER_EXTERNAL_SKILLS` | unset | JSON object containing the host identity and explicit Agent Skill targets | +| `POWERCONTEXT_SERVER_EXTERNAL_SKILLS` | automatic local project targets | JSON override containing the host identity and explicit Agent Skill targets | Static bearer authentication is disabled by default. When enabled, API and MCP requests must include `Authorization: Bearer `; the liveness and readiness endpoints remain public. Plain HTTP is trusted only on a @@ -94,17 +97,55 @@ when TLS is terminated upstream or the network is otherwise controlled, set `POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK=true` to opt in explicitly. Use TLS before exposing an authenticated Server over a network. -The Python Client and CLI apply the matching rule for outbound requests: a configured unencrypted `http://` Server -URL is accepted only for loopback hosts. The Client refuses to send any request, authenticated or not, over -unencrypted non-loopback HTTP. Code whose `http://` base URL is only a routing label for a transport that is secure in -practice, such as an in-process ASGI app, Unix-domain socket, or TLS-terminating proxy, must supply its own -`http_client` and pass `trust_transport_security=True` explicitly. See +The Python Client and CLI apply the matching rule for general outbound requests: a configured unencrypted `http://` +Server URL is accepted only for loopback hosts. The explicit remote Skill Receiver PoC exception is documented below. +Code whose `http://` base URL is only a routing label for a transport that is secure in practice, such as an in-process +ASGI app, Unix-domain socket, or TLS-terminating proxy, must supply its own `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. +By default, the Server treats its startup directory as the workspace and exposes two writable local project targets: +`/.agents/skills` for Codex and `/.claude/skills` for Claude Code. Missing directories are harmless +and are created only after the user confirms an installation in the Dashboard. Set `POWERCONTEXT_SERVER_WORKSPACE` once +for systemd, containers, or other launchers whose working directory is not the project; the page does not ask users to +enter Skill paths. + +Configure `POWERCONTEXT_SERVER_PUBLIC_URL` once when remote Skill Receivers should connect through a different externally +reachable origin than the one used to open the Dashboard. The Skills Dashboard then generates the enrollment command +without asking for an address on every target. When it is unset, the Dashboard automatically uses its current HTTPS +origin, or its current HTTP origin when the explicit insecure switch is enabled. If neither is available, the enrollment +command relies on the remote CLI's configured Server URL. + +For a first-phase PoC on a protected internal test network, direct HTTP requires explicit consent on both sides. Set +`POWERCONTEXT_SERVER_ALLOW_INSECURE_HTTP=true`, advertise an `http://` `POWERCONTEXT_SERVER_PUBLIC_URL`, and bind the +listener to an address reachable by the target. The Dashboard shows a cleartext warning and adds +`remote-enroll --allow-insecure-http`; a manually entered enrollment command must include the same option. Without the +Server setting, the remote endpoints reject non-loopback HTTP. Without the Receiver option, the CLI rejects the URL +before transmitting the one-time enrollment code. The permission is stored in the owner-only Receiver configuration so +`remote-watch` and its systemd user service keep the same policy without embedding credentials or extra flags in the +unit. This switch adds no TLS, network isolation, or protection against interception: do not use it on the public +Internet or an untrusted network, and prefer HTTPS for persistent deployments. + +```bash +export POWERCONTEXT_SERVER_HTTP_HOST=0.0.0.0 +export POWERCONTEXT_SERVER_PUBLIC_URL=http://powercontext.internal.example:8765 +export POWERCONTEXT_SERVER_ALLOW_INSECURE_HTTP=true +export POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK=true +powercontext server run + +# On the target project: +powercontext --server-url http://powercontext.internal.example:8765 \ + skill remote-enroll --workspace "$PWD" --install-service --allow-insecure-http +``` + +The non-loopback opt-in in this example is independent of the Receiver transport exception: it acknowledges that all +Server routes on this listener are reachable without the Server-wide bearer token. Prefer enabling authentication or +terminating TLS in front of a loopback-bound Server whenever the deployment permits it. + 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 Server token in that form; the browser keeps it only in the current tab's session storage. Disable both Dashboard and @@ -166,9 +207,11 @@ change stored Memory or indexes. Provider and structured-output failures remain reranking when search must remain independent of model availability. See [RFC 0080](/en/rfcs/0080_memory_search_reranking/) for the algorithm, concurrency, and API boundaries. -The same configured generation model gates explicit Experience generation, managed Skill generation and evolution, -and external Skill import or fork. Without it, these operations return a capability error before persisting a -Candidate. Candidate Review, exact reads, and external Skill scan/list/resolve continue to work. +The same configured generation model gates explicit Experience generation, managed Skill generation and semantic Skill +fork/evolution. Exact external Skill import and complete package upload do not use a model: PowerContext validates and +stores the canonical package bytes, then creates a pending Candidate with the same package digest. Without a generation +model, semantic generation returns a capability error before persisting a Candidate; Review, package inspection and +download, exact import, usage recording, and external Skill scan/list/resolve continue to work. Experience incubation is a separate APScheduler job with its own persisted Source cursor. Enable it with: @@ -187,7 +230,8 @@ See [Create and review an Experience](../how-to/create-and-review-experience.md) ### Agent Skill targets -Configure Codex and Claude Code host-local targets as one JSON value: +The zero-configuration flow uses the Codex and Claude Code project folders under the workspace. Provide a JSON override +only for custom paths, user-level targets, environment compatibility facts, or to explicitly disable local discovery: ```bash export POWERCONTEXT_SERVER_EXTERNAL_SKILLS='{ @@ -198,7 +242,16 @@ export POWERCONTEXT_SERVER_EXTERNAL_SKILLS='{ "agent_kind": "codex", "installation_scope": "project", "path": "/srv/project/.agents/skills", - "allow_managed_publish": true + "allow_managed_publish": true, + "environment": { + "operating_system": "linux", + "architecture": "x86_64", + "commands": {"python": "3.13.2", "bash": "5.2"}, + "network_policy": "restricted", + "writable_roots": ["workspace"], + "dependency_install_policy": "denied", + "environment_names": ["CI"] + } }, { "target_id": "claude-project", @@ -211,14 +264,26 @@ export POWERCONTEXT_SERVER_EXTERNAL_SKILLS='{ }' ``` -Target IDs must be unique. `agent_kind` supports `codex` and `claude_code`; installation scopes are `user`, `project`, -and `plugin`. PowerContext scans only the immediate Skill package directories under these explicit targets; it does not -infer a home directory, install packages, or grant execution authority. `allow_managed_publish` defaults to `false`; -when true, the authenticated Skills Library or Review page may explicitly create or safely update an approved managed -Skill in that target. The page still cannot submit an arbitrary path or overwrite a foreign or modified package. The +Setting `POWERCONTEXT_SERVER_EXTERNAL_SKILLS` replaces both automatically generated project targets in full; use +`{"host_id": null, "targets": []}` to disable local discovery and publication. Target IDs must be unique. `agent_kind` +supports `codex` and `claude_code`; installation scopes are `user`, `project`, and `plugin`. PowerContext scans only the +immediate Skill package directories under default or explicit targets; it does not infer a user home directory, install +packages, or grant execution authority. The two generated project targets let users explicitly install from the +Dashboard. Custom targets default `allow_managed_publish` to `false`; when true, the authenticated Skills Library or +Review page may explicitly create or safely update an approved managed +Skill in that target. Publication materializes the exact reviewed package, including scripts and references, without +executing it or injecting a sidecar into the package. The same pages can safely unpublish only an intact package whose +binding and tree digest still match; local drift and foreign content remain untouched. The page still cannot submit an +arbitrary path or overwrite a foreign or modified package. The `host_id`, locator, and registration are local-environment state, not a cross-host contract. Existing `codex_roots` configuration remains accepted as a Codex-only compatibility form; new configuration should use `targets`. +The optional `environment` object contains only observed, secret-free compatibility facts. Command values are version +labels, and `environment_names` records names only, never values. PowerContext does not probe or execute package scripts +to construct this profile. When it is absent, packages containing scripts report unknown compatibility; when present, +the Skills Library compares known script interpreters with the observed command names and displays a reasoned assessment. +The assessment does not grant network, filesystem, dependency-install, or environment access. + The Server always creates non-recording OpenTelemetry request context so `X-PowerContext-Request-ID` can be derived from the inbound span. To enable recording and export for a CLI-managed Server, install `powercontext[cli,server,tracing-otlp]`, enable tracing, and configure standard OpenTelemetry variables such as diff --git a/docs/en/rfcs/1351_standard_skill_package_lifecycle.md b/docs/en/rfcs/1351_standard_skill_package_lifecycle.md new file mode 100644 index 000000000..a0e768e62 --- /dev/null +++ b/docs/en/rfcs/1351_standard_skill_package_lifecycle.md @@ -0,0 +1,1366 @@ +- Proposal Name: `standard_skill_package_lifecycle` +- Start Date: 2026-08-21 +- RFC PR: [oceanbase/powercontext#1351](https://github.com/oceanbase/powercontext/pull/1351) +- Related RFCs: [RFC 0031](0050_artifact_candidate_review_inbox.md), + [RFC 0051](0051_experience_skill_artifact_families.md), + [RFC 0072](0072_scoped_statistics_and_usage.md), and + [RFC 1304](1304_experience_skill_review_page.md) + +# Summary + +This RFC turns the PowerContext-managed `skill` Family from an instruction-only record into a governed, standard Agent +Skill package and closes the lifecycle from discovery or authorship through Review, Library search, target publication, +observed use, revision, deprecation, and safe unpublication. + +A managed Skill Revision owns one immutable package rooted at `SKILL.md`. The package may also contain `scripts/`, +`references/`, `assets/`, and other bounded files allowed by the Agent Skills format. PowerContext stores a complete, +content-addressed package snapshot, preserves exact external packages during import, and publishes the same approved +bytes to compatible Codex and Claude Code targets. Agent-specific adapters choose locations and report compatibility; +they do not silently rewrite the approved package. + +The implementation supports configured targets on the PowerContext Server host and an independently accepted remote +distribution slice. In remote mode, the Server stores the desired Revision for a target, +while a lightweight Receiver in the Codex or Claude Code integration pulls it over HTTPS by default, verifies it, installs it +atomically, and returns an exact receipt. A remote host does not need a complete PowerContext Server or database, but it +does need an enrolled Receiver. The Server does not write Agent directories through SSH or a remote filesystem. + +The package declares content and requirements, not authority. Review, approval, search, publication, and an optional +`allowed-tools` field never grant execution, filesystem, network, secret, or dependency-install permissions. Script +execution remains owned by the receiving Agent and its host policy. This RFC defines static validation and environment +compatibility assessment but does not add a general PowerContext script runner. + +The closed loop is: + +```text +discover, upload, or generate a package + -> capture an exact package snapshot + -> validate format, files, provenance, and risk + -> create a pending Candidate + -> Review + -> approve an immutable Skill package Revision + -> index the current active head in Skills Library + -> explicitly publish the exact Revision to a local target or declare it as a remote target's desired state + -> let an available remote Receiver converge and report its observed Revision and digest + -> record bounded selected/invoked/outcome evidence when the integration can observe it + -> propose a successor Revision, deprecate, retire, or safely unpublish +``` + +# Motivation + +## The managed Skill content is not yet a standard package + +The current managed Skill stores `name`, `description`, `instructions`, and `validation`. Publication generates one +`SKILL.md` plus a PowerContext manifest. This is enough to review an instruction core, but it cannot preserve a normal +Agent Skill package containing scripts, references, templates, examples, licenses, or binary assets. + +The current External Skill Registry already fingerprints every regular file under a local package. Explicit import, +however, snapshots only `SKILL.md` and asks a generation model to create new instruction-only content. That behavior is +appropriate for a semantic fork, but not for an exact import: a useful script or reference may disappear even though +the user selected a specific package fingerprint. + +The result is an incomplete loop: + +```text +external package with scripts and references + -> exact whole-package fingerprint + -> SKILL.md-only snapshot + -> generated instruction core + -> SKILL.md-only publication +``` + +PowerContext needs one package contract from import through publication so the reviewer can approve the content that an +Agent will actually discover. + +## Package portability does not imply runtime portability + +A package can be copied between hosts while its scripts still depend on a particular operating system, architecture, +interpreter, executable, working directory, network policy, or environment variable. Codex and Claude Code may also run +under different host policies even when both accept the same package layout. + +PowerContext must not solve that mismatch by producing different unreviewed packages per target. The same approved +package remains authoritative. A target-specific environment profile and a rebuildable compatibility assessment explain +whether the target can use it. Missing capabilities produce `incompatible`, `unknown`, or `manual_review_required`; they +do not trigger an automatic package rewrite or dependency installation. + +## A growing Library needs a governed working set + +Package storage can deduplicate identical content, but storage alone does not prevent discovery noise. Publishing every +approved Skill into every Agent directory would make names conflict, expose stale packages, and enlarge the Agent's +working set without evidence that those Skills are useful for the current project. + +PowerContext therefore distinguishes: + +| Layer | Meaning | +| --- | --- | +| External Registry | Rebuildable observations of Agent-native packages owned elsewhere | +| Governed Library | Approved PowerContext-managed Skills and visible external registrations | +| Active managed heads | Managed Skills eligible for normal Library search and new publication | +| Published set | Exact managed Revisions physically present in one configured Agent target | +| Usage evidence | Bounded observations of selection, invocation, validation, and task outcome | + +The Library may grow, while search remains limited to current eligible heads and publication remains explicit per target. + +## Approval is not the end of the lifecycle + +RFC 0051 and RFC 1304 establish Candidate Review, immutable Artifact Revision, and explicit host-local publication. +They intentionally defer package hosting, retirement, unpublication, ranking, and usage attribution. A usable Skills +product now needs the remaining transitions without weakening those trust boundaries. + +The design must support correction and retirement without mutating history: + +```text +Skill@1 is approved and published + -> later use exposes a missing validation step + -> exact usage evidence targets Skill@1 + -> Review approves Skill@2 + -> target reports update_available + -> explicit publication replaces only the intact managed package + -> Skill@1 remains exactly readable +``` + +# Guide-level explanation + +## Think of a Skill as a package, not a procedure record + +A standard managed Skill looks like this: + +```text +release-check/ +├── SKILL.md +├── scripts/ +│ ├── verify.py +│ ├── linux/ +│ │ └── prepare.sh +│ └── windows/ +│ └── prepare.ps1 +├── references/ +│ └── release-policy.md +├── assets/ +│ └── report-template.json +└── LICENSE +``` + +`SKILL.md` is the package entry point. Its YAML frontmatter provides the discoverable name and description. The Markdown +body tells an Agent when and how to use the package. Other files remain ordinary package resources; PowerContext does not +turn them into a workflow graph or execute them during import, Review, approval, search, or publication. + +The package bytes are authoritative. Name, description, compatibility, and other parsed values shown in Skills Library +are validated caches derived from the exact `SKILL.md`, not an independent editable copy. + +## Understand the four ways content enters the Library + +### Discover an external Skill + +Discovery records a local registration, locator, package fingerprint, Agent kind, host, and installation scope. The +external directory remains authoritative. No package bytes are copied into a managed Artifact, and disappearance or +fingerprint drift makes the registration unavailable. + +### Import an external Skill exactly + +The user selects one visible `external_skill_id` and exact fingerprint and chooses **Import**. PowerContext captures every +admissible file under the package root, verifies that the source fingerprint did not change during capture, stores a +canonical package snapshot, and creates a pending Candidate with the same package tree digest. + +Exact import does not require an LLM and does not rewrite `SKILL.md`. Approval creates a new PowerContext-managed Skill +identity whose first Revision contains exactly the captured package. The external package and the imported managed Skill +are now independent authorities connected by lineage. + +### Fork an external Skill + +**Fork** first stores the same exact external snapshot as immutable Source evidence. A person or configured generation +model then proposes a different complete package. Review shows the original and proposed file trees and their diff. +Approval creates only the proposed managed Revision; the original snapshot remains readable as evidence. + +### Author or generate a managed Skill + +A person may upload a complete package, or a configured generation model may propose one from exact SourceRef and +ArtifactRef evidence. Generation produces package content outside the approval transaction. PowerContext validates and +stores it before creating a pending Candidate. A model cannot approve the Candidate, allocate final Artifact identity, +publish the package, or gain script authority. + +## Review the package that will be published + +The Skill Review detail presents: + +- standard metadata and exact package digest; +- a bounded file tree with path, size, media type, content digest, and executable status; +- rendered `SKILL.md` as inert text or sanitized Markdown; +- text previews for bounded UTF-8 files; +- metadata-only rows for binary assets; +- a file diff for a successor Revision or fork; +- static validation, provenance, license, dependency, secret-scan, and risk findings; +- known target compatibility without executing package scripts. + +Review actions remain Candidate actions. Revising a Candidate creates a complete replacement Candidate version. Approval +commits one immutable package Revision. Editing an approved Skill always creates a successor Candidate; it never reopens +or mutates the approved Revision. + +## Browse current Skills without loading every package + +Skills Library searches a rebuildable projection, not ZIP bytes. For an active managed Skill, PowerContext indexes: + +- name and description; +- the bounded `SKILL.md` body; +- standard compatibility and metadata values; +- package paths; +- bounded text from `references/*.md` and `references/*.txt`. + +The primary index records script and asset paths but does not mix complete script source or binary contents into the +default semantic text. Selecting a result resolves the exact ArtifactRef and package digest before the UI reads package +details. + +Pending and rejected Candidates never enter Library search. Historical Revisions remain exactly readable but do not +enter the default current-head index. + +## Keep lifecycle separate from Revision + +Every managed Skill has a governance state independent from its immutable package Revisions: + +| State | Library behavior | Publication behavior | +| --- | --- | --- | +| `active` | Included in normal search | May be published or updated explicitly | +| `deprecated` | Visible with replacement guidance; excluded by default recommendation | Existing binding remains; new publication requires explicit override | +| `retired` | Hidden from normal search; exact reads remain | New publication and update are blocked; safe unpublication remains available | + +Changing lifecycle state does not create or modify package bytes. A deprecation may point to a replacement managed Skill. +Usage counts and similarity never change lifecycle automatically. + +## Publish the same package to Codex and Claude Code + +An Agent target identifies a configured Agent kind, host, installation scope, package root, and whether managed +publication is allowed. The target adapter validates the standard package and destination rules, materializes the exact +approved package into a staging directory, verifies the complete tree digest, and atomically moves it into place. + +Codex and Claude Code receive the same package bytes. Their adapters may reject an incompatible name, format, or target, +but they do not rewrite frontmatter, remove scripts, or add a manifest inside the package. PowerContext stores publication +ownership and observed digests outside the standard package. + +Publication does not execute a script. Unpublication removes a package only when its exact Artifact identity and tree +digest still match the recorded managed binding. A modified or foreign directory reports `drifted` or `conflict` and is +left untouched. + +## Distribute to a remote host through Agent-side pull + +Cross-host distribution does not turn PowerContext Server into a remote file manager. On first use, an administrator +gives the machine a recognizable name and creates a one-time enrollment code for a scope, Agent kind, and project in the +Dashboard or CLI. The user installs or enables the PowerContext Plugin/Integration on the remote host, selects the local +project, and submits that code. The Dashboard uses the readable name as the primary identity and keeps the stable +`target_id` in technical details; after enrollment it also shows the Receiver-reported hostname and workspace name. +Enrollment uploads no remote absolute path. + +The user then selects that target and an exact Skill Revision in the Dashboard. **Publish** changes only the target's +desired state: + +```text +Dashboard / CLI + -> Server stores the target's desired Revision, tree digest, and generation + -> remote Receiver requests reconciliation from a resident watch, Agent preflight, or explicit sync + -> Receiver downloads the exact canonical package, verifies it, stages it locally, and installs it atomically + -> Receiver reports the observed Revision, tree digest, generation, and result + -> Dashboard shows current only when the receipt matches +``` + +The Codex or Claude Code PowerContext Plugin/Integration carries the Receiver. Its only responsibilities are package +synchronization and result reporting; it is not another PowerContext Server. The Plugin is the bootstrap and managed +Skills are dynamic data, so publishing a Skill does not require reinstalling the Plugin. Without an installed and enabled +Receiver, the Server can show only `pending` or `offline`, never successful delivery. + +The Receiver's Agent adapter resolves the installation root on the remote host. A project-scoped Codex target uses +`.agents/skills//`; a project-scoped Claude Code target uses `.claude/skills//`. Neither the browser nor the +Server submits or interprets a remote absolute path. Both adapters install the same approved package bytes and only +validate their own naming, format, installation-scope, and environment constraints. + +The first remote slice supports only project-scoped Codex and Claude Code targets, explicit Publish/Update/Unpublish, +a resident watch carried by a Linux systemd user service, and manual preflight/sync. An offline target is not a failed +delivery: its next reconciliation converges to the latest desired state. WebSocket/SSE wake-up, fleet policy, canary +rollout, automatic publication, and dependency installation are outside the first remote slice. + +## Describe environment needs without granting them + +Portable Skills should prefer one cross-platform implementation. A package that needs variants may include them under +`scripts/`. Standard `compatibility` text remains readable by people. A PowerContext-managed package may additionally +include the optional namespaced file `powercontext.runtime.yaml`: + +```yaml +schema: powercontext.skill-runtime.v1 +variants: + - id: python + entrypoint: scripts/verify.py + interpreter: python + requirements: + operating_systems: [linux, darwin, windows] + commands: + python: ">=3.11" + network: none + writable_roots: [workspace] + - id: windows-powershell + entrypoint: scripts/windows/prepare.ps1 + interpreter: pwsh + requirements: + operating_systems: [windows] + commands: + pwsh: ">=7" + network: required +``` + +This optional extension is part of the package digest. Consumers that do not understand it can ignore it and continue +to use `SKILL.md`. Exact import never inserts or changes this file. Adding it to an external package requires a fork. + +An Agent environment profile reports observed operating system, architecture, command versions, network policy, +writable roots, dependency-install policy, and environment variable names. It never stores secret values. PowerContext +compares the exact package requirements with the target profile and reports `compatible`, `incompatible`, `unknown`, or +`manual_review_required` with reasons. + +Requirements express needs. The environment and a later execution request control grants. A package declaring +`network: required` does not receive network access by being approved or published. + +## Use outcomes to propose improvement + +An Agent integration may record a bounded usage observation only for states it can actually observe: + +```yaml +skill: artifact:skill/skill_release_check@2 +package_digest: sha256:1234... +target_id: codex-project +selected: true +invoked: true +validation: passed +outcome: success +task_source: source:task-outcome/task_456 +``` + +If the integration knows that a Skill was selected but cannot prove a script or instruction was used, `invoked` remains +`unknown`. Publication is not invocation, and invocation is not task success. + +Usage observations are immutable Source evidence. They may update bounded aggregates and may seed a successor Candidate +against an exact Skill Revision. They never mutate content, approve a Candidate, increase permissions, retire a Skill, +or prove usefulness from a count alone. + +# Reference-level explanation + +## Scope and relationship to existing RFCs + +This RFC defines: + +- a standard package format for PowerContext-managed Skills; +- complete content-addressed package capture and database storage; +- exact external import and semantic fork behavior; +- package-level Review and migration from instruction-only managed Skills; +- current-head Library search, governance lifecycle, and bounded usage evidence; +- Codex and Claude Code environment assessment, publication, drift detection, and safe unpublication; +- target enrollment, desired-state reconciliation, delivery receipts, and trust boundaries for a later remote + Agent-side pull extension; +- public package read semantics and implementation acceptance criteria. + +This RFC refines RFC 0051's instruction-only managed Skill content and RFC 1304's two-file managed projection. It does +not change Experience content, general Candidate identity, Candidate CAS, Review terminal transitions, or Artifact +lineage semantics. + +This RFC does not define: + +- a general workflow, DAG, Routine, or Procedure runtime; +- automatic execution, dependency installation, secret resolution, or sandbox grants; +- SSH, Server-side writes to a remote filesystem, or browser-selected arbitrary remote paths; +- a resident fleet orchestrator, immediate push channel, automatic publication, or generic device management; +- organization-wide RBAC, reviewer identity, package signing, or marketplace billing; +- automatic semantic merge, automatic publication, automatic retirement, or unbounded background generation; +- generic binary extraction, OCR, malware verdicts, or complete code search. + +## Standards baseline + +A managed package conforms to the common Agent Skills package baseline: + +- the package root contains a UTF-8 `SKILL.md`; +- YAML frontmatter contains required `name` and `description` string values; +- `name` is 1 through 64 lowercase letters, digits, or single hyphens, does not begin or end with a hyphen, contains no + consecutive hyphens, and matches the package directory name; +- `description` is non-empty and at most 1,024 characters; +- optional standard fields such as `license`, `compatibility`, `metadata`, and `allowed-tools` are preserved; +- `scripts/`, `references/`, `assets/`, licenses, templates, and other bounded package files are preserved; +- unknown but syntactically valid frontmatter fields remain part of the exact package and are not rewritten. + +PowerContext treats `allowed-tools` as untrusted package content. It may inform display or compatibility, but it is not a +tool grant and cannot bypass an Agent's policy. + +The common baseline deliberately uses constraints accepted by both configured Agent adapters. A target adapter may +report a stricter incompatibility, but it cannot broaden the approved package contract by rewriting content. + +## Skill package content model + +New managed Skill Revisions use a discriminated content model: + +```yaml +schema: powercontext.skill-package.v2 +format: agent-skills +entrypoint: SKILL.md +package: + tree_digest: sha256:... + archive_digest: sha256:... + file_count: 7 + uncompressed_size: 18234 + archive_size: 9541 +metadata: + name: release-check + description: Verify a release candidate before publication. + license: Apache-2.0 + compatibility: Python 3.11 or newer. +``` + +`package` identifies the authoritative package snapshot. `metadata` is a deterministic parsed cache used for validation, +listing, and search. On every write and package read, cached metadata must match `SKILL.md`; mismatch is an integrity +error. The cache cannot be edited independently. + +Review reports, compatibility assessments, lifecycle state, publication bindings, and usage aggregates are not fields +inside `SkillPackageContent`. They have different authorities and change rates. + +## Canonical package capture + +Package identity represents content, not a filesystem image. Capture preserves: + +- every admissible regular file under the selected package root; +- normalized POSIX relative path; +- exact file bytes; +- regular-file mode reduced to non-executable `0644` or executable `0755`. + +Capture does not preserve modification time, user/group IDs, ownership, extended attributes, access-control lists, or +empty directories. Those values vary by host and are not part of Agent Skill content. + +The tree digest is computed over a domain-separated canonical stream of sorted entries: + +```text +format version +relative path length + relative path +normalized mode +file length +file sha256 +``` + +PowerContext then creates a deterministic ZIP with sorted entries, fixed timestamps, normalized modes, no host-specific +extra fields, and a fixed compression policy. `tree_digest` is the content identity; `archive_digest` verifies the stored +and distributed ZIP. Semantically identical input ZIP files converge on one tree digest even when their original order +or compression differs. + +Initial bounds retain the current local Registry scale: + +| Bound | Value | +| --- | --- | +| Regular files | 256 | +| Total uncompressed bytes | 4 MiB | +| Canonical ZIP bytes | 5 MiB | +| `SKILL.md` bytes | 128 KiB | +| Path bytes after UTF-8 encoding | 512 | + +The importer rejects rather than silently excludes: + +- absolute paths, `..`, NUL, invalid UTF-8 paths, and paths outside the package root; +- symlinks, hard-link aliases, sockets, devices, FIFOs, and other special files; +- case-folding or Unicode-normalization path collisions; +- duplicate ZIP members; +- unsupported encryption or decompression bounds; +- packages exceeding any bound; +- a missing, non-UTF-8, malformed, or standard-incompatible `SKILL.md`; +- files blocked by configured secret or package policy. + +If `.env`, `.git`, `node_modules`, or another path is forbidden, the error identifies the path. Exact import never +silently drops it and calls the result complete. + +For a live external directory, capture writes every file into an isolated staging snapshot, computes the staged digest, +and resolves the external registration again. If the source fingerprint changed, capture fails with a typed conflict and +persists no Candidate. The staged bytes, not a later read of the live directory, become the package snapshot. + +## Package persistence + +The first implementation adds an immutable content-addressed table: + +```text +pc_skill_packages + scope_id + tree_digest + archive_digest + archive_bytes + manifest + file_count + uncompressed_size + archive_size + created_at + +PRIMARY KEY (scope_id, tree_digest) +``` + +`archive_bytes` uses SQLAlchemy `LargeBinary`; SQLite stores a BLOB and the MySQL/OceanBase variant uses `MEDIUMBLOB`. +`manifest` is canonical JSON containing path, digest, size, media type, and normalized mode for each entry. No index +includes `archive_bytes` or `manifest` content. + +`pc_artifacts.content`, Candidate proposal content, and captured external snapshot Sources store only the bounded package +reference. Package insertion and the first owning Source or Candidate write occur in one database transaction. Reusing +the same `(scope_id, tree_digest)` validates existing archive and manifest digests before returning the existing row. + +Across the entire RFC, the only new business tables are `pc_skill_packages` and `pc_skill_publications`. Lifecycle uses +the existing Artifact Head, search uses a generic rebuildable projection, and usage evidence uses the existing Source +store. + +Approved Artifact Revisions and retained Candidate or Source evidence keep their packages reachable. The first +implementation performs no automatic package garbage collection. A later collector may delete only packages with no +reachable Artifact, Candidate, or Source reference after a documented retention period. + +## External reference, import, fork, and update + +The operations have distinct authority semantics: + +| Operation | Package authority | Copy | LLM required | +| --- | --- | --- | --- | +| Discover/reference | External local package | No | No | +| Exact import | New managed Artifact after approval | Exact canonical snapshot | No | +| Fork | New managed Artifact after approval | Exact source snapshot plus proposed replacement | Only for model-assisted semantic change | +| External update | External package until a new explicit import/fork | New exact snapshot | No for exact import; optional for fork | + +An exact import Candidate's proposed `tree_digest` must equal the captured external snapshot digest. Candidate revision may +change review annotations but cannot change package content and still remain an exact import. Editing any file changes the +operation to a fork and creates a new proposed digest. + +When a previously imported upstream package changes, Registry shows the new external fingerprint beside the import +provenance. PowerContext does not update the managed Skill automatically. The user may import it as a new managed Skill, +fork it, or propose a successor Revision targeting the current managed ArtifactRef. + +## Validation and risk assessment + +Validation has three layers: + +1. **Package validation**: path safety, bounds, canonicalization, standard metadata, digests, and media detection. +2. **Static governance validation**: secret patterns, licenses, executable files, dependency manifests, runtime declarations, + network/secrets/write requirements, and suspicious binary inventory. +3. **Target compatibility**: Agent format, package name, environment profile, and optional runtime variants. + +None of these layers executes package scripts. A scanner finding is evidence for Review, not proof that a package is safe +or malicious. The Review UI reports scanner version and incomplete coverage where applicable. + +A deterministic risk level helps triage without granting authority: + +| Risk | Minimum trigger | +| --- | --- | +| `instruction_only` | `SKILL.md` and inert text/resources only | +| `local_script` | Any executable or script file | +| `workspace_write` | Declared workspace write requirement | +| `network` | Declared network requirement or network-oriented dependency | +| `secrets` | Declared secret/environment requirement | +| `privileged` | System path, process, container, or other elevated requirement | + +Risk may require stronger Review or publication confirmation under deployment policy. It never authorizes the capability +that caused the level. + +## Candidate and approval transaction + +`SkillPackageContent` remains the Family proposal type, so generic Candidate storage and CAS continue to work. Candidate +detail resolves its package reference through the Skill Package Store. + +Approval performs one transaction: + +1. lock the expected pending Candidate head; +2. resolve and verify the exact package reference; +3. repeat required deterministic validation; +4. validate scope and direct SourceRef/ArtifactRef lineage; +5. create or revise the `skill` Artifact with immutable package content; +6. create the initial governance row for a new Skill, or preserve the existing lifecycle for a successor Revision; +7. update the current-head search projection; +8. commit the Candidate terminal result and Artifact Revision together. + +A stale Candidate, target Artifact head, package mismatch, or validation version conflict returns `409` or a typed +validation failure. Approval never fetches remote content and never substitutes another package digest. + +## Migration from instruction-only managed Skills + +Existing approved Revisions remain readable through the current instruction-core content model. They are not rewritten in +place and retain their historical publication semantics. + +The implementation supports a discriminated union: + +```text +powercontext.skill-instruction.v1 -> existing name/description/instructions/validation +powercontext.skill-package.v2 -> standard package reference and parsed metadata +``` + +Creating a successor from a v1 Skill first renders the current deterministic `SKILL.md`, canonicalizes it as a one-file +v2 package, and presents that complete package as the starting Candidate. Approval creates the next Artifact Revision as +v2. This conversion is explicit and reviewable; reading an old Revision never causes migration. + +New exact imports and new package uploads use v2. Existing semantic generation may initially produce a one-file standard +package, then add scripts or references only when exact evidence and Review justify them. + +## Search projection and Skills Library + +The ZIP BLOB never participates directly in search. `skill_searchable_text(package)` deterministically extracts bounded +text from the exact package: + +```text +name +description +compatibility and metadata values +SKILL.md body +sorted package paths +bounded UTF-8 text from references/*.md and references/*.txt +``` + +The current managed head writes this text to `pc_artifact_heads.searchable_text`. SQLite replaces the rebuildable +Experience-only FTS5 projection with a generic `pc_artifact_fts` projection keyed by scope, Family, Artifact ID, and +Revision. This is a rebuildable replacement, not an additional Skill table. OceanBase continues to use its full-text +index on the generic head field. Both backends filter `family = 'skill'` and lifecycle state when searching Skills. +Rebuilding the projection resolves exact package references and verifies package digests before extraction. + +The default Skill search does not return historical Revisions, pending or rejected Candidates, deprecated Skills unless +explicitly requested, or retired Skills. The projection does not contain full script source or arbitrary binary +extraction. Later code search or vector search uses a separate channel with path and content-digest provenance. + +Skills Library presents a unified read model while preserving authority: + +```text +managed current heads + governance + publication + usage projection +UNION +visible external registrations + local availability +``` + +Every row exposes `authority = managed | external`. Search never turns an external registration into a managed Artifact +or treats a managed package as still controlled by its upstream source. + +## Managed lifecycle and working-set governance + +Lifecycle state is mutable governance over one logical managed Skill and is not stored inside the package. It extends +the existing authoritative Head row instead of introducing `pc_skill_governance`: + +```text +pc_artifact_heads + scope_id + family + artifact_id + revision + searchable_text + lifecycle_state active | deprecated | retired + replacement_artifact_id nullable + governance_generation + +PRIMARY KEY (scope_id, family, artifact_id) +``` + +Existing rows migrate to `active` with governance generation zero. Lifecycle updates require `family = 'skill'` and use +expected `governance_generation` CAS without changing the immutable Artifact Revision or the Head's `revision` pointer. +`replacement_artifact_id`, when present, identifies another in-scope managed Skill Head. Lifecycle transitions are +explicit: + +```text +active <-> deprecated +active or deprecated -> retired +retired -> no automatic transition +``` + +Retirement is irreversible in this RFC. A mistaken retirement can fork or create a new logical Skill while the retired +history remains auditable. Deprecation may name one in-scope replacement and can be reversed explicitly. + +Per-scope and per-target policy may bound pending Candidates, package bytes, active searchable heads, and published +packages. Exceeding a budget blocks the new operation with a typed error; it never evicts or retires an existing Skill. + +## Agent targets and environment compatibility + +`AgentSkillTarget` remains the configured publication boundary and gains an environment profile or provider capable of +observing one: + +The Server uses one workspace as the local filesystem boundary. Without an explicit +`POWERCONTEXT_SERVER_EXTERNAL_SKILLS` value, the workspace defaults to the Server startup directory and produces two +writable project targets: `codex-project -> /.agents/skills` and +`claude-project -> /.claude/skills`. A missing directory means only that no external package exists yet; the +directory is created after the user confirms the first local installation. Service managers and containers pin the +workspace with `POWERCONTEXT_SERVER_WORKSPACE`. An explicit `POWERCONTEXT_SERVER_EXTERNAL_SKILLS` value replaces the +automatic targets and remains the advanced configuration for custom paths, user-level targets, environment profiles, +or disabling local discovery. The Dashboard never accepts a user-supplied local path. + +```yaml +target_id: codex-project +agent_kind: codex +host_id: host-123 +installation_scope: project +path: /workspace/.agents/skills +allow_managed_publish: true +environment: + operating_system: linux + architecture: x86_64 + commands: + python: 3.12.4 + bash: 5.2.26 + network_policy: disabled + writable_roots: [workspace] + dependency_install_policy: denied + environment_names: [CI] +``` + +Secret values never enter the profile. An observed profile has a deterministic fingerprint and timestamp. Compatibility +is keyed by exact Artifact Revision, package tree digest, environment fingerprint, and adapter version: + +```text +compatible +incompatible(reason...) +unknown(reason...) +manual_review_required(reason...) +``` + +Compatibility is a rebuildable assessment, not an Artifact. Environment change invalidates the assessment without +changing the Skill Revision. Known Agent-format incompatibility blocks publication. Unknown runtime compatibility may be +published after the existing explicit confirmation because publication is not execution, but the UI must retain the +warning and cannot claim that scripts will run. + +## Publication, distribution, and unpublication + +The implementation supports host-local configured targets plus credential-bound remote Agent-side pull. It never pushes +to an arbitrary browser path or writes a remote filesystem directly. + +Package download resolves an authorized exact ArtifactRef and returns a bounded JSON envelope containing the canonical +ZIP bytes: + +```text +package: {tree_digest, archive_digest, file_count, uncompressed_size, archive_size} +archive_base64: +``` + +The caller verifies both digests after decoding. The envelope keeps the generated JSON client contract consistent while +preserving byte-exact distribution; the Server never returns a mutable filesystem path. + +Publication desired state and the latest exact observation share one binding row: + +```text +pc_skill_publications + scope_id + target_id + artifact_id + desired_state + desired_revision + desired_tree_digest + observed_revision nullable + observed_tree_digest nullable + observed_generation nullable + destination nullable + state + selected_runtime_variant nullable + environment_fingerprint nullable + last_error_code nullable + observed_at nullable + generation + updated_at + +PRIMARY KEY (scope_id, target_id, artifact_id) +``` + +Publication stages the canonical package on the target filesystem, safely extracts it, recomputes the tree digest, and +atomically renames it. The target package contains only approved package files. The existing `powercontext.json` ownership +file is removed from the published package; ownership is represented by `pc_skill_publications` and verified against the +observed destination tree digest. + +Observable publication state remains separate from runtime compatibility and external discovery: + +```text +unpublished | pending | current | update_available | delivery_failed | conflict | drifted | incompatible +``` + +Safe update or unpublication requires expected publication `generation`, exact recorded Artifact identity, destination, +and observed tree digest. If local content changed, PowerContext reports drift and leaves it untouched. Unpublication +removes only the exact intact managed package and its binding; it never deletes the approved Artifact or package history. + +## Remote Agent-side pull and desired-state convergence + +This section specifies the implemented sixth-slice contract. Remote delivery reuses the `pc_skill_publications` +desired/observed model, but the +target-local Receiver, rather than the Server-local Publisher, produces the observation. + +### Target enrollment and local path ownership + +A remote Receiver uses a one-time enrollment code to register a stable `target_id`. Registration binds at least: + +- an opaque host/installation identity, `agent_kind`, and project installation scope; +- the permitted `scope_id` and Server origin; +- the target-local adapter version, environment fingerprint, and last-seen time; +- an independent target credential subject whose secret value lives only in the remote operating-system secret store or + equivalent secure storage. + +The sixth slice adds a dedicated target registry instead of overloading External Skill Registration: + +```text +pc_agent_skill_targets + scope_id + target_id + display_name + agent_kind + installation_scope + delivery_mode + installation_id nullable + state + enrollment_token_digest nullable + enrollment_expires_at nullable + credential_subject nullable + credential_verifier nullable + receiver_version nullable + environment_fingerprint nullable + machine_hostname nullable + workspace_name nullable + last_seen_at nullable + generation + created_at + updated_at + +PRIMARY KEY (scope_id, target_id) +UNIQUE (scope_id, agent_kind, installation_scope, installation_id) +UNIQUE (enrollment_token_digest) +UNIQUE (credential_subject) +UNIQUE (credential_verifier) +``` + +The administrator supplies `display_name`; it may be renamed with target-generation CAS without changing credentials or +publication bindings. The Server generates a stable `target_id` for API, audit, and diagnostics. The Receiver generates +an opaque `installation_id` for its local Agent/project installation; it is not a filesystem path. At enrollment the +Receiver also reports `machine_hostname` and the workspace basename as `workspace_name`, never an absolute path. The +Dashboard can disambiguate and search targets by display name, hostname, workspace name, or technical ID. The first +remote slice allows only `delivery_mode=agent_pull` and the target +states `pending | active | revoked`: + +- target creation persists only a digest and expiry for a high-entropy one-time enrollment code; +- enrollment validates pending state, expiry, token digest, and target generation in one transaction, binds the unique + installation and credential subject/verifier, clears the enrollment token, and activates the target; +- display-name changes use the same target-generation CAS without changing the credential, `target_id`, or publication identity; +- the plaintext target credential exists only in the Receiver's operating-system secret store or owner-only credential + file; the Server stores only its verifier; +- `last_seen_at` derives an offline display state and does not become a durable target state; +- enrollment and revocation use target `generation` CAS; revocation clears the usable verifier and rejects future + enrollment, reconcile, download, and receipt calls without deleting historical identity or publications. + +The Server stores only a logical installation scope. It neither stores nor accepts a browser-provided remote absolute +path. The Receiver resolves the package root from its locally enrolled workspace and rejects a Skill name or archive +path that escapes that root. One credential represents only its bound `target_id`; a reconciliation request cannot use +it to select another target. + +A target row may exist before any Skill is published. The remote slice does not migrate existing host-local path +configuration into this table and does not reuse `pc_external_skill_registrations`: an external registration is an +observation of a package, not an authority for a remote installation or credential. Every `agent_pull` publication must +resolve to an active target in the same scope. Revocation does not cascade-delete publications or package history; it +only prevents remote authentication and makes the target unable to converge. + +### Publication schema extension + +The sixth slice migrates `pc_skill_publications` with these additions and changes: + +```text +desired_state # published | unpublished +observed_generation nullable +destination nullable # required for host-local; null for agent_pull +last_error_code nullable +observed_at nullable +``` + +The existing `generation` remains the CAS generation of Server-owned desired state. `observed_generation` is the latest +generation processed by a valid receipt; an older receipt cannot update observed fields. Remote unpublication changes +`desired_state` to `unpublished`. The last desired Revision and digest remain as intent history, but are not deletion +authority. Safe deletion depends on the credential-bound ownership checkpoint reported by the Receiver and verified +again locally. A successful unpublication receipt clears the observed Revision and digest and sets the state to +`unpublished`. + +`destination` remains required for host-local publication and must be null for `agent_pull`, because the Receiver resolves +the path locally. Remote publication uses the complete state set: + +```text +unpublished | pending | current | update_available | delivery_failed | conflict | drifted | incompatible +``` + +`pending` means the desired generation has no matching receipt. `delivery_failed` carries a bounded +`last_error_code`. `offline` is derived from the active target's `last_seen_at`; it is not persisted as a publication +state. + +SQLite and OceanBase migrations apply the same deterministic backfill: + +- existing `state=unpublished` rows receive `desired_state=unpublished`; all other rows receive + `desired_state=published`; +- existing rows receive `observed_generation=generation` and `observed_at=updated_at`; +- host-local `destination` values remain unchanged, while only new `agent_pull` rows use null; +- existing rows receive `last_error_code=null`; +- after backfill, `desired_state` is non-null and restricted to `published | unpublished`. + +The first remote slice does not add a `pc_skill_delivery_receipts` table. After verifying +`publication.generation == receipt.generation`, the Server updates the latest observed fields on the +`(scope_id, target_id, artifact_id)` row. A success for the same generation may replace a failure; a failure cannot +replace an existing success; an identical receipt is a no-op; and an old generation never updates current state. If a +deployment later needs complete receipt audit history, it should use the existing Source/Event Store, never a second +authority for current publication state. + +Outside the standard package, the Receiver maintains a credential-bound, integrity-protected ownership checkpoint. It +contains at least the target, ArtifactRef, tree digest, applied generation, and state for each managed artifact. A +bounded pending-action journal recovers a crash between package rename and checkpoint update: if the final directory +matches the authorized action, the Receiver completes the checkpoint and retries the receipt; if it still matches the +old checkpoint, it discards staging; otherwise it reports `conflict` instead of guessing ownership. + +### Reconcile desired state instead of delivering a one-shot job + +A remote publication is desired state: + +```text +Server authority Remote target observation +desired_state observed state/result +desired_revision observed_revision nullable +desired_tree_digest observed_tree_digest nullable +generation observed_generation nullable +delivery_mode = agent_pull bounded error code +``` + +Dashboard Publish, Update, and Unpublish operations only CAS-update desired state and `generation`. The Receiver submits +its local ownership checkpoint and actual directory tree digest: + +```yaml +target_id: codex-project-7f31 +last_processed_generation: 11 +observed: + - artifact_ref: artifact:skill/skill_release_check@1 + tree_digest: sha256:abcd... + applied_generation: 9 +``` + +The Server authenticates the target credential and verifies that the checkpoint ArtifactRef and tree digest name an +exact approved package for the same scope and artifact binding. The observation may be a local precondition for the +returned action, but only a successful receipt updates authoritative observed fields. Install and unpublish use +distinct action shapes: + +```yaml +# install +generation: 12 +action: + operation: install + desired: + artifact_ref: artifact:skill/skill_release_check@2 + tree_digest: sha256:1234... + +--- +# unpublish +generation: 13 +action: + operation: unpublish + artifact_id: skill_release_check + expected_local: + artifact_ref: artifact:skill/skill_release_check@2 + tree_digest: sha256:1234... + applied_generation: 12 +``` + +For unpublication, `expected_local` comes from the authenticated Receiver checkpoint and must match an exact approved +package for that artifact binding. It does not blindly reuse the Server's last observed or desired digest. This lets a +later reconciliation safely remove the exact package owned by the Receiver even when installation succeeded but its +receipt was lost. + +The response contains no arbitrary destination path, shell command, dependency-install instruction, or unapproved +package body. The body still comes from the existing exact Download operation, and the credential may download only an +Artifact Revision referenced by its target's desired state. Reconciliation and receipts for the same +`(scope_id, target_id, generation, artifact_id)` are idempotent. A transient outage, repeated request, or Server restart +does not create duplicate directories or roll the target back to an older Revision. + +Offline means only that a target has not converged. It neither changes desired state to failed nor discards an action. +`current` requires an exact receipt for the latest generation whose Revision and tree digest match the desired values; +until then the Dashboard shows `pending` or `offline`. An older-generation receipt cannot overwrite newer observed +state. A failed receipt writes the current `observed_generation`, preserves the last successful observed Revision and +digest, and sets `delivery_failed`. Reconciliation retries the same generation while desired state remains unsatisfied; +only new operator intent advances `generation`. A later success clears `last_error_code`. + +### Receiver installation and receipt + +For `install`, the Receiver performs these steps in order: + +1. read the exact package envelope with the bound target credential; +2. verify the archive digest, safely extract into a bounded staging directory, and recompute the full tree digest; +3. run Agent-format and target-local compatibility checks without executing scripts or installing dependencies; +4. if the final directory and local checkpoint already match the desired Artifact and digest exactly, skip the rewrite + and proceed to the receipt; +5. otherwise, only when the destination is absent or both it and the checkpoint match the old managed identity, persist + the pending-action journal and atomically rename the complete package; +6. observe the final tree digest, atomically update the checkpoint, remove the journal, and submit the receipt. Any + identity, digest, or checkpoint mismatch reports `drifted` or `conflict` without modifying the directory. + +A receipt contains at least `target_id`, `generation`, operation, ArtifactRef, expected and observed tree digests, +result, environment fingerprint, Receiver version, and a bounded error code. It contains no package body, secret, +arbitrary command output, or absolute path. The Server validates the credential-bound target identity, generation, and +digests; an HTTP success alone is never installation success. The latest valid receipt updates +`pc_skill_publications` under the generation and success-precedence rules above; no separate receipt table is written. + +For `unpublish`, the Receiver first verifies that the authenticated action, `expected_local`, local checkpoint, and +actual tree digest all match. It persists the journal, atomically renames the managed package into a Receiver-private +quarantine, records an absent checkpoint, submits the receipt, and only then removes the quarantine. User or third-party +changes produce `drifted` or `conflict`, and the content remains untouched. Receiver ownership, credentials, +pending-action journals, and receipt checkpoints stay outside the standard package. + +### Codex and Claude Code triggers + +| Agent | Receiver carrier in the first remote slice | Project installation root | Sync trigger | +| --- | --- | --- | --- | +| Codex | lightweight PowerContext Receiver | `.agents/skills/` | systemd user service running `remote-watch`, or Agent preflight/`remote-sync` | +| Claude Code | lightweight PowerContext Receiver | `.claude/skills/` | systemd user service running `remote-watch`, or Agent preflight/`remote-sync` | + +The integration must verify the discovery boundary at which each Agent reads Skills. If SessionStart occurs after that +Agent's scan, a newly installed package may be declared discoverable only in the next session; `installed` must not be +reported as loaded in the current session. A deployment requiring first-session availability runs the same reconciliation +as a preflight before starting the Agent. `remote-watch` only schedules the same reconciliation; a later SSE/WebSocket +channel may also only wake the Receiver, while packages still arrive through the same authenticated pull transport. + +## Usage observation and evolution + +The owning Agent integration may capture a `skill-usage` Source at a bounded task or Agent completion boundary: + +```yaml +skill_ref: artifact:skill/skill_release_check@2 +package_digest: sha256:... +target_id: codex-project +selected: true +invoked: true | false | unknown +validation: passed | failed | unknown +outcome: success | failure | unknown +task_source: source:task-outcome/task_456 +environment_fingerprint: sha256:... +``` + +The adapter must not infer `invoked=true` from retrieval, publication, prompt inclusion, or the model mentioning the Skill. +Unknown is a normal value. The Source records no prompt, secret, command arguments, or unbounded output by default. + +A rebuildable daily projection may aggregate selected, invoked, validation-passed, success, and failure counts by exact +Skill Revision. Counts support Library health views but do not change search eligibility or lifecycle automatically. + +A configured generation model may use caller-selected exact usage Sources to propose a successor Candidate. Exact import, +storage, Review, lifecycle changes, publication, unpublication, and usage recording remain non-LLM foundations. + +## Public and Dashboard operations + +The implementation exposes operations with these semantics; final OpenAPI names follow existing `/v1/skill/...` naming: + +| Operation | Result | +| --- | --- | +| List Library | Managed heads and external registrations with authority-preserving filters | +| Get package manifest | Exact managed Revision metadata and file tree; no binary body | +| Download package | Canonical ZIP for an authorized exact managed Revision | +| Upload package proposal | Canonicalize a caller-provided ZIP and create a pending managed Candidate | +| Import external Skill | Exact import Candidate or fork Candidate from selected fingerprint | +| Update lifecycle | CAS transition for active, deprecated, or retired | +| Inspect publication | Publication and runtime compatibility for configured targets | +| Publish | Exact approved Revision to one configured target | +| Unpublish | Remove only an intact managed target package | +| Record usage | Capture bounded exact usage Source evidence | +| Create/enroll/revoke remote target | Create a one-time code, bind a credential, or revoke a target registration | +| Publish/unpublish remote desired state | CAS-declare an exact Revision or expected absence for a target | +| Reconcile remote target | Compare the target observation with the latest desired generation and return an idempotent action | +| Download remote package | Allow the target credential to download only the exact package referenced by its current generation | +| Record delivery receipt | Record the exact generation, ArtifactRef, digests, and installation result | + +Every List Library item includes display provenance. A managed Skill without an external snapshot is `powercontext`, an +exact import is `external_import`, a fork is `external_fork`, and a registration that has not entered Review is presented +by the browser as `external`. The latter three expose the registration's `host_id`, `agent_kind`, `external_skill_id`, +`installation_scope`, and `locator`. For later managed Revisions, the Runtime checks direct SourceRefs first and then +traces upstream Skill ArtifactRefs to the first external snapshot, so a revision does not incorrectly erase its takeover +origin. This projection reuses persisted Source lineage and external snapshots, requiring no new table or historical-data +migration. Old data without an external snapshot claims only a PowerContext origin; it does not guess whether a human or +a model submitted it. + +The browser submits `target_id`, the Agent kind selected when creating a target, exact ArtifactRef, expected Candidate +version or governance/publication generation, and explicit operation intent. It never submits an arbitrary destination +path, package digest substitution, or execution grant. +Remote operations are part of OpenAPI. Administrators use `remote-status`, `remote-target-create`, +`remote-target-rename`, `remote-publish`, `remote-unpublish`, and `remote-target-revoke` for the complete lifecycle. +Receivers use `remote-enroll`, `remote-watch`, +`remote-sync`, `remote-service-install`, and `remote-service-uninstall` to converge local directories and manage the Linux +user service. When an expected generation is omitted, the CLI reads current status before submitting the CAS mutation. +This does not bypass CAS: a concurrent update still returns a conflict, and automation may provide the generation explicitly. + +The Skills Dashboard exposes a This Server / Remote machine choice in Delivery. Remote mode requires a readable machine +name at creation, searches by that name or Receiver-reported hostname/workspace (with technical IDs as a fallback), and +renames a target without changing its durable identity. It also supports Codex or Claude Code project targets, one-time +enrollment guidance, automatic target and delivery status refresh, exact Revision distribution, safe-removal requests, +and credential revocation. It shows the enrollment code only at creation and gives +copyable Receiver installation and `remote-enroll --install-service` commands. If the code was closed before it was saved, +the administrator revokes the pending target and adds it again. Remote mode refreshes silently every two seconds while a +delivery is pending and every ten seconds while stable; it stops while hidden or in local mode. The Dashboard presents +Publish and Unpublish as desired-state requests and shows installed or removed only after a matching Receiver receipt. It disables target revocation while any +publication is not confirmed unpublished, so credential revocation cannot permanently prevent safe cleanup. +The Server may configure the remotely reachable address once through `POWERCONTEXT_SERVER_PUBLIC_URL`. When it is unset, +the Dashboard uses its current HTTPS origin automatically, or its current HTTP origin after the explicit insecure switch +is enabled; otherwise the remote CLI's existing Server configuration provides the connection address. Adding a target +never asks the administrator to enter the address again. + +HTTPS remains the default transport boundary. A first-phase internal PoC may explicitly enable direct cleartext HTTP by +setting `POWERCONTEXT_SERVER_ALLOW_INSECURE_HTTP=true` on the Server and passing `remote-enroll --allow-insecure-http` on +the target. Either side alone is insufficient: the Server continues to reject non-loopback HTTP Receiver requests when +its switch is off, while the CLI refuses the URL before sending the one-time enrollment code when its option is absent. +If the Server itself binds an unauthenticated listener to a non-loopback address, the operator must separately set +`POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK=true`; that setting acknowledges exposure of all Server routes +and is not implied by the Receiver-only transport exception. +The Dashboard accepts an advertised HTTP URL only while the Server switch is enabled, displays a persistent warning, and +adds the Receiver option to its copyable command. The Receiver stores the permission beside its credential in the +owner-only configuration file, so one-shot sync, watch mode, and the systemd user service share one transport policy. +This additive configuration field requires no database table or historical-data migration. It does not encrypt the +enrollment code, target credential, package, or Receipt, so it is limited to a protected private test network and must +not be treated as a production alternative to HTTPS. + +### Remote distribution CLI flow + +By default, the Server must expose a remotely reachable HTTPS URL. The explicit internal-HTTP PoC exception described +above is the only cleartext alternative. A target machine installs only the `powercontext[cli]` Receiver, +not a Server or database, and accepts no inbound connection from the Server. The administrator first creates a project +target: + +```bash +powercontext --server-url https://powercontext.example.com \ + skill remote-target-create --scope-id project:demo --agent-kind codex --name "Hangzhou build machine" +``` + +The remote operator enters the one-time enrollment code from the target project. Omitting the command-line code uses a +no-echo prompt and stores the target credential in `.powercontext/remote-skill-target.json` with owner-only permissions: + +```bash +cd /srv/project +powercontext --server-url https://powercontext.example.com \ + skill remote-enroll --workspace "$PWD" --install-service +``` + +For the explicit internal-HTTP PoC exception, the corresponding command is: + +```bash +powercontext --server-url http://powercontext.internal.example:8765 \ + skill remote-enroll --workspace "$PWD" --install-service --allow-insecure-http +``` + +`--install-service` creates a target-scoped `systemd --user` unit and immediately runs `enable --now`. The unit references +the owner-only configuration file and never copies its credential. Existing enrollments can run +`powercontext skill remote-service-install`; `powercontext skill remote-service-uninstall` stops and removes the managed +unit. Environments without systemd can run `powercontext skill remote-watch` in their own process supervisor. + +The administrator publishes an exact approved package Revision without manually discovering the initial or current +publication generation: + +```bash +powercontext --server-url https://powercontext.example.com \ + skill remote-publish --scope-id project:demo --target-id codex-abc123 \ + --revision 2 release-check +``` + +The resident Receiver reconciles every five seconds by default. Codex receives `.agents/skills/`; Claude Code receives +`.claude/skills/`. A deployment that requires the first current session to discover a just-published Skill still runs an +explicit preflight before starting the Agent: + +```bash +powercontext skill remote-sync +codex # or claude +``` + +The administrator can inspect desired/observed status, request safe removal, or revoke the credential: + +```bash +powercontext skill remote-status --scope-id project:demo --target-id codex-abc123 +powercontext skill remote-unpublish --scope-id project:demo --target-id codex-abc123 release-check +powercontext skill remote-target-revoke --scope-id project:demo codex-abc123 +``` + +`remote-publish` and `remote-unpublish` change only Server desired state. Only a later successful watch/sync Receipt makes +the publication `current` or `unpublished`. Dashboard auto-refresh reads only that durable state; it does not treat an HTTP +success as an installation or claim that the current Agent session rescanned its Skills. + +## Security and trust boundary + +Every package and every Candidate remains untrusted. PowerContext: + +- parses ZIP and YAML with bounded safe parsers and no custom tags; +- renders package text inertly and never loads remote resources named by content; +- does not log package bodies, secrets, usage arguments, or arbitrary Source bodies; +- does not execute scripts during scan, import, indexing, Review, approval, publication, or compatibility assessment; +- does not install dependencies during publication; +- does not treat `allowed-tools`, compatibility text, runtime requirements, or risk level as permission; +- never exposes a package solely because the caller knows its digest; +- authorizes reads through scope and exact Source, Candidate, or Artifact reachability; +- verifies digests before every exact read, diff, download, and publication; +- requires HTTPS for every non-loopback remote connection by default; the internal-PoC escape hatch requires explicit + Server and Receiver opt-in and keeps the cleartext risk visible; +- uses an independent credential per remote target, limited to reading its desired state, downloading the exact + referenced Artifacts, and submitting its own receipts; +- binds each receipt to its Server-side target identity, generation, and digests and accepts no browser- or + Receiver-selected arbitrary remote path; +- preserves restrictive browser Content Security Policy and safe rendering rules from RFC 1304. + +`scope_id` remains a business partition, not an ACL. Deployments that need organizational authorization must enforce it +through Server authentication and policy; this RFC does not infer user permissions from scope names. + +## Implementation slices + +The implementation is organized as five independently dogfoodable local slices and one independently accepted remote +slice. Remote distribution does not change acceptance of the first five: + +1. **Package foundation**: canonical package validation, `pc_skill_packages`, v1/v2 content union, exact reads, and + SQLite/OceanBase round trips. +2. **Exact import and package Review**: complete external snapshots, non-LLM import, fork semantics, file tree, inert + previews, digest-visible successor comparison, and approval transaction. +3. **Library and lifecycle**: generic SQLite/OceanBase Artifact FTS adapters, lifecycle columns and CAS on + `pc_artifact_heads`, filters, and replacement guidance. +4. **Agent delivery**: environment profiles, compatibility assessment, `pc_skill_publications`, exact Codex/Claude Code + publication, package download, drift detection, and safe unpublication. +5. **Observed evolution**: bounded usage Sources and explicitly triggered successor Candidates. Aggregated health views + can be added later as rebuildable projections without changing usage evidence. +6. **Remote target reconciliation**: `pc_agent_skill_targets`, migration of remote fields in + `pc_skill_publications`, Codex/Claude Code Receivers, one-time enrollment, per-target credentials, desired-state + reconciliation, exact package pull, atomic installation, delivery receipts, offline convergence, and safe remote + unpublication. + +No slice introduces a PowerContext script runner. Each slice preserves exact reads and prior instruction-only Revisions. +Remote capability claims remain subject to the independent acceptance below. Installed, receipt-current, and discovered +in the Agent's current session remain three distinct facts. + +## Acceptance + +| Scenario | Passing condition | +| --- | --- | +| Standard package | A valid `SKILL.md` package with scripts, references, assets, license, and optional metadata round-trips exactly | +| Canonical identity | Equivalent directory and differently ordered ZIP inputs produce the same tree digest | +| Executable mode | A script's normalized executable bit survives capture, storage, download, and publication | +| Complete snapshot | Hidden and nested admissible files remain present; forbidden files cause named rejection rather than silent omission | +| Archive safety | Traversal, duplicate entries, symlinks, special files, collisions, malformed YAML, and decompression bounds are rejected | +| Mutable source | External content changing during capture produces conflict and no Candidate | +| Exact import | Import preserves the source tree digest, requires no LLM, and creates only a pending Candidate | +| Fork | Original exact package remains Source evidence and the proposed package has a separate digest and visible diff | +| Approval | Only the expected pending version commits one immutable package Artifact Revision and current search projection | +| Legacy read | Existing instruction-only Revisions remain exactly readable and are not migrated on access | +| Legacy successor | A successor from v1 presents an explicit one-file v2 package conversion before approval | +| SQLite package store | Maximum-sized canonical ZIP and manifest commit, read, and digest-check through SQLite | +| OceanBase package store | The same package round-trip uses `MEDIUMBLOB` and does not load ZIP bytes in list/search queries | +| Search | Generic Artifact FTS returns only active approved Skill heads by default; exact name and description queries return expected rows | +| External search | External availability remains local and does not become managed authority | +| Lifecycle | Head governance CAS controls deprecation and retirement, preserves all Revisions, and never auto-deletes or auto-publishes | +| Compatibility | The same package gets independent reasoned assessments for Codex and Claude Code environment profiles | +| No execution | Import, Review, indexing, approval, compatibility, publication, and unpublication never execute package scripts | +| Publication | Codex and Claude Code targets receive the same approved package tree without injected package files | +| Safe update | Only an intact, identity- and digest-matching managed destination can be replaced | +| Safe unpublication | Only an intact managed destination is removed; drift or foreign content remains untouched | +| Initial schema | The first five local slices add only `pc_skill_packages` and `pc_skill_publications`; the remote slice also adds `pc_agent_skill_targets` and migrates publication fields; SQLite FTS is rebuildable | +| Usage truth | Selected, invoked, validation, and outcome remain distinct; unknown observation is preserved | +| Evolution | Usage evidence can seed a pending successor against an exact Revision but cannot mutate or approve it | +| Scope | Package, Library, lifecycle, publication, usage, and download operations cannot cross caller scope | +| Browser trust | Candidate and package content remain inert in real Chromium, including malicious Markdown, SVG, and filenames | +| Packaging | Server templates and static assets for package Review and Library ship in the wheel | +| Local defaults | Without advanced target configuration, local Codex and Claude Code resolve `.agents/skills/` and `.claude/skills/` under the workspace; neither directory is created before the user confirms installation | + +The implementation must run `make check`, `make test`, `make docs-test`, and `make contract-test` for API changes. It must +also exercise a real SQLite Server flow, an OceanBase package round trip, real Codex and Claude Code package discovery, +and a browser flow covering exact import, file inspection, approval, search, publication, drift, unpublication, both +locales, keyboard operation, and a narrow viewport. + +### Remote-distribution slice acceptance + +The implemented sixth slice must satisfy these conditions; local tests from the first five slices cannot replace them: + +| Scenario | Passing condition | +| --- | --- | +| Enrollment | A one-time code creates only one credential-bound target; replay and cross-scope use are rejected | +| Remote schema | `pc_agent_skill_targets` is added and `pc_skill_publications` is migrated without adding a job queue or receipt-history table | +| Schema backfill | SQLite and OceanBase produce the same desired state, observed generation/time, destination, and error-field values for existing rows | +| Target uniqueness | One installation, enrollment token, or credential subject cannot bind multiple active targets; revoked credentials stop working | +| No full remote Server | The remote host installs only a Plugin/Integration Receiver, not PowerContext Server or its database | +| Agent roots | Codex and Claude Code adapters locally resolve `.agents/skills/` and `.claude/skills/`; the Server receives no absolute path | +| Exact delivery | The Receiver downloads the desired ArtifactRef's canonical package and verifies archive/tree digests before and after installation | +| Atomic install | Interruption, disk error, or verification failure leaves only removable staging, exposes no partial package, and preserves the intact old version | +| Offline convergence | After multiple updates while offline, the next reconciliation converges directly to the latest generation without replaying stale Revisions | +| Receipt truth | Only a receipt matching credential, target, generation, ArtifactRef, and digests can produce `current` | +| Idempotency | Repeated reconciliation, download, and receipt submission create no duplicate directories or bindings and cannot regress state | +| Lost receipt recovery | After installation succeeds and the receipt is lost, the Receiver uses its checkpoint to retry without rewriting or reporting a false conflict | +| Failed delivery retry | A failed receipt preserves the last successful observation and retries the same generation; only new intent advances generation | +| Safe remote update | A drifted target tree is not replaced and reports `drifted` or `conflict` | +| Safe remote unpublication | Only an intact identity/digest-matching managed package is removed; foreign content remains untouched | +| Transport isolation | Non-loopback plaintext HTTP is rejected by default and accepted only with Server plus Receiver opt-in; one target credential cannot read or acknowledge another target's state | +| Discovery boundary | Tests distinguish installed, discoverable in the current session, and discoverable in the next session without false success claims | +| No execution | Reconciliation, installation, and receipt handling execute no scripts, install no dependencies, and expand no Agent permissions | + +# Drawbacks + +- Full package governance adds ZIP parsing, BLOB persistence, file-level Review, and more failure states than an + instruction-only record. +- Generic lifecycle columns broaden `pc_artifact_heads`, and SQLite must rebuild its Experience-only FTS projection as + a Family-aware Artifact projection. +- Database BLOB storage is simple and transactional at the current bounds but is not the final answer for large packages + or high-volume remote distribution. +- A common standard baseline may reject a package accepted by one Agent's more permissive parser. +- Static validation cannot prove that a script is safe or useful, while stronger sandbox execution is deliberately out + of scope. +- Lifecycle, publication, compatibility, and usage are separate axes, increasing UI and API complexity. +- Remote desired/observed state, credential lifecycle, and eventual convergence add operational and failure states absent + from local publication. +- Exact import may preserve redundant or low-quality files; the correct response is visible Review or fork, not silent + normalization. +- Usage evidence will be incomplete until Agent integrations can distinguish actual invocation from retrieval or mention. + +# Rationale and alternatives + +| Alternative | Decision | +| --- | --- | +| Keep managed Skills instruction-only | Rejected; it cannot preserve or review normal Agent Skill packages | +| Store ZIP bytes directly inside generic Artifact JSON | Rejected; base64 inflates payloads and couples generic Artifact reads to package transfer | +| Store only a filesystem path | Rejected; paths are host-local, mutable, and cannot support immutable Review or distribution | +| Store one row per package file initially | Rejected; current 4 MiB packages can use one transactional canonical ZIP plus manifest with less schema and I/O complexity | +| Add a separate `pc_skill_governance` table | Rejected; lifecycle governs the current logical Artifact and fits the existing authoritative Head row with independent CAS | +| Keep publication ownership only on the local filesystem | Rejected; safe unpublication, target removal, multiple Server instances, and future remote delivery need a durable target binding | +| Use an object store immediately | Deferred; a `SkillPackageStore` abstraction keeps this path open without adding deployment dependencies now | +| Let exact import regenerate instructions with an LLM | Rejected; it loses package bytes and changes authority; model-assisted change is fork | +| Add PowerContext metadata inside every published package | Rejected; publication must preserve the approved standard package tree | +| Generate different approved packages for Codex and Claude Code | Rejected; target adapters report compatibility and location without creating unreviewed content variants | +| Auto-install dependencies during publication | Rejected; publication is not execution or environment mutation authority | +| Push from the Server through SSH, SCP, or a remote filesystem | Rejected; it expands Server privilege and network reachability and cannot safely handle offline targets, NAT, or local drift | +| Deliver remote packages through a one-shot job queue | Rejected; offline targets can lose or replay work, while desired-state reconciliation is naturally idempotent and converges to the latest state | +| Release a new Plugin for every published Skill | Rejected; the Plugin is stable bootstrap, while managed Skills update independently as exact package data | +| Synchronize before every user prompt | Rejected; it adds latency and noise; the resident watch stays outside the prompt path, while preflight only guarantees first-session discovery | +| Publish every active Library Skill | Rejected; Library inventory and Agent working set have different scale and intent | +| Auto-retire unused or low-success Skills | Rejected; observation coverage and attribution are incomplete, and counts cannot replace Review | +| Build a script runner in this RFC | Rejected; package governance and host policy can close a useful local loop without inventing another execution platform | + +Not adopting a complete package model leaves external import lossy, keeps the Review surface disconnected from actual +Agent content, and makes scripts, assets, compatibility, and usage governance impossible to represent faithfully. + +# Prior art + +- The [Agent Skills specification](https://github.com/agentskills/agentskills/blob/main/docs/specification.mdx) defines a + `SKILL.md` package with optional scripts, references, assets, and metadata. This RFC adopts that package as portable + content while keeping PowerContext governance outside the standard authority boundary. +- The [OpenAI Skills API](https://developers.openai.com/api/reference/go/resources/skills) uses downloadable ZIP bundles + and immutable Skill versions. This RFC similarly separates logical Skill identity, immutable content version, and + package distribution. +- Skillsgate validates standard frontmatter, applies package-size limits, maps multiple Agent installation targets, and + copies a directory package. PowerContext adopts its useful package/target separation but does not silently exclude + files or treat installation as execution. +- RFC 0051 defines external versus managed content authority, exact local fingerprints, Candidate evolution, and the + execution boundary. This RFC supplies the managed package format it deliberately deferred. +- RFC 1304 defines typed Review, explicit publication, safe update, and browser trust boundaries. This RFC extends those + contracts from two generated files to the exact approved package and adds safe unpublication. +- Existing Memory and Experience indexes demonstrate authoritative rows plus rebuildable SQLite and OceanBase search + projections. Skill search reuses that separation rather than indexing ZIP bytes. + +# Unresolved questions + +No unresolved question blocks the RFC. The implementation must confirm the documented canonical ZIP test vectors across +supported Python versions before publication. + +The following decisions are intentionally outside this RFC: + +- the organization-specific credential provider, short-lived token exchange, device attestation, rotation, and + revocation implementation; +- object-store selection and package garbage-collection retention; +- organization-level owners, reviewer identity, RBAC, and two-person approval for privileged packages; +- package signatures, transparency logs, vulnerability databases, and marketplace trust levels; +- generic code search, embeddings, hybrid ranking, automatic recommendation, and just-in-time mounting; +- dependency environment creation, OCI execution, and a PowerContext-owned sandbox runner; +- whether another Artifact Family should represent reusable Procedure or Workflow semantics. + +# Future possibilities + +Natural extensions include: + +- low-latency Receiver wake-up through SSE or WebSocket, plus fleet policy, canary rollout, and bulk target views, after + resident Pull reconciliation is validated in real hosts; +- short-lived token exchange, automatic rotation, device attestation, or mTLS above the per-target credential contract; +- object-backed `SkillPackageStore` implementations while retaining database metadata and tree digests; +- signed package manifests and organization trust policies; +- path-level code and semantic search with exact package/chunk provenance; +- per-project enabled sets and temporary task-scoped mounting after measured retrieval quality; +- isolated dependency caches keyed by package, lock-file, runtime variant, platform, and environment fingerprint; +- a separately reviewed sandboxed `SkillRun` contract with read-only package mounts, explicit grants, resource limits, and + bounded evidence; +- governance dashboards for unused, failing, drifted, incompatible, unowned, or upstream-outdated Skills. + +These extensions must preserve the central contract: the approved package Revision is immutable content; environment, +publication, and execution authority remain explicit bindings outside it; and observed outcomes can propose change but +cannot silently rewrite governed history. diff --git a/docs/zh/development/server-web-ui.md b/docs/zh/development/server-web-ui.md index fd86e2462..4eb4bc68c 100644 --- a/docs/zh/development/server-web-ui.md +++ b/docs/zh/development/server-web-ui.md @@ -79,6 +79,7 @@ router.add_api_route( | Memory entries | 当前 Memory Artifact 中的 entry | | Artifacts | 按 family 分组的当前 Artifact head | | Pending review | 按 family 和 status 分组的当前 Candidate head | +| Skill 出处 | Managed Skill 的不可变 lineage;外部 Skill 的 registration | | Model usage | 持久化的每日 generation 和 embedding usage | | Recall 命中、Token 减少量与节约趋势 | 当前 estimator 对应的持久化每日 recall measurement | @@ -87,6 +88,11 @@ bucket 和 token reduction。浏览器将 `ready_preparations` 展示为 Recall `token_reduction` 绘制为节约趋势。Heatmap 的每个日期格同时使用这两个字段,固定分档为:无命中、命中但没有正向 减少、减少 1–255、256–1023,以及 1024 个以上预估 Token。固定阈值避免稀疏活动和异常大值改变其他日期的颜色含义。 +Skills 页面必须让每一项都能直接看出出处,并使用与生命周期状态一致的紧凑徽标。普通 managed Skill 显示“自生成”; +exact import 显示“接管”,fork 显示“派生”;尚未进入 Review 的 Agent-native package 显示“本地”。对于 +import、fork 和 Agent-native package,详情同时显示 registration 中的来源机器、Agent、外部 Skill ID、安装范围和原始 +位置。managed Skill 的后续 Revision 沿上游 Skill lineage 追溯最初的 external snapshot,因此修订不会丢失接管机器。 + ## 只复用稳定的页面结构 document-level 结构放在 `base.html`。一个片段已经被复用,或者本身是完整 UI 单元时,才放入 diff --git a/docs/zh/docs/reference/configuration.md b/docs/zh/docs/reference/configuration.md index e7c6d3c4c..ca1e95787 100644 --- a/docs/zh/docs/reference/configuration.md +++ b/docs/zh/docs/reference/configuration.md @@ -48,10 +48,13 @@ Server 配置使用 `POWERCONTEXT_SERVER_` 前缀。 | --- | --- | --- | | `POWERCONTEXT_SERVER_HTTP_HOST` | `127.0.0.1` | 监听地址 | | `POWERCONTEXT_SERVER_HTTP_PORT` | `8000` | 监听端口 | +| `POWERCONTEXT_SERVER_WORKSPACE` | Server 启动目录 | 本机项目级 Agent Skill 目录的解析根目录 | | `POWERCONTEXT_SERVER_MCP_ENABLED` | `true` | 启用 Streamable HTTP MCP | | `POWERCONTEXT_SERVER_MCP_PATH` | `/mcp` | MCP 路径 | | `POWERCONTEXT_SERVER_AUTH_ENABLED` | `false` | HTTP 和 MCP 是否要求一个静态 Bearer token | | `POWERCONTEXT_SERVER_AUTH_TOKEN` | 未设置 | 静态 Bearer token;启用鉴权时必须设置 | +| `POWERCONTEXT_SERVER_PUBLIC_URL` | 未设置 | 远端技能注册引导使用的可达基础地址;默认要求 HTTPS | +| `POWERCONTEXT_SERVER_ALLOW_INSECURE_HTTP` | `false` | 显式允许远端技能接收端接口和注册引导使用明文 HTTP | | `POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK` | `false` | 在鉴权关闭时显式允许绑定非 loopback 地址 | | `POWERCONTEXT_SERVER_DASHBOARD_ENABLED` | `true` | 在 Server 根路径 `/` 启用 Dashboard | | `POWERCONTEXT_SERVER_DASHBOARD_SCOPES` | `[]` | Dashboard 可选择的 scope JSON 数组 | @@ -81,7 +84,7 @@ Server 配置使用 `POWERCONTEXT_SERVER_` 前缀。 | `POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_TIMEOUT_SECONDS` | `30` | 单次 embedding 请求的超时秒数 | | `POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_BATCH_SIZE` | `10` | 单次 embedding 请求最多发送的文本数量 | | `POWERCONTEXT_SERVER_RUNTIME_EXPERIENCE_SCHEDULE_SECONDS` | 未设置 | Experience 孵化间隔;未设置即不启用该 job | -| `POWERCONTEXT_SERVER_EXTERNAL_SKILLS` | 未设置 | 包含 host identity 和显式 Agent Skill targets 的 JSON object | +| `POWERCONTEXT_SERVER_EXTERNAL_SKILLS` | 自动生成本机项目 target | 覆盖默认值的 host identity 和显式 Agent Skill targets JSON object | 静态 Bearer 鉴权默认关闭。启用后,API 和 MCP 请求必须携带 `Authorization: Bearer `;liveness 和 readiness endpoint 仍然公开。明文 HTTP 仅在 loopback 地址(`localhost`、`::1` 及 `127.0.0.0/8` 网段内的任意 @@ -90,16 +93,49 @@ TLS 由上游终止或网络本身受控的场景下, 显式设置 `POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK=true` 主动选择接受。通过网络暴露启用鉴权的 Server 前必须配置 TLS。 -Python Client 和 CLI 对出站请求应用相同规则:配置的明文 `http://` Server URL 仅接受 loopback 主机,并且 Client 拒绝 -通过明文的非 loopback HTTP 发送任何请求,无论是否携带 Bearer token。当代码的 `http://` base URL 只是路由标签、 -实际传输是安全的,例如进程内 ASGI 应用、Unix domain socket 或由代理终止 TLS 时,必须自行传入 `http_client` 并 -显式设置 `trust_transport_security=True`。 +Python Client 和 CLI 对一般出站请求应用相同规则:配置的明文 `http://` Server URL 仅接受 loopback 主机;远端 Skill +Receiver 的内部 PoC 显式例外见下文。当代码的 `http://` base URL 只是路由标签、实际传输是安全的,例如进程内 ASGI +应用、Unix domain socket 或由代理终止 TLS 时,必须自行传入 `http_client` 并显式设置 +`trust_transport_security=True`。 安全的 Docker 和远程访问配置见[部署 Server](../how-to/deploy-server.md)。 Dashboard 默认启用,并与 HTTP API、MCP 共用监听地址和端口。默认未配置 scope,页面会显示空状态;Dashboard 初始化失败只记录包含直接原因的 warning,不影响 Server 的 HTTP API、MCP 和健康检查启动。 +Server 默认把启动目录作为 workspace,并自动提供两个可写的本机项目级目标:Codex 使用 +`/.agents/skills`,Claude Code 使用 `/.claude/skills`。目录不存在时不会报错;用户首次在 +Dashboard 中确认安装后才会创建目录。以 systemd、容器或其他不保证工作目录的方式启动时,应设置一次 +`POWERCONTEXT_SERVER_WORKSPACE`,之后页面不再要求用户填写 Skill 路径。 + +远端技能接收端需要通过不同于当前 Dashboard 访问地址的外部入口连接时,只需在 Server 上配置一次 +`POWERCONTEXT_SERVER_PUBLIC_URL`。Skills Dashboard 会自动用它生成注册命令,不再要求每次添加目标时填写地址。 +未配置时,Dashboard 自动使用当前 HTTPS 来源;显式启用不安全开关后,也可以使用当前 HTTP 来源。两者都不可用时, +注册命令使用远端命令行已经配置的服务地址。 + +一期 PoC 如果运行在受保护的内部测试网络,可以让 Server 和 Receiver 双端显式同意直连 HTTP:Server 设置 +`POWERCONTEXT_SERVER_ALLOW_INSECURE_HTTP=true`,并用 `POWERCONTEXT_SERVER_PUBLIC_URL` 公布 `http://` 地址; +Receiver 注册时同时传入 `--allow-insecure-http`。Dashboard 会显示明文传输警告,并自动把该参数加入注册命令。 +Server 未打开开关时,远端接口仍拒绝非 loopback HTTP;Receiver 未传参数时,CLI 会在发送一次性注册口令之前拒绝 +该 URL。许可会写入权限为 owner-only 的 Receiver 配置,因此 `remote-watch` 和 systemd user service 会沿用同一策略, +unit 文件不需要保存凭据或额外参数。该开关不提供 TLS、网络隔离或防窃听能力,不能用于公网或不可信网络;长期部署 +应使用 HTTPS。 + +```bash +export POWERCONTEXT_SERVER_HTTP_HOST=0.0.0.0 +export POWERCONTEXT_SERVER_PUBLIC_URL=http://powercontext.internal.example:8765 +export POWERCONTEXT_SERVER_ALLOW_INSECURE_HTTP=true +export POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK=true +powercontext server run + +# 在远端项目中: +powercontext --server-url http://powercontext.internal.example:8765 \ + skill remote-enroll --workspace "$PWD" --install-service --allow-insecure-http +``` + +示例中的非 loopback opt-in 与 Receiver 传输例外彼此独立:它表示操作者接受该监听器上的所有 Server route 在没有 +Server 级 Bearer token 时可达。部署条件允许时,应优先启用鉴权,或在仅绑定 loopback 的 Server 前终止 TLS。 + 启用 Bearer 鉴权后,`/`、`/skills`、`/reviews`、`/handoff-reports` 的 HTML 外壳及其静态资源仍保持公开,以便 浏览器渲染登录表单;数据请求仍受鉴权保护。在表单中输入 Server token 后,浏览器只把它保存在当前标签页的 session storage 中。如果连这些登录页也不能暴露,应同时关闭 Dashboard 和 Handoff Report。 @@ -157,9 +193,10 @@ search request 最终 `limit` 的结果。它不会修改已存储 Memory 或索 显式返回;如果搜索必须独立于模型可用性,请关闭 rerank。算法、并发与 API 边界见 [RFC 0080](/zh/rfcs/0080_memory_search_reranking/)。 -同一个 generation model 也控制显式 Experience generation、managed Skill generation/evolution,以及 -external Skill import/fork。未配置模型时,这些 operation 会在持久化 Candidate 前返回 capability error; -Candidate Review、exact read 和 external Skill scan/list/resolve 仍可使用。 +同一个 generation model 也控制显式 Experience generation、managed Skill generation,以及语义化的 Skill +fork/evolution。External Skill 精确导入和完整 package 上传不使用模型:PowerContext 会校验并保存 canonical package +bytes,再创建 package digest 完全相同的 pending Candidate。未配置模型时,语义生成会在持久化 Candidate 前返回 +capability error;Review、package 检查与下载、精确导入、usage recording 和 external Skill scan/list/resolve 仍可使用。 Experience 孵化使用独立的 APScheduler job 和持久化 Source cursor,可通过以下配置启用: @@ -178,7 +215,8 @@ PreparedContext、创建 managed Skill、将它导出到 Agent target 或执行 ### Agent Skill 目标 -通过一个 JSON 值配置 Codex 和 Claude Code 的 host-local target: +零配置流程使用上述 workspace 中的 Codex 和 Claude Code 项目级目录。只有需要自定义路径、用户级 target、环境兼容性 +事实或显式关闭本机发现时,才需要通过一个 JSON 值覆盖默认的 host-local target: ```bash export POWERCONTEXT_SERVER_EXTERNAL_SKILLS='{ @@ -189,7 +227,16 @@ export POWERCONTEXT_SERVER_EXTERNAL_SKILLS='{ "agent_kind": "codex", "installation_scope": "project", "path": "/srv/project/.agents/skills", - "allow_managed_publish": true + "allow_managed_publish": true, + "environment": { + "operating_system": "linux", + "architecture": "x86_64", + "commands": {"python": "3.13.2", "bash": "5.2"}, + "network_policy": "restricted", + "writable_roots": ["workspace"], + "dependency_install_policy": "denied", + "environment_names": ["CI"] + } }, { "target_id": "claude-project", @@ -202,13 +249,23 @@ export POWERCONTEXT_SERVER_EXTERNAL_SKILLS='{ }' ``` -每个 target ID 必须唯一;`agent_kind` 支持 `codex` 和 `claude_code`,installation scope 支持 `user`、`project` -和 `plugin`。PowerContext 只扫描这些显式 target 的直接 Skill package 子目录,不会推断 home 目录、安装 package -或授予执行权限。`allow_managed_publish` 默认是 `false`;设为 `true` 后,authenticated Skills Library 或 Review +显式设置 `POWERCONTEXT_SERVER_EXTERNAL_SKILLS` 会完整替换自动生成的两个项目级 target;设置为 +`{"host_id": null, "targets": []}` 可以关闭本机发现和发布。每个 target ID 必须唯一;`agent_kind` 支持 `codex` 和 +`claude_code`,installation scope 支持 `user`、`project` 和 `plugin`。PowerContext 只扫描默认或显式 target 的直接 +Skill package 子目录,不会推断用户 home 目录、安装 package 或授予执行权限。自动生成的两个项目级 target 允许用户 +在 Dashboard 中显式安装;自定义 target 的 `allow_managed_publish` 默认是 `false`,设为 `true` 后,authenticated Skills Library 或 Review 页面可以把 approved managed Skill 显式创建或安全更新到该 target。页面仍不能提交任意路径,也不会覆盖外部或 -已被修改的 package。`host_id`、locator 和 registration 都是本地环境状态,不是跨 host contract。已有的 +已被修改的 package。发布会物化 Review 通过的完整精确 package(包括 scripts 和 references),不会执行其中内容, +也不会向 package 注入 sidecar。相同页面只能在 binding 与 tree digest 仍匹配时安全取消发布;本地漂移和外部内容 +会保持不动。`host_id`、locator 和 registration 都是本地环境状态,不是跨 host contract。已有的 `codex_roots` 配置继续作为 Codex-only 兼容格式被接受;新配置应使用 `targets`。 +可选的 `environment` object 只包含已观测且不含密钥的兼容性事实。Command value 是版本标签; +`environment_names` 只记录名称,绝不记录值。PowerContext 不会为了构造该 profile 而探测或执行 package script。 +未配置时,包含 script 的 package 会显示未知兼容性;配置后,Skills Library 会把已知 script interpreter 与已观测 +command name 对比,并显示带原因的 Assessment。Assessment 不会授予 network、filesystem、dependency install 或 +environment 访问权。 + Server 始终创建 non-recording OpenTelemetry request context,从 inbound span 派生 `X-PowerContext-Request-ID`。如需为 CLI 管理的 Server 启用 recording 和 export,请安装 `powercontext[cli,server,tracing-otlp]`、启用 tracing, 并使用 `OTEL_EXPORTER_OTLP_ENDPOINT`、`OTEL_EXPORTER_OTLP_HEADERS` 和 `OTEL_SERVICE_NAME` 等标准 diff --git a/docs/zh/rfcs/1351_standard_skill_package_lifecycle.md b/docs/zh/rfcs/1351_standard_skill_package_lifecycle.md new file mode 100644 index 000000000..5cb994f59 --- /dev/null +++ b/docs/zh/rfcs/1351_standard_skill_package_lifecycle.md @@ -0,0 +1,1297 @@ +- Proposal Name: `standard_skill_package_lifecycle` +- Start Date: 2026-08-21 +- RFC PR: [oceanbase/powercontext#1351](https://github.com/oceanbase/powercontext/pull/1351) +- Related RFCs: [RFC 0031](0050_artifact_candidate_review_inbox.md)、 + [RFC 0051](0051_experience_skill_artifact_families.md)、 + [RFC 0072](0072_scoped_statistics_and_usage.md) 和 + [RFC 1304](1304_experience_skill_review_page.md) + +# Summary + +本 RFC 将 PowerContext 受管 `skill` Family 从只包含指令的记录升级为受治理的标准 Agent Skill 包,并闭合从 +发现或创作,到 Review、Skills Library 检索、目标发布、使用观测、修订、废弃和安全取消发布的完整生命周期。 + +每个受管 Skill Revision 拥有一个以 `SKILL.md` 为根入口的不可变包。包还可以包含 `scripts/`、`references/`、 +`assets/`,以及 Agent Skills 格式允许的其他有界文件。PowerContext 保存完整、内容寻址的包快照,在导入外部 +Skill 时保留精确包,并把同一份已批准内容发布到兼容的 Codex 和 Claude Code 目标。Agent Adapter 只负责选择 +位置和报告兼容性,不会静默改写已批准的包。 + +实现同时支持 PowerContext Server 所在宿主机上的 configured target,以及独立验收的远端分发切片。远端模式下, +Server 保存 target 的期望 Revision,Codex 或 Claude Code 集成内的轻量 Receiver 通过 +默认通过 HTTPS Pull、校验、原子安装并回传精确 Receipt。远端宿主不需要运行完整 PowerContext Server 或数据库,但必须有 +一个经过注册的 Receiver;Server 不通过 SSH 或远程文件系统主动写入 Agent 目录。 + +包声明内容和需求,不声明权限。Review、批准、检索、发布以及可选的 `allowed-tools` 字段都不会授予执行、文件 +系统、网络、Secret 或安装依赖的权限。脚本执行仍由接收 Skill 的 Agent 和宿主策略负责。本 RFC 定义静态校验 +与环境兼容性评估,但不新增通用的 PowerContext 脚本 Runner。 + +闭环如下: + +```text +发现、上传或生成一个包 + -> 捕获精确包快照 + -> 校验格式、文件、来源和风险 + -> 创建 pending Candidate + -> Review + -> 批准为不可变 Skill 包 Revision + -> 将当前 active Head 加入 Skills Library 索引 + -> 显式把精确 Revision 发布到本地 target,或声明为远端 target 的期望状态 + -> 远端 Receiver 在可用时收敛并回传 observed Revision 和 digest + -> 在集成能够观测时记录有界的 selected/invoked/outcome 证据 + -> 提议后继 Revision、废弃、退役或安全取消发布 +``` + +# Motivation + +## 当前受管 Skill 还不是标准包 + +当前受管 Skill 保存 `name`、`description`、`instructions` 和 `validation`。发布时生成一个 `SKILL.md` 和一个 +PowerContext manifest。这足以 Review 指令核心,却无法保留包含脚本、参考资料、模板、示例、许可证或二进制 +资源的常规 Agent Skill 包。 + +当前 External Skill Registry 已经为本地包下的每个普通文件计算 fingerprint。然而显式导入只快照 `SKILL.md`, +随后要求生成模型产出新的指令型内容。这适合语义 Fork,但不适合精确 Import:即使用户选择了特定 package +fingerprint,有用的脚本或参考资料仍可能丢失。 + +因此现有流程并不完整: + +```text +包含脚本和参考资料的外部包 + -> 精确的全包 fingerprint + -> 仅 SKILL.md 的快照 + -> 生成的指令核心 + -> 仅 SKILL.md 的发布结果 +``` + +PowerContext 需要从 Import 到 Publication 使用同一份 package contract,确保 Reviewer 批准的内容就是 Agent +最终发现的内容。 + +## 包可移植不代表运行时可移植 + +一个包可以复制到不同宿主机,但其脚本仍可能依赖特定操作系统、架构、解释器、可执行程序、工作目录、网络 +策略或环境变量。即使 Codex 和 Claude Code 都接受相同的包结构,它们也可能运行在不同宿主策略之下。 + +PowerContext 不能通过为每个 target 生成不同且未经 Review 的包来解决差异。同一份已批准包始终是内容权威。 +target 专属的环境 Profile 和可重建兼容性评估负责解释该 target 是否可用。能力缺失会产生 `incompatible`、 +`unknown` 或 `manual_review_required`,不会触发自动改写包或安装依赖。 + +## 持续增长的 Library 需要受治理的工作集 + +包存储可以对相同内容去重,但仅靠存储无法消除发现噪音。如果把所有已批准 Skill 发布到每个 Agent 目录, +会产生名称冲突、暴露过期包,并在没有当前项目使用证据时持续扩大 Agent 工作集。 + +PowerContext 因此区分: + +| 层次 | 含义 | +| --- | --- | +| External Registry | 对外部系统拥有的 Agent-native 包进行可重建观测 | +| Governed Library | 已批准的 PowerContext 受管 Skill 和可见外部 Registration | +| Active managed heads | 可进入常规 Library 检索和新发布的受管 Skill | +| Published set | 实际存在于某个已配置 Agent target 中的精确受管 Revision | +| Usage evidence | 对选择、调用、校验和任务结果的有界观测 | + +Library 可以增长,但检索只覆盖当前合格 Head,发布仍然要求针对每个 target 显式执行。 + +## 批准不是生命周期终点 + +RFC 0051 和 RFC 1304 已经建立 Candidate Review、不可变 Artifact Revision 和显式的宿主本地发布。它们有意 +延后了包托管、退役、取消发布、排序和使用归因。要形成可用的 Skills 产品,需要补齐剩余状态转换,同时继续 +保留这些信任边界。 + +设计必须在不修改历史的前提下支持纠正和退役: + +```text +Skill@1 已批准并发布 + -> 后续使用发现缺少一项校验步骤 + -> 精确使用证据指向 Skill@1 + -> Review 批准 Skill@2 + -> target 报告 update_available + -> 显式发布只替换完整且仍受管的包 + -> Skill@1 仍可被精确读取 +``` + +# Guide-level explanation + +## 把 Skill 理解成包,而不是流程记录 + +一个标准受管 Skill 如下: + +```text +release-check/ +├── SKILL.md +├── scripts/ +│ ├── verify.py +│ ├── linux/ +│ │ └── prepare.sh +│ └── windows/ +│ └── prepare.ps1 +├── references/ +│ └── release-policy.md +├── assets/ +│ └── report-template.json +└── LICENSE +``` + +`SKILL.md` 是包入口。它的 YAML frontmatter 提供可发现的名称和描述,Markdown 正文告诉 Agent 何时以及如何 +使用这个包。其他文件仍是普通 package resources;PowerContext 不会把它们转换为工作流图,也不会在 Import、 +Review、批准、检索或发布阶段执行它们。 + +包字节是内容权威。Skills Library 展示的 name、description、compatibility 和其他解析值,都是从精确 +`SKILL.md` 派生并校验的缓存,不是另一份可以独立编辑的内容。 + +## 理解内容进入 Library 的四种方式 + +### 发现外部 Skill + +Discovery 记录本地 Registration、locator、package fingerprint、Agent kind、host 和 installation scope。外部目录 +仍是内容权威。PowerContext 不会把包字节复制到受管 Artifact 中;目录消失或 fingerprint 漂移会使 Registration +变为 unavailable。 + +### 精确导入外部 Skill + +用户选择一个可见的 `external_skill_id`、精确 fingerprint 和 **Import**。PowerContext 捕获包根目录下每个允许的 +文件,确认源 fingerprint 在捕获期间没有变化,保存 canonical package snapshot,并创建 tree digest 完全相同的 +pending Candidate。 + +精确 Import 不需要 LLM,也不会改写 `SKILL.md`。批准后会创建新的 PowerContext 受管 Skill 身份,其第一个 +Revision 完整包含捕获的包。外部包和导入后的受管 Skill 成为两个独立内容权威,并通过 lineage 建立联系。 + +### Fork 外部 Skill + +**Fork** 首先把相同的精确外部快照保存为不可变 Source evidence。之后由人工或已配置的生成模型提出另一份完整 +包。Review 展示原始包与提案包的文件树和 diff。批准只会创建提案中的受管 Revision;原始快照仍可作为证据读取。 + +### 创作或生成受管 Skill + +用户可以上传完整包,也可以由已配置的生成模型根据精确 SourceRef 和 ArtifactRef 证据提出一个包。生成在批准 +事务之外完成。PowerContext 先校验和保存包,再创建 pending Candidate。模型不能批准自己的 Candidate、分配最终 +Artifact identity、发布包或获得脚本执行权限。 + +## Review 最终会被发布的包 + +Skill Review 详情展示: + +- 标准元数据和精确 package digest; +- 有界文件树,包括 path、size、media type、content digest 和 executable 状态; +- 以惰性文本或经过安全处理的 Markdown 展示 `SKILL.md`; +- 对有界 UTF-8 文件提供文本预览; +- 二进制资源只显示元数据; +- 后继 Revision 或 Fork 的文件 diff; +- 静态校验、来源、许可证、依赖、Secret scan 和风险发现; +- 在不执行包内脚本的前提下展示已知 target 兼容性。 + +Review 操作仍然是 Candidate 操作。修订 Candidate 会产生完整 replacement Candidate version。批准会提交一个不可变 +package Revision。编辑已批准 Skill 始终创建后继 Candidate,不会重新打开或修改已批准 Revision。 + +## 不加载全部包也能浏览当前 Skills + +Skills Library 检索可重建 projection,而不是检索 ZIP 字节。对于 active 受管 Skill,PowerContext 索引: + +- name 和 description; +- 有界的 `SKILL.md` 正文; +- 标准 compatibility 和 metadata 值; +- package path; +- `references/*.md` 和 `references/*.txt` 中有界的文本。 + +主索引记录脚本和资源路径,但不会把完整脚本源码或二进制内容混入默认语义文本。选择结果后,系统会先解析精确 +ArtifactRef 和 package digest,再由 UI 读取包详情。 + +Pending 和 Rejected Candidate 永远不进入 Library 检索。历史 Revision 仍可被精确读取,但不进入默认 current-head +索引。 + +## 将生命周期与 Revision 分开 + +每个受管 Skill 都有独立于不可变 package Revision 的治理状态: + +| 状态 | Library 行为 | 发布行为 | +| --- | --- | --- | +| `active` | 进入常规检索 | 可以显式发布或更新 | +| `deprecated` | 显示替代指导;默认推荐排除 | 现有 binding 保留;新发布需要显式 override | +| `retired` | 从常规检索隐藏;精确读取保留 | 阻止新发布和更新;仍可安全取消发布 | + +改变 lifecycle state 不会创建或修改包字节。Deprecated Skill 可以指向一个 replacement managed Skill。使用次数 +和相似度不会自动改变生命周期。 + +## 向 Codex 和 Claude Code 发布同一份包 + +Agent target 标识已配置的 Agent kind、host、installation scope、package root,以及是否允许 managed +publication。Target Adapter 校验标准包和目标路径规则,把精确已批准包写入 staging directory,校验完整 tree +digest,再原子移动到目标位置。 + +Codex 和 Claude Code 获得完全相同的 package bytes。它们的 Adapter 可以拒绝不兼容的名称、格式或 target,但 +不能改写 frontmatter、删除脚本或向包内加入 manifest。PowerContext 在标准包之外保存发布归属和已观测 digest。 + +Publication 不会执行脚本。Unpublication 只有在精确 Artifact identity 和 tree digest 仍与已记录的 managed +binding 匹配时才删除包。被修改或不属于 PowerContext 的目录会报告 `drifted` 或 `conflict`,并保持不变。 + +## 通过 Agent-side Pull 向远端主机分发 + +跨宿主分发不把 PowerContext Server 变成远程文件管理器。首次使用时,管理员在 Dashboard 或 CLI 为指定 scope、 +Agent kind 和项目填写容易识别的机器名称,并创建一次性 enrollment code;用户在远端安装或启用 PowerContext +Plugin/Integration,选择本地项目并提交该 code。Dashboard 以机器名称作为主标识,把稳定 `target_id` 仅放在技术详情中; +Receiver 注册成功后状态变为已连接,并自动补充主机名和工作区名。 +Enrollment 不上传远端绝对路径。 + +之后用户在 Dashboard 选择这个远端 target 和精确 Skill Revision,点击 **Publish**。Server 只更新该 target 的期望 +状态: + +```text +Dashboard / CLI + -> Server 保存 target 的 desired Revision、tree digest 和 generation + -> 远端 Receiver 通过常驻 watch、Agent 启动前置或显式 sync 请求 reconcile + -> Receiver 下载精确 canonical package,校验 digest,在本机 staging 后原子安装 + -> Receiver 回传 observed Revision、tree digest、generation 和结果 + -> Dashboard 只在 Receipt 匹配时显示 current +``` + +Receiver 由 Codex 或 Claude Code 的 PowerContext Plugin/Integration 携带,职责只是同步包和上报结果;它不是另一套 +PowerContext Server。Plugin 是 bootstrap,受管 Skill 是动态数据,因此每次发布 Skill 不需要重新安装 Plugin。 +如果远端没有安装或启用 Receiver,Server 只能保持 `pending` 或 `offline`,不能声称已经下发成功。 + +Receiver 的 Agent Adapter 在远端本机解析安装根目录。项目级 Codex target 使用 `.agents/skills//`,项目级 +Claude Code target 使用 `.claude/skills//`。浏览器和 Server 都不提交或解释远端绝对路径。两个 Adapter 安装 +同一份已批准 package bytes,只校验各自的名称、格式、installation scope 和环境兼容性,不修改包内容。 + +首个远端切片只支持项目级 Codex 和 Claude Code target、显式 Publish/Update/Unpublish、Linux systemd user service +承载的常驻 watch,以及手动 preflight/sync。远端离线不是失败:下次 reconcile 仍以最新期望状态收敛。 +WebSocket/SSE 即时唤醒、Fleet Policy、灰度发布、自动发布和依赖安装不属于首个远端切片。 + +## 描述环境需求,但不授予权限 + +可移植 Skill 应优先使用一个跨平台实现。需要多个变体的包可以将它们放在 `scripts/` 下。标准 +`compatibility` 文本继续供人阅读。PowerContext 受管包还可以包含可选的 namespaced 文件 +`powercontext.runtime.yaml`: + +```yaml +schema: powercontext.skill-runtime.v1 +variants: + - id: python + entrypoint: scripts/verify.py + interpreter: python + requirements: + operating_systems: [linux, darwin, windows] + commands: + python: ">=3.11" + network: none + writable_roots: [workspace] + - id: windows-powershell + entrypoint: scripts/windows/prepare.ps1 + interpreter: pwsh + requirements: + operating_systems: [windows] + commands: + pwsh: ">=7" + network: required +``` + +这个可选扩展属于 package digest。不了解它的 Consumer 可以忽略并继续使用 `SKILL.md`。Exact Import 不会插入 +或修改该文件;为外部包增加该文件必须通过 Fork。 + +Agent environment profile 报告已观测的操作系统、架构、命令版本、网络策略、可写根目录、依赖安装策略和环境 +变量名称,但从不保存 Secret value。PowerContext 比较精确包需求和 target profile,返回包含原因的 +`compatible`、`incompatible`、`unknown` 或 `manual_review_required`。 + +Requirement 表达需要什么,Environment 和后续 Execution Request 控制实际 grant。包声明 +`network: required` 不会因为被批准或发布而获得网络访问。 + +## 用结果推动改进 + +Agent Integration 只能为它能够真实观测的状态记录有界 usage observation: + +```yaml +skill: artifact:skill/skill_release_check@2 +package_digest: sha256:1234... +target_id: codex-project +selected: true +invoked: true +validation: passed +outcome: success +task_source: source:task-outcome/task_456 +``` + +如果 Integration 知道 Skill 被选中,却无法证明脚本或指令真正被使用,`invoked` 保持 `unknown`。Publication +不是 Invocation,Invocation 也不等于 Task Success。 + +Usage Observation 是不可变 Source evidence。它可以更新有界 Aggregate,也可以针对精确 Skill Revision 发起 +successor Candidate,但永远不会修改内容、批准 Candidate、提升权限、退役 Skill,或仅凭计数证明有用性。 + +# Reference-level explanation + +## 范围以及与现有 RFC 的关系 + +本 RFC 定义: + +- PowerContext 受管 Skill 的标准包格式; +- 完整的内容寻址 package capture 和数据库存储; +- 精确 External Import 和语义 Fork 行为; +- package-level Review,以及从 instruction-only managed Skill 迁移的方法; +- current-head Library 检索、治理生命周期和有界使用证据; +- Codex 与 Claude Code 的环境评估、发布、drift detection 和安全取消发布; +- 后续远端 Agent-side Pull 扩展的 target 注册、期望状态收敛、Delivery Receipt 和安全边界; +- 公共 package read 语义和实现验收标准。 + +本 RFC 细化 RFC 0051 的 instruction-only managed Skill content 和 RFC 1304 的 two-file managed projection。它不 +改变 Experience content、通用 Candidate identity、Candidate CAS、Review terminal transition 或 Artifact lineage +语义。 + +本 RFC 不定义: + +- 通用 Workflow、DAG、Routine 或 Procedure Runtime; +- 自动执行、安装依赖、解析 Secret 或 Sandbox grant; +- SSH、Server 主动写入远端文件系统或浏览器指定任意远端路径; +- 常驻 Fleet Orchestrator、即时推送通道、自动发布或通用设备管理; +- 组织级 RBAC、Reviewer identity、package signing 或 marketplace billing; +- 自动语义合并、自动发布、自动退役或无界后台生成; +- 通用二进制提取、OCR、恶意软件结论或完整代码搜索。 + +## 标准基线 + +受管包遵循通用 Agent Skills package baseline: + +- package root 包含 UTF-8 `SKILL.md`; +- YAML frontmatter 包含必需的 `name` 和 `description` 字符串; +- `name` 由 1 到 64 个小写字母、数字或单个连字符组成,不能以连字符开头或结尾,不能包含连续连字符,并且 + 必须与 package directory name 匹配; +- `description` 非空且最多 1,024 个字符; +- 保留 `license`、`compatibility`、`metadata` 和 `allowed-tools` 等可选标准字段; +- 保留 `scripts/`、`references/`、`assets/`、license、template 以及其他有界 package file; +- 未识别但语法有效的 frontmatter field 仍属于精确包,不会被改写。 + +PowerContext 把 `allowed-tools` 当作不受信任的 package content。它可以用于展示或兼容性判断,但不是 Tool Grant, +也不能绕过 Agent Policy。 + +通用基线使用所有已配置 Agent Adapter 都能接受的约束。某个 target Adapter 可以报告更严格的不兼容,但不能 +通过改写内容扩大已批准 package contract。 + +## Skill package content model + +新的 managed Skill Revision 使用带 discriminator 的 content model: + +```yaml +schema: powercontext.skill-package.v2 +format: agent-skills +entrypoint: SKILL.md +package: + tree_digest: sha256:... + archive_digest: sha256:... + file_count: 7 + uncompressed_size: 18234 + archive_size: 9541 +metadata: + name: release-check + description: Verify a release candidate before publication. + license: Apache-2.0 + compatibility: Python 3.11 or newer. +``` + +`package` 标识权威 package snapshot。`metadata` 是用于校验、列表和检索的确定性解析缓存。每次写入和读取包时, +缓存 metadata 都必须与 `SKILL.md` 匹配;不匹配属于 integrity error。缓存不能独立编辑。 + +Review report、compatibility assessment、lifecycle state、publication binding 和 usage aggregate 不属于 +`SkillPackageContent`。它们具有不同权威和变化频率。 + +## Canonical package capture + +Package identity 代表内容,不代表完整 filesystem image。Capture 保留: + +- 所选 package root 下每个允许的 regular file; +- 规范化 POSIX relative path; +- 精确 file bytes; +- 被约化为不可执行 `0644` 或可执行 `0755` 的 regular-file mode。 + +Capture 不保留 modification time、user/group ID、ownership、extended attribute、ACL 或 empty directory。这些值会 +随宿主变化,不属于 Agent Skill 内容。 + +Tree digest 对按路径排序、带 domain separation 的 canonical stream 计算: + +```text +format version +relative path length + relative path +normalized mode +file length +file sha256 +``` + +之后 PowerContext 创建 deterministic ZIP:entry 排序、timestamp 固定、mode 规范化、移除宿主专属 extra field,并 +使用固定压缩策略。`tree_digest` 是内容身份;`archive_digest` 校验存储和分发的 ZIP。即使原始 ZIP 的顺序和压缩 +方式不同,语义相同的输入仍得到同一 tree digest。 + +初始边界延续当前本地 Registry 的规模: + +| 边界 | 值 | +| --- | --- | +| Regular file | 256 | +| 未压缩总字节 | 4 MiB | +| Canonical ZIP 字节 | 5 MiB | +| `SKILL.md` 字节 | 128 KiB | +| UTF-8 编码后的 path 字节 | 512 | + +Importer 对下列情况直接拒绝,而不是静默排除: + +- absolute path、`..`、NUL、无效 UTF-8 path 和 package root 外路径; +- symlink、hard-link alias、socket、device、FIFO 和其他特殊文件; +- case-folding 或 Unicode-normalization path collision; +- 重复 ZIP member; +- 不支持的加密或超出 decompression bound; +- 超出任意边界的包; +- 缺失、非 UTF-8、格式错误或不符合标准的 `SKILL.md`; +- 被已配置 Secret 或 Package Policy 阻止的文件。 + +如果 `.env`、`.git`、`node_modules` 或其他路径被禁止,错误会指出具体路径。Exact Import 不会静默丢弃这些内容 +后再声称结果完整。 + +对于实时外部目录,Capture 会把每个文件写入隔离的 staging snapshot,计算 staged digest,然后再次解析外部 +Registration。如果源 fingerprint 已变化,Capture 以 typed conflict 失败并且不持久化 Candidate。Package +snapshot 使用 staging bytes,而不是稍后再次读取 live directory。 + +## Package persistence + +初始实现新增一个不可变 content-addressed table: + +```text +pc_skill_packages + scope_id + tree_digest + archive_digest + archive_bytes + manifest + file_count + uncompressed_size + archive_size + created_at + +PRIMARY KEY (scope_id, tree_digest) +``` + +`archive_bytes` 使用 SQLAlchemy `LargeBinary`;SQLite 存储为 BLOB,MySQL/OceanBase variant 使用 `MEDIUMBLOB`。 +`manifest` 是 canonical JSON,包含每个 entry 的 path、digest、size、media type 和 normalized mode。任何索引都不 +包含 `archive_bytes` 或 `manifest` 内容。 + +`pc_artifacts.content`、Candidate proposal content 和捕获的 external snapshot Source 只保存有界 package +reference。Package insertion 与第一个持有它的 Source 或 Candidate write 在一个数据库事务中完成。复用相同 +`(scope_id, tree_digest)` 时,先校验现有 archive 和 manifest digest,再返回现有记录。 + +前五个本地切片新增 `pc_skill_packages` 和 `pc_skill_publications` 两张业务表。Lifecycle 复用现有 Artifact +Head,Search 使用通用可重建 Projection,Usage Evidence 复用现有 Source Store。第六个远端分发切片新增 +`pc_agent_skill_targets`,并迁移 `pc_skill_publications` 以表达远端期望状态和最新 Receipt;首个远端切片不新增任务 +队列表或 Receipt 历史表。 + +已批准 Artifact Revision 以及仍被保留的 Candidate 或 Source evidence 会保持 package reachable。初始实现不做 +自动 Package GC。后续 Collector 只能在文档化 retention period 结束后,删除不再被 Artifact、Candidate 或 Source +引用的 package。 + +## External reference、Import、Fork 与 Update + +各操作具有不同的权威语义: + +| 操作 | Package authority | 是否复制 | 是否需要 LLM | +| --- | --- | --- | --- | +| Discover/reference | 外部本地包 | 否 | 否 | +| Exact import | 批准后的新 managed Artifact | 精确 canonical snapshot | 否 | +| Fork | 批准后的新 managed Artifact | 精确 source snapshot 加 proposed replacement | 只有模型辅助语义修改才需要 | +| External update | 显式新 Import/Fork 前仍是外部包 | 新的精确 snapshot | Exact Import 不需要;Fork 可选 | + +Exact Import Candidate 的 proposed `tree_digest` 必须等于捕获的 external snapshot digest。Candidate revision 可以 +修改 Review annotation,但如果修改 package content,就不能继续称为 Exact Import;编辑任意文件都会把操作变为 +Fork,并产生新的 proposed digest。 + +先前导入的上游包发生变化后,Registry 会在 import provenance 旁显示新的 external fingerprint。PowerContext +不会自动更新 managed Skill。用户可以把它导入为新的 managed Skill、Fork,或针对当前 managed ArtifactRef 提出 +successor Revision。 + +## Validation 与风险评估 + +Validation 分为三层: + +1. **Package validation**:path safety、bounds、canonicalization、标准 metadata、digest 和 media detection。 +2. **Static governance validation**:Secret pattern、license、executable file、dependency manifest、runtime declaration、 + network/secret/write requirement 和可疑 binary inventory。 +3. **Target compatibility**:Agent format、package name、environment profile 和可选 runtime variant。 + +这些层都不会执行 package script。Scanner finding 是 Review evidence,不是包安全或恶意的证明。Review UI 会展示 +Scanner version,并在适用时说明覆盖不完整。 + +确定性 risk level 用于分流,不授予权限: + +| Risk | 最低触发条件 | +| --- | --- | +| `instruction_only` | 只有 `SKILL.md` 和惰性文本/资源 | +| `local_script` | 任意 executable 或 script file | +| `workspace_write` | 声明需要写 workspace | +| `network` | 声明网络需求或 network-oriented dependency | +| `secrets` | 声明 Secret/environment requirement | +| `privileged` | System path、process、container 或其他 elevated requirement | + +部署策略可以要求风险更高的包经过更严格 Review 或 Publication confirmation,但 risk 永远不会授权导致该等级的 +能力。 + +## Candidate 与批准事务 + +`SkillPackageContent` 继续作为 Family proposal type,因此通用 Candidate storage 和 CAS 可以继续复用。Candidate +detail 通过 Skill Package Store 解析 package reference。 + +Approval 在一个事务中完成: + +1. 锁定 expected pending Candidate head; +2. 解析并校验精确 package reference; +3. 重复执行必需的 deterministic validation; +4. 校验 scope 和直接 SourceRef/ArtifactRef lineage; +5. 用不可变 package content 创建或修订 `skill` Artifact; +6. 为新 Skill 创建初始 governance row,或为 successor Revision 保留现有 lifecycle; +7. 更新 current-head search projection; +8. 一起提交 Candidate terminal result 和 Artifact Revision。 + +Stale Candidate、目标 Artifact head、package mismatch 或 validation version conflict 返回 `409` 或 typed validation +failure。Approval 不会获取远程内容,也不会替换成另一 package digest。 + +## 从 instruction-only managed Skill 迁移 + +现有已批准 Revision 继续通过当前 instruction-core content model 精确读取,不会被原地改写,并保留历史 publication +语义。 + +实现支持带 discriminator 的 union: + +```text +powercontext.skill-instruction.v1 -> 现有 name/description/instructions/validation +powercontext.skill-package.v2 -> 标准 package reference 和 parsed metadata +``` + +从 v1 Skill 创建 successor 时,先渲染当前 deterministic `SKILL.md`,将其 canonicalize 为单文件 v2 package,并把 +完整包作为起始 Candidate 展示。批准后下一个 Artifact Revision 才成为 v2。该转换是显式且可 Review 的;读取旧 +Revision 永远不会触发迁移。 + +新的 Exact Import 和新 Package Upload 使用 v2。现有语义生成最初可以生成单文件标准包,只有在精确证据和 Review +支持时才增加 script 或 reference。 + +## Search projection 与 Skills Library + +ZIP BLOB 永远不直接参与 Search。`skill_searchable_text(package)` 从精确包确定性提取有界文本: + +```text +name +description +compatibility 和 metadata 值 +SKILL.md body +排序后的 package path +references/*.md 与 references/*.txt 中有界的 UTF-8 text +``` + +当前 managed head 把文本写入 `pc_artifact_heads.searchable_text`。SQLite 把现有仅面向 Experience 的可重建 FTS5 +projection 替换为按 scope、Family、Artifact ID 和 Revision 建立的通用 `pc_artifact_fts`。这是对现有可重建投影的 +替换,不是新增 Skill 表。OceanBase 继续使用通用 Head field 上的全文索引。两个 Backend 在搜索 Skill 时都过滤 +`family = 'skill'` 和 Lifecycle State。重建 projection 会解析精确 package reference,并在提取前校验 package +digest。 + +默认 Skill Search 不返回历史 Revision、Pending/Rejected Candidate、未显式请求的 Deprecated Skill 或 Retired +Skill。Projection 不包含完整 script source 或任意 binary extraction。后续 Code Search 或 Vector Search 使用独立 +通道,并保留 path 和 content-digest provenance。 + +Skills Library 提供统一 read model,同时保留 authority: + +```text +managed current heads + governance + publication + usage projection +UNION +visible external registrations + local availability +``` + +每一行都暴露 `authority = managed | external`。Search 不会把 external registration 转成 managed Artifact,也不会 +把 managed package 当成仍由 upstream source 控制。 + +## Managed lifecycle 与工作集治理 + +Lifecycle state 是针对逻辑 managed Skill 的可变治理状态,不保存在 package 内。它扩展现有权威 Head row,而不新增 +`pc_skill_governance`: + +```text +pc_artifact_heads + scope_id + family + artifact_id + revision + searchable_text + lifecycle_state active | deprecated | retired + replacement_artifact_id nullable + governance_generation + +PRIMARY KEY (scope_id, family, artifact_id) +``` + +现有 row 迁移为 `active`,Governance Generation 为零。Lifecycle update 必须满足 `family = 'skill'`,并使用 +expected `governance_generation` CAS;它不会改变不可变 Artifact Revision,也不会移动 Head 的 `revision` pointer。 +`replacement_artifact_id` 如果存在,必须指向同 Scope 的另一个 managed Skill Head。Lifecycle transition 显式进行: + +```text +active <-> deprecated +active 或 deprecated -> retired +retired -> 无自动转换 +``` + +本 RFC 中 Retirement 不可逆。错误退役后可以 Fork 或创建新的逻辑 Skill,同时 Retired history 继续可审计。 +Deprecation 可以指定一个同 scope replacement,并可以被显式撤销。 + +Scope 和 target policy 可以限制 Pending Candidate、package bytes、active searchable head 和 published package 数量。 +超过 budget 会用 typed error 阻止新操作,但绝不会驱逐或退役现有 Skill。 + +## Agent target 与环境兼容性 + +`AgentSkillTarget` 继续作为已配置 publication boundary,并增加 environment profile,或增加能够观测该 Profile 的 +Provider: + +Server 以 workspace 作为本机路径边界。未显式设置 `POWERCONTEXT_SERVER_EXTERNAL_SKILLS` 时,workspace 默认为 Server +启动目录,并自动生成 `codex-project -> /.agents/skills` 与 +`claude-project -> /.claude/skills` 两个允许受管发布的项目级 target。目录缺失只表示当前没有外部 package; +首次由用户确认本机安装时才创建。服务管理器或容器必须通过 `POWERCONTEXT_SERVER_WORKSPACE` 固定 workspace。显式 +`POWERCONTEXT_SERVER_EXTERNAL_SKILLS` 完整覆盖自动 target,并继续承担自定义路径、用户级 target、环境 Profile 和关闭 +本机发现等高级配置。Dashboard 不接收用户输入的本机路径。 + +```yaml +target_id: codex-project +agent_kind: codex +host_id: host-123 +installation_scope: project +path: /workspace/.agents/skills +allow_managed_publish: true +environment: + operating_system: linux + architecture: x86_64 + commands: + python: 3.12.4 + bash: 5.2.26 + network_policy: disabled + writable_roots: [workspace] + dependency_install_policy: denied + environment_names: [CI] +``` + +Secret value 永远不进入 Profile。Observed Profile 具有 deterministic fingerprint 和 timestamp。Compatibility 由精确 +Artifact Revision、package tree digest、environment fingerprint 和 adapter version 共同确定: + +```text +compatible +incompatible(reason...) +unknown(reason...) +manual_review_required(reason...) +``` + +Compatibility 是可重建 Assessment,不是 Artifact。环境变化会使 Assessment 失效,但不会改变 Skill Revision。 +已知 Agent-format incompatibility 会阻止 Publication。未知 runtime compatibility 可以在现有显式确认后发布,因为 +Publication 不是 Execution;但 UI 必须保留警告,不能声称脚本一定可运行。 + +## Publication、Distribution 与 Unpublication + +初始实现支持宿主本地的 configured target 和精确 authenticated package download,不会向任意浏览器路径或远程 +宿主主动推送。 + +Package download 解析调用方有权访问的精确 ArtifactRef,并返回包含 canonical ZIP bytes 的有界 JSON envelope: + +```text +package: {tree_digest, archive_digest, file_count, uncompressed_size, archive_size} +archive_base64: +``` + +调用方解码后校验两个 digest。该 envelope 让生成式 JSON Client contract 保持一致,同时保留字节精确分发;Server +不会返回可变的 filesystem path。 + +初始五个本地切片的 Publication state 保存在本 RFC 新增的第二张业务表中: + +```text +pc_skill_publications + scope_id + target_id + artifact_id + desired_revision + desired_tree_digest + observed_revision nullable + observed_tree_digest nullable + destination + state + selected_runtime_variant nullable + environment_fingerprint nullable + generation + updated_at + +PRIMARY KEY (scope_id, target_id, artifact_id) +``` + +Publication 在 target filesystem 上 staging canonical package,安全解压,重新计算 tree digest,再原子 rename。Target +package 只包含已批准 package file。现有 `powercontext.json` ownership file 不再写入发布包;Ownership 由 +`pc_skill_publications` 表示,并通过 observed destination tree digest 校验。 + +可观测 publication state 与 runtime compatibility、external discovery 分开: + +```text +unpublished | current | update_available | conflict | drifted | incompatible +``` + +Safe Update 或 Unpublication 需要 expected publication `generation`、精确 recorded Artifact identity、destination 和 +observed tree digest。如果本地内容已变化,PowerContext 报告 Drift 并保持原样。Unpublication 只移除完整的 managed +package 及其 binding,不会删除已批准 Artifact 或 package history。 + +## 远端 Agent-side Pull 与期望状态收敛 + +本节规定已实现的第六个切片 Contract。远端分发沿用相同的 +`pc_skill_publications` 期望/观测模型,但由 target-local Receiver 而不是 Server 本地 Publisher 产生观测结果。 + +### Target 注册与本地路径归属 + +远端 Receiver 使用一次性 enrollment code 注册一个稳定的 `target_id`。注册至少绑定: + +- 不透明的 host/installation identity、`agent_kind` 和 project installation scope; +- 允许访问的 `scope_id` 和 Server origin; +- target-local Adapter version、environment fingerprint 和最近在线时间; +- 独立的 target credential subject;Secret value 只保存在远端操作系统 Secret Store 或等价安全存储中。 + +第六个切片新增第三张业务表,持久保存 enrollment、认证主体和 target liveness: + +```text +pc_agent_skill_targets + scope_id + target_id + display_name + agent_kind + installation_scope + delivery_mode + installation_id nullable + state + enrollment_token_digest nullable + enrollment_expires_at nullable + credential_subject nullable + credential_verifier nullable + receiver_version nullable + environment_fingerprint nullable + machine_hostname nullable + workspace_name nullable + last_seen_at nullable + generation + created_at + updated_at + +PRIMARY KEY (scope_id, target_id) +UNIQUE (scope_id, agent_kind, installation_scope, installation_id) +UNIQUE (enrollment_token_digest) +UNIQUE (credential_subject) +UNIQUE (credential_verifier) +``` + +`display_name` 是管理员提供、可通过 target generation CAS 修改的人类可读名称;重命名不改变凭据或任何分发绑定。 +`target_id` 由 Server 生成并保持稳定,只用于 API、审计和故障排查。`installation_id` 是 Receiver 为本地 +Agent/project installation 生成的不透明身份,不是 filesystem path;它在 enrollment 前为空,在 enrollment transaction +中写入。Receiver 同时上报不含绝对路径的 `machine_hostname` 和 workspace basename `workspace_name`,便于 Dashboard +按名称、主机、工作区或技术 ID 搜索和消歧。首个远端切片只允许 +`delivery_mode=agent_pull`,并使用 +`pending | active | revoked` target state: + +- 创建 enrollment 时,Server 生成 `pending` row,只保存高熵一次性 code 的 digest 和 expiry; +- enrollment transaction 同时校验 pending state、expiry、token digest 和 expected target `generation`,绑定唯一 + `installation_id`、`credential_subject` 和 verifier,清除 token digest,再转为 `active`; +- target display name 的修改使用同一 `generation` CAS,但不改变 credential、`target_id` 或 publication identity; +- Secret credential value 只保存在远端操作系统 Secret Store;Server 只保存用于验证的 hash、key 或 provider + reference; +- `last_seen_at` 用于派生 `offline` 展示,离线不是持久 target state; +- enrollment 和 revoke 使用 target `generation` 做 CAS;credential rotation 若后续引入,也必须复用同一 CAS contract; +- `revoked` target 的 enrollment、reconcile、download 和 Receipt 全部被拒绝;credential verifier 被清除或通过 + provider 撤销,历史 target identity 仍保留用于审计。 + +一条 target row 可以在尚未发布任何 Skill 时独立存在。首个远端切片不把现有 host-local path configuration 迁入 +该表,也不复用 `pc_external_skill_registrations`;后者只是外部包的观测,不是远端设备或认证 Authority。 +三个 Unique Contract 分别阻止同一 installation 重复注册、一次性 code 被两个 target 消费,以及一个 credential +subject 同时代表多个 target;数据库对 nullable unique column 允许多个 `NULL`。 + +Server 只保存逻辑 installation scope,不保存或接受浏览器传入的远端绝对路径。Receiver 根据本地已注册 workspace +解析 package root,并拒绝逃逸该 root 的 Skill name 或 archive path。一个 credential 只能代表其绑定的 `target_id`, +不能在 reconcile 请求中切换为另一个 target。 + +每条 `agent_pull` publication 必须解析到同 scope 的 `active pc_agent_skill_targets` row;host-local publication 继续解析 +现有 Server configuration,不要求 target table row。Revoke 不级联删除 publication 或 package history,只阻止远端 +认证并让 Dashboard 显示 target 已不可收敛。 + +### Publication schema 扩展 + +第六个切片迁移现有 `pc_skill_publications`,增加或变更: + +```text +desired_state # published | unpublished +observed_generation nullable +destination nullable # host-local 必填;agent_pull 必须为空 +last_error_code nullable +observed_at nullable +``` + +原有 `generation` 继续表示 Server desired state 的 CAS generation。`observed_generation` 表示最新有效 Receipt 已处理的 +generation;旧 generation 的 Receipt 不能更新 observed fields。远端 Unpublish 把 `desired_state` 改为 +`unpublished`。最后一个 desired Revision/digest 继续保留为 intent history,但它本身不是删除 Authority;安全删除 +依据是 Receiver 在 reconcile 中报告并在本地再次校验的 credential-bound ownership checkpoint。成功 Receipt 将 +observed Revision/digest 变为空,并把 state 设为 `unpublished`。 + +`destination` 对现有 host-local publication 仍然必填;对 `agent_pull` 必须为空,因为路径由 Receiver 本地解析。远端 +切片将 publication state 扩展为: + +```text +unpublished | pending | current | update_available | delivery_failed | conflict | drifted | incompatible +``` + +`pending` 表示 desired generation 尚无匹配 Receipt;`delivery_failed` 携带有界 `last_error_code`。`offline` 根据 active +target 的 `last_seen_at` 派生,不写入每条 publication state。 + +Schema migration 必须在 SQLite 和 OceanBase 上执行相同的确定性 backfill: + +- 现有 `state=unpublished` row 写入 `desired_state=unpublished`,其余 row 写入 `desired_state=published`; +- 现有 row 写入 `observed_generation=generation` 和 `observed_at=updated_at`; +- 现有 host-local `destination` 原值保持不变,只有新的 `agent_pull` publication 使用 `NULL`; +- 现有 row 写入 `last_error_code=NULL`; +- backfill 完成后 `desired_state` 为 non-null,并限制为 `published | unpublished`。 + +首个远端切片不新增 `pc_skill_delivery_receipts`。Receipt 在 +`(scope_id, target_id, artifact_id)` row 上校验 `publication.generation == receipt.generation` 后更新最新 observed fields: +相同 generation 的成功结果可以覆盖失败结果,失败结果不能覆盖已经成功的结果,重复相同结果是 no-op,旧 +generation 一律不能更新当前 state。这足以实现幂等收敛。若以后需要完整 Receipt 审计历史,应复用现有 +Source/Event Store;审计记录不能成为当前 publication state 的 Authority。 + +Receiver 在标准 package 之外维护一个 credential-bound、完整性受保护的本地 ownership checkpoint。每个 managed +artifact 的 checkpoint 至少包含 `target_id`、ArtifactRef、tree digest、applied generation 和状态;package script +不能读写该状态。Receiver 还维护一个有界 pending-action journal,使 package rename 和 checkpoint 更新之间发生崩溃 +时能够恢复:最终目录匹配已授权 action 时完成 checkpoint 并补发 Receipt;仍匹配旧 checkpoint 时放弃 staging; +其他情况报告 `conflict`,不会猜测 ownership。 + +### Reconcile,而不是一次性投递队列 + +远端 Publication 是期望状态: + +```text +Server authority Remote target observation +desired_state observed state/result +desired_revision observed_revision nullable +desired_tree_digest observed_tree_digest nullable +generation observed_generation nullable +delivery_mode = agent_pull bounded error code +``` + +Dashboard 的 Publish、Update 或 Unpublish 只以 CAS 更新 desired state 和 `generation`。Receiver 在 reconcile request +中提交本地 ownership checkpoint 和实际目录 tree digest: + +```yaml +target_id: codex-project-7f31 +last_processed_generation: 11 +observed: + - artifact_ref: artifact:skill/skill_release_check@1 + tree_digest: sha256:abcd... + applied_generation: 9 +``` + +Server 使用 target credential 校验请求,并确认 checkpoint 中的 ArtifactRef/tree digest 指向同 scope、同 +`artifact_id` 的精确 approved package。Reconcile observation 可以作为本次动作的本地 precondition,但只有成功 +Receipt 才更新 authoritative observed fields。Response 使用区分明确的 action shape: + +```yaml +# install +generation: 12 +action: + operation: install + desired: + artifact_ref: artifact:skill/skill_release_check@2 + tree_digest: sha256:1234... + +--- +# unpublish +generation: 13 +action: + operation: unpublish + artifact_id: skill_release_check + expected_local: + artifact_ref: artifact:skill/skill_release_check@2 + tree_digest: sha256:1234... + applied_generation: 12 +``` + +对于 Unpublish,`expected_local` 来自本次经过认证的 Receiver checkpoint,并且必须与同 Artifact binding 的 exact +approved package 匹配;它不盲目使用 Server 上最后一次 observed 或 desired digest。这样即使 package 已安装而成功 +Receipt 丢失,下一次 reconcile 仍能安全确认并移除 Receiver 实际拥有的精确目录。 + +Response 不包含任意 destination path、shell command、dependency install instruction 或未批准的 package body。 +Package body 继续通过现有精确 Download operation 获取,并且 credential 只能下载当前 target desired state 引用的 +Artifact Revision。相同 `(scope_id, target_id, generation, artifact_id)` 的 reconcile 和 Receipt 必须幂等;短暂断网、 +重复请求或 Server 重启不会导致重复目录或回退到旧 Revision。 + +离线只表示 target 尚未收敛,不把 desired state 改回失败或丢弃动作。`current` 只在最新 generation 的精确 Receipt +匹配 desired Revision 和 tree digest 时成立;在此之前 Dashboard 显示 `pending` 或 `offline`。较旧 generation 的 +Receipt 不能覆盖较新的 observed state;如果部署启用审计,可以把它写入现有 Source/Event Store。 + +失败 Receipt 将 `observed_generation` 写为本次 generation,保留上一个成功 observed Revision/digest,并将 state +设为 `delivery_failed`。只要 desired state 尚未满足,reconcile 就会在有界退避后重新返回同 generation 的幂等 +action;重试不会增加 publication generation。只有新的 Dashboard intent 才推进 desired `generation`,后续成功 +Receipt 会清除 `last_error_code` 并替换失败状态。 + +### Receiver 安装与 Receipt + +对于 `install`,Receiver 必须按下列顺序执行: + +1. 使用绑定 target credential 读取精确 package envelope; +2. 在有界 staging directory 中校验 archive digest、安全解压并重算完整 tree digest; +3. 运行 Agent-format 和 target-local compatibility 校验,但不执行脚本或安装依赖; +4. 如果最终目录和本地 checkpoint 已经精确匹配本次 desired Artifact/digest,不重写目录,直接进入 Receipt; +5. 否则只在目标不存在,或现有目录和本地 checkpoint 同时匹配且 action 授权替换时,先持久化 pending-action + journal,再原子 rename 完整 package; +6. 从最终目录再次观测 tree digest,原子更新本地 checkpoint,清理 journal,并回传 Receipt;任何 identity、digest + 或 checkpoint 不匹配都报告 `drifted` 或 `conflict`,保持目录不变。 + +Receipt 至少包含 `target_id`、`generation`、operation、ArtifactRef、expected/observed tree digest、结果、environment +fingerprint、Receiver version 和有界 error code。Package body、Secret、任意命令输出和绝对路径不进入 Receipt。 +Server 必须以 credential 绑定的 target identity、generation 和 digest 校验 Receipt,不能把一次 HTTP 成功当作安装 +成功。通过验证的最新 Receipt 按上述 generation 和成功优先规则更新 `pc_skill_publications`,不写入独立 Receipt 表。 + +对于 `unpublish`,Receiver 先校验 authenticated action、`expected_local`、本地 checkpoint 和实际 tree digest 四者 +一致并持久化 pending-action journal,再把完整 managed package 原子 rename 到 Receiver-private quarantine,写入 +“absent” checkpoint 并回传 Receipt,最后清理 quarantine。若用户或其他工具修改了目录,Receiver 回传 `drifted` +或 `conflict` 并保持内容不变。Receiver 的 ownership、credential、pending-action journal 和 Receipt checkpoint 都 +保存在标准 package 之外。 + +### Codex 与 Claude Code 触发方式 + +| Agent | 首个远端切片的 Receiver 载体 | 项目级安装根 | 同步触发 | +| --- | --- | --- | --- | +| Codex | PowerContext 轻量 Receiver | `.agents/skills/` | systemd user service 运行 `remote-watch`,或 Agent 启动前置/`remote-sync` | +| Claude Code | PowerContext 轻量 Receiver | `.claude/skills/` | systemd user service 运行 `remote-watch`,或 Agent 启动前置/`remote-sync` | + +集成必须验证 Agent 在哪个 discovery boundary 读取 Skill。如果 SessionStart 晚于该 Agent 的本次扫描,新安装包只 +能声明为下一 session 可发现,不能把 `installed` 等同于本次 session 已加载。需要“首次会话即可使用”的部署应在 +启动 Agent 前运行同一个 reconcile preflight。`remote-watch` 只定期触发同一个 reconcile;后续 SSE/WebSocket 也只能 +用于唤醒 Receiver,package 仍通过相同的 authenticated pull transport 获取。 + +## Usage observation 与 Evolution + +拥有该集成的 Agent Integration 可以在有界 Task 或 Agent completion boundary 捕获 `skill-usage` Source: + +```yaml +skill_ref: artifact:skill/skill_release_check@2 +package_digest: sha256:... +target_id: codex-project +selected: true +invoked: true | false | unknown +validation: passed | failed | unknown +outcome: success | failure | unknown +task_source: source:task-outcome/task_456 +environment_fingerprint: sha256:... +``` + +Adapter 不能根据 Retrieval、Publication、Prompt Inclusion 或模型提到 Skill 就推断 `invoked=true`。Unknown 是正常值。 +Source 默认不记录 Prompt、Secret、Command Argument 或无界输出。 + +可重建 daily projection 可以按精确 Skill Revision 聚合 selected、invoked、validation-passed、success 和 failure +count。这些 count 支持 Library health view,但不会自动改变 Search eligibility 或 Lifecycle。 + +已配置 generation model 可以使用调用方选择的精确 Usage Source 提出 Successor Candidate。Exact Import、Storage、 +Review、Lifecycle Change、Publication、Unpublication 和 Usage Recording 仍然是非 LLM 基础能力。 + +## Public 与 Dashboard operation + +实现暴露具有下列语义的 operation;最终 OpenAPI 命名遵循现有 `/v1/skill/...` 风格: + +| Operation | 结果 | +| --- | --- | +| List Library | 返回保留 Authority 的 Managed Head 和 External Registration,并支持过滤 | +| Get package manifest | 返回精确 Managed Revision metadata 和 file tree,不返回 binary body | +| Download package | 为有权访问的精确 Managed Revision 返回 canonical ZIP | +| Upload package proposal | Canonicalize 调用方提供的 ZIP,并创建 pending managed Candidate | +| Import external Skill | 从选定 fingerprint 创建 Exact Import Candidate 或 Fork Candidate | +| Update lifecycle | 对 active、deprecated 或 retired 执行 CAS transition | +| Inspect publication | 返回 configured target 的 publication 与 runtime compatibility | +| Publish | 把精确 Approved Revision 发布到一个 configured target | +| Unpublish | 只移除完整的 managed target package | +| Record usage | 捕获有界、精确的 usage Source evidence | +| Create/enroll/revoke remote target | 创建一次性 code、绑定或撤销 credential-bound target registration | +| Publish/unpublish remote desired state | 以 publication generation CAS 声明精确 Revision 或期望缺席 | +| Reconcile remote target | 比较 target observation 与最新 desired generation,返回幂等动作 | +| Download remote package | 只允许 target credential 下载其当前 generation 引用的精确包 | +| Record delivery receipt | 记录精确 generation、ArtifactRef、digest 和安装结果 | + +List Library 的每一项都返回可展示的出处。未引用 external snapshot 的 managed Skill 归为 `powercontext`;exact import +归为 `external_import`;fork 归为 `external_fork`;尚未进入 Review 的 registration 在浏览器中归为 `external`。后面三类 +同时显示 registration 的 `host_id`、`agent_kind`、`external_skill_id`、`installation_scope` 和 `locator`。对于 managed +Skill 的后续 Revision,Runtime 先检查直接 SourceRef,再沿上游 Skill ArtifactRef 追溯最初的 external snapshot,避免一次 +修订后把接管来源错误地显示成 PowerContext。该投影复用已持久化的 Source lineage 和 external snapshot,不新增表或历史 +数据迁移;旧数据没有 external snapshot 时只声明为 PowerContext 来源,不猜测是人工提交还是模型生成。 + +浏览器提交 `target_id`、创建目标时选择的 Agent kind、精确 ArtifactRef、expected Candidate version 或 +governance/publication generation,以及显式 operation intent。浏览器永远不提交任意 destination path、package +digest replacement 或 execution grant。 +远端 operation 已进入 OpenAPI。管理员通过 `remote-status`、`remote-target-create`、`remote-target-rename`、`remote-publish`、 +`remote-unpublish` 和 `remote-target-revoke` 完成完整生命周期;Receiver 端通过 `remote-enroll`、`remote-watch`、 +`remote-sync`、`remote-service-install` 和 `remote-service-uninstall` 收敛本地目录并管理 Linux user service。CLI 在 +未显式提供 expected generation 时先读取最新状态再提交 CAS;自动解析不会绕过 CAS,竞争更新仍返回 conflict。 +管理员可以显式提供 generation 以实现自动化中的 compare-and-swap。 + +Skills Dashboard 在交付区提供“本机目录 / 远端机器”选择。远端模式要求创建时填写机器名称,支持按名称、Receiver +上报的主机名、工作区名或技术 ID 搜索,并可在不改变分发身份的前提下重命名。它还支持 Codex 或 Claude Code 项目目标、 +一次性注册引导、目标与分发状态自动刷新、精确 Revision 分发、请求安全移除和凭据撤销。页面只在创建时展示一次注册口令,并 +直接给出可复制的 Receiver 安装和带 `--install-service` 的 `remote-enroll` 命令;关闭前未保存口令时,管理员撤销 +pending target 并重新添加。远端模式在 pending 时每两秒、稳定时每十秒静默刷新,页面不可见或切回本机模式时停止。 +Dashboard 把 Publish/Unpublish 表述为期望状态请求,只有匹配的 Receiver Receipt 才显示已安装或已移除。 +存在尚未确认移除的 publication 时,页面禁止撤销 target,避免先使 Receiver 凭据失效而永久失去安全清理能力。 +Server 可通过 `POWERCONTEXT_SERVER_PUBLIC_URL` 一次性配置远端可达地址;未配置时,Dashboard 自动采用当前 HTTPS +来源,显式启用不安全开关后也可以采用当前 HTTP 来源;否则由远端 CLI 的既有 Server 配置提供连接地址。添加 target +不要求重复填写服务地址。 + +HTTPS 仍是默认传输边界。受保护的内部测试网络可以在一期 PoC 中显式启用直连明文 HTTP:Server 设置 +`POWERCONTEXT_SERVER_ALLOW_INSECURE_HTTP=true`,远端注册同时传入 `remote-enroll --allow-insecure-http`。任一端单独 +启用都不足以放行:Server 开关关闭时继续拒绝非 loopback HTTP 的 Receiver 请求;CLI 未提供参数时,会在发送一次性 +注册口令前拒绝该 URL。如果 Server 自身以未鉴权方式绑定非 loopback 地址,操作者还必须单独设置 +`POWERCONTEXT_SERVER_ALLOW_UNAUTHENTICATED_NON_LOOPBACK=true`;它表示接受所有 Server route 暴露,不能由仅针对 +Receiver 的传输例外隐式开启。只有 Server 开关已启用时,Dashboard 才接受公布的 HTTP 地址,并持续展示明文传输警告、在 +可复制命令中加入 Receiver 参数。Receiver 把许可和凭据一起保存到 owner-only 配置文件,因此一次同步、watch 模式和 +systemd user service 共用同一传输策略。该配置字段为向后兼容的增量字段,不新增数据库表,也不需要历史数据迁移。 +明文链路不会加密注册口令、target credential、技能包或 Receipt,只能用于受保护的内部测试网络,不能视为生产环境中 +HTTPS 的替代方案。 + +### 远端分发 CLI 流程 + +默认情况下,Server 必须提供远端可达的 HTTPS URL;上文定义的内部 HTTP PoC 显式例外是唯一明文替代方案。远端机器 +只安装 `powercontext[cli]` Receiver,不安装 Server 或数据库,也不接受 +Server 入站连接。管理员先创建 project target: + +```bash +powercontext --server-url https://powercontext.example.com \ + skill remote-target-create --scope-id project:demo --agent-kind codex +``` + +远端 operator 在目标 project 中输入一次性 enrollment code;省略命令行参数会使用无回显 prompt,并把 target credential +以 owner-only 权限写入 `.powercontext/remote-skill-target.json`: + +```bash +cd /srv/project +powercontext --server-url https://powercontext.example.com \ + skill remote-enroll --workspace "$PWD" --install-service +``` + +内部 HTTP PoC 显式例外对应的命令为: + +```bash +powercontext --server-url http://powercontext.internal.example:8765 \ + skill remote-enroll --workspace "$PWD" --install-service --allow-insecure-http +``` + +`--install-service` 以目标 ID 创建独立的 `systemd --user` unit,并立即 `enable --now`。unit 只引用 owner-only 配置文件, +不复制 credential。已有注册可运行 `powercontext skill remote-service-install`,需要停用时运行 +`powercontext skill remote-service-uninstall`;不支持 systemd 的环境可以前台运行 `powercontext skill remote-watch`。 + +管理员发布精确 approved package Revision,不需要手工查询首次或当前 publication generation: + +```bash +powercontext --server-url https://powercontext.example.com \ + skill remote-publish --scope-id project:demo --target-id codex-abc123 \ + --revision 2 release-check +``` + +常驻 Receiver 默认每五秒执行 reconcile。Codex 写入 `.agents/skills/`,Claude Code 写入 `.claude/skills/`。如果要求 +当前首次会话必定发现刚发布的 Skill,仍在启动 Agent 前显式执行一次 preflight: + +```bash +powercontext skill remote-sync +codex # 或 claude +``` + +管理员可以随时检查 desired/observed 状态、请求安全移除或撤销 credential: + +```bash +powercontext skill remote-status --scope-id project:demo --target-id codex-abc123 +powercontext skill remote-unpublish --scope-id project:demo --target-id codex-abc123 release-check +powercontext skill remote-target-revoke --scope-id project:demo codex-abc123 +``` + +`remote-publish` 和 `remote-unpublish` 只改变 Server desired state;只有后续成功 watch/sync Receipt 才把状态变为 +`current` 或 `unpublished`。Dashboard 自动刷新只读取该持久状态,不把 HTTP 请求成功当成安装成功,也不声称当前 +Agent session 已重新扫描。 + +## Security 与信任边界 + +每个 Package 和 Candidate 都是不受信任内容。PowerContext: + +- 使用有界 safe parser 解析 ZIP 和 YAML,不允许 custom tag; +- 惰性渲染 package text,永远不加载内容中指定的远程资源; +- 不记录 package body、Secret、usage argument 或任意 Source body; +- 不在 Scan、Import、Index、Review、Approval、Publication 或 Compatibility Assessment 中执行脚本; +- 不在 Publication 中安装依赖; +- 不把 `allowed-tools`、compatibility text、runtime requirement 或 risk level 当成 Permission; +- 不会因为调用方知道 digest 就暴露 package; +- 通过 scope 和精确 Source、Candidate 或 Artifact reachability 授权读取; +- 在每次 exact read、diff、download 和 publication 前校验 digest; +- 默认对所有非 loopback 远端连接强制 HTTPS;内部 PoC 例外要求 Server 和 Receiver 双端显式同意,并持续展示明文风险; +- 为每个远端 target 使用独立 credential,只允许读取自己的 desired state、下载其中的精确 Artifact 并回传 Receipt; +- 在 Server 端绑定 Receipt 的 target identity、generation 和 digest,不接受浏览器或 Receiver 指定任意远端路径; +- 延续 RFC 1304 的 Restrictive Browser CSP 与 Safe Rendering Rule。 + +`scope_id` 继续是业务分区,不是 ACL。需要组织级授权的部署必须通过 Server Authentication 和 Policy 执行;本 RFC +不会从 Scope Name 推断 User Permission。 + +## Implementation slices + +实现按五个可独立 dogfood 的本地切片和一个独立验收的远端切片组织;远端实现不改变前五个切片的验收: + +1. **Package foundation**:canonical package validation、`pc_skill_packages`、v1/v2 content union、exact read 和 + SQLite/OceanBase round trip。 +2. **Exact import 与 package Review**:完整 external snapshot、非 LLM import、Fork 语义、file tree、inert preview、 + digest 可见的 successor comparison 和 approval transaction。 +3. **Library 与 lifecycle**:通用 SQLite/OceanBase Artifact FTS Adapter、`pc_artifact_heads` 上的 Lifecycle column + 与 CAS、filter 和 replacement guidance。 +4. **Agent delivery**:environment profile、compatibility assessment、`pc_skill_publications`、精确 Codex/Claude + Code publication、package download、drift detection 和 safe unpublication。 +5. **Observed evolution**:有界 usage Source 和显式触发 successor Candidate。聚合 health view 可在以后作为可重建 + projection 增加,不需要改变 usage evidence。 +6. **Remote target reconcile**:`pc_agent_skill_targets`、`pc_skill_publications` 远端字段迁移、Codex/Claude Code + Plugin Receiver、一次性 enrollment、per-target credential、desired-state reconcile、精确 package Pull、原子 + 安装、Delivery Receipt、离线收敛和 safe remote unpublication。 + +任何切片都不会引入 PowerContext Script Runner。所有切片都保留 Exact Read 和既有 Instruction-only Revision。 +远端能力的发布声明以本节独立验收为准;`installed`、Receipt `current` 与 Agent 当前 session 已发现仍是三个不同事实。 + +## Acceptance + +| 场景 | 通过条件 | +| --- | --- | +| Standard package | 包含 script、reference、asset、license 和 optional metadata 的有效 `SKILL.md` package 可以精确 round-trip | +| Canonical identity | 等价 directory 与 entry 顺序不同的 ZIP 输入得到相同 tree digest | +| Executable mode | Script normalized executable bit 在 capture、storage、download 和 publication 后仍保留 | +| Complete snapshot | 允许的 hidden/nested file 保留;forbidden file 产生带路径的 rejection,而不是静默省略 | +| Archive safety | 拒绝 traversal、duplicate entry、symlink、special file、collision、malformed YAML 和超界 decompression | +| Mutable source | External content 在 capture 期间改变会产生 conflict 且不创建 Candidate | +| Exact import | Import 保留 source tree digest,不需要 LLM,并且只创建 pending Candidate | +| Fork | 原始精确包作为 Source evidence 保留,proposed package 有独立 digest 和可见 diff | +| Approval | 只有 expected pending version 提交一个 immutable package Artifact Revision 和 current search projection | +| Legacy read | 现有 instruction-only Revision 继续精确可读,访问时不触发迁移 | +| Legacy successor | 从 v1 创建 successor 时,在批准前展示显式 one-file v2 package conversion | +| SQLite package store | 最大 canonical ZIP 与 manifest 能通过 SQLite commit、read 和 digest check | +| OceanBase package store | 相同 package 使用 `MEDIUMBLOB` round-trip,list/search query 不加载 ZIP bytes | +| Search | 通用 Artifact FTS 默认只返回 active approved Skill head;精确 name/description query 返回预期结果 | +| External search | External availability 仍是本地状态,不会成为 managed authority | +| Lifecycle | Head Governance CAS 控制 Deprecation 与 Retirement,保留所有 Revision,且不会自动 delete 或 publish | +| Compatibility | 同一 package 针对 Codex 和 Claude Code environment profile 获得独立且带原因的 Assessment | +| No execution | Import、Review、Index、Approval、Compatibility、Publication、Unpublication 都不执行 package script | +| Publication | Codex 和 Claude Code target 获得同一 approved package tree,且不注入额外 package file | +| Safe update | 只有 identity/digest 匹配且完整的 managed destination 能被替换 | +| Safe unpublication | 只移除完整 managed destination;drift 或 foreign content 保持不变 | +| Initial schema | 前五个本地切片只新增 `pc_skill_packages` 和 `pc_skill_publications`;远端切片另新增 `pc_agent_skill_targets` 并迁移 publication 字段;SQLite FTS 是可重建的替换投影 | +| Usage truth | Selected、invoked、validation 和 outcome 保持独立,并保留 unknown observation | +| Evolution | Usage evidence 可以针对精确 Revision 创建 pending successor,但不能修改或批准它 | +| Scope | Package、Library、Lifecycle、Publication、Usage 和 Download operation 不能跨 caller scope | +| Browser trust | 在真实 Chromium 中 Candidate 和 package content 保持惰性,包括恶意 Markdown、SVG 和 filename | +| Packaging | Package Review 和 Library 所需 Server template/static asset 被包含在 wheel 中 | +| Local defaults | 未配置高级 target 时,本机 Codex 与 Claude Code 分别解析 workspace 下的 `.agents/skills/` 与 `.claude/skills/`;目录在用户确认安装前不创建 | + +实现必须运行 `make check`、`make test`、`make docs-test`,API 变化还要运行 `make contract-test`。还必须验证真实 +SQLite Server flow、OceanBase package round trip、真实 Codex 和 Claude Code package discovery,以及 Browser flow: +Exact Import、File Inspection、Approval、Search、Publication、Drift、Unpublication、双语、Keyboard Operation 和 +窄屏布局。 + +### 远端分发切片验收 + +已实现的第六个切片必须满足下列条件,且不能以前五个切片的本地测试替代: + +| 场景 | 通过条件 | +| --- | --- | +| Enrollment | 一次性 code 只能激活指定 pending target;重放、重复 installation 或跨 scope 使用被拒绝 | +| Remote schema | 新增 `pc_agent_skill_targets`,并迁移 `pc_skill_publications`;不新增任务队列表或 Receipt 历史表 | +| Schema backfill | SQLite 与 OceanBase 对现有 desired state、observed generation/time、destination 和 error field 得到相同结果 | +| Target uniqueness | 同一 installation、enrollment token 或 credential subject 不能绑定多个 active target,revoked credential 不能继续调用 | +| No full remote Server | 远端仅安装 Plugin/Integration Receiver,不需要 PowerContext Server 或数据库 | +| Agent roots | Codex 与 Claude Code Adapter 分别在远端本机解析 `.agents/skills/` 和 `.claude/skills/`,Server 不接收绝对路径 | +| Exact delivery | Receiver 下载 desired ArtifactRef 的 canonical package,并在安装前后校验 archive/tree digest | +| Atomic install | 中断、磁盘错误或校验失败只留下可清理 staging,不暴露半个 package,也不覆盖完整旧版本 | +| Offline convergence | target 离线期间多次 Update 后,下一次 reconcile 直接收敛到最新 generation,不回放过期 Revision | +| Receipt truth | 只有 credential、target、generation、ArtifactRef 和 digest 全部匹配的 Receipt 才能产生 `current` | +| Idempotency | 重复 reconcile、download 和 Receipt 不产生重复目录、重复 binding 或状态回退 | +| Lost receipt recovery | 安装成功但 Receipt 丢失后,Receiver 通过本地 checkpoint 对同一 desired package 幂等补报,不重写或冲突 | +| Failed delivery retry | 失败 Receipt 保留最后成功观测,并以同 generation 有界重试;只有新 intent 推进 generation | +| Safe remote update | 本地 target tree 漂移时不替换内容,并回传 `drifted` 或 `conflict` | +| Safe remote unpublication | Receipt 丢失后仍只删除 authenticated checkpoint 与实际 tree 匹配的完整 managed package;foreign content 保持不变 | +| Transport isolation | 非 loopback 明文 HTTP 默认被拒绝,仅在 Server 与 Receiver 双端显式同意后放行;一个 target credential 不能读取或确认另一个 target 的状态 | +| Discovery boundary | 测试明确区分 installed、当前 session 已发现和下一 session 可发现,不作虚假成功声明 | +| No execution | Reconcile、安装和 Receipt 全流程不执行脚本、不安装依赖、不扩大 Agent permission | + +# Drawbacks + +- 完整 Package Governance 比 Instruction-only Record 增加 ZIP Parsing、BLOB Persistence、File-level Review 和更多 + Failure State。 +- 通用 Lifecycle Column 扩展了 `pc_artifact_heads`,SQLite 还必须把 Experience-only FTS 重建为识别 Family 的 + Artifact Projection。 +- 在当前边界下 Database BLOB 简单且具备事务性,但它不是 Large Package 或高频 Remote Distribution 的最终方案。 +- 通用 Standard Baseline 可能拒绝某个 Agent 的宽松 Parser 能接受的 Package。 +- Static Validation 无法证明 Script 安全或有用,而更强的 Sandbox Execution 被有意排除在范围外。 +- Lifecycle、Publication、Compatibility 和 Usage 是独立维度,会增加 UI 和 API 复杂度。 +- 远端期望/观测状态、credential lifecycle 和 eventual convergence 会增加本地发布没有的运维与故障状态。 +- Exact Import 可能保留冗余或低质量文件;正确处理方式是可见 Review 或 Fork,而不是静默规范化。 +- 在 Agent Integration 能区分真实 Invocation 与 Retrieval/Mention 之前,Usage Evidence 会不完整。 + +# Rationale and alternatives + +| 备选方案 | 决策 | +| --- | --- | +| 保持 Managed Skill 为 instruction-only | 拒绝;无法保留或 Review 常规 Agent Skill package | +| 把 ZIP bytes 直接放入通用 Artifact JSON | 拒绝;Base64 放大 payload,并使通用 Artifact read 与 package transfer 耦合 | +| 只保存 filesystem path | 拒绝;Path 是 host-local 且可变,无法支持 immutable Review 或 distribution | +| 初始就为每个 package file 保存一行 | 拒绝;当前 4 MiB package 使用单个 transactional canonical ZIP 加 manifest,schema 与 I/O 更简单 | +| 新增独立 `pc_skill_governance` 表 | 拒绝;Lifecycle 治理当前逻辑 Artifact,适合放在现有权威 Head row,并保持独立 CAS | +| 只在本地文件系统保存 Publication Ownership | 拒绝;Safe Unpublication、Target Removal、多 Server Instance 和未来 Remote Delivery 都需要持久 Target Binding | +| 立即使用 object store | 延后;`SkillPackageStore` abstraction 保留该路径,不需要现在新增部署依赖 | +| Exact Import 时让 LLM 重新生成 Instructions | 拒绝;会丢失 package bytes 并改变 authority;模型辅助修改应定义为 Fork | +| 把 PowerContext metadata 放进每个 published package | 拒绝;Publication 必须保留 approved standard package tree | +| 为 Codex 和 Claude Code 生成不同 approved package | 拒绝;Target Adapter 应报告 Compatibility 和 Location,而不是制造未 Review 的 Content Variant | +| Publication 时自动安装 Dependency | 拒绝;Publication 不是 Execution 或 Environment Mutation Authority | +| 由 Server 通过 SSH、SCP 或远程文件系统 Push | 拒绝;扩大 Server 权限和网络可达面,且无法安全处理离线、NAT 与本地 drift | +| 使用一次性任务队列投递远端包 | 拒绝;离线 target 容易丢动作或回放旧动作;desired-state reconcile 天然幂等并收敛到最新状态 | +| 每发布一个 Skill 就发布新版 Plugin | 拒绝;Plugin 只作为稳定 bootstrap,受管 Skill 必须作为精确 package data 独立更新 | +| 在每次 User Prompt 前同步 | 拒绝;增加请求延迟和噪音;常驻 watch 在 prompt 路径之外同步,启动前置只保障首次会话发现 | +| 发布所有 Active Library Skill | 拒绝;Library Inventory 与 Agent Working Set 具有不同规模和意图 | +| 自动退役未使用或低成功率 Skill | 拒绝;Observation Coverage 和 Attribution 不完整,Count 不能替代 Review | +| 在本 RFC 中实现 Script Runner | 拒绝;Package Governance 与 Host Policy 已能闭合可用本地流程,无需再发明执行平台 | + +如果不采用完整 Package Model,External Import 将继续丢失内容,Review Surface 会与 Agent 实际使用的内容脱节, +Script、Asset、Compatibility 和 Usage Governance 也无法得到忠实表达。 + +# Prior art + +- [Agent Skills specification](https://github.com/agentskills/agentskills/blob/main/docs/specification.mdx) 定义了包含 + `SKILL.md` 以及可选 script、reference、asset 和 metadata 的 package。本 RFC 把该 package 作为 portable content, + 同时将 PowerContext governance 留在标准 authority boundary 之外。 +- [OpenAI Skills API](https://developers.openai.com/api/reference/go/resources/skills) 使用可下载 ZIP bundle 和 immutable + Skill version。本 RFC 同样分离 logical Skill identity、immutable content version 和 package distribution。 +- Skillsgate 校验标准 frontmatter、应用 package size limit、映射多个 Agent installation target 并复制 directory + package。PowerContext 借鉴 package/target separation,但不会静默排除文件,也不会把 Installation 当成 Execution。 +- RFC 0051 定义 External/Managed Content Authority、精确本地 fingerprint、Candidate Evolution 和 Execution Boundary。 + 本 RFC 提供它有意延后的 Managed Package Format。 +- RFC 1304 定义 Typed Review、显式 Publication、Safe Update 和 Browser Trust Boundary。本 RFC 将这些 Contract 从 + 两个生成文件扩展到精确 approved package,并增加 Safe Unpublication。 +- 现有 Memory 与 Experience Index 展示 Authoritative Row 加可重建 SQLite/OceanBase Search Projection 的模式。 + Skill Search 复用该分离方式,而不是索引 ZIP bytes。 + +# Unresolved questions + +没有未决问题阻塞本 RFC。实现必须在发布前确认文档化的 canonical ZIP test vector 在所有受支持 Python 版本上 +得到一致结果。 + +下列决策被有意排除在本 RFC 之外: + +- 具体 credential provider、短期 token exchange、设备证明、轮换和吊销的组织级实现; +- Object-store Selection 和 Package GC Retention; +- 组织级 Owner、Reviewer Identity、RBAC,以及 Privileged Package 的双人批准; +- Package Signature、Transparency Log、Vulnerability Database 和 Marketplace Trust Level; +- 通用 Code Search、Embedding、Hybrid Ranking、Automatic Recommendation 和 Just-in-time Mounting; +- Dependency Environment Creation、OCI Execution 和 PowerContext-owned Sandbox Runner; +- 是否使用另一个 Artifact Family 表示可复用 Procedure 或 Workflow 语义。 + +# Future possibilities + +自然扩展包括: + +- 在常驻 Pull reconcile 已被真实验证后,用 SSE 或 WebSocket 只做低延迟唤醒,并增加 Fleet Policy、灰度发布和 + 批量 target 视图; +- 在 per-target credential contract 之上增加短期 token exchange、自动轮换、设备证明或 mTLS; +- 在保留数据库 metadata 和 tree digest 的前提下增加 Object-backed `SkillPackageStore`; +- Signed Package Manifest 和 Organization Trust Policy; +- 具有精确 package/chunk provenance 的 Path-level Code Search 和 Semantic Search; +- 在检索质量得到验证后增加 Per-project Enabled Set 和 Temporary Task-scoped Mounting; +- 根据 package、lock file、runtime variant、platform 和 environment fingerprint 建立隔离 Dependency Cache; +- 单独 Review 的 Sandboxed `SkillRun` Contract,包含 Read-only Package Mount、Explicit Grant、Resource Limit 和有界 + Evidence; +- 针对 Unused、Failing、Drifted、Incompatible、Unowned 或 Upstream-outdated Skill 的 Governance Dashboard。 + +这些扩展必须保留核心 Contract:Approved Package Revision 是不可变内容;Environment、Publication 和 Execution +Authority 是包外的显式 Binding;Observed Outcome 可以提出变更,但不能静默改写受治理历史。 diff --git a/e2e/bub/uv.lock b/e2e/bub/uv.lock index 435a3197c..2b9705635 100644 --- a/e2e/bub/uv.lock +++ b/e2e/bub/uv.lock @@ -1610,7 +1610,9 @@ dependencies = [ client = [ { name = "httpx", extra = ["socks"] }, { name = "opentelemetry-api" }, + { name = "packaging" }, { name = "pydantic-settings" }, + { name = "pyyaml" }, ] [package.metadata] @@ -1632,6 +1634,11 @@ requires-dist = [ { name = "opentelemetry-api", marker = "extra == 'server'", specifier = ">=1.30,<2" }, { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'tracing-otlp'", specifier = ">=1.30,<2" }, { name = "opentelemetry-sdk", marker = "extra == 'server'", specifier = ">=1.30,<2" }, + { name = "packaging", marker = "extra == 'builtin'", specifier = ">=24,<27" }, + { name = "packaging", marker = "extra == 'cli'", specifier = ">=24,<27" }, + { name = "packaging", marker = "extra == 'client'", specifier = ">=24,<27" }, + { name = "packaging", marker = "extra == 'seekdb'", specifier = ">=24,<27" }, + { name = "packaging", marker = "extra == 'server'", specifier = ">=24,<27" }, { name = "platformdirs", marker = "extra == 'cli'", specifier = ">=4,<5" }, { name = "platformdirs", marker = "extra == 'server'", specifier = ">=4,<5" }, { name = "prometheus-client", marker = "extra == 'server'", specifier = ">=0.21,<1" }, @@ -1648,6 +1655,11 @@ requires-dist = [ { name = "pyobvector", marker = "extra == 'builtin'", specifier = ">=0.2.28,<0.3" }, { name = "pyobvector", marker = "extra == 'seekdb'", specifier = ">=0.2.28,<0.3" }, { name = "pyobvector", marker = "extra == 'server'", specifier = ">=0.2.28,<0.3" }, + { name = "pyyaml", marker = "extra == 'builtin'", specifier = ">=6,<7" }, + { name = "pyyaml", marker = "extra == 'cli'", specifier = ">=6,<7" }, + { name = "pyyaml", marker = "extra == 'client'", specifier = ">=6,<7" }, + { name = "pyyaml", marker = "extra == 'seekdb'", specifier = ">=6,<7" }, + { name = "pyyaml", marker = "extra == 'server'", specifier = ">=6,<7" }, { name = "rfc8785", specifier = ">=0.1.4,<1" }, { name = "scalar-fastapi", marker = "extra == 'server'", specifier = ">=1.8.2,<2" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'builtin'", specifier = ">=2,<3" }, diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index 9c977a528..a045db7da 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -249,6 +249,102 @@ const OPERATIONS = { location: "body", scope: true }, + list_managed_skills: { + method: "POST", + path: "/v1/skill/library", + location: "body", + scope: true + }, + update_skill_lifecycle: { + method: "POST", + path: "/v1/skill/lifecycle", + location: "body", + scope: true + }, + get_skill_package_manifest: { + method: "POST", + path: "/v1/skill/package/manifest", + location: "body", + scope: true + }, + download_skill_package: { + method: "POST", + path: "/v1/skill/package/download", + location: "body", + scope: true + }, + propose_skill_package: { + method: "POST", + path: "/v1/skill/package/propose", + location: "body", + scope: true + }, + record_skill_usage: { + method: "POST", + path: "/v1/skill/usage", + location: "body", + scope: true + }, + list_remote_skill_targets: { + method: "POST", + path: "/v1/skill/remote/targets", + location: "body", + scope: true + }, + create_remote_skill_target: { + method: "POST", + path: "/v1/skill/remote/target/create", + location: "body", + scope: true + }, + enroll_remote_skill_target: { + method: "POST", + path: "/v1/skill/remote/target/enroll", + location: "body", + scope: false + }, + rename_remote_skill_target: { + method: "POST", + path: "/v1/skill/remote/target/rename", + location: "body", + scope: true + }, + revoke_remote_skill_target: { + method: "POST", + path: "/v1/skill/remote/target/revoke", + location: "body", + scope: true + }, + publish_remote_skill: { + method: "POST", + path: "/v1/skill/remote/publication/publish", + location: "body", + scope: true + }, + unpublish_remote_skill: { + method: "POST", + path: "/v1/skill/remote/publication/unpublish", + location: "body", + scope: true + }, + reconcile_remote_skills: { + method: "POST", + path: "/v1/skill/remote/reconcile", + location: "body", + scope: false + }, + download_remote_skill_package: { + method: "POST", + path: "/v1/skill/remote/package/download", + location: "body", + scope: false + }, + record_remote_skill_receipt: { + method: "POST", + path: "/v1/skill/remote/receipt", + location: "body", + scope: false + }, scan_external_skills: { method: "POST", path: "/v1/external-skills/scan", diff --git a/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml b/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml index a38ba8922..b4a40cfa3 100644 --- a/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml +++ b/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml @@ -887,28 +887,28 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/external-skills/scan: + /v1/skill/library: post: tags: [skill] - summary: Scan configured external Skill roots - description: Replace the current host-local Registry projection without copying or rewriting package content. - operationId: scan_external_skills + summary: List or search current managed Skills + description: Return current managed Skill heads with lifecycle governance; retired Skills remain exact-read only. + operationId: list_managed_skills requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ScanExternalSkillsRequest" + $ref: "#/components/schemas/ListManagedSkillsRequest" responses: "200": - description: The rebuildable provider snapshot. + description: Current managed Skill Library rows. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" content: application/json: schema: - $ref: "#/components/schemas/ScanExternalSkillsResponse" + $ref: "#/components/schemas/ListManagedSkillsResponse" "401": $ref: "#/components/responses/Unauthorized" "422": @@ -917,28 +917,32 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/external-skills/list: + /v1/skill/lifecycle: post: tags: [skill] - summary: List external Skills visible on this host - description: Return live local resolutions; unavailable registrations are omitted unless explicitly requested. - operationId: list_external_skills + summary: Update managed Skill lifecycle + description: Apply an explicit lifecycle transition using governance generation CAS without changing package bytes. + operationId: update_skill_lifecycle requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ListExternalSkillsRequest" + $ref: "#/components/schemas/UpdateSkillLifecycleRequest" responses: "200": - description: External Skills resolved against the current Agent, host, scope, and fingerprint. + description: Updated managed Skill governance. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" content: application/json: schema: - $ref: "#/components/schemas/ListExternalSkillsResponse" + $ref: "#/components/schemas/SkillGovernance" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" "422": @@ -947,28 +951,25 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/external-skills/resolve: + /v1/skill/package/manifest: post: tags: [skill] - summary: Resolve an exact external Skill fingerprint - description: Resolve only the registered local package version requested by the caller; never install or fall back. - operationId: resolve_external_skill + summary: Get an exact managed Skill package manifest + description: Return verified metadata and file inventory without executing or returning file bodies. + operationId: get_skill_package_manifest requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ResolveExternalSkillRequest" + $ref: "#/components/schemas/GetSkillPackageRequest" responses: "200": - description: The live exact-resolution result, which may be unavailable. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" + description: Verified exact package manifest. content: application/json: schema: - $ref: "#/components/schemas/ExternalSkillResolution" + $ref: "#/components/schemas/SkillPackageManifest" "404": $ref: "#/components/responses/NotFound" "401": @@ -979,32 +980,27 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/external-skills/import: + /v1/skill/package/download: post: tags: [skill] - summary: Import or fork an external Skill into Review - description: Capture one exact local snapshot and use the configured model to propose a new managed Skill Candidate. - operationId: import_external_skill + summary: Download an exact managed Skill package + description: Return canonical ZIP bytes as bounded base64 with their content-addressed reference. + operationId: download_skill_package requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ImportExternalSkillRequest" + $ref: "#/components/schemas/GetSkillPackageRequest" responses: "200": - description: A pending managed Skill Candidate or an explicit semantic no-op. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" + description: Canonical exact package archive. content: application/json: schema: - $ref: "#/components/schemas/GeneratedCandidateResponse" + $ref: "#/components/schemas/SkillPackageDownload" "404": $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" "422": @@ -1013,28 +1009,27 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/artifact-candidates/list: + /v1/skill/package/propose: post: - tags: [review] - summary: List Artifact Candidates - description: Page current Candidate heads; pending is the default Review Inbox view. - operationId: list_artifact_candidates + tags: [skill] + summary: Propose an uploaded standard Skill package + description: Canonicalize exact ZIP bytes, store them once, and create a pending Candidate without LLM rewriting. + operationId: propose_skill_package requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ListArtifactCandidatesRequest" + $ref: "#/components/schemas/ProposeSkillPackageRequest" responses: - "200": - description: The selected current Candidate heads. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" + "201": + description: Pending exact package Candidate. content: application/json: schema: - $ref: "#/components/schemas/ArtifactCandidatePage" + $ref: "#/components/schemas/ArtifactCandidate" + "409": + $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" "422": @@ -1043,30 +1038,29 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/artifact-candidates/get: + /v1/skill/usage: post: - tags: [review] - summary: Get an Artifact Candidate - description: Read the current head and exact immutable proposal version. - operationId: get_artifact_candidate + tags: [skill] + summary: Record a bounded Skill usage observation + description: Validate an exact managed Skill Revision and capture immutable bounded usage Source evidence. + operationId: record_skill_usage requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/GetArtifactCandidateRequest" + $ref: "#/components/schemas/RecordSkillUsageRequest" responses: - "200": - description: The current Candidate head. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" + "201": + description: Accepted immutable usage Source evidence. content: application/json: schema: - $ref: "#/components/schemas/ArtifactCandidate" + $ref: "#/components/schemas/CaptureContentSourceResponse" "404": $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" "422": @@ -1075,32 +1069,25 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/artifact-candidates/approve: + /v1/skill/remote/targets: post: - tags: [review] - summary: Approve an Artifact Candidate - description: Commit the reviewed proposal and mark the Candidate approved in one transaction. - operationId: approve_artifact_candidate + tags: [skill] + summary: List remote Agent Skill target status + description: Return credential-free target metadata and desired/observed publication state for one scope. + operationId: list_remote_skill_targets requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ApproveArtifactCandidateRequest" + $ref: "#/components/schemas/ListRemoteSkillTargetsRequest" responses: "200": - description: The approved Candidate and exact result Artifact. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" + description: Remote target status rows visible to the administrative caller. content: application/json: schema: - $ref: "#/components/schemas/ArtifactCandidate" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/Conflict" + $ref: "#/components/schemas/ListRemoteSkillTargetsResponse" "401": $ref: "#/components/responses/Unauthorized" "422": @@ -1109,30 +1096,25 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/artifact-candidates/reject: + /v1/skill/remote/target/create: post: - tags: [review] - summary: Reject an Artifact Candidate - description: Move the exact pending version to its rejected terminal state without writing an Artifact. - operationId: reject_artifact_candidate + tags: [skill] + summary: Create a remote Agent Skill target enrollment + description: Create a pending project target and return one short-lived enrollment code exactly once. + operationId: create_remote_skill_target requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/RejectArtifactCandidateRequest" + $ref: "#/components/schemas/CreateRemoteSkillTargetRequest" responses: - "200": - description: The rejected Candidate. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" + "201": + description: Pending remote target enrollment. content: application/json: schema: - $ref: "#/components/schemas/ArtifactCandidate" - "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/schemas/RemoteSkillTargetEnrollment" "409": $ref: "#/components/responses/Conflict" "401": @@ -1143,392 +1125,353 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/artifact-candidates/revise: + /v1/skill/remote/target/enroll: post: - tags: [review] - summary: Revise an Artifact Candidate - description: Append a complete replacement proposal as the next immutable pending version. - operationId: revise_artifact_candidate + security: [] + tags: [skill] + summary: Enroll a remote Agent Skill Receiver + description: Consume one short-lived enrollment code and return a per-target credential exactly once. + operationId: enroll_remote_skill_target requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ReviseArtifactCandidateRequest" + $ref: "#/components/schemas/EnrollRemoteSkillTargetRequest" responses: "200": - description: The next pending Candidate version. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" + description: Activated remote target credential. content: application/json: schema: - $ref: "#/components/schemas/ArtifactCandidate" - "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/schemas/RemoteSkillTargetCredential" "409": $ref: "#/components/responses/Conflict" - "401": - $ref: "#/components/responses/Unauthorized" "422": $ref: "#/components/responses/InvalidRequest" - "503": - $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/stats: - get: - tags: [stats] - summary: Get scoped product statistics - 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" + /v1/skill/remote/target/rename: + post: + tags: [skill] + summary: Rename a remote Agent Skill target + description: Change the human-readable target name with target generation CAS while retaining its durable identity. + operationId: rename_remote_skill_target + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RenameRemoteSkillTargetRequest" responses: "200": - description: Current inventory, model usage, and recall token estimates for the scope. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - Cache-Control: - description: Prevent caches from retaining scoped statistics. - schema: - type: string - enum: [no-store] + description: Renamed remote target. content: application/json: schema: - $ref: "#/components/schemas/ScopedStats" + $ref: "#/components/schemas/RemoteSkillTarget" "401": $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" "422": $ref: "#/components/responses/InvalidRequest" - "503": - $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/projects/create: + /v1/skill/remote/target/revoke: post: - tags: [handoff-reports] - summary: Create a Handoff Report Project - operationId: create_handoff_report_project + tags: [skill] + summary: Revoke a remote Agent Skill target + description: Revoke the per-target credential with target generation CAS while retaining durable identity. + operationId: revoke_remote_skill_target requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/CreateHandoffReportProjectRequest" + $ref: "#/components/schemas/RevokeRemoteSkillTargetRequest" responses: - "201": - description: The created Report Project. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" + "200": + description: Revoked remote target. content: application/json: schema: - $ref: "#/components/schemas/ProjectDescriptor" - "409": - $ref: "#/components/responses/Conflict" + $ref: "#/components/schemas/RemoteSkillTarget" "401": $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" "422": $ref: "#/components/responses/InvalidRequest" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/projects/list: + /v1/skill/remote/publication/publish: post: - tags: [handoff-reports] - summary: List Handoff Report Projects - operationId: list_handoff_report_projects + tags: [skill] + summary: Set a remote target Skill desired Revision + description: Advance only Server-owned desired state; delivery is confirmed later by an exact Receipt. + operationId: publish_remote_skill requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ListHandoffReportProjectsRequest" + $ref: "#/components/schemas/PublishRemoteSkillRequest" responses: "200": - description: A cursor-paginated page of Report Projects. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" + description: Latest remote publication desired state. content: application/json: schema: - $ref: "#/components/schemas/ProjectPage" + $ref: "#/components/schemas/RemoteSkillPublication" "401": $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" "422": $ref: "#/components/responses/InvalidRequest" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/scopes/list-known: + /v1/skill/remote/publication/unpublish: post: - tags: [handoff-reports] - summary: List scopes that contain a committed Handoff - operationId: list_handoff_report_known_scopes + tags: [skill] + summary: Set remote target Skill desired absence + description: Advance desired state without claiming that any remote directory has already been removed. + operationId: unpublish_remote_skill requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ListHandoffReportKnownScopesRequest" + $ref: "#/components/schemas/UnpublishRemoteSkillRequest" 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: Latest remote publication desired state. content: application/json: schema: - $ref: "#/components/schemas/KnownHandoffScopePage" + $ref: "#/components/schemas/RemoteSkillPublication" "401": $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" "422": $ref: "#/components/responses/InvalidRequest" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/projects/get: + /v1/skill/remote/reconcile: post: - tags: [handoff-reports] - summary: Get a Handoff Report Project - operationId: get_handoff_report_project + security: + - TargetBearerAuth: [] + tags: [skill] + summary: Reconcile a remote Agent Skill target + description: Authenticate one target and return only latest-generation idempotent install or unpublish actions. + operationId: reconcile_remote_skills requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/GetHandoffReportProjectRequest" + $ref: "#/components/schemas/ReconcileRemoteSkillsRequest" responses: "200": - description: The exact current Report Project descriptor. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" + description: Latest desired-state actions for this target only. content: application/json: schema: - $ref: "#/components/schemas/ProjectDescriptor" - "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/schemas/ReconcileRemoteSkillsResponse" "401": $ref: "#/components/responses/Unauthorized" + "409": + $ref: "#/components/responses/Conflict" "422": $ref: "#/components/responses/InvalidRequest" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/projects/update: + /v1/skill/remote/package/download: post: - tags: [handoff-reports] - summary: Update a Handoff Report Project - operationId: update_handoff_report_project + security: + - TargetBearerAuth: [] + tags: [skill] + summary: Download the exact package desired by a remote target + description: Return canonical ZIP bytes only when target, generation, Artifact Revision, and package reference all match. + operationId: download_remote_skill_package requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/UpdateHandoffReportProjectRequest" + $ref: "#/components/schemas/DownloadRemoteSkillPackageRequest" responses: "200": - description: The updated Report Project descriptor. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" + description: Canonical exact package archive. content: application/json: schema: - $ref: "#/components/schemas/ProjectDescriptor" + $ref: "#/components/schemas/SkillPackageDownload" + "401": + $ref: "#/components/responses/Unauthorized" "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: + /v1/skill/remote/receipt: post: - tags: [handoff-reports] - summary: Register a Handoff Report Workstream - operationId: register_handoff_report_workstream + security: + - TargetBearerAuth: [] + tags: [skill] + summary: Record an exact remote Skill delivery Receipt + description: Update latest observed state only after credential, generation, Artifact, operation, and digest validation. + operationId: record_remote_skill_receipt requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/RegisterHandoffReportWorkstreamRequest" + $ref: "#/components/schemas/RecordRemoteSkillReceiptRequest" responses: - "201": - description: The registered Report Workstream. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" + "200": + description: Receipt acceptance and latest publication observation. content: application/json: schema: - $ref: "#/components/schemas/WorkstreamDescriptor" + $ref: "#/components/schemas/RemoteSkillReceiptResponse" + "401": + $ref: "#/components/responses/Unauthorized" "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: + /v1/external-skills/scan: post: - tags: [handoff-reports] - summary: List Handoff Report Workstreams - operationId: list_handoff_report_workstreams + tags: [skill] + summary: Scan configured external Skill roots + description: Replace the current host-local Registry projection without copying or rewriting package content. + operationId: scan_external_skills requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ListHandoffReportWorkstreamsRequest" + $ref: "#/components/schemas/ScanExternalSkillsRequest" responses: "200": - description: A cursor-paginated page of Report Workstreams. + description: The rebuildable provider snapshot. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" content: application/json: schema: - $ref: "#/components/schemas/WorkstreamPage" - "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/schemas/ScanExternalSkillsResponse" "401": $ref: "#/components/responses/Unauthorized" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workstreams/update: + /v1/external-skills/list: post: - tags: [handoff-reports] - summary: Update a Handoff Report Workstream - operationId: update_handoff_report_workstream + tags: [skill] + summary: List external Skills visible on this host + description: Return live local resolutions; unavailable registrations are omitted unless explicitly requested. + operationId: list_external_skills requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/UpdateHandoffReportWorkstreamRequest" + $ref: "#/components/schemas/ListExternalSkillsRequest" responses: "200": - description: The updated Report Workstream descriptor. + description: External Skills resolved against the current Agent, host, scope, and fingerprint. 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" + $ref: "#/components/schemas/ListExternalSkillsResponse" "401": $ref: "#/components/responses/Unauthorized" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/get: + /v1/external-skills/resolve: post: - tags: [handoff-reports] - summary: Generate a Handoff Report - operationId: get_handoff_report + tags: [skill] + summary: Resolve an exact external Skill fingerprint + description: Resolve only the registered local package version requested by the caller; never install or fall back. + operationId: resolve_external_skill requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/GetHandoffReportRequest" + $ref: "#/components/schemas/ResolveExternalSkillRequest" responses: "200": - description: A canonical JSON report, optionally accompanied by Markdown. + description: The live exact-resolution result, which may be unavailable. 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 + $ref: "#/components/schemas/ExternalSkillResolution" "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: + /v1/external-skills/import: post: - tags: [handoff-reports] - summary: Record a Handoff Report Activity - operationId: record_handoff_report_activity + tags: [skill] + summary: Import or fork an external Skill into Review + description: Capture one exact local snapshot and use the configured model to propose a new managed Skill Candidate. + operationId: import_external_skill requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/RecordHandoffReportActivityRequest" + $ref: "#/components/schemas/ImportExternalSkillRequest" responses: - "201": - description: The idempotently recorded Report Activity. + "200": + description: A pending managed Skill Candidate or an explicit semantic no-op. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" content: application/json: schema: - $ref: "#/components/schemas/StoredHandoffReportActivity" + $ref: "#/components/schemas/GeneratedCandidateResponse" "404": $ref: "#/components/responses/NotFound" "409": @@ -1537,116 +1480,128 @@ paths: $ref: "#/components/responses/Unauthorized" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/activities/list: + /v1/artifact-candidates/list: post: - tags: [handoff-reports] - summary: List Handoff Report Activities - operationId: list_handoff_report_activities + tags: [review] + summary: List Artifact Candidates + description: Page current Candidate heads; pending is the default Review Inbox view. + operationId: list_artifact_candidates requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ListHandoffReportActivitiesRequest" + $ref: "#/components/schemas/ListArtifactCandidatesRequest" responses: "200": - description: A frozen cursor page of Report Activities. + description: The selected current Candidate heads. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" content: application/json: schema: - $ref: "#/components/schemas/HandoffReportActivityPage" - "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/schemas/ArtifactCandidatePage" "401": $ref: "#/components/responses/Unauthorized" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/activities/purge: + /v1/artifact-candidates/get: post: - tags: [handoff-reports] - summary: Purge Handoff Report Activities - operationId: purge_handoff_report_activities + tags: [review] + summary: Get an Artifact Candidate + description: Read the current head and exact immutable proposal version. + operationId: get_artifact_candidate requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/PurgeHandoffReportActivitiesRequest" + $ref: "#/components/schemas/GetArtifactCandidateRequest" responses: "200": - description: The number of deleted Report-owned Activity rows. + description: The current Candidate head. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" content: application/json: schema: - $ref: "#/components/schemas/PurgeHandoffReportActivitiesResponse" + $ref: "#/components/schemas/ArtifactCandidate" "404": $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workspace-bindings/get: + /v1/artifact-candidates/approve: post: - tags: [handoff-reports] - summary: Get a Handoff Report Workspace Binding - operationId: get_handoff_report_workspace + tags: [review] + summary: Approve an Artifact Candidate + description: Commit the reviewed proposal and mark the Candidate approved in one transaction. + operationId: approve_artifact_candidate requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/GetHandoffReportWorkspaceRequest" + $ref: "#/components/schemas/ApproveArtifactCandidateRequest" responses: "200": - description: The confirmed Workspace binding. + description: The approved Candidate and exact result Artifact. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" content: application/json: schema: - $ref: "#/components/schemas/HandoffReportWorkspaceBinding" + $ref: "#/components/schemas/ArtifactCandidate" "404": $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workspace-bindings/attach: + /v1/artifact-candidates/reject: post: - tags: [handoff-reports] - summary: Attach a Handoff Report Workspace Binding - operationId: attach_handoff_report_workspace + tags: [review] + summary: Reject an Artifact Candidate + description: Move the exact pending version to its rejected terminal state without writing an Artifact. + operationId: reject_artifact_candidate requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/AttachHandoffReportWorkspaceRequest" + $ref: "#/components/schemas/RejectArtifactCandidateRequest" responses: "200": - description: The confirmed Workspace binding. + description: The rejected Candidate. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" content: application/json: schema: - $ref: "#/components/schemas/HandoffReportWorkspaceBinding" + $ref: "#/components/schemas/ArtifactCandidate" "404": $ref: "#/components/responses/NotFound" "409": @@ -1655,29 +1610,32 @@ paths: $ref: "#/components/responses/Unauthorized" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workspace-bindings/detach: + /v1/artifact-candidates/revise: post: - tags: [handoff-reports] - summary: Detach a Handoff Report Workspace Binding - operationId: detach_handoff_report_workspace + tags: [review] + summary: Revise an Artifact Candidate + description: Append a complete replacement proposal as the next immutable pending version. + operationId: revise_artifact_candidate requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/DetachHandoffReportWorkspaceRequest" + $ref: "#/components/schemas/ReviseArtifactCandidateRequest" responses: "200": - description: The detached Workspace binding record. + description: The next pending Candidate version. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" content: application/json: schema: - $ref: "#/components/schemas/HandoffReportWorkspaceBinding" + $ref: "#/components/schemas/ArtifactCandidate" "404": $ref: "#/components/responses/NotFound" "409": @@ -1686,56 +1644,573 @@ paths: $ref: "#/components/responses/Unauthorized" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "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: + /v1/stats: + get: + tags: [stats] + summary: Get scoped product statistics + operationId: get_stats + parameters: + - name: scope_id + in: query + required: true 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: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + - name: period + in: query + required: false schema: - $ref: "#/components/schemas/ErrorResponse" - ReportTooLarge: - description: The selected Handoff Report exceeds the deterministic output limit. + $ref: "#/components/schemas/StatsPeriod" + responses: + "200": + description: Current inventory, model usage, and recall token estimates for the scope. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + Cache-Control: + description: Prevent caches from retaining scoped statistics. + schema: + type: string + enum: [no-store] + content: + application/json: + schema: + $ref: "#/components/schemas/ScopedStats" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $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: + post: + tags: [handoff-reports] + summary: List Handoff Report Projects + operationId: list_handoff_report_projects + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ListHandoffReportProjectsRequest" + 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: + 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. + TargetBearerAuth: + type: http + scheme: bearer + description: Per-target credential issued once during remote Receiver enrollment. + 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" @@ -1774,1311 +2249,2143 @@ components: ActivateHandoffRequest: type: object additionalProperties: false - required: [scope_id, boundary_source, objective] + 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: + 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: + type: array + maxItems: 30 + items: + $ref: "#/components/schemas/ModelUsageDay" + TokenEstimatorProfile: + type: object + additionalProperties: false + required: [estimator_id, version] + properties: + 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 + 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: [date, preparations, ready_preparations, comparable_preparations, baseline_tokens, recalled_tokens, token_reduction] + properties: + date: + 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: + type: object + additionalProperties: false + required: [period, estimator, totals, daily] + 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: + type: object + additionalProperties: false + required: [scope_id, as_of, inventory, usage, recall] + properties: + scope_id: + 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: [scope_id] + 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: + type: string + minLength: 1 + maxLength: 8192 + 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: + type: string + enum: [untrusted_input] + objective: + type: string + minLength: 1 + maxLength: 8192 + 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: + 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: [scope_id, source_id, contract] + properties: + scope_id: + type: string + 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, trust, objective, state, disposition, next_action, omissions] properties: - scope_id: + schema: type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - boundary_source: - $ref: "#/components/schemas/SourceReference" + enum: [powercontext.current-work-handoff.v1] + trust: + type: string + enum: [untrusted_input] objective: type: string minLength: 1 maxLength: 8192 pattern: '.*\S.*' - evidence: + state: type: array - maxItems: 32 + minItems: 1 + maxItems: 64 items: - $ref: "#/components/schemas/HandoffCitation" - default: [] - max_bytes: - type: integer - minimum: 512 - maximum: 32768 - default: 8000 - ArtifactReference: + $ref: "#/components/schemas/WorkClaim" + disposition: + $ref: "#/components/schemas/HandoffDisposition" + next_action: + $ref: "#/components/schemas/WorkClaim" + nullable: true + omissions: + type: array + maxItems: 64 + items: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + HandoffCurrentWorkRequest: type: object additionalProperties: false - required: [family, artifact_id, revision] + required: [scope_id, source_id, handoff] properties: - family: + scope_id: type: string minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - artifact_id: + maxLength: 256 + pattern: '.*\S.*' + source_id: type: string minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - revision: + 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 - ArtifactCandidate: + content_digest: + type: string + minLength: 71 + maxLength: 71 + pattern: '^sha256:[0-9a-f]{64}$' + PreparedWorkHandoff: type: object additionalProperties: false - required: - - candidate_id - - version - - family - - status - - proposal - - source_refs - - artifact_refs - - target - - reason - - result_artifact - - decision_reason + required: [boundary, handoff] properties: - candidate_id: + 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 + 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: + 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: 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: + maxLength: 256 + pattern: '.*\S.*' + source_id: type: string minLength: 1 - maxLength: 2000 + maxLength: 256 + pattern: '.*\S.*' + receiver: + 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 - result_artifact: + prepared: + $ref: "#/components/schemas/PreparedHandoff" + nullable: true + revision: $ref: "#/components/schemas/ArtifactReference" nullable: true - decision_reason: + message: type: string minLength: 1 - maxLength: 2000 + maxLength: 8192 + pattern: '.*\S.*' nullable: true - ArtifactCandidatePage: + HandoffAcknowledgement: type: object additionalProperties: false - required: [candidates, next_cursor] + required: [resolution, receipt] properties: - candidates: - type: array - items: - $ref: "#/components/schemas/ArtifactCandidate" - next_cursor: - type: string - nullable: true - ApproveArtifactCandidateRequest: + 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: [scope_id, candidate_id, expected_version] + required: [name, status, basis, evidence] properties: - scope_id: + name: type: string minLength: 1 - maxLength: 256 + maxLength: 8192 pattern: '.*\S.*' - candidate_id: + status: + $ref: "#/components/schemas/TaskCheckStatus" + details: type: string minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - expected_version: - type: integer - minimum: 1 - Capabilities: + maxLength: 8192 + pattern: '.*\S.*' + nullable: true + basis: + $ref: "#/components/schemas/WorkClaimBasis" + evidence: + type: array + maxItems: 32 + items: + $ref: "#/components/schemas/HandoffCitation" + TaskOutcome: type: object additionalProperties: false - required: - [source_types, artifact_families, memory_extraction, handoff_generation, search_modes, context_versions] + required: [schema, trust, objective, status, summary, observations, checks, produced_artifacts, remaining_work] properties: - source_types: + schema: + type: string + enum: [powercontext.task-outcome.v1] + trust: + type: string + enum: [untrusted_observation] + objective: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + 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: - type: string - artifact_families: + $ref: "#/components/schemas/WorkClaim" + checks: type: array + maxItems: 64 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: + $ref: "#/components/schemas/TaskCheck" + produced_artifacts: type: array + maxItems: 32 items: - $ref: "#/components/schemas/MemorySearchMode" - context_versions: + $ref: "#/components/schemas/ArtifactReference" + remaining_work: type: array + maxItems: 64 items: - $ref: "#/components/schemas/PreparedContextSchema" - FamilyCount: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + RecordTaskOutcomeRequest: type: object additionalProperties: false - required: [family, total] + required: [scope_id, source_id, outcome] properties: - family: + scope_id: 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: + maxLength: 256 + pattern: '.*\S.*' + source_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + outcome: + $ref: "#/components/schemas/TaskOutcome" + CaptureContentSourceRequest: type: object additionalProperties: false - required: [kind, total, active, inactive] + required: [scope_id, source_id, content] properties: - kind: + scope_id: type: string minLength: 1 - maxLength: 128 - total: - type: integer - minimum: 0 - active: - type: integer - minimum: 0 - inactive: - type: integer - minimum: 0 - SourceInventoryStatistics: + maxLength: 256 + pattern: '.*\S.*' + source_id: + type: string + minLength: 1 + maxLength: 256 + content: + type: string + minLength: 1 + maxLength: 200000 + metadata: + type: object + additionalProperties: true + nullable: true + CaptureContentSourceResponse: type: object additionalProperties: false - required: [total, memory_processed, memory_pending] + required: [status, source, position] properties: - total: - type: integer - minimum: 0 - memory_processed: - type: integer - minimum: 0 - memory_pending: + status: + $ref: "#/components/schemas/CaptureStatus" + source: + $ref: "#/components/schemas/SourceReference" + position: type: integer - minimum: 0 - ArtifactInventoryStatistics: + minimum: 1 + CommitHandoffRequest: type: object additionalProperties: false - required: [total, by_family] + required: [scope_id, handoff] properties: - total: - type: integer - minimum: 0 - by_family: - type: array - items: - $ref: "#/components/schemas/FamilyCount" - CandidateInventoryStatistics: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + handoff: + $ref: "#/components/schemas/PreparedHandoff" + CommittedHandoff: type: object additionalProperties: false - required: [total, pending, approved, rejected, by_family] + required: [reference, content, source_refs, artifact_refs] properties: - total: - type: integer - minimum: 0 - pending: - type: integer - minimum: 0 - approved: - type: integer - minimum: 0 - rejected: - type: integer - minimum: 0 - by_family: + reference: + $ref: "#/components/schemas/ArtifactReference" + content: + $ref: "#/components/schemas/HandoffContent" + source_refs: 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: + $ref: "#/components/schemas/SourceReference" + artifact_refs: 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: + $ref: "#/components/schemas/ArtifactReference" + ContinueHandoffRequest: type: object additionalProperties: false - required: [requests, input_tokens, output_tokens] + required: [scope_id, selection] properties: - requests: - type: integer - minimum: 0 - input_tokens: - type: integer - minimum: 0 + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + selection: + $ref: "#/components/schemas/HandoffSelection" + prepared: + $ref: "#/components/schemas/PreparedHandoff" nullable: true - output_tokens: - type: integer - minimum: 0 + revision: + $ref: "#/components/schemas/ArtifactReference" nullable: true - ModelUsageStatistics: - type: object - additionalProperties: false - required: [generation, embedding] - properties: - generation: - $ref: "#/components/schemas/ModelUsageValue" - embedding: - $ref: "#/components/schemas/ModelUsageValue" - ModelUsagePurposeBreakdown: + FinalizeHandoffRequest: type: object additionalProperties: false - required: [purpose, generation, embedding] + required: [scope_id, draft] properties: - purpose: + scope_id: type: string - minLength: 1 - maxLength: 64 - generation: - $ref: "#/components/schemas/ModelUsageValue" - embedding: - $ref: "#/components/schemas/ModelUsageValue" - ModelUsageDay: + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + draft: + $ref: "#/components/schemas/HandoffDraft" + HandoffArtifactCitation: type: object additionalProperties: false - required: [date, generation, embedding, by_purpose] + required: [kind, artifact_ref] properties: - date: + kind: 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: + enum: [artifact] + artifact_ref: + $ref: "#/components/schemas/ArtifactReference" + HandoffActivation: type: object additionalProperties: false - required: [preset, start_date, end_date, timezone] + required: [status, boundary_source, previous_position, current_position, draft] properties: - preset: - $ref: "#/components/schemas/StatsPeriod" - start_date: - type: string - format: date - end_date: - type: string - format: date - timezone: - type: string - enum: [UTC] - UsageStatistics: + status: + $ref: "#/components/schemas/HandoffActivationStatus" + boundary_source: + $ref: "#/components/schemas/SourceReference" + previous_position: + type: integer + 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: [period, totals, by_purpose, daily] + required: [schema, objective, state, disposition, next_action, omissions] properties: - period: - $ref: "#/components/schemas/ResolvedUsagePeriod" - totals: - $ref: "#/components/schemas/ModelUsageStatistics" - by_purpose: + schema: + $ref: "#/components/schemas/HandoffSchema" + objective: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + state: type: array - maxItems: 16 + minItems: 1 + maxItems: 64 items: - $ref: "#/components/schemas/ModelUsagePurposeBreakdown" - daily: + $ref: "#/components/schemas/HandoffStatement" + disposition: + $ref: "#/components/schemas/HandoffDisposition" + next_action: + $ref: "#/components/schemas/HandoffStatement" + nullable: true + omissions: type: array - maxItems: 30 + maxItems: 64 items: - $ref: "#/components/schemas/ModelUsageDay" - TokenEstimatorProfile: + $ref: "#/components/schemas/HandoffOmission" + HandoffDraft: type: object additionalProperties: false - required: [estimator_id, version] + required: [objective, state, disposition, next_action, omissions] properties: - estimator_id: - type: string - minLength: 1 - maxLength: 128 - version: + objective: type: string minLength: 1 - maxLength: 64 - RecallTokenValue: + 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: + type: array + maxItems: 64 + items: + $ref: "#/components/schemas/HandoffOmission" + HandoffEvidenceCheck: type: object additionalProperties: false - required: [preparations, ready_preparations, comparable_preparations, baseline_tokens, recalled_tokens, token_reduction] + required: [claim, state_index, status, unavailable_evidence] 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: + claim: + $ref: "#/components/schemas/HandoffClaim" + state_index: type: integer minimum: 0 - token_reduction: - type: integer - RecallTokenDay: + nullable: true + status: + $ref: "#/components/schemas/HandoffEvidenceStatus" + unavailable_evidence: + type: array + maxItems: 32 + items: + $ref: "#/components/schemas/HandoffCitation" + HandoffMemoryCitation: type: object additionalProperties: false - required: [date, preparations, ready_preparations, comparable_preparations, baseline_tokens, recalled_tokens, token_reduction] + required: [kind, memory_citation] properties: - date: + kind: 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: + enum: [memory] + memory_citation: + $ref: "#/components/schemas/MemoryCitation" + HandoffOmission: type: object additionalProperties: false - required: [period, estimator, totals, daily] + required: [text, citation] properties: - period: - $ref: "#/components/schemas/ResolvedUsagePeriod" - estimator: - $ref: "#/components/schemas/TokenEstimatorProfile" + text: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + citation: + $ref: "#/components/schemas/HandoffCitation" nullable: true - totals: - $ref: "#/components/schemas/RecallTokenValue" - daily: - type: array - maxItems: 30 - items: - $ref: "#/components/schemas/RecallTokenDay" - ScopedStats: + HandoffResolution: type: object additionalProperties: false - required: [scope_id, as_of, inventory, usage, recall] + required: + [trust, status, scope_id, content, selection, selected_revision, current_revision, evidence_checks] properties: - scope_id: + trust: type: string - as_of: + enum: [untrusted_history] + status: + $ref: "#/components/schemas/HandoffResolutionStatus" + scope_id: type: string - format: date-time - inventory: - $ref: "#/components/schemas/InventoryStatistics" - usage: - $ref: "#/components/schemas/UsageStatistics" - recall: - $ref: "#/components/schemas/RecallTokenStatistics" - GetStatsRequest: + 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: + type: array + maxItems: 65 + items: + $ref: "#/components/schemas/HandoffEvidenceCheck" + HandoffSourceCitation: type: object additionalProperties: false - required: [scope_id] + required: [kind, source_ref] properties: - scope_id: + kind: type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - period: - $ref: "#/components/schemas/StatsPeriod" - default: 30d - WorkClaimBasis: - type: string - enum: [declared, verified] - WorkClaim: + enum: [source] + source_ref: + $ref: "#/components/schemas/SourceReference" + HandoffStatement: type: object additionalProperties: false - required: [text, basis, evidence] + required: [text, citations] properties: text: type: string minLength: 1 maxLength: 8192 pattern: '.*\S.*' - basis: - $ref: "#/components/schemas/WorkClaimBasis" - evidence: + citations: type: array - maxItems: 31 + minItems: 1 + maxItems: 32 items: $ref: "#/components/schemas/HandoffCitation" - WorkContract: + PrepareHandoffRequest: type: object additionalProperties: false - required: [schema, trust, objective, facts, in_scope, exclusions, completion_criteria, authorization_notes, open_questions] + required: [scope_id, objective, evidence] properties: - schema: - type: string - enum: [powercontext.work-contract.v1] - trust: + scope_id: type: string - enum: [untrusted_input] + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' objective: type: string minLength: 1 maxLength: 8192 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: + evidence: 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 + maxItems: 32 items: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - CreateWorkContractRequest: + $ref: "#/components/schemas/HandoffCitation" + max_bytes: + type: integer + minimum: 512 + maximum: 32768 + default: 8000 + PreparedHandoff: type: object additionalProperties: false - required: [scope_id, source_id, contract] + required: [schema, scope_id, base, content] properties: + schema: + $ref: "#/components/schemas/PreparedHandoffSchema" scope_id: type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - source_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - contract: - $ref: "#/components/schemas/WorkContract" - CurrentWorkHandoff: + base: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + content: + $ref: "#/components/schemas/HandoffContent" + PreparedContext: type: object additionalProperties: false - required: [schema, trust, objective, state, disposition, next_action, omissions] + required: [schema, status, content, content_bytes] properties: schema: + $ref: "#/components/schemas/PreparedContextSchema" + status: + $ref: "#/components/schemas/PreparedContextStatus" + content: type: string - enum: [powercontext.current-work-handoff.v1] - trust: + nullable: true + 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: + op: + $ref: "#/components/schemas/EntryChangeOperation" + entry_id: type: string - enum: [untrusted_input] - objective: + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + from_entry_version_id: type: string minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - state: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + nullable: true + to_entry_version_id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + nullable: true + reason: + type: string + nullable: true + 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 - minItems: 1 - maxItems: 64 items: - $ref: "#/components/schemas/WorkClaim" - disposition: - $ref: "#/components/schemas/HandoffDisposition" - next_action: - $ref: "#/components/schemas/WorkClaim" - nullable: true - omissions: + $ref: "#/components/schemas/SourceReference" + artifact_refs: type: array - maxItems: 64 items: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - HandoffCurrentWorkRequest: + $ref: "#/components/schemas/ArtifactReference" + ExperienceProposal: type: object additionalProperties: false - required: [scope_id, source_id, handoff] + required: [situation, action, outcome, lesson] properties: - scope_id: + situation: type: string minLength: 1 - maxLength: 256 + maxLength: 8000 pattern: '.*\S.*' - source_id: + action: type: string minLength: 1 - maxLength: 256 + maxLength: 8000 pattern: '.*\S.*' - handoff: - $ref: "#/components/schemas/CurrentWorkHandoff" - WorkSourceKind: + outcome: + type: string + minLength: 1 + maxLength: 8000 + pattern: '.*\S.*' + lesson: + type: string + 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" + SkillLifecycleState: type: string - enum: [work-contract, handoff-boundary, handoff-receipt, task-outcome] - WorkSourceReceipt: + enum: [active, deprecated, retired] + SkillGovernance: type: object additionalProperties: false - required: [kind, source, position, content_digest] + required: [artifact, lifecycle_state, replacement_artifact_id, governance_generation] properties: - kind: - $ref: "#/components/schemas/WorkSourceKind" - source: - $ref: "#/components/schemas/SourceReference" - position: - type: integer - minimum: 1 - content_digest: + artifact: + $ref: "#/components/schemas/ArtifactReference" + lifecycle_state: + $ref: "#/components/schemas/SkillLifecycleState" + replacement_artifact_id: type: string - minLength: 71 - maxLength: 71 - pattern: '^sha256:[0-9a-f]{64}$' - PreparedWorkHandoff: + minLength: 1 + maxLength: 128 + nullable: true + governance_generation: + type: integer + minimum: 0 + ManagedSkillLibraryEntry: type: object additionalProperties: false - required: [boundary, handoff] + required: [artifact, content, source_refs, artifact_refs, governance] 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: + 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" + governance: + $ref: "#/components/schemas/SkillGovernance" + ListManagedSkillsRequest: 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: [scope_id] properties: - live_state: - $ref: "#/components/schemas/LiveStateCheckStatus" - capability: - $ref: "#/components/schemas/ReceiverReadinessCheckStatus" - authorization: - $ref: "#/components/schemas/ReceiverReadinessCheckStatus" - AcknowledgeHandoffRequest: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + query: + type: string + minLength: 1 + maxLength: 2000 + nullable: true + include_deprecated: + type: boolean + default: false + limit: + type: integer + minimum: 1 + maximum: 200 + default: 100 + ListManagedSkillsResponse: type: object additionalProperties: false - required: [scope_id, source_id, receiver, status, selection] + required: [skills] + properties: + skills: + type: array + maxItems: 200 + items: + $ref: "#/components/schemas/ManagedSkillLibraryEntry" + UpdateSkillLifecycleRequest: + type: object + additionalProperties: false + required: [scope_id, artifact_id, expected_generation, lifecycle_state] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - source_id: + artifact_id: type: string minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - receiver: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + expected_generation: + type: integer + minimum: 0 + lifecycle_state: + $ref: "#/components/schemas/SkillLifecycleState" + replacement_artifact_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" + maxLength: 128 + pattern: '^[\x21-\x7E]+$' nullable: true - prepared: - $ref: "#/components/schemas/PreparedHandoff" + SkillProposal: + type: object + additionalProperties: false + required: [name, description, instructions, validation] + properties: + name: + type: string + minLength: 1 + maxLength: 128 + pattern: '^\S(?:.*\S)?$' + description: + type: string + minLength: 1 + maxLength: 2000 + pattern: '^\S(?:.*\S)?$' + instructions: + type: string + maxLength: 131072 + validation: + type: array + maxItems: 32 + items: + $ref: "#/components/schemas/SkillValidationItem" + package: + $ref: "#/components/schemas/SkillPackageReference" nullable: true - revision: - $ref: "#/components/schemas/ArtifactReference" + license: + type: string + minLength: 1 + maxLength: 512 nullable: true - message: + compatibility: type: string minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' + maxLength: 500 nullable: true - HandoffAcknowledgement: + metadata: + type: object + maxProperties: 64 + additionalProperties: + type: string + allowed_tools: + type: string + minLength: 1 + maxLength: 2000 + nullable: true + SkillPackageReference: type: object additionalProperties: false - required: [resolution, receipt] + required: [tree_digest, archive_digest, file_count, uncompressed_size, archive_size] 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: + tree_digest: + type: string + pattern: '^[0-9a-f]{64}$' + archive_digest: + type: string + pattern: '^[0-9a-f]{64}$' + file_count: + type: integer + minimum: 1 + maximum: 256 + uncompressed_size: + type: integer + minimum: 1 + maximum: 4194304 + archive_size: + type: integer + minimum: 1 + maximum: 5242880 + SkillPackageFile: type: object additionalProperties: false - required: [name, status, basis, evidence] + required: [path, digest, size, media_type, executable] properties: - name: + path: type: string minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - status: - $ref: "#/components/schemas/TaskCheckStatus" - details: + maxLength: 512 + digest: + type: string + pattern: '^[0-9a-f]{64}$' + size: + type: integer + minimum: 0 + maximum: 4194304 + media_type: type: string minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - nullable: true - basis: - $ref: "#/components/schemas/WorkClaimBasis" - evidence: - type: array - maxItems: 32 - items: - $ref: "#/components/schemas/HandoffCitation" - TaskOutcome: + maxLength: 255 + executable: + type: boolean + SkillPackageManifest: type: object additionalProperties: false - required: [schema, trust, objective, status, summary, observations, checks, produced_artifacts, remaining_work] + required: [package, name, description, metadata, files] properties: - schema: + package: + $ref: "#/components/schemas/SkillPackageReference" + name: type: string - enum: [powercontext.task-outcome.v1] - trust: + minLength: 1 + maxLength: 64 + description: type: string - enum: [untrusted_observation] - objective: + minLength: 1 + maxLength: 1024 + license: type: string minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - status: - $ref: "#/components/schemas/TaskOutcomeStatus" - summary: + maxLength: 512 + nullable: true + compatibility: type: string minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - handoff_receipt_ref: - $ref: "#/components/schemas/SourceReference" + maxLength: 500 nullable: true - observations: + metadata: + type: object + maxProperties: 64 + additionalProperties: + type: string + allowed_tools: + type: string + minLength: 1 + maxLength: 2000 + nullable: true + files: type: array minItems: 1 - maxItems: 64 - items: - $ref: "#/components/schemas/WorkClaim" - checks: - type: array - maxItems: 64 - items: - $ref: "#/components/schemas/TaskCheck" - produced_artifacts: - type: array - maxItems: 32 - items: - $ref: "#/components/schemas/ArtifactReference" - remaining_work: - type: array - maxItems: 64 + maxItems: 256 items: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - RecordTaskOutcomeRequest: + $ref: "#/components/schemas/SkillPackageFile" + GetSkillPackageRequest: type: object additionalProperties: false - required: [scope_id, source_id, outcome] + required: [scope_id, artifact] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - source_id: + artifact: + $ref: "#/components/schemas/ArtifactReference" + SkillPackageDownload: + type: object + additionalProperties: false + required: [package, archive_base64] + properties: + package: + $ref: "#/components/schemas/SkillPackageReference" + archive_base64: + type: string + minLength: 1 + maxLength: 6990508 + pattern: '^[A-Za-z0-9+/]*={0,2}$' + RemoteAgentKind: + type: string + enum: [codex, claude_code] + RemoteSkillTargetState: + type: string + enum: [pending, active, revoked] + RemoteSkillTarget: + type: object + additionalProperties: false + required: + - scope_id + - target_id + - display_name + - agent_kind + - installation_scope + - delivery_mode + - installation_id + - state + - receiver_version + - environment_fingerprint + - machine_hostname + - workspace_name + - last_seen_at + - generation + properties: + scope_id: type: string minLength: 1 maxLength: 256 + target_id: + type: string + minLength: 1 + maxLength: 64 + pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' + display_name: + type: string + minLength: 1 + maxLength: 128 pattern: '.*\S.*' - outcome: - $ref: "#/components/schemas/TaskOutcome" - CaptureContentSourceRequest: + agent_kind: + $ref: "#/components/schemas/RemoteAgentKind" + installation_scope: + type: string + enum: [project] + delivery_mode: + type: string + enum: [agent_pull] + installation_id: + type: string + minLength: 1 + maxLength: 128 + nullable: true + state: + $ref: "#/components/schemas/RemoteSkillTargetState" + receiver_version: + type: string + minLength: 1 + maxLength: 64 + nullable: true + environment_fingerprint: + type: string + pattern: '^[0-9a-f]{64}$' + nullable: true + machine_hostname: + type: string + minLength: 1 + maxLength: 255 + pattern: '.*\S.*' + nullable: true + workspace_name: + type: string + minLength: 1 + maxLength: 128 + pattern: '.*\S.*' + nullable: true + last_seen_at: + type: string + format: date-time + nullable: true + generation: + type: integer + minimum: 0 + ListRemoteSkillTargetsRequest: type: object additionalProperties: false - required: [scope_id, source_id, content] + required: [scope_id] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - source_id: - type: string - minLength: 1 - maxLength: 256 - content: + target_id: type: string minLength: 1 - maxLength: 200000 - metadata: - type: object - additionalProperties: true + maxLength: 64 + pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' nullable: true - CaptureContentSourceResponse: - type: object - additionalProperties: false - required: [status, source, position] - properties: - status: - $ref: "#/components/schemas/CaptureStatus" - source: - $ref: "#/components/schemas/SourceReference" - position: + limit: type: integer minimum: 1 - CommitHandoffRequest: + maximum: 200 + default: 100 + RemoteSkillTargetStatus: type: object additionalProperties: false - required: [scope_id, handoff] + required: [target, publications] properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - handoff: - $ref: "#/components/schemas/PreparedHandoff" - CommittedHandoff: + target: + $ref: "#/components/schemas/RemoteSkillTarget" + publications: + type: array + maxItems: 256 + items: + $ref: "#/components/schemas/RemoteSkillPublication" + ListRemoteSkillTargetsResponse: type: object additionalProperties: false - required: [reference, content, source_refs, artifact_refs] + required: [targets] properties: - reference: - $ref: "#/components/schemas/ArtifactReference" - content: - $ref: "#/components/schemas/HandoffContent" - source_refs: - type: array - items: - $ref: "#/components/schemas/SourceReference" - artifact_refs: + targets: type: array + maxItems: 200 items: - $ref: "#/components/schemas/ArtifactReference" - ContinueHandoffRequest: + $ref: "#/components/schemas/RemoteSkillTargetStatus" + CreateRemoteSkillTargetRequest: type: object additionalProperties: false - required: [scope_id, selection] + required: [scope_id, agent_kind, display_name] 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: - type: object - additionalProperties: false - required: [scope_id, draft] - properties: - scope_id: + agent_kind: + $ref: "#/components/schemas/RemoteAgentKind" + display_name: type: string minLength: 1 - maxLength: 256 + maxLength: 128 pattern: '.*\S.*' - draft: - $ref: "#/components/schemas/HandoffDraft" - HandoffArtifactCitation: + RemoteSkillTargetEnrollment: type: object additionalProperties: false - required: [kind, artifact_ref] + required: [target, enrollment_code, enrollment_expires_at] properties: - kind: + target: + $ref: "#/components/schemas/RemoteSkillTarget" + enrollment_code: type: string - enum: [artifact] - artifact_ref: - $ref: "#/components/schemas/ArtifactReference" - HandoffActivation: + minLength: 32 + maxLength: 256 + enrollment_expires_at: + type: string + format: date-time + EnrollRemoteSkillTargetRequest: type: object additionalProperties: false - required: [status, boundary_source, previous_position, current_position, draft] + required: [enrollment_code, installation_id, receiver_version] properties: - status: - $ref: "#/components/schemas/HandoffActivationStatus" - boundary_source: - $ref: "#/components/schemas/SourceReference" - previous_position: - type: integer - minimum: 0 - current_position: - type: integer - minimum: 0 - draft: - $ref: "#/components/schemas/HandoffDraft" + enrollment_code: + type: string + minLength: 32 + maxLength: 256 + installation_id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + receiver_version: + type: string + minLength: 1 + maxLength: 64 + pattern: '^[\x21-\x7E]+$' + environment_fingerprint: + type: string + pattern: '^[0-9a-f]{64}$' 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: [schema, objective, state, disposition, next_action, omissions] - properties: - schema: - $ref: "#/components/schemas/HandoffSchema" - objective: + machine_hostname: type: string minLength: 1 - maxLength: 8192 + maxLength: 255 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: - type: array - maxItems: 64 - items: - $ref: "#/components/schemas/HandoffOmission" - HandoffDraft: - type: object - additionalProperties: false - required: [objective, state, disposition, next_action, omissions] - properties: - objective: + workspace_name: type: string minLength: 1 - maxLength: 8192 + maxLength: 128 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: - type: array - maxItems: 64 - items: - $ref: "#/components/schemas/HandoffOmission" - HandoffEvidenceCheck: - type: object - additionalProperties: false - required: [claim, state_index, status, unavailable_evidence] - properties: - claim: - $ref: "#/components/schemas/HandoffClaim" - state_index: - type: integer - minimum: 0 nullable: true - status: - $ref: "#/components/schemas/HandoffEvidenceStatus" - unavailable_evidence: - type: array - maxItems: 32 - items: - $ref: "#/components/schemas/HandoffCitation" - HandoffMemoryCitation: + RemoteSkillTargetCredential: type: object additionalProperties: false - required: [kind, memory_citation] + required: [scope_id, target_id, agent_kind, credential] properties: - kind: + scope_id: type: string - enum: [memory] - memory_citation: - $ref: "#/components/schemas/MemoryCitation" - HandoffOmission: + minLength: 1 + maxLength: 256 + target_id: + type: string + minLength: 1 + maxLength: 64 + agent_kind: + $ref: "#/components/schemas/RemoteAgentKind" + credential: + type: string + minLength: 32 + maxLength: 256 + RevokeRemoteSkillTargetRequest: type: object additionalProperties: false - required: [text, citation] + required: [scope_id, target_id, expected_generation] properties: - text: + scope_id: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' - citation: - $ref: "#/components/schemas/HandoffCitation" - nullable: true - HandoffResolution: + target_id: + type: string + minLength: 1 + maxLength: 64 + pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' + expected_generation: + type: integer + minimum: 0 + RenameRemoteSkillTargetRequest: type: object additionalProperties: false - required: - [trust, status, scope_id, content, selection, selected_revision, current_revision, evidence_checks] + required: [scope_id, target_id, display_name, expected_generation] 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: - type: array - maxItems: 65 - items: - $ref: "#/components/schemas/HandoffEvidenceCheck" - HandoffSourceCitation: - type: object - additionalProperties: false - required: [kind, source_ref] - properties: - kind: + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + target_id: type: string - enum: [source] - source_ref: - $ref: "#/components/schemas/SourceReference" - HandoffStatement: + minLength: 1 + maxLength: 64 + pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' + display_name: + type: string + minLength: 1 + maxLength: 128 + pattern: '.*\S.*' + expected_generation: + type: integer + minimum: 0 + PublishRemoteSkillRequest: type: object additionalProperties: false - required: [text, citations] + required: [scope_id, target_id, artifact, expected_generation] properties: - text: + scope_id: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' - citations: - type: array - minItems: 1 - maxItems: 32 - items: - $ref: "#/components/schemas/HandoffCitation" - PrepareHandoffRequest: + target_id: + type: string + minLength: 1 + maxLength: 64 + pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' + artifact: + $ref: "#/components/schemas/ArtifactReference" + expected_generation: + type: integer + minimum: 0 + nullable: true + allow_deprecated: + type: boolean + default: false + UnpublishRemoteSkillRequest: type: object additionalProperties: false - required: [scope_id, objective, evidence] + required: [scope_id, target_id, artifact_id, expected_generation] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - objective: + target_id: type: string minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - evidence: - type: array - minItems: 1 - maxItems: 32 - items: - $ref: "#/components/schemas/HandoffCitation" - max_bytes: + maxLength: 64 + pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' + artifact_id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + expected_generation: type: integer - minimum: 512 - maximum: 32768 - default: 8000 - PreparedHandoff: + minimum: 0 + RemoteSkillDesiredState: + type: string + enum: [published, unpublished] + RemoteSkillPublicationState: + type: string + enum: [unpublished, pending, current, update_available, delivery_failed, conflict, drifted, incompatible] + RemoteSkillPublication: type: object additionalProperties: false - required: [schema, scope_id, base, content] + required: + - scope_id + - target_id + - artifact_id + - desired_state + - desired_revision + - desired_tree_digest + - observed_revision + - observed_tree_digest + - observed_generation + - state + - last_error_code + - observed_at + - generation properties: - schema: - $ref: "#/components/schemas/PreparedHandoffSchema" scope_id: type: string - base: - $ref: "#/components/schemas/ArtifactReference" + target_id: + type: string + artifact_id: + type: string + desired_state: + $ref: "#/components/schemas/RemoteSkillDesiredState" + desired_revision: + type: integer + minimum: 1 + desired_tree_digest: + type: string + pattern: '^[0-9a-f]{64}$' + observed_revision: + type: integer + minimum: 1 nullable: true - content: - $ref: "#/components/schemas/HandoffContent" - PreparedContext: + observed_tree_digest: + type: string + pattern: '^[0-9a-f]{64}$' + nullable: true + observed_generation: + type: integer + minimum: 0 + nullable: true + state: + $ref: "#/components/schemas/RemoteSkillPublicationState" + last_error_code: + type: string + minLength: 1 + maxLength: 128 + nullable: true + observed_at: + type: string + format: date-time + nullable: true + generation: + type: integer + minimum: 0 + RemoteSkillObservation: type: object additionalProperties: false - required: [schema, status, content, content_bytes] + required: [artifact, tree_digest, actual_tree_digest, skill_name, applied_generation] properties: - schema: - $ref: "#/components/schemas/PreparedContextSchema" - status: - $ref: "#/components/schemas/PreparedContextStatus" - content: + artifact: + $ref: "#/components/schemas/ArtifactReference" + tree_digest: type: string + pattern: '^[0-9a-f]{64}$' + actual_tree_digest: + type: string + pattern: '^[0-9a-f]{64}$' nullable: true - content_bytes: + skill_name: + type: string + minLength: 1 + maxLength: 64 + pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' + applied_generation: type: integer minimum: 0 - EntryChange: + ReconcileRemoteSkillsRequest: type: object additionalProperties: false - required: [op, entry_id, from_entry_version_id, to_entry_version_id, reason] + required: [observations, receiver_version] properties: - op: - $ref: "#/components/schemas/EntryChangeOperation" - entry_id: + observations: + type: array + maxItems: 256 + items: + $ref: "#/components/schemas/RemoteSkillObservation" + receiver_version: type: string minLength: 1 - maxLength: 128 + maxLength: 64 pattern: '^[\x21-\x7E]+$' - from_entry_version_id: + environment_fingerprint: type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' + pattern: '^[0-9a-f]{64}$' nullable: true - to_entry_version_id: + RemoteSkillOperation: + type: string + enum: [install, unpublish] + RemoteSkillAction: + type: object + additionalProperties: false + required: + [operation, generation, artifact, tree_digest, skill_name, package, expected_local, blocked_error_code] + properties: + operation: + $ref: "#/components/schemas/RemoteSkillOperation" + generation: + type: integer + minimum: 0 + artifact: + $ref: "#/components/schemas/ArtifactReference" + tree_digest: + type: string + pattern: '^[0-9a-f]{64}$' + skill_name: type: string minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' + maxLength: 64 + package: + $ref: "#/components/schemas/SkillPackageReference" nullable: true - reason: + expected_local: + $ref: "#/components/schemas/RemoteSkillObservation" + nullable: true + blocked_error_code: type: string + minLength: 1 + maxLength: 128 nullable: true - ExperienceArtifact: + ReconcileRemoteSkillsResponse: type: object additionalProperties: false - required: [artifact, content, source_refs, artifact_refs] + required: [scope_id, target_id, actions] properties: - artifact: - $ref: "#/components/schemas/ArtifactReference" - content: - $ref: "#/components/schemas/ExperienceProposal" - source_refs: - type: array - items: - $ref: "#/components/schemas/SourceReference" - artifact_refs: + scope_id: + type: string + target_id: + type: string + actions: type: array + maxItems: 256 items: - $ref: "#/components/schemas/ArtifactReference" - ExperienceProposal: + $ref: "#/components/schemas/RemoteSkillAction" + DownloadRemoteSkillPackageRequest: type: object additionalProperties: false - required: [situation, action, outcome, lesson] + required: [generation, artifact, package] properties: - situation: + generation: + type: integer + minimum: 0 + artifact: + $ref: "#/components/schemas/ArtifactReference" + package: + $ref: "#/components/schemas/SkillPackageReference" + RemoteSkillReceiptOutcome: + type: string + enum: [succeeded, failed] + RemoteSkillFailureState: + type: string + enum: [delivery_failed, conflict, drifted, incompatible] + RecordRemoteSkillReceiptRequest: + type: object + additionalProperties: false + required: + - operation + - generation + - artifact + - expected_tree_digest + - observed_tree_digest + - outcome + - failure_state + - error_code + - receiver_version + - environment_fingerprint + properties: + operation: + $ref: "#/components/schemas/RemoteSkillOperation" + generation: + type: integer + minimum: 0 + artifact: + $ref: "#/components/schemas/ArtifactReference" + expected_tree_digest: type: string - minLength: 1 - maxLength: 8000 - pattern: '.*\S.*' - action: + pattern: '^[0-9a-f]{64}$' + observed_tree_digest: type: string - minLength: 1 - maxLength: 8000 - pattern: '.*\S.*' + pattern: '^[0-9a-f]{64}$' + nullable: true outcome: + $ref: "#/components/schemas/RemoteSkillReceiptOutcome" + failure_state: + $ref: "#/components/schemas/RemoteSkillFailureState" + nullable: true + error_code: type: string minLength: 1 - maxLength: 8000 - pattern: '.*\S.*' - lesson: + maxLength: 128 + nullable: true + receiver_version: type: string minLength: 1 - maxLength: 8000 - pattern: '.*\S.*' - SkillArtifact: + maxLength: 64 + pattern: '^[\x21-\x7E]+$' + environment_fingerprint: + type: string + pattern: '^[0-9a-f]{64}$' + nullable: true + RemoteSkillReceiptResponse: type: object additionalProperties: false - required: [artifact, content, source_refs, artifact_refs] + required: [accepted, stale, publication] 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: + accepted: + type: boolean + stale: + type: boolean + publication: + $ref: "#/components/schemas/RemoteSkillPublication" + ProposeSkillPackageRequest: type: object additionalProperties: false - required: [name, description, instructions, validation] + required: [scope_id, archive_base64] properties: - name: + scope_id: type: string minLength: 1 - maxLength: 128 - pattern: '^\S(?:.*\S)?$' - description: + maxLength: 256 + pattern: '.*\S.*' + archive_base64: + type: string + minLength: 1 + maxLength: 6990508 + pattern: '^[A-Za-z0-9+/]*={0,2}$' + reason: type: string minLength: 1 maxLength: 2000 - pattern: '^\S(?:.*\S)?$' - instructions: + nullable: true + target: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + description: Exact managed Skill Revision replaced by this complete package Candidate. + RecordSkillUsageRequest: + type: object + additionalProperties: false + required: + - scope_id + - observation_id + - skill_ref + - package_digest + - target_id + - selected + - invoked + - validation + - outcome + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + observation_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + skill_ref: + $ref: "#/components/schemas/ArtifactReference" + package_digest: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + target_id: type: string minLength: 1 - maxLength: 32000 + maxLength: 128 pattern: '.*\S.*' + selected: + type: boolean + invoked: + type: string + enum: ['true', 'false', unknown] validation: - type: array - minItems: 1 - maxItems: 32 - items: - $ref: "#/components/schemas/SkillValidationItem" + type: string + enum: [passed, failed, unknown] + outcome: + type: string + enum: [success, failure, unknown] + task_source: + $ref: "#/components/schemas/SourceReference" + nullable: true + environment_fingerprint: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + nullable: true SkillValidationItem: type: string minLength: 1 diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 092c7108a..dbfd763c1 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -45,6 +45,22 @@ export const OPERATIONS = { propose_skill: { method: 'POST', path: '/v1/skill/propose', location: "body", scope: true }, generate_skill: { method: 'POST', path: '/v1/skill/generate', location: "body", scope: true }, get_skill: { method: 'POST', path: '/v1/skill/get', location: "body", scope: true }, + list_managed_skills: { method: 'POST', path: '/v1/skill/library', location: "body", scope: true }, + update_skill_lifecycle: { method: 'POST', path: '/v1/skill/lifecycle', location: "body", scope: true }, + get_skill_package_manifest: { method: 'POST', path: '/v1/skill/package/manifest', location: "body", scope: true }, + download_skill_package: { method: 'POST', path: '/v1/skill/package/download', location: "body", scope: true }, + propose_skill_package: { method: 'POST', path: '/v1/skill/package/propose', location: "body", scope: true }, + record_skill_usage: { method: 'POST', path: '/v1/skill/usage', location: "body", scope: true }, + list_remote_skill_targets: { method: 'POST', path: '/v1/skill/remote/targets', location: "body", scope: true }, + create_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/create', location: "body", scope: true }, + enroll_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/enroll', location: "body", scope: false }, + rename_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/rename', location: "body", scope: true }, + revoke_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/revoke', location: "body", scope: true }, + publish_remote_skill: { method: 'POST', path: '/v1/skill/remote/publication/publish', location: "body", scope: true }, + unpublish_remote_skill: { method: 'POST', path: '/v1/skill/remote/publication/unpublish', location: "body", scope: true }, + reconcile_remote_skills: { method: 'POST', path: '/v1/skill/remote/reconcile', location: "body", scope: false }, + download_remote_skill_package: { method: 'POST', path: '/v1/skill/remote/package/download', location: "body", scope: false }, + record_remote_skill_receipt: { method: 'POST', path: '/v1/skill/remote/receipt', location: "body", scope: false }, scan_external_skills: { method: 'POST', path: '/v1/external-skills/scan', location: "body", scope: true }, list_external_skills: { method: 'POST', path: '/v1/external-skills/list', location: "body", scope: true }, resolve_external_skill: { method: 'POST', path: '/v1/external-skills/resolve', location: "body", scope: true }, diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 092c7108a..dbfd763c1 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -45,6 +45,22 @@ export const OPERATIONS = { propose_skill: { method: 'POST', path: '/v1/skill/propose', location: "body", scope: true }, generate_skill: { method: 'POST', path: '/v1/skill/generate', location: "body", scope: true }, get_skill: { method: 'POST', path: '/v1/skill/get', location: "body", scope: true }, + list_managed_skills: { method: 'POST', path: '/v1/skill/library', location: "body", scope: true }, + update_skill_lifecycle: { method: 'POST', path: '/v1/skill/lifecycle', location: "body", scope: true }, + get_skill_package_manifest: { method: 'POST', path: '/v1/skill/package/manifest', location: "body", scope: true }, + download_skill_package: { method: 'POST', path: '/v1/skill/package/download', location: "body", scope: true }, + propose_skill_package: { method: 'POST', path: '/v1/skill/package/propose', location: "body", scope: true }, + record_skill_usage: { method: 'POST', path: '/v1/skill/usage', location: "body", scope: true }, + list_remote_skill_targets: { method: 'POST', path: '/v1/skill/remote/targets', location: "body", scope: true }, + create_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/create', location: "body", scope: true }, + enroll_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/enroll', location: "body", scope: false }, + rename_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/rename', location: "body", scope: true }, + revoke_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/revoke', location: "body", scope: true }, + publish_remote_skill: { method: 'POST', path: '/v1/skill/remote/publication/publish', location: "body", scope: true }, + unpublish_remote_skill: { method: 'POST', path: '/v1/skill/remote/publication/unpublish', location: "body", scope: true }, + reconcile_remote_skills: { method: 'POST', path: '/v1/skill/remote/reconcile', location: "body", scope: false }, + download_remote_skill_package: { method: 'POST', path: '/v1/skill/remote/package/download', location: "body", scope: false }, + record_remote_skill_receipt: { method: 'POST', path: '/v1/skill/remote/receipt', location: "body", scope: false }, scan_external_skills: { method: 'POST', path: '/v1/external-skills/scan', location: "body", scope: true }, list_external_skills: { method: 'POST', path: '/v1/external-skills/list', location: "body", scope: true }, resolve_external_skill: { method: 'POST', path: '/v1/external-skills/resolve', location: "body", scope: true }, diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index 092c7108a..dbfd763c1 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -45,6 +45,22 @@ export const OPERATIONS = { propose_skill: { method: 'POST', path: '/v1/skill/propose', location: "body", scope: true }, generate_skill: { method: 'POST', path: '/v1/skill/generate', location: "body", scope: true }, get_skill: { method: 'POST', path: '/v1/skill/get', location: "body", scope: true }, + list_managed_skills: { method: 'POST', path: '/v1/skill/library', location: "body", scope: true }, + update_skill_lifecycle: { method: 'POST', path: '/v1/skill/lifecycle', location: "body", scope: true }, + get_skill_package_manifest: { method: 'POST', path: '/v1/skill/package/manifest', location: "body", scope: true }, + download_skill_package: { method: 'POST', path: '/v1/skill/package/download', location: "body", scope: true }, + propose_skill_package: { method: 'POST', path: '/v1/skill/package/propose', location: "body", scope: true }, + record_skill_usage: { method: 'POST', path: '/v1/skill/usage', location: "body", scope: true }, + list_remote_skill_targets: { method: 'POST', path: '/v1/skill/remote/targets', location: "body", scope: true }, + create_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/create', location: "body", scope: true }, + enroll_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/enroll', location: "body", scope: false }, + rename_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/rename', location: "body", scope: true }, + revoke_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/revoke', location: "body", scope: true }, + publish_remote_skill: { method: 'POST', path: '/v1/skill/remote/publication/publish', location: "body", scope: true }, + unpublish_remote_skill: { method: 'POST', path: '/v1/skill/remote/publication/unpublish', location: "body", scope: true }, + reconcile_remote_skills: { method: 'POST', path: '/v1/skill/remote/reconcile', location: "body", scope: false }, + download_remote_skill_package: { method: 'POST', path: '/v1/skill/remote/package/download', location: "body", scope: false }, + record_remote_skill_receipt: { method: 'POST', path: '/v1/skill/remote/receipt', location: "body", scope: false }, scan_external_skills: { method: 'POST', path: '/v1/external-skills/scan', location: "body", scope: true }, list_external_skills: { method: 'POST', path: '/v1/external-skills/list', location: "body", scope: true }, resolve_external_skill: { method: 'POST', path: '/v1/external-skills/resolve', location: "body", scope: true }, diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index a38ba8922..b4a40cfa3 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -887,28 +887,28 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/external-skills/scan: + /v1/skill/library: post: tags: [skill] - summary: Scan configured external Skill roots - description: Replace the current host-local Registry projection without copying or rewriting package content. - operationId: scan_external_skills + summary: List or search current managed Skills + description: Return current managed Skill heads with lifecycle governance; retired Skills remain exact-read only. + operationId: list_managed_skills requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ScanExternalSkillsRequest" + $ref: "#/components/schemas/ListManagedSkillsRequest" responses: "200": - description: The rebuildable provider snapshot. + description: Current managed Skill Library rows. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" content: application/json: schema: - $ref: "#/components/schemas/ScanExternalSkillsResponse" + $ref: "#/components/schemas/ListManagedSkillsResponse" "401": $ref: "#/components/responses/Unauthorized" "422": @@ -917,28 +917,32 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/external-skills/list: + /v1/skill/lifecycle: post: tags: [skill] - summary: List external Skills visible on this host - description: Return live local resolutions; unavailable registrations are omitted unless explicitly requested. - operationId: list_external_skills + summary: Update managed Skill lifecycle + description: Apply an explicit lifecycle transition using governance generation CAS without changing package bytes. + operationId: update_skill_lifecycle requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ListExternalSkillsRequest" + $ref: "#/components/schemas/UpdateSkillLifecycleRequest" responses: "200": - description: External Skills resolved against the current Agent, host, scope, and fingerprint. + description: Updated managed Skill governance. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" content: application/json: schema: - $ref: "#/components/schemas/ListExternalSkillsResponse" + $ref: "#/components/schemas/SkillGovernance" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" "422": @@ -947,28 +951,25 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/external-skills/resolve: + /v1/skill/package/manifest: post: tags: [skill] - summary: Resolve an exact external Skill fingerprint - description: Resolve only the registered local package version requested by the caller; never install or fall back. - operationId: resolve_external_skill + summary: Get an exact managed Skill package manifest + description: Return verified metadata and file inventory without executing or returning file bodies. + operationId: get_skill_package_manifest requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ResolveExternalSkillRequest" + $ref: "#/components/schemas/GetSkillPackageRequest" responses: "200": - description: The live exact-resolution result, which may be unavailable. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" + description: Verified exact package manifest. content: application/json: schema: - $ref: "#/components/schemas/ExternalSkillResolution" + $ref: "#/components/schemas/SkillPackageManifest" "404": $ref: "#/components/responses/NotFound" "401": @@ -979,32 +980,27 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/external-skills/import: + /v1/skill/package/download: post: tags: [skill] - summary: Import or fork an external Skill into Review - description: Capture one exact local snapshot and use the configured model to propose a new managed Skill Candidate. - operationId: import_external_skill + summary: Download an exact managed Skill package + description: Return canonical ZIP bytes as bounded base64 with their content-addressed reference. + operationId: download_skill_package requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ImportExternalSkillRequest" + $ref: "#/components/schemas/GetSkillPackageRequest" responses: "200": - description: A pending managed Skill Candidate or an explicit semantic no-op. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" + description: Canonical exact package archive. content: application/json: schema: - $ref: "#/components/schemas/GeneratedCandidateResponse" + $ref: "#/components/schemas/SkillPackageDownload" "404": $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" "422": @@ -1013,28 +1009,27 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/artifact-candidates/list: + /v1/skill/package/propose: post: - tags: [review] - summary: List Artifact Candidates - description: Page current Candidate heads; pending is the default Review Inbox view. - operationId: list_artifact_candidates + tags: [skill] + summary: Propose an uploaded standard Skill package + description: Canonicalize exact ZIP bytes, store them once, and create a pending Candidate without LLM rewriting. + operationId: propose_skill_package requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ListArtifactCandidatesRequest" + $ref: "#/components/schemas/ProposeSkillPackageRequest" responses: - "200": - description: The selected current Candidate heads. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" + "201": + description: Pending exact package Candidate. content: application/json: schema: - $ref: "#/components/schemas/ArtifactCandidatePage" + $ref: "#/components/schemas/ArtifactCandidate" + "409": + $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" "422": @@ -1043,30 +1038,29 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/artifact-candidates/get: + /v1/skill/usage: post: - tags: [review] - summary: Get an Artifact Candidate - description: Read the current head and exact immutable proposal version. - operationId: get_artifact_candidate + tags: [skill] + summary: Record a bounded Skill usage observation + description: Validate an exact managed Skill Revision and capture immutable bounded usage Source evidence. + operationId: record_skill_usage requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/GetArtifactCandidateRequest" + $ref: "#/components/schemas/RecordSkillUsageRequest" responses: - "200": - description: The current Candidate head. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" + "201": + description: Accepted immutable usage Source evidence. content: application/json: schema: - $ref: "#/components/schemas/ArtifactCandidate" + $ref: "#/components/schemas/CaptureContentSourceResponse" "404": $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" "422": @@ -1075,32 +1069,25 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/artifact-candidates/approve: + /v1/skill/remote/targets: post: - tags: [review] - summary: Approve an Artifact Candidate - description: Commit the reviewed proposal and mark the Candidate approved in one transaction. - operationId: approve_artifact_candidate + tags: [skill] + summary: List remote Agent Skill target status + description: Return credential-free target metadata and desired/observed publication state for one scope. + operationId: list_remote_skill_targets requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ApproveArtifactCandidateRequest" + $ref: "#/components/schemas/ListRemoteSkillTargetsRequest" responses: "200": - description: The approved Candidate and exact result Artifact. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" + description: Remote target status rows visible to the administrative caller. content: application/json: schema: - $ref: "#/components/schemas/ArtifactCandidate" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/Conflict" + $ref: "#/components/schemas/ListRemoteSkillTargetsResponse" "401": $ref: "#/components/responses/Unauthorized" "422": @@ -1109,30 +1096,25 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/artifact-candidates/reject: + /v1/skill/remote/target/create: post: - tags: [review] - summary: Reject an Artifact Candidate - description: Move the exact pending version to its rejected terminal state without writing an Artifact. - operationId: reject_artifact_candidate + tags: [skill] + summary: Create a remote Agent Skill target enrollment + description: Create a pending project target and return one short-lived enrollment code exactly once. + operationId: create_remote_skill_target requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/RejectArtifactCandidateRequest" + $ref: "#/components/schemas/CreateRemoteSkillTargetRequest" responses: - "200": - description: The rejected Candidate. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" + "201": + description: Pending remote target enrollment. content: application/json: schema: - $ref: "#/components/schemas/ArtifactCandidate" - "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/schemas/RemoteSkillTargetEnrollment" "409": $ref: "#/components/responses/Conflict" "401": @@ -1143,392 +1125,353 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/artifact-candidates/revise: + /v1/skill/remote/target/enroll: post: - tags: [review] - summary: Revise an Artifact Candidate - description: Append a complete replacement proposal as the next immutable pending version. - operationId: revise_artifact_candidate + security: [] + tags: [skill] + summary: Enroll a remote Agent Skill Receiver + description: Consume one short-lived enrollment code and return a per-target credential exactly once. + operationId: enroll_remote_skill_target requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ReviseArtifactCandidateRequest" + $ref: "#/components/schemas/EnrollRemoteSkillTargetRequest" responses: "200": - description: The next pending Candidate version. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" + description: Activated remote target credential. content: application/json: schema: - $ref: "#/components/schemas/ArtifactCandidate" - "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/schemas/RemoteSkillTargetCredential" "409": $ref: "#/components/responses/Conflict" - "401": - $ref: "#/components/responses/Unauthorized" "422": $ref: "#/components/responses/InvalidRequest" - "503": - $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/stats: - get: - tags: [stats] - summary: Get scoped product statistics - 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" + /v1/skill/remote/target/rename: + post: + tags: [skill] + summary: Rename a remote Agent Skill target + description: Change the human-readable target name with target generation CAS while retaining its durable identity. + operationId: rename_remote_skill_target + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RenameRemoteSkillTargetRequest" responses: "200": - description: Current inventory, model usage, and recall token estimates for the scope. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - Cache-Control: - description: Prevent caches from retaining scoped statistics. - schema: - type: string - enum: [no-store] + description: Renamed remote target. content: application/json: schema: - $ref: "#/components/schemas/ScopedStats" + $ref: "#/components/schemas/RemoteSkillTarget" "401": $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" "422": $ref: "#/components/responses/InvalidRequest" - "503": - $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/projects/create: + /v1/skill/remote/target/revoke: post: - tags: [handoff-reports] - summary: Create a Handoff Report Project - operationId: create_handoff_report_project + tags: [skill] + summary: Revoke a remote Agent Skill target + description: Revoke the per-target credential with target generation CAS while retaining durable identity. + operationId: revoke_remote_skill_target requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/CreateHandoffReportProjectRequest" + $ref: "#/components/schemas/RevokeRemoteSkillTargetRequest" responses: - "201": - description: The created Report Project. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" + "200": + description: Revoked remote target. content: application/json: schema: - $ref: "#/components/schemas/ProjectDescriptor" - "409": - $ref: "#/components/responses/Conflict" + $ref: "#/components/schemas/RemoteSkillTarget" "401": $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" "422": $ref: "#/components/responses/InvalidRequest" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/projects/list: + /v1/skill/remote/publication/publish: post: - tags: [handoff-reports] - summary: List Handoff Report Projects - operationId: list_handoff_report_projects + tags: [skill] + summary: Set a remote target Skill desired Revision + description: Advance only Server-owned desired state; delivery is confirmed later by an exact Receipt. + operationId: publish_remote_skill requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ListHandoffReportProjectsRequest" + $ref: "#/components/schemas/PublishRemoteSkillRequest" responses: "200": - description: A cursor-paginated page of Report Projects. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" + description: Latest remote publication desired state. content: application/json: schema: - $ref: "#/components/schemas/ProjectPage" + $ref: "#/components/schemas/RemoteSkillPublication" "401": $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" "422": $ref: "#/components/responses/InvalidRequest" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/scopes/list-known: + /v1/skill/remote/publication/unpublish: post: - tags: [handoff-reports] - summary: List scopes that contain a committed Handoff - operationId: list_handoff_report_known_scopes + tags: [skill] + summary: Set remote target Skill desired absence + description: Advance desired state without claiming that any remote directory has already been removed. + operationId: unpublish_remote_skill requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ListHandoffReportKnownScopesRequest" + $ref: "#/components/schemas/UnpublishRemoteSkillRequest" 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: Latest remote publication desired state. content: application/json: schema: - $ref: "#/components/schemas/KnownHandoffScopePage" + $ref: "#/components/schemas/RemoteSkillPublication" "401": $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" "422": $ref: "#/components/responses/InvalidRequest" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/projects/get: + /v1/skill/remote/reconcile: post: - tags: [handoff-reports] - summary: Get a Handoff Report Project - operationId: get_handoff_report_project + security: + - TargetBearerAuth: [] + tags: [skill] + summary: Reconcile a remote Agent Skill target + description: Authenticate one target and return only latest-generation idempotent install or unpublish actions. + operationId: reconcile_remote_skills requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/GetHandoffReportProjectRequest" + $ref: "#/components/schemas/ReconcileRemoteSkillsRequest" responses: "200": - description: The exact current Report Project descriptor. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" + description: Latest desired-state actions for this target only. content: application/json: schema: - $ref: "#/components/schemas/ProjectDescriptor" - "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/schemas/ReconcileRemoteSkillsResponse" "401": $ref: "#/components/responses/Unauthorized" + "409": + $ref: "#/components/responses/Conflict" "422": $ref: "#/components/responses/InvalidRequest" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/projects/update: + /v1/skill/remote/package/download: post: - tags: [handoff-reports] - summary: Update a Handoff Report Project - operationId: update_handoff_report_project + security: + - TargetBearerAuth: [] + tags: [skill] + summary: Download the exact package desired by a remote target + description: Return canonical ZIP bytes only when target, generation, Artifact Revision, and package reference all match. + operationId: download_remote_skill_package requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/UpdateHandoffReportProjectRequest" + $ref: "#/components/schemas/DownloadRemoteSkillPackageRequest" responses: "200": - description: The updated Report Project descriptor. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" + description: Canonical exact package archive. content: application/json: schema: - $ref: "#/components/schemas/ProjectDescriptor" + $ref: "#/components/schemas/SkillPackageDownload" + "401": + $ref: "#/components/responses/Unauthorized" "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: + /v1/skill/remote/receipt: post: - tags: [handoff-reports] - summary: Register a Handoff Report Workstream - operationId: register_handoff_report_workstream + security: + - TargetBearerAuth: [] + tags: [skill] + summary: Record an exact remote Skill delivery Receipt + description: Update latest observed state only after credential, generation, Artifact, operation, and digest validation. + operationId: record_remote_skill_receipt requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/RegisterHandoffReportWorkstreamRequest" + $ref: "#/components/schemas/RecordRemoteSkillReceiptRequest" responses: - "201": - description: The registered Report Workstream. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" + "200": + description: Receipt acceptance and latest publication observation. content: application/json: schema: - $ref: "#/components/schemas/WorkstreamDescriptor" + $ref: "#/components/schemas/RemoteSkillReceiptResponse" + "401": + $ref: "#/components/responses/Unauthorized" "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: + /v1/external-skills/scan: post: - tags: [handoff-reports] - summary: List Handoff Report Workstreams - operationId: list_handoff_report_workstreams + tags: [skill] + summary: Scan configured external Skill roots + description: Replace the current host-local Registry projection without copying or rewriting package content. + operationId: scan_external_skills requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ListHandoffReportWorkstreamsRequest" + $ref: "#/components/schemas/ScanExternalSkillsRequest" responses: "200": - description: A cursor-paginated page of Report Workstreams. + description: The rebuildable provider snapshot. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" content: application/json: schema: - $ref: "#/components/schemas/WorkstreamPage" - "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/schemas/ScanExternalSkillsResponse" "401": $ref: "#/components/responses/Unauthorized" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workstreams/update: + /v1/external-skills/list: post: - tags: [handoff-reports] - summary: Update a Handoff Report Workstream - operationId: update_handoff_report_workstream + tags: [skill] + summary: List external Skills visible on this host + description: Return live local resolutions; unavailable registrations are omitted unless explicitly requested. + operationId: list_external_skills requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/UpdateHandoffReportWorkstreamRequest" + $ref: "#/components/schemas/ListExternalSkillsRequest" responses: "200": - description: The updated Report Workstream descriptor. + description: External Skills resolved against the current Agent, host, scope, and fingerprint. 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" + $ref: "#/components/schemas/ListExternalSkillsResponse" "401": $ref: "#/components/responses/Unauthorized" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/get: + /v1/external-skills/resolve: post: - tags: [handoff-reports] - summary: Generate a Handoff Report - operationId: get_handoff_report + tags: [skill] + summary: Resolve an exact external Skill fingerprint + description: Resolve only the registered local package version requested by the caller; never install or fall back. + operationId: resolve_external_skill requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/GetHandoffReportRequest" + $ref: "#/components/schemas/ResolveExternalSkillRequest" responses: "200": - description: A canonical JSON report, optionally accompanied by Markdown. + description: The live exact-resolution result, which may be unavailable. 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 + $ref: "#/components/schemas/ExternalSkillResolution" "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: + /v1/external-skills/import: post: - tags: [handoff-reports] - summary: Record a Handoff Report Activity - operationId: record_handoff_report_activity + tags: [skill] + summary: Import or fork an external Skill into Review + description: Capture one exact local snapshot and use the configured model to propose a new managed Skill Candidate. + operationId: import_external_skill requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/RecordHandoffReportActivityRequest" + $ref: "#/components/schemas/ImportExternalSkillRequest" responses: - "201": - description: The idempotently recorded Report Activity. + "200": + description: A pending managed Skill Candidate or an explicit semantic no-op. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" content: application/json: schema: - $ref: "#/components/schemas/StoredHandoffReportActivity" + $ref: "#/components/schemas/GeneratedCandidateResponse" "404": $ref: "#/components/responses/NotFound" "409": @@ -1537,116 +1480,128 @@ paths: $ref: "#/components/responses/Unauthorized" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/activities/list: + /v1/artifact-candidates/list: post: - tags: [handoff-reports] - summary: List Handoff Report Activities - operationId: list_handoff_report_activities + tags: [review] + summary: List Artifact Candidates + description: Page current Candidate heads; pending is the default Review Inbox view. + operationId: list_artifact_candidates requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ListHandoffReportActivitiesRequest" + $ref: "#/components/schemas/ListArtifactCandidatesRequest" responses: "200": - description: A frozen cursor page of Report Activities. + description: The selected current Candidate heads. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" content: application/json: schema: - $ref: "#/components/schemas/HandoffReportActivityPage" - "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/schemas/ArtifactCandidatePage" "401": $ref: "#/components/responses/Unauthorized" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/activities/purge: + /v1/artifact-candidates/get: post: - tags: [handoff-reports] - summary: Purge Handoff Report Activities - operationId: purge_handoff_report_activities + tags: [review] + summary: Get an Artifact Candidate + description: Read the current head and exact immutable proposal version. + operationId: get_artifact_candidate requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/PurgeHandoffReportActivitiesRequest" + $ref: "#/components/schemas/GetArtifactCandidateRequest" responses: "200": - description: The number of deleted Report-owned Activity rows. + description: The current Candidate head. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" content: application/json: schema: - $ref: "#/components/schemas/PurgeHandoffReportActivitiesResponse" + $ref: "#/components/schemas/ArtifactCandidate" "404": $ref: "#/components/responses/NotFound" "401": $ref: "#/components/responses/Unauthorized" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workspace-bindings/get: + /v1/artifact-candidates/approve: post: - tags: [handoff-reports] - summary: Get a Handoff Report Workspace Binding - operationId: get_handoff_report_workspace + tags: [review] + summary: Approve an Artifact Candidate + description: Commit the reviewed proposal and mark the Candidate approved in one transaction. + operationId: approve_artifact_candidate requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/GetHandoffReportWorkspaceRequest" + $ref: "#/components/schemas/ApproveArtifactCandidateRequest" responses: "200": - description: The confirmed Workspace binding. + description: The approved Candidate and exact result Artifact. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" content: application/json: schema: - $ref: "#/components/schemas/HandoffReportWorkspaceBinding" + $ref: "#/components/schemas/ArtifactCandidate" "404": $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" "401": $ref: "#/components/responses/Unauthorized" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workspace-bindings/attach: + /v1/artifact-candidates/reject: post: - tags: [handoff-reports] - summary: Attach a Handoff Report Workspace Binding - operationId: attach_handoff_report_workspace + tags: [review] + summary: Reject an Artifact Candidate + description: Move the exact pending version to its rejected terminal state without writing an Artifact. + operationId: reject_artifact_candidate requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/AttachHandoffReportWorkspaceRequest" + $ref: "#/components/schemas/RejectArtifactCandidateRequest" responses: "200": - description: The confirmed Workspace binding. + description: The rejected Candidate. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" content: application/json: schema: - $ref: "#/components/schemas/HandoffReportWorkspaceBinding" + $ref: "#/components/schemas/ArtifactCandidate" "404": $ref: "#/components/responses/NotFound" "409": @@ -1655,29 +1610,32 @@ paths: $ref: "#/components/responses/Unauthorized" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/handoff-reports/workspace-bindings/detach: + /v1/artifact-candidates/revise: post: - tags: [handoff-reports] - summary: Detach a Handoff Report Workspace Binding - operationId: detach_handoff_report_workspace + tags: [review] + summary: Revise an Artifact Candidate + description: Append a complete replacement proposal as the next immutable pending version. + operationId: revise_artifact_candidate requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/DetachHandoffReportWorkspaceRequest" + $ref: "#/components/schemas/ReviseArtifactCandidateRequest" responses: "200": - description: The detached Workspace binding record. + description: The next pending Candidate version. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" content: application/json: schema: - $ref: "#/components/schemas/HandoffReportWorkspaceBinding" + $ref: "#/components/schemas/ArtifactCandidate" "404": $ref: "#/components/responses/NotFound" "409": @@ -1686,56 +1644,573 @@ paths: $ref: "#/components/responses/Unauthorized" "422": $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" "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: + /v1/stats: + get: + tags: [stats] + summary: Get scoped product statistics + operationId: get_stats + parameters: + - name: scope_id + in: query + required: true 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: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + - name: period + in: query + required: false schema: - $ref: "#/components/schemas/ErrorResponse" - ReportTooLarge: - description: The selected Handoff Report exceeds the deterministic output limit. + $ref: "#/components/schemas/StatsPeriod" + responses: + "200": + description: Current inventory, model usage, and recall token estimates for the scope. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + Cache-Control: + description: Prevent caches from retaining scoped statistics. + schema: + type: string + enum: [no-store] + content: + application/json: + schema: + $ref: "#/components/schemas/ScopedStats" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $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: + post: + tags: [handoff-reports] + summary: List Handoff Report Projects + operationId: list_handoff_report_projects + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ListHandoffReportProjectsRequest" + 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: + 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. + TargetBearerAuth: + type: http + scheme: bearer + description: Per-target credential issued once during remote Receiver enrollment. + 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" @@ -1774,1311 +2249,2143 @@ components: ActivateHandoffRequest: type: object additionalProperties: false - required: [scope_id, boundary_source, objective] + 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: + 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: + type: array + maxItems: 30 + items: + $ref: "#/components/schemas/ModelUsageDay" + TokenEstimatorProfile: + type: object + additionalProperties: false + required: [estimator_id, version] + properties: + 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 + 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: [date, preparations, ready_preparations, comparable_preparations, baseline_tokens, recalled_tokens, token_reduction] + properties: + date: + 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: + type: object + additionalProperties: false + required: [period, estimator, totals, daily] + 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: + type: object + additionalProperties: false + required: [scope_id, as_of, inventory, usage, recall] + properties: + scope_id: + 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: [scope_id] + 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: + type: string + minLength: 1 + maxLength: 8192 + 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: + type: string + enum: [untrusted_input] + objective: + type: string + minLength: 1 + maxLength: 8192 + 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: + 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: [scope_id, source_id, contract] + properties: + scope_id: + type: string + 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, trust, objective, state, disposition, next_action, omissions] properties: - scope_id: + schema: type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - boundary_source: - $ref: "#/components/schemas/SourceReference" + enum: [powercontext.current-work-handoff.v1] + trust: + type: string + enum: [untrusted_input] objective: type: string minLength: 1 maxLength: 8192 pattern: '.*\S.*' - evidence: + state: type: array - maxItems: 32 + minItems: 1 + maxItems: 64 items: - $ref: "#/components/schemas/HandoffCitation" - default: [] - max_bytes: - type: integer - minimum: 512 - maximum: 32768 - default: 8000 - ArtifactReference: + $ref: "#/components/schemas/WorkClaim" + disposition: + $ref: "#/components/schemas/HandoffDisposition" + next_action: + $ref: "#/components/schemas/WorkClaim" + nullable: true + omissions: + type: array + maxItems: 64 + items: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + HandoffCurrentWorkRequest: type: object additionalProperties: false - required: [family, artifact_id, revision] + required: [scope_id, source_id, handoff] properties: - family: + scope_id: type: string minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - artifact_id: + maxLength: 256 + pattern: '.*\S.*' + source_id: type: string minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - revision: + 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 - ArtifactCandidate: + content_digest: + type: string + minLength: 71 + maxLength: 71 + pattern: '^sha256:[0-9a-f]{64}$' + PreparedWorkHandoff: type: object additionalProperties: false - required: - - candidate_id - - version - - family - - status - - proposal - - source_refs - - artifact_refs - - target - - reason - - result_artifact - - decision_reason + required: [boundary, handoff] properties: - candidate_id: + 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 + 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: + 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: 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: + maxLength: 256 + pattern: '.*\S.*' + source_id: type: string minLength: 1 - maxLength: 2000 + maxLength: 256 + pattern: '.*\S.*' + receiver: + 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 - result_artifact: + prepared: + $ref: "#/components/schemas/PreparedHandoff" + nullable: true + revision: $ref: "#/components/schemas/ArtifactReference" nullable: true - decision_reason: + message: type: string minLength: 1 - maxLength: 2000 + maxLength: 8192 + pattern: '.*\S.*' nullable: true - ArtifactCandidatePage: + HandoffAcknowledgement: type: object additionalProperties: false - required: [candidates, next_cursor] + required: [resolution, receipt] properties: - candidates: - type: array - items: - $ref: "#/components/schemas/ArtifactCandidate" - next_cursor: - type: string - nullable: true - ApproveArtifactCandidateRequest: + 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: [scope_id, candidate_id, expected_version] + required: [name, status, basis, evidence] properties: - scope_id: + name: type: string minLength: 1 - maxLength: 256 + maxLength: 8192 pattern: '.*\S.*' - candidate_id: + status: + $ref: "#/components/schemas/TaskCheckStatus" + details: type: string minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - expected_version: - type: integer - minimum: 1 - Capabilities: + maxLength: 8192 + pattern: '.*\S.*' + nullable: true + basis: + $ref: "#/components/schemas/WorkClaimBasis" + evidence: + type: array + maxItems: 32 + items: + $ref: "#/components/schemas/HandoffCitation" + TaskOutcome: type: object additionalProperties: false - required: - [source_types, artifact_families, memory_extraction, handoff_generation, search_modes, context_versions] + required: [schema, trust, objective, status, summary, observations, checks, produced_artifacts, remaining_work] properties: - source_types: + schema: + type: string + enum: [powercontext.task-outcome.v1] + trust: + type: string + enum: [untrusted_observation] + objective: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + 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: - type: string - artifact_families: + $ref: "#/components/schemas/WorkClaim" + checks: type: array + maxItems: 64 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: + $ref: "#/components/schemas/TaskCheck" + produced_artifacts: type: array + maxItems: 32 items: - $ref: "#/components/schemas/MemorySearchMode" - context_versions: + $ref: "#/components/schemas/ArtifactReference" + remaining_work: type: array + maxItems: 64 items: - $ref: "#/components/schemas/PreparedContextSchema" - FamilyCount: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + RecordTaskOutcomeRequest: type: object additionalProperties: false - required: [family, total] + required: [scope_id, source_id, outcome] properties: - family: + scope_id: 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: + maxLength: 256 + pattern: '.*\S.*' + source_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + outcome: + $ref: "#/components/schemas/TaskOutcome" + CaptureContentSourceRequest: type: object additionalProperties: false - required: [kind, total, active, inactive] + required: [scope_id, source_id, content] properties: - kind: + scope_id: type: string minLength: 1 - maxLength: 128 - total: - type: integer - minimum: 0 - active: - type: integer - minimum: 0 - inactive: - type: integer - minimum: 0 - SourceInventoryStatistics: + maxLength: 256 + pattern: '.*\S.*' + source_id: + type: string + minLength: 1 + maxLength: 256 + content: + type: string + minLength: 1 + maxLength: 200000 + metadata: + type: object + additionalProperties: true + nullable: true + CaptureContentSourceResponse: type: object additionalProperties: false - required: [total, memory_processed, memory_pending] + required: [status, source, position] properties: - total: - type: integer - minimum: 0 - memory_processed: - type: integer - minimum: 0 - memory_pending: + status: + $ref: "#/components/schemas/CaptureStatus" + source: + $ref: "#/components/schemas/SourceReference" + position: type: integer - minimum: 0 - ArtifactInventoryStatistics: + minimum: 1 + CommitHandoffRequest: type: object additionalProperties: false - required: [total, by_family] + required: [scope_id, handoff] properties: - total: - type: integer - minimum: 0 - by_family: - type: array - items: - $ref: "#/components/schemas/FamilyCount" - CandidateInventoryStatistics: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + handoff: + $ref: "#/components/schemas/PreparedHandoff" + CommittedHandoff: type: object additionalProperties: false - required: [total, pending, approved, rejected, by_family] + required: [reference, content, source_refs, artifact_refs] properties: - total: - type: integer - minimum: 0 - pending: - type: integer - minimum: 0 - approved: - type: integer - minimum: 0 - rejected: - type: integer - minimum: 0 - by_family: + reference: + $ref: "#/components/schemas/ArtifactReference" + content: + $ref: "#/components/schemas/HandoffContent" + source_refs: 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: + $ref: "#/components/schemas/SourceReference" + artifact_refs: 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: + $ref: "#/components/schemas/ArtifactReference" + ContinueHandoffRequest: type: object additionalProperties: false - required: [requests, input_tokens, output_tokens] + required: [scope_id, selection] properties: - requests: - type: integer - minimum: 0 - input_tokens: - type: integer - minimum: 0 + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + selection: + $ref: "#/components/schemas/HandoffSelection" + prepared: + $ref: "#/components/schemas/PreparedHandoff" nullable: true - output_tokens: - type: integer - minimum: 0 + revision: + $ref: "#/components/schemas/ArtifactReference" nullable: true - ModelUsageStatistics: - type: object - additionalProperties: false - required: [generation, embedding] - properties: - generation: - $ref: "#/components/schemas/ModelUsageValue" - embedding: - $ref: "#/components/schemas/ModelUsageValue" - ModelUsagePurposeBreakdown: + FinalizeHandoffRequest: type: object additionalProperties: false - required: [purpose, generation, embedding] + required: [scope_id, draft] properties: - purpose: + scope_id: type: string - minLength: 1 - maxLength: 64 - generation: - $ref: "#/components/schemas/ModelUsageValue" - embedding: - $ref: "#/components/schemas/ModelUsageValue" - ModelUsageDay: + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + draft: + $ref: "#/components/schemas/HandoffDraft" + HandoffArtifactCitation: type: object additionalProperties: false - required: [date, generation, embedding, by_purpose] + required: [kind, artifact_ref] properties: - date: + kind: 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: + enum: [artifact] + artifact_ref: + $ref: "#/components/schemas/ArtifactReference" + HandoffActivation: type: object additionalProperties: false - required: [preset, start_date, end_date, timezone] + required: [status, boundary_source, previous_position, current_position, draft] properties: - preset: - $ref: "#/components/schemas/StatsPeriod" - start_date: - type: string - format: date - end_date: - type: string - format: date - timezone: - type: string - enum: [UTC] - UsageStatistics: + status: + $ref: "#/components/schemas/HandoffActivationStatus" + boundary_source: + $ref: "#/components/schemas/SourceReference" + previous_position: + type: integer + 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: [period, totals, by_purpose, daily] + required: [schema, objective, state, disposition, next_action, omissions] properties: - period: - $ref: "#/components/schemas/ResolvedUsagePeriod" - totals: - $ref: "#/components/schemas/ModelUsageStatistics" - by_purpose: + schema: + $ref: "#/components/schemas/HandoffSchema" + objective: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + state: type: array - maxItems: 16 + minItems: 1 + maxItems: 64 items: - $ref: "#/components/schemas/ModelUsagePurposeBreakdown" - daily: + $ref: "#/components/schemas/HandoffStatement" + disposition: + $ref: "#/components/schemas/HandoffDisposition" + next_action: + $ref: "#/components/schemas/HandoffStatement" + nullable: true + omissions: type: array - maxItems: 30 + maxItems: 64 items: - $ref: "#/components/schemas/ModelUsageDay" - TokenEstimatorProfile: + $ref: "#/components/schemas/HandoffOmission" + HandoffDraft: type: object additionalProperties: false - required: [estimator_id, version] + required: [objective, state, disposition, next_action, omissions] properties: - estimator_id: - type: string - minLength: 1 - maxLength: 128 - version: + objective: type: string minLength: 1 - maxLength: 64 - RecallTokenValue: + 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: + type: array + maxItems: 64 + items: + $ref: "#/components/schemas/HandoffOmission" + HandoffEvidenceCheck: type: object additionalProperties: false - required: [preparations, ready_preparations, comparable_preparations, baseline_tokens, recalled_tokens, token_reduction] + required: [claim, state_index, status, unavailable_evidence] 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: + claim: + $ref: "#/components/schemas/HandoffClaim" + state_index: type: integer minimum: 0 - token_reduction: - type: integer - RecallTokenDay: + nullable: true + status: + $ref: "#/components/schemas/HandoffEvidenceStatus" + unavailable_evidence: + type: array + maxItems: 32 + items: + $ref: "#/components/schemas/HandoffCitation" + HandoffMemoryCitation: type: object additionalProperties: false - required: [date, preparations, ready_preparations, comparable_preparations, baseline_tokens, recalled_tokens, token_reduction] + required: [kind, memory_citation] properties: - date: + kind: 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: + enum: [memory] + memory_citation: + $ref: "#/components/schemas/MemoryCitation" + HandoffOmission: type: object additionalProperties: false - required: [period, estimator, totals, daily] + required: [text, citation] properties: - period: - $ref: "#/components/schemas/ResolvedUsagePeriod" - estimator: - $ref: "#/components/schemas/TokenEstimatorProfile" + text: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + citation: + $ref: "#/components/schemas/HandoffCitation" nullable: true - totals: - $ref: "#/components/schemas/RecallTokenValue" - daily: - type: array - maxItems: 30 - items: - $ref: "#/components/schemas/RecallTokenDay" - ScopedStats: + HandoffResolution: type: object additionalProperties: false - required: [scope_id, as_of, inventory, usage, recall] + required: + [trust, status, scope_id, content, selection, selected_revision, current_revision, evidence_checks] properties: - scope_id: + trust: type: string - as_of: + enum: [untrusted_history] + status: + $ref: "#/components/schemas/HandoffResolutionStatus" + scope_id: type: string - format: date-time - inventory: - $ref: "#/components/schemas/InventoryStatistics" - usage: - $ref: "#/components/schemas/UsageStatistics" - recall: - $ref: "#/components/schemas/RecallTokenStatistics" - GetStatsRequest: + 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: + type: array + maxItems: 65 + items: + $ref: "#/components/schemas/HandoffEvidenceCheck" + HandoffSourceCitation: type: object additionalProperties: false - required: [scope_id] + required: [kind, source_ref] properties: - scope_id: + kind: type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - period: - $ref: "#/components/schemas/StatsPeriod" - default: 30d - WorkClaimBasis: - type: string - enum: [declared, verified] - WorkClaim: + enum: [source] + source_ref: + $ref: "#/components/schemas/SourceReference" + HandoffStatement: type: object additionalProperties: false - required: [text, basis, evidence] + required: [text, citations] properties: text: type: string minLength: 1 maxLength: 8192 pattern: '.*\S.*' - basis: - $ref: "#/components/schemas/WorkClaimBasis" - evidence: + citations: type: array - maxItems: 31 + minItems: 1 + maxItems: 32 items: $ref: "#/components/schemas/HandoffCitation" - WorkContract: + PrepareHandoffRequest: type: object additionalProperties: false - required: [schema, trust, objective, facts, in_scope, exclusions, completion_criteria, authorization_notes, open_questions] + required: [scope_id, objective, evidence] properties: - schema: - type: string - enum: [powercontext.work-contract.v1] - trust: + scope_id: type: string - enum: [untrusted_input] + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' objective: type: string minLength: 1 maxLength: 8192 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: + evidence: 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 + maxItems: 32 items: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - CreateWorkContractRequest: + $ref: "#/components/schemas/HandoffCitation" + max_bytes: + type: integer + minimum: 512 + maximum: 32768 + default: 8000 + PreparedHandoff: type: object additionalProperties: false - required: [scope_id, source_id, contract] + required: [schema, scope_id, base, content] properties: + schema: + $ref: "#/components/schemas/PreparedHandoffSchema" scope_id: type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - source_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - contract: - $ref: "#/components/schemas/WorkContract" - CurrentWorkHandoff: + base: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + content: + $ref: "#/components/schemas/HandoffContent" + PreparedContext: type: object additionalProperties: false - required: [schema, trust, objective, state, disposition, next_action, omissions] + required: [schema, status, content, content_bytes] properties: schema: + $ref: "#/components/schemas/PreparedContextSchema" + status: + $ref: "#/components/schemas/PreparedContextStatus" + content: type: string - enum: [powercontext.current-work-handoff.v1] - trust: + nullable: true + 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: + op: + $ref: "#/components/schemas/EntryChangeOperation" + entry_id: type: string - enum: [untrusted_input] - objective: + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + from_entry_version_id: type: string minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - state: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + nullable: true + to_entry_version_id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + nullable: true + reason: + type: string + nullable: true + 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 - minItems: 1 - maxItems: 64 items: - $ref: "#/components/schemas/WorkClaim" - disposition: - $ref: "#/components/schemas/HandoffDisposition" - next_action: - $ref: "#/components/schemas/WorkClaim" - nullable: true - omissions: + $ref: "#/components/schemas/SourceReference" + artifact_refs: type: array - maxItems: 64 items: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - HandoffCurrentWorkRequest: + $ref: "#/components/schemas/ArtifactReference" + ExperienceProposal: type: object additionalProperties: false - required: [scope_id, source_id, handoff] + required: [situation, action, outcome, lesson] properties: - scope_id: + situation: type: string minLength: 1 - maxLength: 256 + maxLength: 8000 pattern: '.*\S.*' - source_id: + action: type: string minLength: 1 - maxLength: 256 + maxLength: 8000 pattern: '.*\S.*' - handoff: - $ref: "#/components/schemas/CurrentWorkHandoff" - WorkSourceKind: + outcome: + type: string + minLength: 1 + maxLength: 8000 + pattern: '.*\S.*' + lesson: + type: string + 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" + SkillLifecycleState: type: string - enum: [work-contract, handoff-boundary, handoff-receipt, task-outcome] - WorkSourceReceipt: + enum: [active, deprecated, retired] + SkillGovernance: type: object additionalProperties: false - required: [kind, source, position, content_digest] + required: [artifact, lifecycle_state, replacement_artifact_id, governance_generation] properties: - kind: - $ref: "#/components/schemas/WorkSourceKind" - source: - $ref: "#/components/schemas/SourceReference" - position: - type: integer - minimum: 1 - content_digest: + artifact: + $ref: "#/components/schemas/ArtifactReference" + lifecycle_state: + $ref: "#/components/schemas/SkillLifecycleState" + replacement_artifact_id: type: string - minLength: 71 - maxLength: 71 - pattern: '^sha256:[0-9a-f]{64}$' - PreparedWorkHandoff: + minLength: 1 + maxLength: 128 + nullable: true + governance_generation: + type: integer + minimum: 0 + ManagedSkillLibraryEntry: type: object additionalProperties: false - required: [boundary, handoff] + required: [artifact, content, source_refs, artifact_refs, governance] 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: + 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" + governance: + $ref: "#/components/schemas/SkillGovernance" + ListManagedSkillsRequest: 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: [scope_id] properties: - live_state: - $ref: "#/components/schemas/LiveStateCheckStatus" - capability: - $ref: "#/components/schemas/ReceiverReadinessCheckStatus" - authorization: - $ref: "#/components/schemas/ReceiverReadinessCheckStatus" - AcknowledgeHandoffRequest: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + query: + type: string + minLength: 1 + maxLength: 2000 + nullable: true + include_deprecated: + type: boolean + default: false + limit: + type: integer + minimum: 1 + maximum: 200 + default: 100 + ListManagedSkillsResponse: type: object additionalProperties: false - required: [scope_id, source_id, receiver, status, selection] + required: [skills] + properties: + skills: + type: array + maxItems: 200 + items: + $ref: "#/components/schemas/ManagedSkillLibraryEntry" + UpdateSkillLifecycleRequest: + type: object + additionalProperties: false + required: [scope_id, artifact_id, expected_generation, lifecycle_state] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - source_id: + artifact_id: type: string minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - receiver: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + expected_generation: + type: integer + minimum: 0 + lifecycle_state: + $ref: "#/components/schemas/SkillLifecycleState" + replacement_artifact_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" + maxLength: 128 + pattern: '^[\x21-\x7E]+$' nullable: true - prepared: - $ref: "#/components/schemas/PreparedHandoff" + SkillProposal: + type: object + additionalProperties: false + required: [name, description, instructions, validation] + properties: + name: + type: string + minLength: 1 + maxLength: 128 + pattern: '^\S(?:.*\S)?$' + description: + type: string + minLength: 1 + maxLength: 2000 + pattern: '^\S(?:.*\S)?$' + instructions: + type: string + maxLength: 131072 + validation: + type: array + maxItems: 32 + items: + $ref: "#/components/schemas/SkillValidationItem" + package: + $ref: "#/components/schemas/SkillPackageReference" nullable: true - revision: - $ref: "#/components/schemas/ArtifactReference" + license: + type: string + minLength: 1 + maxLength: 512 nullable: true - message: + compatibility: type: string minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' + maxLength: 500 nullable: true - HandoffAcknowledgement: + metadata: + type: object + maxProperties: 64 + additionalProperties: + type: string + allowed_tools: + type: string + minLength: 1 + maxLength: 2000 + nullable: true + SkillPackageReference: type: object additionalProperties: false - required: [resolution, receipt] + required: [tree_digest, archive_digest, file_count, uncompressed_size, archive_size] 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: + tree_digest: + type: string + pattern: '^[0-9a-f]{64}$' + archive_digest: + type: string + pattern: '^[0-9a-f]{64}$' + file_count: + type: integer + minimum: 1 + maximum: 256 + uncompressed_size: + type: integer + minimum: 1 + maximum: 4194304 + archive_size: + type: integer + minimum: 1 + maximum: 5242880 + SkillPackageFile: type: object additionalProperties: false - required: [name, status, basis, evidence] + required: [path, digest, size, media_type, executable] properties: - name: + path: type: string minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - status: - $ref: "#/components/schemas/TaskCheckStatus" - details: + maxLength: 512 + digest: + type: string + pattern: '^[0-9a-f]{64}$' + size: + type: integer + minimum: 0 + maximum: 4194304 + media_type: type: string minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - nullable: true - basis: - $ref: "#/components/schemas/WorkClaimBasis" - evidence: - type: array - maxItems: 32 - items: - $ref: "#/components/schemas/HandoffCitation" - TaskOutcome: + maxLength: 255 + executable: + type: boolean + SkillPackageManifest: type: object additionalProperties: false - required: [schema, trust, objective, status, summary, observations, checks, produced_artifacts, remaining_work] + required: [package, name, description, metadata, files] properties: - schema: + package: + $ref: "#/components/schemas/SkillPackageReference" + name: type: string - enum: [powercontext.task-outcome.v1] - trust: + minLength: 1 + maxLength: 64 + description: type: string - enum: [untrusted_observation] - objective: + minLength: 1 + maxLength: 1024 + license: type: string minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - status: - $ref: "#/components/schemas/TaskOutcomeStatus" - summary: + maxLength: 512 + nullable: true + compatibility: type: string minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - handoff_receipt_ref: - $ref: "#/components/schemas/SourceReference" + maxLength: 500 nullable: true - observations: + metadata: + type: object + maxProperties: 64 + additionalProperties: + type: string + allowed_tools: + type: string + minLength: 1 + maxLength: 2000 + nullable: true + files: type: array minItems: 1 - maxItems: 64 - items: - $ref: "#/components/schemas/WorkClaim" - checks: - type: array - maxItems: 64 - items: - $ref: "#/components/schemas/TaskCheck" - produced_artifacts: - type: array - maxItems: 32 - items: - $ref: "#/components/schemas/ArtifactReference" - remaining_work: - type: array - maxItems: 64 + maxItems: 256 items: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - RecordTaskOutcomeRequest: + $ref: "#/components/schemas/SkillPackageFile" + GetSkillPackageRequest: type: object additionalProperties: false - required: [scope_id, source_id, outcome] + required: [scope_id, artifact] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - source_id: + artifact: + $ref: "#/components/schemas/ArtifactReference" + SkillPackageDownload: + type: object + additionalProperties: false + required: [package, archive_base64] + properties: + package: + $ref: "#/components/schemas/SkillPackageReference" + archive_base64: + type: string + minLength: 1 + maxLength: 6990508 + pattern: '^[A-Za-z0-9+/]*={0,2}$' + RemoteAgentKind: + type: string + enum: [codex, claude_code] + RemoteSkillTargetState: + type: string + enum: [pending, active, revoked] + RemoteSkillTarget: + type: object + additionalProperties: false + required: + - scope_id + - target_id + - display_name + - agent_kind + - installation_scope + - delivery_mode + - installation_id + - state + - receiver_version + - environment_fingerprint + - machine_hostname + - workspace_name + - last_seen_at + - generation + properties: + scope_id: type: string minLength: 1 maxLength: 256 + target_id: + type: string + minLength: 1 + maxLength: 64 + pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' + display_name: + type: string + minLength: 1 + maxLength: 128 pattern: '.*\S.*' - outcome: - $ref: "#/components/schemas/TaskOutcome" - CaptureContentSourceRequest: + agent_kind: + $ref: "#/components/schemas/RemoteAgentKind" + installation_scope: + type: string + enum: [project] + delivery_mode: + type: string + enum: [agent_pull] + installation_id: + type: string + minLength: 1 + maxLength: 128 + nullable: true + state: + $ref: "#/components/schemas/RemoteSkillTargetState" + receiver_version: + type: string + minLength: 1 + maxLength: 64 + nullable: true + environment_fingerprint: + type: string + pattern: '^[0-9a-f]{64}$' + nullable: true + machine_hostname: + type: string + minLength: 1 + maxLength: 255 + pattern: '.*\S.*' + nullable: true + workspace_name: + type: string + minLength: 1 + maxLength: 128 + pattern: '.*\S.*' + nullable: true + last_seen_at: + type: string + format: date-time + nullable: true + generation: + type: integer + minimum: 0 + ListRemoteSkillTargetsRequest: type: object additionalProperties: false - required: [scope_id, source_id, content] + required: [scope_id] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - source_id: - type: string - minLength: 1 - maxLength: 256 - content: + target_id: type: string minLength: 1 - maxLength: 200000 - metadata: - type: object - additionalProperties: true + maxLength: 64 + pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' nullable: true - CaptureContentSourceResponse: - type: object - additionalProperties: false - required: [status, source, position] - properties: - status: - $ref: "#/components/schemas/CaptureStatus" - source: - $ref: "#/components/schemas/SourceReference" - position: + limit: type: integer minimum: 1 - CommitHandoffRequest: + maximum: 200 + default: 100 + RemoteSkillTargetStatus: type: object additionalProperties: false - required: [scope_id, handoff] + required: [target, publications] properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - handoff: - $ref: "#/components/schemas/PreparedHandoff" - CommittedHandoff: + target: + $ref: "#/components/schemas/RemoteSkillTarget" + publications: + type: array + maxItems: 256 + items: + $ref: "#/components/schemas/RemoteSkillPublication" + ListRemoteSkillTargetsResponse: type: object additionalProperties: false - required: [reference, content, source_refs, artifact_refs] + required: [targets] properties: - reference: - $ref: "#/components/schemas/ArtifactReference" - content: - $ref: "#/components/schemas/HandoffContent" - source_refs: - type: array - items: - $ref: "#/components/schemas/SourceReference" - artifact_refs: + targets: type: array + maxItems: 200 items: - $ref: "#/components/schemas/ArtifactReference" - ContinueHandoffRequest: + $ref: "#/components/schemas/RemoteSkillTargetStatus" + CreateRemoteSkillTargetRequest: type: object additionalProperties: false - required: [scope_id, selection] + required: [scope_id, agent_kind, display_name] 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: - type: object - additionalProperties: false - required: [scope_id, draft] - properties: - scope_id: + agent_kind: + $ref: "#/components/schemas/RemoteAgentKind" + display_name: type: string minLength: 1 - maxLength: 256 + maxLength: 128 pattern: '.*\S.*' - draft: - $ref: "#/components/schemas/HandoffDraft" - HandoffArtifactCitation: + RemoteSkillTargetEnrollment: type: object additionalProperties: false - required: [kind, artifact_ref] + required: [target, enrollment_code, enrollment_expires_at] properties: - kind: + target: + $ref: "#/components/schemas/RemoteSkillTarget" + enrollment_code: type: string - enum: [artifact] - artifact_ref: - $ref: "#/components/schemas/ArtifactReference" - HandoffActivation: + minLength: 32 + maxLength: 256 + enrollment_expires_at: + type: string + format: date-time + EnrollRemoteSkillTargetRequest: type: object additionalProperties: false - required: [status, boundary_source, previous_position, current_position, draft] + required: [enrollment_code, installation_id, receiver_version] properties: - status: - $ref: "#/components/schemas/HandoffActivationStatus" - boundary_source: - $ref: "#/components/schemas/SourceReference" - previous_position: - type: integer - minimum: 0 - current_position: - type: integer - minimum: 0 - draft: - $ref: "#/components/schemas/HandoffDraft" + enrollment_code: + type: string + minLength: 32 + maxLength: 256 + installation_id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + receiver_version: + type: string + minLength: 1 + maxLength: 64 + pattern: '^[\x21-\x7E]+$' + environment_fingerprint: + type: string + pattern: '^[0-9a-f]{64}$' 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: [schema, objective, state, disposition, next_action, omissions] - properties: - schema: - $ref: "#/components/schemas/HandoffSchema" - objective: + machine_hostname: type: string minLength: 1 - maxLength: 8192 + maxLength: 255 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: - type: array - maxItems: 64 - items: - $ref: "#/components/schemas/HandoffOmission" - HandoffDraft: - type: object - additionalProperties: false - required: [objective, state, disposition, next_action, omissions] - properties: - objective: + workspace_name: type: string minLength: 1 - maxLength: 8192 + maxLength: 128 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: - type: array - maxItems: 64 - items: - $ref: "#/components/schemas/HandoffOmission" - HandoffEvidenceCheck: - type: object - additionalProperties: false - required: [claim, state_index, status, unavailable_evidence] - properties: - claim: - $ref: "#/components/schemas/HandoffClaim" - state_index: - type: integer - minimum: 0 nullable: true - status: - $ref: "#/components/schemas/HandoffEvidenceStatus" - unavailable_evidence: - type: array - maxItems: 32 - items: - $ref: "#/components/schemas/HandoffCitation" - HandoffMemoryCitation: + RemoteSkillTargetCredential: type: object additionalProperties: false - required: [kind, memory_citation] + required: [scope_id, target_id, agent_kind, credential] properties: - kind: + scope_id: type: string - enum: [memory] - memory_citation: - $ref: "#/components/schemas/MemoryCitation" - HandoffOmission: + minLength: 1 + maxLength: 256 + target_id: + type: string + minLength: 1 + maxLength: 64 + agent_kind: + $ref: "#/components/schemas/RemoteAgentKind" + credential: + type: string + minLength: 32 + maxLength: 256 + RevokeRemoteSkillTargetRequest: type: object additionalProperties: false - required: [text, citation] + required: [scope_id, target_id, expected_generation] properties: - text: + scope_id: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' - citation: - $ref: "#/components/schemas/HandoffCitation" - nullable: true - HandoffResolution: + target_id: + type: string + minLength: 1 + maxLength: 64 + pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' + expected_generation: + type: integer + minimum: 0 + RenameRemoteSkillTargetRequest: type: object additionalProperties: false - required: - [trust, status, scope_id, content, selection, selected_revision, current_revision, evidence_checks] + required: [scope_id, target_id, display_name, expected_generation] 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: - type: array - maxItems: 65 - items: - $ref: "#/components/schemas/HandoffEvidenceCheck" - HandoffSourceCitation: - type: object - additionalProperties: false - required: [kind, source_ref] - properties: - kind: + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + target_id: type: string - enum: [source] - source_ref: - $ref: "#/components/schemas/SourceReference" - HandoffStatement: + minLength: 1 + maxLength: 64 + pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' + display_name: + type: string + minLength: 1 + maxLength: 128 + pattern: '.*\S.*' + expected_generation: + type: integer + minimum: 0 + PublishRemoteSkillRequest: type: object additionalProperties: false - required: [text, citations] + required: [scope_id, target_id, artifact, expected_generation] properties: - text: + scope_id: type: string minLength: 1 - maxLength: 8192 + maxLength: 256 pattern: '.*\S.*' - citations: - type: array - minItems: 1 - maxItems: 32 - items: - $ref: "#/components/schemas/HandoffCitation" - PrepareHandoffRequest: + target_id: + type: string + minLength: 1 + maxLength: 64 + pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' + artifact: + $ref: "#/components/schemas/ArtifactReference" + expected_generation: + type: integer + minimum: 0 + nullable: true + allow_deprecated: + type: boolean + default: false + UnpublishRemoteSkillRequest: type: object additionalProperties: false - required: [scope_id, objective, evidence] + required: [scope_id, target_id, artifact_id, expected_generation] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - objective: + target_id: type: string minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - evidence: - type: array - minItems: 1 - maxItems: 32 - items: - $ref: "#/components/schemas/HandoffCitation" - max_bytes: + maxLength: 64 + pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' + artifact_id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + expected_generation: type: integer - minimum: 512 - maximum: 32768 - default: 8000 - PreparedHandoff: + minimum: 0 + RemoteSkillDesiredState: + type: string + enum: [published, unpublished] + RemoteSkillPublicationState: + type: string + enum: [unpublished, pending, current, update_available, delivery_failed, conflict, drifted, incompatible] + RemoteSkillPublication: type: object additionalProperties: false - required: [schema, scope_id, base, content] + required: + - scope_id + - target_id + - artifact_id + - desired_state + - desired_revision + - desired_tree_digest + - observed_revision + - observed_tree_digest + - observed_generation + - state + - last_error_code + - observed_at + - generation properties: - schema: - $ref: "#/components/schemas/PreparedHandoffSchema" scope_id: type: string - base: - $ref: "#/components/schemas/ArtifactReference" + target_id: + type: string + artifact_id: + type: string + desired_state: + $ref: "#/components/schemas/RemoteSkillDesiredState" + desired_revision: + type: integer + minimum: 1 + desired_tree_digest: + type: string + pattern: '^[0-9a-f]{64}$' + observed_revision: + type: integer + minimum: 1 nullable: true - content: - $ref: "#/components/schemas/HandoffContent" - PreparedContext: + observed_tree_digest: + type: string + pattern: '^[0-9a-f]{64}$' + nullable: true + observed_generation: + type: integer + minimum: 0 + nullable: true + state: + $ref: "#/components/schemas/RemoteSkillPublicationState" + last_error_code: + type: string + minLength: 1 + maxLength: 128 + nullable: true + observed_at: + type: string + format: date-time + nullable: true + generation: + type: integer + minimum: 0 + RemoteSkillObservation: type: object additionalProperties: false - required: [schema, status, content, content_bytes] + required: [artifact, tree_digest, actual_tree_digest, skill_name, applied_generation] properties: - schema: - $ref: "#/components/schemas/PreparedContextSchema" - status: - $ref: "#/components/schemas/PreparedContextStatus" - content: + artifact: + $ref: "#/components/schemas/ArtifactReference" + tree_digest: type: string + pattern: '^[0-9a-f]{64}$' + actual_tree_digest: + type: string + pattern: '^[0-9a-f]{64}$' nullable: true - content_bytes: + skill_name: + type: string + minLength: 1 + maxLength: 64 + pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' + applied_generation: type: integer minimum: 0 - EntryChange: + ReconcileRemoteSkillsRequest: type: object additionalProperties: false - required: [op, entry_id, from_entry_version_id, to_entry_version_id, reason] + required: [observations, receiver_version] properties: - op: - $ref: "#/components/schemas/EntryChangeOperation" - entry_id: + observations: + type: array + maxItems: 256 + items: + $ref: "#/components/schemas/RemoteSkillObservation" + receiver_version: type: string minLength: 1 - maxLength: 128 + maxLength: 64 pattern: '^[\x21-\x7E]+$' - from_entry_version_id: + environment_fingerprint: type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' + pattern: '^[0-9a-f]{64}$' nullable: true - to_entry_version_id: + RemoteSkillOperation: + type: string + enum: [install, unpublish] + RemoteSkillAction: + type: object + additionalProperties: false + required: + [operation, generation, artifact, tree_digest, skill_name, package, expected_local, blocked_error_code] + properties: + operation: + $ref: "#/components/schemas/RemoteSkillOperation" + generation: + type: integer + minimum: 0 + artifact: + $ref: "#/components/schemas/ArtifactReference" + tree_digest: + type: string + pattern: '^[0-9a-f]{64}$' + skill_name: type: string minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' + maxLength: 64 + package: + $ref: "#/components/schemas/SkillPackageReference" nullable: true - reason: + expected_local: + $ref: "#/components/schemas/RemoteSkillObservation" + nullable: true + blocked_error_code: type: string + minLength: 1 + maxLength: 128 nullable: true - ExperienceArtifact: + ReconcileRemoteSkillsResponse: type: object additionalProperties: false - required: [artifact, content, source_refs, artifact_refs] + required: [scope_id, target_id, actions] properties: - artifact: - $ref: "#/components/schemas/ArtifactReference" - content: - $ref: "#/components/schemas/ExperienceProposal" - source_refs: - type: array - items: - $ref: "#/components/schemas/SourceReference" - artifact_refs: + scope_id: + type: string + target_id: + type: string + actions: type: array + maxItems: 256 items: - $ref: "#/components/schemas/ArtifactReference" - ExperienceProposal: + $ref: "#/components/schemas/RemoteSkillAction" + DownloadRemoteSkillPackageRequest: type: object additionalProperties: false - required: [situation, action, outcome, lesson] + required: [generation, artifact, package] properties: - situation: + generation: + type: integer + minimum: 0 + artifact: + $ref: "#/components/schemas/ArtifactReference" + package: + $ref: "#/components/schemas/SkillPackageReference" + RemoteSkillReceiptOutcome: + type: string + enum: [succeeded, failed] + RemoteSkillFailureState: + type: string + enum: [delivery_failed, conflict, drifted, incompatible] + RecordRemoteSkillReceiptRequest: + type: object + additionalProperties: false + required: + - operation + - generation + - artifact + - expected_tree_digest + - observed_tree_digest + - outcome + - failure_state + - error_code + - receiver_version + - environment_fingerprint + properties: + operation: + $ref: "#/components/schemas/RemoteSkillOperation" + generation: + type: integer + minimum: 0 + artifact: + $ref: "#/components/schemas/ArtifactReference" + expected_tree_digest: type: string - minLength: 1 - maxLength: 8000 - pattern: '.*\S.*' - action: + pattern: '^[0-9a-f]{64}$' + observed_tree_digest: type: string - minLength: 1 - maxLength: 8000 - pattern: '.*\S.*' + pattern: '^[0-9a-f]{64}$' + nullable: true outcome: + $ref: "#/components/schemas/RemoteSkillReceiptOutcome" + failure_state: + $ref: "#/components/schemas/RemoteSkillFailureState" + nullable: true + error_code: type: string minLength: 1 - maxLength: 8000 - pattern: '.*\S.*' - lesson: + maxLength: 128 + nullable: true + receiver_version: type: string minLength: 1 - maxLength: 8000 - pattern: '.*\S.*' - SkillArtifact: + maxLength: 64 + pattern: '^[\x21-\x7E]+$' + environment_fingerprint: + type: string + pattern: '^[0-9a-f]{64}$' + nullable: true + RemoteSkillReceiptResponse: type: object additionalProperties: false - required: [artifact, content, source_refs, artifact_refs] + required: [accepted, stale, publication] 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: + accepted: + type: boolean + stale: + type: boolean + publication: + $ref: "#/components/schemas/RemoteSkillPublication" + ProposeSkillPackageRequest: type: object additionalProperties: false - required: [name, description, instructions, validation] + required: [scope_id, archive_base64] properties: - name: + scope_id: type: string minLength: 1 - maxLength: 128 - pattern: '^\S(?:.*\S)?$' - description: + maxLength: 256 + pattern: '.*\S.*' + archive_base64: + type: string + minLength: 1 + maxLength: 6990508 + pattern: '^[A-Za-z0-9+/]*={0,2}$' + reason: type: string minLength: 1 maxLength: 2000 - pattern: '^\S(?:.*\S)?$' - instructions: + nullable: true + target: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + description: Exact managed Skill Revision replaced by this complete package Candidate. + RecordSkillUsageRequest: + type: object + additionalProperties: false + required: + - scope_id + - observation_id + - skill_ref + - package_digest + - target_id + - selected + - invoked + - validation + - outcome + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + observation_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + skill_ref: + $ref: "#/components/schemas/ArtifactReference" + package_digest: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + target_id: type: string minLength: 1 - maxLength: 32000 + maxLength: 128 pattern: '.*\S.*' + selected: + type: boolean + invoked: + type: string + enum: ['true', 'false', unknown] validation: - type: array - minItems: 1 - maxItems: 32 - items: - $ref: "#/components/schemas/SkillValidationItem" + type: string + enum: [passed, failed, unknown] + outcome: + type: string + enum: [success, failure, unknown] + task_source: + $ref: "#/components/schemas/SourceReference" + nullable: true + environment_fingerprint: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + nullable: true SkillValidationItem: type: string minLength: 1 diff --git a/pyproject.toml b/pyproject.toml index d84445595..36f1816b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,8 +40,10 @@ classifiers = [ builtin = [ "aiosqlite>=0.22,<1", "apscheduler>=3.11,<4", + "packaging>=24,<27", "pydantic-ai-slim[anthropic,openai]>=2.27.1,<3", "pydantic-settings>=2.7,<3", + "pyyaml>=6,<7", "pyobvector>=0.2.28,<0.3", "sqlalchemy[asyncio]>=2,<3", "sqlite-vec>=0.1.9,<0.2", @@ -53,7 +55,9 @@ seekdb = [ client = [ "httpx[socks]>=0.28,<1", "opentelemetry-api>=1.30,<2", + "packaging>=24,<27", "pydantic-settings>=2.7,<3", + "pyyaml>=6,<7", ] server = [ "fastapi>=0.115,<1", @@ -73,8 +77,10 @@ tracing-otlp = [ ] cli = [ "inquirerpy>=0.3,<1", + "packaging>=24,<27", "platformdirs>=4,<5", "powercontext[client]", + "pyyaml>=6,<7", "typer>=0.16,<1", ] diff --git a/src/powercontext/builtin/artifacts/skill/__init__.py b/src/powercontext/builtin/artifacts/skill/__init__.py index a94954c66..5816e862d 100644 --- a/src/powercontext/builtin/artifacts/skill/__init__.py +++ b/src/powercontext/builtin/artifacts/skill/__init__.py @@ -14,6 +14,15 @@ """Built-in managed Skill Artifact Family.""" +from powercontext.builtin.artifacts.skill.compatibility import ( + SkillCompatibilityAssessment, + SkillCompatibilityState, + SkillRuntimeManifest, + SkillRuntimeRequirements, + SkillRuntimeVariant, + assess_skill_compatibility, + target_environment_fingerprint, +) from powercontext.builtin.artifacts.skill.external import ( MAX_EXTERNAL_SKILL_DESCRIPTION_LENGTH, MAX_EXTERNAL_SKILL_FILES, @@ -22,9 +31,11 @@ MAX_EXTERNAL_SKILL_MANIFEST_BYTES, MAX_EXTERNAL_SKILL_NAME_LENGTH, MAX_EXTERNAL_SKILL_PACKAGE_BYTES, + AgentEnvironmentProfile, AgentKind, AgentSkillProvider, AgentSkillTarget, + CapturedExternalSkillPackage, CodexSkillProvider, CodexSkillRoot, ExternalSkillInstallationScope, @@ -44,6 +55,7 @@ SkillGenerator, ) from powercontext.builtin.artifacts.skill.models import ( + MAX_SKILL_COMPATIBILITY_LENGTH, MAX_SKILL_DESCRIPTION_LENGTH, MAX_SKILL_INSTRUCTIONS_LENGTH, MAX_SKILL_NAME_LENGTH, @@ -52,11 +64,32 @@ Skill, SkillContent, SkillDraft, + SkillPackageRef, +) +from powercontext.builtin.artifacts.skill.package import ( + MAX_SKILL_ARCHIVE_BYTES, + MAX_SKILL_ENTRYPOINT_BYTES, + MAX_SKILL_MANIFEST_BYTES, + MAX_SKILL_PACKAGE_BYTES, + MAX_SKILL_PACKAGE_FILES, + MAX_SKILL_PATH_BYTES, + SKILL_ENTRYPOINT, + SkillPackageEntry, + SkillPackageError, + SkillPackageMetadata, + SkillPackageSnapshot, + build_instruction_skill_package, + capture_skill_archive, + capture_skill_directory, + materialize_skill_package, + package_file, ) from powercontext.builtin.artifacts.skill.prompts import ( SKILL_GENERATION_INSTRUCTIONS, SKILL_GENERATION_INSTRUCTIONS_VERSION, ) +from powercontext.builtin.artifacts.skill.provenance import SkillOrigin, SkillOriginKind +from powercontext.builtin.artifacts.skill.search import SkillSearchHit, skill_search_text, skill_searchable_text __all__ = [ "MAX_EXTERNAL_SKILL_DESCRIPTION_LENGTH", @@ -66,16 +99,26 @@ "MAX_EXTERNAL_SKILL_MANIFEST_BYTES", "MAX_EXTERNAL_SKILL_NAME_LENGTH", "MAX_EXTERNAL_SKILL_PACKAGE_BYTES", + "MAX_SKILL_ARCHIVE_BYTES", + "MAX_SKILL_COMPATIBILITY_LENGTH", "MAX_SKILL_DESCRIPTION_LENGTH", + "MAX_SKILL_ENTRYPOINT_BYTES", "MAX_SKILL_INSTRUCTIONS_LENGTH", + "MAX_SKILL_MANIFEST_BYTES", "MAX_SKILL_NAME_LENGTH", + "MAX_SKILL_PACKAGE_BYTES", + "MAX_SKILL_PACKAGE_FILES", + "MAX_SKILL_PATH_BYTES", "MAX_SKILL_VALIDATION_ITEMS", "MAX_SKILL_VALIDATION_ITEM_LENGTH", + "SKILL_ENTRYPOINT", "SKILL_GENERATION_INSTRUCTIONS", "SKILL_GENERATION_INSTRUCTIONS_VERSION", + "AgentEnvironmentProfile", "AgentKind", "AgentSkillProvider", "AgentSkillTarget", + "CapturedExternalSkillPackage", "CodexSkillProvider", "CodexSkillRoot", "ExternalSkillInstallationScope", @@ -90,8 +133,30 @@ "ExternalSkillSnapshotUnavailableError", "LLMSkillGenerator", "Skill", + "SkillCompatibilityAssessment", + "SkillCompatibilityState", "SkillContent", "SkillDraft", "SkillGenerationOutput", "SkillGenerator", + "SkillOrigin", + "SkillOriginKind", + "SkillPackageEntry", + "SkillPackageError", + "SkillPackageMetadata", + "SkillPackageRef", + "SkillPackageSnapshot", + "SkillRuntimeManifest", + "SkillRuntimeRequirements", + "SkillRuntimeVariant", + "SkillSearchHit", + "assess_skill_compatibility", + "build_instruction_skill_package", + "capture_skill_archive", + "capture_skill_directory", + "materialize_skill_package", + "package_file", + "skill_search_text", + "skill_searchable_text", + "target_environment_fingerprint", ] diff --git a/src/powercontext/builtin/artifacts/skill/compatibility.py b/src/powercontext/builtin/artifacts/skill/compatibility.py new file mode 100644 index 000000000..acde1db54 --- /dev/null +++ b/src/powercontext/builtin/artifacts/skill/compatibility.py @@ -0,0 +1,366 @@ +# 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. + +"""Deterministic, non-executing compatibility assessment for Agent targets.""" + +from __future__ import annotations + +import hashlib +import json +from enum import StrEnum +from pathlib import PurePosixPath +from typing import Literal + +import yaml +from packaging.specifiers import InvalidSpecifier, SpecifierSet +from packaging.version import InvalidVersion, Version +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator + +from powercontext.builtin.artifacts.skill.external import AgentEnvironmentProfile, AgentSkillTarget +from powercontext.builtin.artifacts.skill.models import SkillContent +from powercontext.builtin.artifacts.skill.package import SkillPackageError, SkillPackageSnapshot, package_file +from powercontext.builtin.artifacts.skill.projection import validate_skill_projection_target + + +class SkillCompatibilityState(StrEnum): + """Static compatibility conclusion without executing package content.""" + + COMPATIBLE = "compatible" + INCOMPATIBLE = "incompatible" + UNKNOWN = "unknown" + MANUAL_REVIEW_REQUIRED = "manual_review_required" + + +class SkillCompatibilityAssessment(BaseModel): + """One rebuildable assessment for an exact package and target profile.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + state: SkillCompatibilityState + reasons: tuple[str, ...] + environment_fingerprint: str + selected_runtime_variant: str | None = None + + +class SkillRuntimeRequirements(BaseModel): + """Declarative needs for one optional PowerContext runtime variant.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + operating_systems: tuple[Literal["linux", "darwin", "windows", "other"], ...] = Field(min_length=1) + commands: dict[str, str] = Field(default_factory=dict, max_length=64) + network: Literal["none", "required"] = "none" + writable_roots: tuple[str, ...] = Field(default=(), max_length=32) + + +class SkillRuntimeVariant(BaseModel): + """One non-executing runtime choice declared inside the exact package.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + id: str = Field(min_length=1, max_length=64, pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + entrypoint: str = Field(min_length=1, max_length=512) + interpreter: str = Field(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9][A-Za-z0-9+._-]*$") + requirements: SkillRuntimeRequirements + + @field_validator("entrypoint") + @classmethod + def validate_entrypoint(cls, value: str) -> str: + path = PurePosixPath(value) + if path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts) or "\\" in value: + raise ValueError("runtime entrypoint must be a safe package-relative path") # noqa: TRY003 + return value + + +class SkillRuntimeManifest(BaseModel): + """Optional namespaced runtime declaration retained inside the package.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + schema_: Literal["powercontext.skill-runtime.v1"] = Field(alias="schema") + variants: tuple[SkillRuntimeVariant, ...] = Field(min_length=1, max_length=32) + + @field_validator("variants") + @classmethod + def validate_unique_variants(cls, value: tuple[SkillRuntimeVariant, ...]) -> tuple[SkillRuntimeVariant, ...]: + ids = [variant.id for variant in value] + if len(ids) != len(set(ids)): + raise ValueError("runtime variant IDs must be unique") # noqa: TRY003 + return value + + +def assess_skill_compatibility( + content: SkillContent, + package: SkillPackageSnapshot, + target: AgentSkillTarget, + /, +) -> SkillCompatibilityAssessment: + """Assess format and observable interpreter availability without running scripts.""" + + fingerprint = target_environment_fingerprint(target) + try: + validate_skill_projection_target(content, target) + except ValueError as error: + return SkillCompatibilityAssessment( + state=SkillCompatibilityState.INCOMPATIBLE, + reasons=(str(error),), + environment_fingerprint=fingerprint, + ) + + scripts = tuple(entry for entry in package.entries if entry.path.startswith("scripts/")) + runtime = _runtime_manifest(package) + if isinstance(runtime, str): + return SkillCompatibilityAssessment( + state=SkillCompatibilityState.INCOMPATIBLE, + reasons=(runtime,), + environment_fingerprint=fingerprint, + ) + if runtime is not None: + return _assess_runtime_manifest(package, target, runtime, fingerprint) + if not scripts: + return SkillCompatibilityAssessment( + state=SkillCompatibilityState.COMPATIBLE, + reasons=("The package satisfies the Agent format and contains no scripts.",), + environment_fingerprint=fingerprint, + ) + if target.environment is None: + return SkillCompatibilityAssessment( + state=SkillCompatibilityState.UNKNOWN, + reasons=("The target has no observed environment profile for package scripts.",), + environment_fingerprint=fingerprint, + ) + + required_commands = sorted({_required_command(entry.path) for entry in scripts} - {None}) + missing = tuple(command for command in required_commands if command not in target.environment.commands) + unclassified = tuple(entry.path for entry in scripts if _required_command(entry.path) is None) + reasons = [] + if missing: + reasons.append(f"Observed target profile does not report required commands: {', '.join(missing)}.") + if unclassified: + reasons.append(f"Script runtime requires manual review: {', '.join(unclassified)}.") + if reasons: + return SkillCompatibilityAssessment( + state=SkillCompatibilityState.MANUAL_REVIEW_REQUIRED, + reasons=tuple(reasons), + environment_fingerprint=fingerprint, + ) + return SkillCompatibilityAssessment( + state=SkillCompatibilityState.COMPATIBLE, + reasons=("The Agent format and declared script interpreters match the observed target profile.",), + environment_fingerprint=fingerprint, + ) + + +def _runtime_manifest(package: SkillPackageSnapshot) -> SkillRuntimeManifest | str | None: + path = "powercontext.runtime.yaml" + if path not in {entry.path for entry in package.entries}: + return None + try: + content = package_file(package, path).decode("utf-8") + parsed = yaml.safe_load(content) + return SkillRuntimeManifest.model_validate(parsed) + except (UnicodeDecodeError, yaml.YAMLError, ValidationError, SkillPackageError) as error: + return f"The optional PowerContext runtime declaration is invalid: {error}" + + +def _assess_runtime_manifest( + package: SkillPackageSnapshot, + target: AgentSkillTarget, + manifest: SkillRuntimeManifest, + fingerprint: str, +) -> SkillCompatibilityAssessment: + entries = {entry.path for entry in package.entries} + missing_entrypoints = tuple( + variant.entrypoint for variant in manifest.variants if variant.entrypoint not in entries + ) + if missing_entrypoints: + return SkillCompatibilityAssessment( + state=SkillCompatibilityState.INCOMPATIBLE, + reasons=(f"Runtime variants refer to missing package files: {', '.join(missing_entrypoints)}.",), + environment_fingerprint=fingerprint, + ) + if target.environment is None: + return SkillCompatibilityAssessment( + state=SkillCompatibilityState.UNKNOWN, + reasons=("The target has no observed environment profile for declared runtime variants.",), + environment_fingerprint=fingerprint, + ) + + operating_system = ( + "darwin" if target.environment.operating_system == "macos" else target.environment.operating_system + ) + matching_os = tuple( + variant for variant in manifest.variants if operating_system in variant.requirements.operating_systems + ) + if not matching_os: + return SkillCompatibilityAssessment( + state=SkillCompatibilityState.INCOMPATIBLE, + reasons=(f"No runtime variant supports the observed operating system: {operating_system}.",), + environment_fingerprint=fingerprint, + ) + + manual_reasons: list[str] = [] + incompatible_reasons: list[str] = [] + for variant in matching_os: + result = _assess_variant(variant, target.environment) + if result is None: + return SkillCompatibilityAssessment( + state=SkillCompatibilityState.COMPATIBLE, + reasons=(f"Runtime variant {variant.id} matches the observed target profile.",), + environment_fingerprint=fingerprint, + selected_runtime_variant=variant.id, + ) + state, reason = result + (manual_reasons if state is SkillCompatibilityState.MANUAL_REVIEW_REQUIRED else incompatible_reasons).append( + reason + ) + if manual_reasons: + return SkillCompatibilityAssessment( + state=SkillCompatibilityState.MANUAL_REVIEW_REQUIRED, + reasons=tuple(manual_reasons + incompatible_reasons), + environment_fingerprint=fingerprint, + ) + return SkillCompatibilityAssessment( + state=SkillCompatibilityState.INCOMPATIBLE, + reasons=tuple(incompatible_reasons), + environment_fingerprint=fingerprint, + ) + + +def _assess_variant( + variant: SkillRuntimeVariant, + environment: AgentEnvironmentProfile, +) -> tuple[SkillCompatibilityState, str] | None: + commands = { + **variant.requirements.commands, + variant.interpreter: variant.requirements.commands.get(variant.interpreter, ""), + } + command_assessment, uncertain_versions = _assess_command_requirements(variant, environment, commands) + if command_assessment is not None: + return command_assessment + + network_assessment = _assess_network_requirement(variant, environment) + if network_assessment is not None: + return network_assessment + + missing_roots = set(variant.requirements.writable_roots) - set(environment.writable_roots) + if missing_roots: + return ( + SkillCompatibilityState.INCOMPATIBLE, + f"Runtime variant {variant.id} requires unavailable writable roots: {', '.join(sorted(missing_roots))}.", + ) + if uncertain_versions: + return ( + SkillCompatibilityState.MANUAL_REVIEW_REQUIRED, + f"Runtime variant {variant.id} has unparseable command versions: {', '.join(uncertain_versions)}.", + ) + return None + + +def _assess_command_requirements( + variant: SkillRuntimeVariant, + environment: AgentEnvironmentProfile, + commands: dict[str, str], +) -> tuple[tuple[SkillCompatibilityState, str] | None, tuple[str, ...]]: + missing = tuple(command for command in commands if command not in environment.commands) + if missing: + return ( + ( + SkillCompatibilityState.INCOMPATIBLE, + f"Runtime variant {variant.id} requires unavailable commands: {', '.join(sorted(missing))}.", + ), + (), + ) + uncertain_versions = [] + mismatched_versions = [] + for command, requirement in commands.items(): + if not requirement: + continue + matches = _version_matches(environment.commands[command], requirement) + if matches is None: + uncertain_versions.append(command) + elif not matches: + mismatched_versions.append(command) + if mismatched_versions: + return ( + ( + SkillCompatibilityState.INCOMPATIBLE, + f"Runtime variant {variant.id} has unsupported command versions: {', '.join(sorted(mismatched_versions))}.", + ), + (), + ) + return None, tuple(sorted(uncertain_versions)) + + +def _assess_network_requirement( + variant: SkillRuntimeVariant, + environment: AgentEnvironmentProfile, +) -> tuple[SkillCompatibilityState, str] | None: + if variant.requirements.network != "required": + return None + if environment.network_policy == "disabled": + return ( + SkillCompatibilityState.INCOMPATIBLE, + f"Runtime variant {variant.id} requires disabled network access.", + ) + if environment.network_policy in {"restricted", "unknown"}: + return ( + SkillCompatibilityState.MANUAL_REVIEW_REQUIRED, + f"Runtime variant {variant.id} requires network access under {environment.network_policy} policy.", + ) + return None + + +def _version_matches(observed: str, requirement: str) -> bool | None: + try: + return Version(observed) in SpecifierSet(requirement) + except (InvalidVersion, InvalidSpecifier): + return None + + +def target_environment_fingerprint(target: AgentSkillTarget, /) -> str: + """Hash only secret-free target compatibility facts and adapter identity.""" + + value = { + "agent_kind": target.agent_kind, + "installation_scope": target.installation_scope, + "environment": None if target.environment is None else target.environment.model_dump(mode="json"), + } + canonical = json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode() + return hashlib.sha256(canonical).hexdigest() + + +def _required_command(path: str) -> str | None: + suffix = path.rpartition(".")[2].casefold() + return { + "py": "python", + "sh": "bash", + "bash": "bash", + "js": "node", + "mjs": "node", + "ts": "node", + "ps1": "pwsh", + "rb": "ruby", + }.get(suffix) + + +__all__ = [ + "SkillCompatibilityAssessment", + "SkillCompatibilityState", + "SkillRuntimeManifest", + "SkillRuntimeRequirements", + "SkillRuntimeVariant", + "assess_skill_compatibility", + "target_environment_fingerprint", +] diff --git a/src/powercontext/builtin/artifacts/skill/distribution.py b/src/powercontext/builtin/artifacts/skill/distribution.py new file mode 100644 index 000000000..454bb0990 --- /dev/null +++ b/src/powercontext/builtin/artifacts/skill/distribution.py @@ -0,0 +1,886 @@ +# 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. + +"""Credential-bound desired-state reconciliation for remote Agent Skill targets.""" + +# Domain failures keep bounded contextual detail at the call site while exposing +# stable error codes to HTTP adapters. +# ruff: noqa: TRY003 + +from __future__ import annotations + +import hashlib +import secrets +from collections.abc import Callable +from datetime import UTC, datetime, timedelta +from enum import StrEnum +from hmac import compare_digest +from uuid import uuid4 + +from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator + +from powercontext.artifacts import ArtifactRef +from powercontext.builtin.artifacts.skill.external import AgentKind +from powercontext.builtin.artifacts.skill.models import Skill, SkillPackageRef +from powercontext.builtin.artifacts.skill.package import SkillPackageSnapshot +from powercontext.builtin.artifacts.skill.projection import AgentSkillProjectionState +from powercontext.builtin.persistence.agent_skill_targets import ( + RemoteAgentSkillTarget, + RemoteAgentSkillTargetRepository, + RemoteAgentSkillTargetState, +) +from powercontext.builtin.persistence.artifact_governance import ( + ArtifactGovernanceRepository, + ArtifactLifecycleState, +) +from powercontext.builtin.persistence.artifacts import ArtifactRepository +from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.persistence.errors import RepositoryNotFoundError, StoredPayloadConflictError +from powercontext.builtin.persistence.skill_packages import SkillPackageRepository +from powercontext.builtin.persistence.skill_publications import ( + SkillPublication, + SkillPublicationDesiredState, + SkillPublicationRepository, +) +from powercontext.builtin.sources import validate_scope_id +from powercontext.errors import PowerContextError + +Clock = Callable[[], datetime] +ValueFactory = Callable[[], str] +_ENROLLMENT_LIFETIME = timedelta(minutes=10) + + +class RemoteSkillDistributionError(PowerContextError): + """Base error with a stable, non-secret code suitable for an HTTP response.""" + + code = "remote_skill_distribution_error" + + def __init__(self, detail: str) -> None: + self.detail = detail + super().__init__(detail) + + +class RemoteTargetAuthenticationError(RemoteSkillDistributionError): + code = "invalid_target_credential" + + +class RemoteTargetEnrollmentError(RemoteSkillDistributionError): + code = "invalid_enrollment" + + +class RemoteTargetStateError(RemoteSkillDistributionError): + code = "invalid_target_state" + + +class RemotePublicationGenerationError(RemoteSkillDistributionError): + code = "publication_generation_conflict" + + +class RemoteSkillLifecycleError(RemoteSkillDistributionError): + code = "invalid_skill_lifecycle" + + +class RemoteTargetEnrollment(BaseModel): + """Pending target plus the enrollment code returned exactly once.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + target: RemoteAgentSkillTarget + enrollment_code: SecretStr + + +class RemoteTargetCredential(BaseModel): + """Activated target plus its credential returned exactly once.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + scope_id: str + target_id: str + agent_kind: AgentKind + credential: SecretStr + + +class RemoteSkillOperation(StrEnum): + INSTALL = "install" + UNPUBLISH = "unpublish" + + +class RemoteSkillReceiptOutcome(StrEnum): + SUCCEEDED = "succeeded" + FAILED = "failed" + + +class RemoteSkillObservation(BaseModel): + """Credential-bound ownership checkpoint plus the target's actual tree observation.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + artifact: ArtifactRef + tree_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + actual_tree_digest: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$") + skill_name: str = Field(min_length=1, max_length=64, pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + applied_generation: int = Field(ge=0) + + +class RemoteSkillAction(BaseModel): + """One idempotent desired-state action; it never carries a path or executable command.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + operation: RemoteSkillOperation + generation: int = Field(ge=0) + artifact: ArtifactRef + tree_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + skill_name: str = Field(min_length=1, max_length=64, pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + package: SkillPackageRef | None = None + expected_local: RemoteSkillObservation | None = None + blocked_error_code: str | None = Field(default=None, min_length=1, max_length=128) + + @model_validator(mode="after") + def validate_operation_payload(self) -> RemoteSkillAction: + if self.operation is RemoteSkillOperation.INSTALL and self.package is None: + raise ValueError("install action requires an exact package") + if self.operation is RemoteSkillOperation.UNPUBLISH and self.package is not None: + raise ValueError("unpublish action cannot carry a package") + return self + + +class RemoteSkillReconcileResult(BaseModel): + """Latest target intent, with obsolete generations collapsed before delivery.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + scope_id: str + target_id: str + actions: tuple[RemoteSkillAction, ...] + + +class RemoteSkillReceipt(BaseModel): + """Bounded evidence for one exact action generation.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + operation: RemoteSkillOperation + generation: int = Field(ge=0) + artifact: ArtifactRef + expected_tree_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + observed_tree_digest: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$") + outcome: RemoteSkillReceiptOutcome + failure_state: AgentSkillProjectionState | None = None + error_code: str | None = Field(default=None, min_length=1, max_length=128) + receiver_version: str = Field(min_length=1, max_length=64) + environment_fingerprint: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$") + + @model_validator(mode="after") + def validate_outcome_payload(self) -> RemoteSkillReceipt: + failure_states = { + AgentSkillProjectionState.DELIVERY_FAILED, + AgentSkillProjectionState.CONFLICT, + AgentSkillProjectionState.DRIFTED, + AgentSkillProjectionState.INCOMPATIBLE, + } + if self.outcome is RemoteSkillReceiptOutcome.SUCCEEDED: + if self.failure_state is not None or self.error_code is not None: + raise ValueError("successful Receipt cannot carry failure details") + if self.operation is RemoteSkillOperation.INSTALL and self.observed_tree_digest is None: + raise ValueError("successful install Receipt requires an observed tree digest") + if self.operation is RemoteSkillOperation.UNPUBLISH and self.observed_tree_digest is not None: + raise ValueError("successful unpublish Receipt must observe absence") + elif self.failure_state not in failure_states or self.error_code is None: + raise ValueError("failed Receipt requires a bounded delivery failure state and code") + return self + + +class RemoteSkillReceiptResult(BaseModel): + """Whether a Receipt changed or already matched the authoritative observation.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + accepted: bool + stale: bool + publication: SkillPublication + + +class RemoteSkillTargetStatus(BaseModel): + """Credential-free administrative view of one target and its publications.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + target: RemoteAgentSkillTarget + publications: tuple[SkillPublication, ...] + + +class RemoteSkillDistributionService: + """Own remote target identity, desired publication state, exact package access, and Receipts.""" + + def __init__( + self, + *, + database: AsyncDatabase, + targets: RemoteAgentSkillTargetRepository, + artifacts: ArtifactRepository, + governance: ArtifactGovernanceRepository, + packages: SkillPackageRepository, + publications: SkillPublicationRepository, + clock: Clock | None = None, + id_factory: ValueFactory | None = None, + secret_factory: ValueFactory | None = None, + ) -> None: + self._database = database + self._targets = targets + self._artifacts = artifacts + self._governance = governance + self._packages = packages + self._publications = publications + self._clock = _utc_now if clock is None else clock + self._id_factory = _random_id if id_factory is None else id_factory + self._secret_factory = _random_secret if secret_factory is None else secret_factory + + async def list_targets( + self, + scope_id: str, + /, + *, + target_id: str | None = None, + limit: int = 100, + ) -> tuple[RemoteSkillTargetStatus, ...]: + """Return bounded target status without enrollment or installation credentials.""" + + scope = validate_scope_id(scope_id) + async with self._database.transaction() as connection: + if target_id is None: + targets = await self._targets.list_for_scope(connection, scope, limit=limit) + else: + target = await self._targets.find(connection, scope, target_id) + targets = () if target is None else (target,) + statuses: list[RemoteSkillTargetStatus] = [] + for target in targets: + statuses.append( + RemoteSkillTargetStatus( + target=target, + publications=await self._publications.list_for_target( + connection, + target.scope_id, + target.target_id, + ), + ) + ) + return tuple(statuses) + + async def create_target( + self, + scope_id: str, + agent_kind: AgentKind, + display_name: str, + /, + ) -> RemoteTargetEnrollment: + """Create a pending project target and return its one-time enrollment code.""" + + scope = validate_scope_id(scope_id) + now = _as_utc(self._clock()) + target_id = f"{agent_kind.replace('_', '-')}-{self._id_factory()[:12]}" + enrollment_code = f"pce_{self._secret_factory()}" + target = RemoteAgentSkillTarget( + scope_id=scope, + target_id=target_id, + display_name=_normalized_target_display_name(display_name), + agent_kind=agent_kind, + state=RemoteAgentSkillTargetState.PENDING, + enrollment_token_digest=_digest(enrollment_code), + enrollment_expires_at=now + _ENROLLMENT_LIFETIME, + generation=0, + created_at=now, + updated_at=now, + ) + async with self._database.transaction() as connection: + await self._targets.create(connection, target) + return RemoteTargetEnrollment(target=target, enrollment_code=SecretStr(enrollment_code)) + + async def enroll( + self, + enrollment_code: str, + installation_id: str, + receiver_version: str, + environment_fingerprint: str | None, + machine_hostname: str | None = None, + workspace_name: str | None = None, + /, + ) -> RemoteTargetCredential: + """Consume one pending code and bind a unique Receiver installation.""" + + now = _as_utc(self._clock()) + async with self._database.transaction() as connection: + target = await self._targets.find_by_enrollment_token(connection, _digest(enrollment_code)) + if ( + target is None + or target.state is not RemoteAgentSkillTargetState.PENDING + or target.generation != 0 + or _as_utc(target.enrollment_expires_at) <= now + ): + raise RemoteTargetEnrollmentError("the enrollment code is invalid, expired, or already consumed") + subject = f"installation-{self._id_factory()}" + credential = f"pct_{subject}.{self._secret_factory()}" + active_payload = target.model_copy( + update={ + "installation_id": installation_id, + "state": RemoteAgentSkillTargetState.ACTIVE, + "enrollment_token_digest": None, + "enrollment_expires_at": None, + "credential_subject": subject, + "credential_verifier": _digest(credential), + "receiver_version": receiver_version, + "environment_fingerprint": environment_fingerprint, + "machine_hostname": _normalized_optional_label(machine_hostname, max_length=255), + "workspace_name": _normalized_optional_label(workspace_name, max_length=128), + "last_seen_at": now, + } + ) + try: + active = await self._targets.replace(connection, active_payload, target.generation) + except StoredPayloadConflictError as error: + raise RemoteTargetEnrollmentError("the enrollment code or installation is already bound") from error + return RemoteTargetCredential( + scope_id=active.scope_id, + target_id=active.target_id, + agent_kind=active.agent_kind, + credential=SecretStr(credential), + ) + + async def rename_target( + self, + scope_id: str, + target_id: str, + expected_generation: int, + display_name: str, + /, + ) -> RemoteAgentSkillTarget: + """Change only the human-readable target name using generation CAS.""" + + scope = validate_scope_id(scope_id) + normalized_name = _normalized_target_display_name(display_name) + async with self._database.transaction() as connection: + target = await self._require_target(connection, scope, target_id) + if target.generation != expected_generation: + raise RemoteTargetStateError("remote target generation changed") + if target.display_name == normalized_name: + return target + return await self._targets.replace( + connection, + target.model_copy(update={"display_name": normalized_name}), + expected_generation, + ) + + async def revoke_target( + self, + scope_id: str, + target_id: str, + expected_generation: int, + /, + ) -> RemoteAgentSkillTarget: + """Revoke future target calls while retaining durable identity and publications.""" + + scope = validate_scope_id(scope_id) + async with self._database.transaction() as connection: + target = await self._require_target(connection, scope, target_id) + if target.generation != expected_generation: + raise RemoteTargetStateError("remote target generation changed") + if target.state is RemoteAgentSkillTargetState.REVOKED: + return target + revoked = target.model_copy( + update={ + "state": RemoteAgentSkillTargetState.REVOKED, + "enrollment_token_digest": None, + "enrollment_expires_at": None, + "credential_verifier": None, + "last_seen_at": target.last_seen_at, + } + ) + return await self._targets.replace(connection, revoked, expected_generation) + + async def publish( + self, + scope_id: str, + target_id: str, + artifact: ArtifactRef, + expected_generation: int | None, + /, + *, + allow_deprecated: bool = False, + ) -> SkillPublication: + """Set one exact approved package as the latest remote desired state.""" + + scope = validate_scope_id(scope_id) + now = _as_utc(self._clock()) + async with self._database.transaction() as connection: + await self._require_active_target(connection, scope, target_id) + skill, package = await self._load_package_backed_skill(connection, scope, artifact) + governance = await self._governance.get(connection, scope, Skill.family, artifact.artifact_id) + if governance.lifecycle_state is ArtifactLifecycleState.RETIRED: + raise RemoteSkillLifecycleError("retired managed Skills cannot be published") + if governance.lifecycle_state is ArtifactLifecycleState.DEPRECATED and not allow_deprecated: + raise RemoteSkillLifecycleError("deprecated managed Skills require an explicit publication override") + current = await self._publications.find(connection, scope, target_id, artifact.artifact_id) + if current is None: + if expected_generation is not None: + raise RemotePublicationGenerationError("remote publication does not exist") + publication = SkillPublication( + scope_id=scope, + target_id=target_id, + artifact_id=artifact.artifact_id, + desired_state=SkillPublicationDesiredState.PUBLISHED, + desired_revision=artifact.revision, + desired_tree_digest=package.reference.tree_digest, + state=AgentSkillProjectionState.PENDING, + generation=0, + updated_at=now, + ) + return await self._publications.create(connection, publication) + self._require_publication_generation(current, expected_generation) + desired = current.model_copy( + update={ + "desired_state": SkillPublicationDesiredState.PUBLISHED, + "desired_revision": skill.revision, + "desired_tree_digest": package.reference.tree_digest, + "destination": None, + "state": AgentSkillProjectionState.PENDING, + "last_error_code": None, + } + ) + if _same_publication_payload(desired, current): + return current + return await self._publications.replace(connection, desired, current.generation) + + async def unpublish( + self, + scope_id: str, + target_id: str, + artifact_id: str, + expected_generation: int, + /, + ) -> SkillPublication: + """Set desired absence without claiming that the remote filesystem already changed.""" + + scope = validate_scope_id(scope_id) + async with self._database.transaction() as connection: + await self._require_active_target(connection, scope, target_id) + current = await self._publications.find(connection, scope, target_id, artifact_id) + if current is None: + raise RemoteTargetStateError("remote publication does not exist") + self._require_publication_generation(current, expected_generation) + if current.desired_state is SkillPublicationDesiredState.UNPUBLISHED: + return current + desired = current.model_copy( + update={ + "desired_state": SkillPublicationDesiredState.UNPUBLISHED, + "destination": None, + "state": AgentSkillProjectionState.PENDING, + "last_error_code": None, + } + ) + return await self._publications.replace(connection, desired, current.generation) + + async def reconcile( + self, + credential: str, + observations: tuple[RemoteSkillObservation, ...], + receiver_version: str, + environment_fingerprint: str | None, + /, + ) -> RemoteSkillReconcileResult: + """Return only latest-generation idempotent actions for the authenticated target.""" + + by_artifact = {observation.artifact.artifact_id: observation for observation in observations} + if len(by_artifact) != len(observations): + raise RemoteTargetStateError("reconcile observations contain duplicate artifact identities") + now = _as_utc(self._clock()) + async with self._database.transaction() as connection: + target = await self._authenticate(connection, credential) + await self._targets.observe( + connection, + target, + receiver_version=receiver_version, + environment_fingerprint=environment_fingerprint, + observed_at=now, + ) + publications = await self._publications.list_for_target(connection, target.scope_id, target.target_id) + actions: list[RemoteSkillAction] = [] + for publication in publications: + observation = by_artifact.get(publication.artifact_id) + verified, observation_error = await self._verify_observation( + connection, + target.scope_id, + publication.artifact_id, + observation, + ) + if observation_error is not None: + publication = await self._record_observation_error( + connection, + publication, + observation_error, + environment_fingerprint, + now, + ) + action = await self._reconcile_publication( + connection, + target.scope_id, + publication, + verified, + observation_error, + ) + if action is not None: + actions.append(action) + return RemoteSkillReconcileResult( + scope_id=target.scope_id, + target_id=target.target_id, + actions=tuple(actions), + ) + + async def download( + self, + credential: str, + generation: int, + artifact: ArtifactRef, + package: SkillPackageRef, + /, + ) -> SkillPackageSnapshot: + """Read only the exact package currently desired by the authenticated target.""" + + async with self._database.transaction() as connection: + target = await self._authenticate(connection, credential) + publication = await self._publications.find( + connection, + target.scope_id, + target.target_id, + artifact.artifact_id, + ) + if ( + publication is None + or publication.desired_state is not SkillPublicationDesiredState.PUBLISHED + or publication.generation != generation + or publication.desired_revision != artifact.revision + or publication.desired_tree_digest != package.tree_digest + ): + raise RemoteTargetAuthenticationError("the target is not authorized for this package") + skill, stored = await self._load_package_backed_skill(connection, target.scope_id, artifact) + if skill.content.package != package or stored.reference != package: + raise RemoteTargetAuthenticationError("the target is not authorized for this package") + return stored + + async def receipt(self, credential: str, receipt: RemoteSkillReceipt, /) -> RemoteSkillReceiptResult: + """Apply a generation-bound Receipt with stale rejection and success precedence.""" + + now = _as_utc(self._clock()) + async with self._database.transaction() as connection: + target = await self._authenticate(connection, credential) + await self._targets.observe( + connection, + target, + receiver_version=receipt.receiver_version, + environment_fingerprint=receipt.environment_fingerprint, + observed_at=now, + ) + publication = await self._publications.find( + connection, + target.scope_id, + target.target_id, + receipt.artifact.artifact_id, + ) + if publication is None: + raise RemoteTargetStateError("remote publication does not exist") + if receipt.generation < publication.generation: + return RemoteSkillReceiptResult(accepted=False, stale=True, publication=publication) + if receipt.generation > publication.generation: + raise RemotePublicationGenerationError("Receipt generation is newer than desired state") + self._validate_receipt_identity(publication, receipt) + revised = self._receipt_observation(publication, receipt, now) + if _same_publication_payload(revised, publication): + return RemoteSkillReceiptResult(accepted=True, stale=False, publication=publication) + stored = await self._publications.observe( + connection, + revised, + publication.generation, + preserve_success=receipt.outcome is RemoteSkillReceiptOutcome.FAILED, + ) + return RemoteSkillReceiptResult(accepted=True, stale=False, publication=stored) + + async def _authenticate(self, connection, credential: str) -> RemoteAgentSkillTarget: + verifier = _digest(credential) + target = await self._targets.find_by_credential(connection, verifier) + if ( + target is None + or target.state is not RemoteAgentSkillTargetState.ACTIVE + or target.credential_verifier is None + or not compare_digest(target.credential_verifier, verifier) + ): + raise RemoteTargetAuthenticationError("the target credential is invalid or revoked") + return target + + async def _require_target(self, connection, scope_id: str, target_id: str) -> RemoteAgentSkillTarget: + target = await self._targets.find(connection, scope_id, target_id) + if target is None: + raise RemoteTargetStateError("remote target does not exist") + return target + + async def _require_active_target(self, connection, scope_id: str, target_id: str) -> RemoteAgentSkillTarget: + target = await self._require_target(connection, scope_id, target_id) + if target.state is not RemoteAgentSkillTargetState.ACTIVE: + raise RemoteTargetStateError("remote target is not active") + return target + + async def _load_package_backed_skill( + self, + connection, + scope_id: str, + artifact: ArtifactRef, + ) -> tuple[Skill, SkillPackageSnapshot]: + value = await self._artifacts.get(connection, scope_id, artifact) + if not isinstance(value, Skill) or value.content.package is None: + raise RemoteTargetStateError("remote publication requires a package-backed Skill Revision") + package = await self._packages.get(connection, scope_id, value.content.package) + return value, package + + async def _verify_observation( + self, + connection, + scope_id: str, + artifact_id: str, + observation: RemoteSkillObservation | None, + ) -> tuple[RemoteSkillObservation | None, str | None]: + if observation is None: + return None, None + if observation.artifact.family != Skill.family or observation.artifact.artifact_id != artifact_id: + return None, "invalid_checkpoint_identity" + try: + skill, package = await self._load_package_backed_skill(connection, scope_id, observation.artifact) + except (RepositoryNotFoundError, RemoteTargetStateError): + return None, "unknown_checkpoint_package" + if package.reference.tree_digest != observation.tree_digest or skill.content.name != observation.skill_name: + return None, "invalid_checkpoint_digest" + if observation.actual_tree_digest != observation.tree_digest: + return None, "drifted" + return observation, None + + async def _record_observation_error( + self, + connection, + publication: SkillPublication, + error_code: str, + environment_fingerprint: str | None, + observed_at: datetime, + ) -> SkillPublication: + state = AgentSkillProjectionState.DRIFTED if error_code == "drifted" else AgentSkillProjectionState.CONFLICT + observed = publication.model_copy( + update={ + "observed_generation": publication.generation, + "state": state, + "last_error_code": error_code, + "environment_fingerprint": environment_fingerprint, + "observed_at": observed_at, + } + ) + return await self._publications.observe( + connection, + observed, + publication.generation, + preserve_success=False, + ) + + async def _reconcile_publication( + self, + connection, + scope_id: str, + publication: SkillPublication, + observation: RemoteSkillObservation | None, + observation_error: str | None, + ) -> RemoteSkillAction | None: + artifact = ArtifactRef( + family=Skill.family, + artifact_id=publication.artifact_id, + revision=publication.desired_revision, + ) + skill, package = await self._load_package_backed_skill(connection, scope_id, artifact) + if publication.desired_state is SkillPublicationDesiredState.PUBLISHED: + if ( + observation is not None + and observation.artifact == artifact + and observation.tree_digest == publication.desired_tree_digest + and publication.observed_generation == publication.generation + and publication.observed_revision == artifact.revision + and publication.observed_tree_digest == publication.desired_tree_digest + and publication.state is AgentSkillProjectionState.CURRENT + ): + return None + return RemoteSkillAction( + operation=RemoteSkillOperation.INSTALL, + generation=publication.generation, + artifact=artifact, + tree_digest=publication.desired_tree_digest, + skill_name=skill.content.name, + package=package.reference, + expected_local=observation, + blocked_error_code=observation_error, + ) + if ( + observation is None + and observation_error is None + and publication.observed_generation == publication.generation + and publication.state is AgentSkillProjectionState.UNPUBLISHED + ): + return None + return RemoteSkillAction( + operation=RemoteSkillOperation.UNPUBLISH, + generation=publication.generation, + artifact=artifact, + tree_digest=publication.desired_tree_digest, + skill_name=skill.content.name, + expected_local=observation, + blocked_error_code=observation_error, + ) + + @staticmethod + def _require_publication_generation( + publication: SkillPublication, + expected_generation: int | None, + ) -> None: + if expected_generation is None or publication.generation != expected_generation: + raise RemotePublicationGenerationError("remote publication generation changed") + + @staticmethod + def _validate_receipt_identity(publication: SkillPublication, receipt: RemoteSkillReceipt) -> None: + expected_operation = ( + RemoteSkillOperation.INSTALL + if publication.desired_state is SkillPublicationDesiredState.PUBLISHED + else RemoteSkillOperation.UNPUBLISH + ) + if ( + receipt.operation is not expected_operation + or receipt.artifact.family != Skill.family + or receipt.artifact.artifact_id != publication.artifact_id + or receipt.artifact.revision != publication.desired_revision + or receipt.expected_tree_digest != publication.desired_tree_digest + ): + raise RemoteTargetStateError("Receipt does not match the latest desired action") + if ( + receipt.outcome is RemoteSkillReceiptOutcome.SUCCEEDED + and receipt.operation is RemoteSkillOperation.INSTALL + and receipt.observed_tree_digest != publication.desired_tree_digest + ): + raise RemoteTargetStateError("successful install Receipt digest does not match desired package") + + @staticmethod + def _receipt_observation( + publication: SkillPublication, + receipt: RemoteSkillReceipt, + observed_at: datetime, + ) -> SkillPublication: + if receipt.outcome is RemoteSkillReceiptOutcome.FAILED: + return publication.model_copy( + update={ + "observed_generation": publication.generation, + "state": receipt.failure_state, + "last_error_code": receipt.error_code, + "observed_at": observed_at, + "environment_fingerprint": receipt.environment_fingerprint, + } + ) + if receipt.operation is RemoteSkillOperation.INSTALL: + return publication.model_copy( + update={ + "observed_revision": receipt.artifact.revision, + "observed_tree_digest": receipt.observed_tree_digest, + "observed_generation": publication.generation, + "state": AgentSkillProjectionState.CURRENT, + "last_error_code": None, + "observed_at": observed_at, + "environment_fingerprint": receipt.environment_fingerprint, + } + ) + return publication.model_copy( + update={ + "observed_revision": None, + "observed_tree_digest": None, + "observed_generation": publication.generation, + "state": AgentSkillProjectionState.UNPUBLISHED, + "last_error_code": None, + "observed_at": observed_at, + "environment_fingerprint": receipt.environment_fingerprint, + } + ) + + +def _same_publication_payload(left: SkillPublication, right: SkillPublication) -> bool: + return left.model_dump(exclude={"generation", "updated_at", "observed_at"}) == right.model_dump( + exclude={"generation", "updated_at", "observed_at"} + ) + + +def _normalized_target_display_name(value: str) -> str: + normalized = value.strip() + if not normalized or len(normalized) > 128: + raise ValueError("remote target display name must contain 1 to 128 characters") + return normalized + + +def _normalized_optional_label(value: str | None, *, max_length: int) -> str | None: + if value is None: + return None + normalized = value.strip() + if not normalized or len(normalized) > max_length: + raise ValueError(f"remote target environment label must contain 1 to {max_length} characters") + return normalized + + +def _digest(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _as_utc(value: datetime | None) -> datetime: + if value is None: + return datetime.min.replace(tzinfo=UTC) + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + +def _utc_now() -> datetime: + return datetime.now(UTC) + + +def _random_id() -> str: + return uuid4().hex + + +def _random_secret() -> str: + return secrets.token_urlsafe(32) + + +__all__ = [ + "RemotePublicationGenerationError", + "RemoteSkillAction", + "RemoteSkillDistributionError", + "RemoteSkillDistributionService", + "RemoteSkillLifecycleError", + "RemoteSkillObservation", + "RemoteSkillOperation", + "RemoteSkillReceipt", + "RemoteSkillReceiptOutcome", + "RemoteSkillReceiptResult", + "RemoteSkillReconcileResult", + "RemoteSkillTargetStatus", + "RemoteTargetAuthenticationError", + "RemoteTargetCredential", + "RemoteTargetEnrollment", + "RemoteTargetEnrollmentError", + "RemoteTargetStateError", +] diff --git a/src/powercontext/builtin/artifacts/skill/external.py b/src/powercontext/builtin/artifacts/skill/external.py index 8d1d663b5..bbba1cd7f 100644 --- a/src/powercontext/builtin/artifacts/skill/external.py +++ b/src/powercontext/builtin/artifacts/skill/external.py @@ -18,13 +18,17 @@ import hashlib import json +import re from collections.abc import Iterable +from dataclasses import dataclass from enum import StrEnum from pathlib import Path from typing import Annotated, Literal, Protocol from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, field_validator +from powercontext.builtin.artifacts.skill.models import SkillPackageRef +from powercontext.builtin.artifacts.skill.package import SkillPackageSnapshot, package_file from powercontext.errors import PowerContextError from powercontext.limits import ( MAX_ARTIFACT_ID_LENGTH, @@ -118,12 +122,30 @@ class ExternalSkillProviderScan(BaseModel): class ExternalSkillSnapshot(BaseModel): - """Exact primary content plus the fingerprint of its authoritative package.""" + """Durable evidence that refers to one exact stored external package.""" registration: ExternalSkillRegistration + package: SkillPackageRef manifest: str = Field(min_length=1, max_length=MAX_EXTERNAL_SKILL_MANIFEST_BYTES) +@dataclass(frozen=True) +class CapturedExternalSkillPackage: + """In-memory capture kept only until package and Source evidence are stored.""" + + registration: ExternalSkillRegistration + package: SkillPackageSnapshot + + def as_source_snapshot(self) -> ExternalSkillSnapshot: + """Return bounded durable evidence without copying archive bytes into Source storage.""" + + return ExternalSkillSnapshot( + registration=self.registration, + package=self.package.reference, + manifest=package_file(self.package, "SKILL.md").decode("utf-8"), + ) + + class ExternalSkillProvider(Protocol): """Discover and resolve packages owned by one local Agent environment.""" @@ -137,6 +159,51 @@ def scan(self) -> ExternalSkillProviderScan: ... def resolve(self, registration: ExternalSkillRegistration, /) -> ExternalSkillResolution: ... +class AgentEnvironmentProfile(BaseModel): + """Secret-free facts a target adapter can actually observe about its host.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + operating_system: Literal["linux", "macos", "windows", "other"] + architecture: str = Field(min_length=1, max_length=64) + commands: dict[str, str] = Field(default_factory=dict, max_length=64) + network_policy: Literal["disabled", "restricted", "enabled", "unknown"] = "unknown" + writable_roots: tuple[str, ...] = Field(default=(), max_length=32) + dependency_install_policy: Literal["denied", "allowed", "unknown"] = "unknown" + environment_names: tuple[str, ...] = Field(default=(), max_length=64) + + @field_validator("architecture") + @classmethod + def validate_architecture(cls, value: str) -> str: + if value != value.strip(): + raise ValueError("Agent environment architecture must be trimmed") # noqa: TRY003 + return value + + @field_validator("commands") + @classmethod + def validate_commands(cls, value: dict[str, str]) -> dict[str, str]: + for command, version in value.items(): + if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9+._-]{0,63}", command) is None: + raise ValueError("Agent environment command names must be simple executable names") # noqa: TRY003 + if not version.strip() or version != version.strip() or len(version) > 128: + raise ValueError("Agent environment command versions must be bounded trimmed labels") # noqa: TRY003 + return value + + @field_validator("writable_roots") + @classmethod + def validate_writable_roots(cls, value: tuple[str, ...]) -> tuple[str, ...]: + if any(not item.strip() or item != item.strip() or len(item) > 512 for item in value): + raise ValueError("Agent environment writable roots must be bounded trimmed labels") # noqa: TRY003 + return value + + @field_validator("environment_names") + @classmethod + def validate_environment_names(cls, value: tuple[str, ...]) -> tuple[str, ...]: + if any(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,127}", item) is None for item in value): + raise ValueError("Agent environment names must contain names only, not assignments") # noqa: TRY003 + return value + + class AgentSkillTarget(BaseModel): """One explicitly configured host-local Agent Skill target.""" @@ -147,6 +214,7 @@ class AgentSkillTarget(BaseModel): installation_scope: ExternalSkillInstallationScope path: Path allow_managed_publish: bool = False + environment: AgentEnvironmentProfile | None = None class CodexSkillRoot(BaseModel): @@ -333,11 +401,13 @@ def _package_fingerprint(package: Path) -> str: for path in files: relative = path.relative_to(package).as_posix().encode("utf-8") content = path.read_bytes() + mode = 0o755 if path.stat().st_mode & 0o111 else 0o644 total_bytes += len(content) if total_bytes > MAX_EXTERNAL_SKILL_PACKAGE_BYTES: raise ValueError("Agent Skill package exceeds the supported size") # noqa: TRY003 digest.update(len(relative).to_bytes(4, "big")) digest.update(relative) + digest.update(mode.to_bytes(2, "big")) digest.update(len(content).to_bytes(8, "big")) digest.update(content) return digest.hexdigest() @@ -359,9 +429,11 @@ def _package_files(package: Path) -> Iterable[Path]: "MAX_EXTERNAL_SKILL_MANIFEST_BYTES", "MAX_EXTERNAL_SKILL_NAME_LENGTH", "MAX_EXTERNAL_SKILL_PACKAGE_BYTES", + "AgentEnvironmentProfile", "AgentKind", "AgentSkillProvider", "AgentSkillTarget", + "CapturedExternalSkillPackage", "CodexSkillProvider", "CodexSkillRoot", "ExternalSkillInstallationScope", diff --git a/src/powercontext/builtin/artifacts/skill/generation.py b/src/powercontext/builtin/artifacts/skill/generation.py index 779ef0ad7..e314b4542 100644 --- a/src/powercontext/builtin/artifacts/skill/generation.py +++ b/src/powercontext/builtin/artifacts/skill/generation.py @@ -25,10 +25,16 @@ from powercontext.builtin.inference import GenerationResult, InvalidInferenceOutputError, StructuredGenerator +class _GeneratedSkillContent(SkillContent): + """Model-authored instruction content cannot claim an existing package snapshot.""" + + package: None = None + + class SkillGenerationOutput(BaseModel): """A typed managed Skill proposal or an explicit no-op.""" - proposal: SkillContent | None = None + proposal: _GeneratedSkillContent | None = None class SkillGenerator(Protocol): diff --git a/src/powercontext/builtin/artifacts/skill/models.py b/src/powercontext/builtin/artifacts/skill/models.py index cd07dc857..5bcd7582d 100644 --- a/src/powercontext/builtin/artifacts/skill/models.py +++ b/src/powercontext/builtin/artifacts/skill/models.py @@ -18,29 +18,47 @@ from typing import Annotated, ClassVar -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from powercontext.artifacts import Artifact, ArtifactDraft MAX_SKILL_NAME_LENGTH = 128 MAX_SKILL_DESCRIPTION_LENGTH = 2_000 -MAX_SKILL_INSTRUCTIONS_LENGTH = 32_000 +MAX_SKILL_INSTRUCTIONS_LENGTH = 128 * 1024 MAX_SKILL_VALIDATION_ITEMS = 32 MAX_SKILL_VALIDATION_ITEM_LENGTH = 2_000 +MAX_SKILL_COMPATIBILITY_LENGTH = 500 SkillName = Annotated[str, Field(min_length=1, max_length=MAX_SKILL_NAME_LENGTH)] SkillDescription = Annotated[str, Field(min_length=1, max_length=MAX_SKILL_DESCRIPTION_LENGTH)] -SkillInstructions = Annotated[str, Field(min_length=1, max_length=MAX_SKILL_INSTRUCTIONS_LENGTH)] +SkillInstructions = Annotated[str, Field(max_length=MAX_SKILL_INSTRUCTIONS_LENGTH)] SkillValidationItem = Annotated[str, Field(min_length=1, max_length=MAX_SKILL_VALIDATION_ITEM_LENGTH)] +class SkillPackageRef(BaseModel): + """Content-addressed reference to one canonical Agent Skill package.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + tree_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + archive_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + file_count: int = Field(ge=1, le=256) + uncompressed_size: int = Field(ge=1, le=4 * 1024 * 1024) + archive_size: int = Field(ge=1, le=5 * 1024 * 1024) + + class SkillContent(BaseModel): - """A portable instruction core governed as an immutable Artifact.""" + """A legacy instruction core or a standard package-backed managed Skill.""" name: SkillName description: SkillDescription instructions: SkillInstructions - validation: tuple[SkillValidationItem, ...] = Field(min_length=1, max_length=MAX_SKILL_VALIDATION_ITEMS) + validation: tuple[SkillValidationItem, ...] = Field(default=(), max_length=MAX_SKILL_VALIDATION_ITEMS) + package: SkillPackageRef | None = None + license: str | None = Field(default=None, min_length=1, max_length=512) + compatibility: str | None = Field(default=None, min_length=1, max_length=MAX_SKILL_COMPATIBILITY_LENGTH) + metadata: dict[str, str] = Field(default_factory=dict) + allowed_tools: str | None = Field(default=None, min_length=1, max_length=2_000) @field_validator("name", "description") @classmethod @@ -52,8 +70,8 @@ def reject_untrimmed_text(cls, value: str) -> str: @field_validator("instructions") @classmethod def reject_blank_instructions(cls, value: str) -> str: - if not value.strip(): - raise ValueError("Skill instructions must not be blank") # noqa: TRY003 + if value and value != value.rstrip(): + raise ValueError("Skill instructions must not have trailing whitespace") # noqa: TRY003 return value @field_validator("validation") @@ -63,6 +81,39 @@ def reject_blank_validation(cls, values: tuple[str, ...]) -> tuple[str, ...]: raise ValueError("Skill validation items must be non-empty and trimmed") # noqa: TRY003 return values + @field_validator("license", "compatibility", "allowed_tools") + @classmethod + def reject_untrimmed_optional_text(cls, value: str | None) -> str | None: + if value is not None and value != value.strip(): + raise ValueError("optional Skill metadata must be trimmed") # noqa: TRY003 + return value + + @field_validator("metadata") + @classmethod + def validate_metadata(cls, value: dict[str, str]) -> dict[str, str]: + if len(value) > 64: + raise ValueError("Skill metadata must not exceed 64 entries") # noqa: TRY003 + if any(not key.strip() or key != key.strip() or len(key) > 128 for key in value): + raise ValueError("Skill metadata keys must be non-empty trimmed strings of at most 128 characters") # noqa: TRY003 + if any(item != item.strip() or len(item) > 2_000 for item in value.values()): + raise ValueError("Skill metadata values must be trimmed strings of at most 2000 characters") # noqa: TRY003 + return value + + @model_validator(mode="after") + def validate_content_kind(self) -> SkillContent: + if self.package is None: + if not self.instructions.strip(): + raise ValueError("legacy Skill instructions must not be blank") # noqa: TRY003 + if not self.validation: + raise ValueError("legacy Skill validation must not be empty") # noqa: TRY003 + return self + + @property + def package_backed(self) -> bool: + """Return whether the exact standard package is the content authority.""" + + return self.package is not None + class Skill(Artifact[SkillContent]): """An approved immutable managed Skill revision.""" @@ -77,6 +128,7 @@ class SkillDraft(ArtifactDraft[SkillContent]): __all__ = [ + "MAX_SKILL_COMPATIBILITY_LENGTH", "MAX_SKILL_DESCRIPTION_LENGTH", "MAX_SKILL_INSTRUCTIONS_LENGTH", "MAX_SKILL_NAME_LENGTH", @@ -85,4 +137,5 @@ class SkillDraft(ArtifactDraft[SkillContent]): "Skill", "SkillContent", "SkillDraft", + "SkillPackageRef", ] diff --git a/src/powercontext/builtin/artifacts/skill/package.py b/src/powercontext/builtin/artifacts/skill/package.py new file mode 100644 index 000000000..9765384ea --- /dev/null +++ b/src/powercontext/builtin/artifacts/skill/package.py @@ -0,0 +1,497 @@ +# 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. + +"""Canonical capture and validation for standard Agent Skill packages.""" + +# Package validation deliberately reports precise bounded failures at the +# trust boundary instead of hiding them behind one generic message. +# ruff: noqa: TRY003 + +from __future__ import annotations + +import hashlib +import io +import json +import mimetypes +import os +import re +import stat +import unicodedata +import zipfile +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +import yaml +from pydantic import BaseModel, ConfigDict, Field + +from powercontext.builtin.artifacts.skill.models import SkillContent, SkillPackageRef + +MAX_SKILL_PACKAGE_FILES = 256 +MAX_SKILL_PACKAGE_BYTES = 4 * 1024 * 1024 +MAX_SKILL_ARCHIVE_BYTES = 5 * 1024 * 1024 +MAX_SKILL_MANIFEST_BYTES = 128 * 1024 +MAX_SKILL_PATH_BYTES = 512 +MAX_SKILL_ENTRYPOINT_BYTES = 128 * 1024 +SKILL_ENTRYPOINT = "SKILL.md" +_CANONICAL_TREE_DOMAIN = b"powercontext.skill-package-tree.v1\0" +_FORBIDDEN_COMPONENTS = frozenset({".env", ".git", "node_modules"}) +_SKILL_NAME = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +_ZIP_TIMESTAMP = (1980, 1, 1, 0, 0, 0) + + +class SkillPackageError(ValueError): + """Raised when a package cannot be captured without changing its meaning.""" + + +class SkillPackageEntry(BaseModel): + """One canonical regular file in a package manifest.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + path: str = Field(min_length=1, max_length=MAX_SKILL_PATH_BYTES) + digest: str = Field(pattern=r"^[0-9a-f]{64}$") + size: int = Field(ge=0, le=MAX_SKILL_PACKAGE_BYTES) + media_type: str = Field(min_length=1, max_length=255) + mode: int + + +class SkillPackageMetadata(BaseModel): + """Validated standard metadata derived from the exact entrypoint.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + name: str = Field(min_length=1, max_length=64) + description: str = Field(min_length=1, max_length=1_024) + license: str | None = Field(default=None, min_length=1, max_length=512) + compatibility: str | None = Field(default=None, min_length=1, max_length=500) + metadata: dict[str, str] = Field(default_factory=dict) + allowed_tools: str | None = Field(default=None, min_length=1, max_length=2_000) + + +@dataclass(frozen=True) +class SkillPackageSnapshot: + """Canonical archive plus deterministic metadata needed by storage and Review.""" + + reference: SkillPackageRef + entries: tuple[SkillPackageEntry, ...] + metadata: SkillPackageMetadata + instructions: str + archive_bytes: bytes + + @property + def manifest_bytes(self) -> bytes: + """Return deterministic JSON for the canonical file manifest.""" + + values = [entry.model_dump(mode="json") for entry in self.entries] + return json.dumps(values, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8") + + def as_skill_content(self) -> SkillContent: + """Return the package-backed managed Artifact content cache.""" + + return SkillContent( + name=self.metadata.name, + description=self.metadata.description, + instructions=self.instructions, + package=self.reference, + license=self.metadata.license, + compatibility=self.metadata.compatibility, + metadata=self.metadata.metadata, + allowed_tools=self.metadata.allowed_tools, + ) + + +@dataclass(frozen=True) +class _PackageFile: + path: str + content: bytes + mode: int + + +def capture_skill_directory( + package: Path, + /, + *, + expected_name: str | None = None, +) -> SkillPackageSnapshot: + """Capture a stable local package directory using its owned logical name by default.""" + + root = package.expanduser().resolve(strict=True) + if not root.is_dir() or package.is_symlink(): + raise SkillPackageError("Agent Skill package must be a regular directory") + return _canonical_snapshot( + _directory_files(root), expected_name=root.name if expected_name is None else expected_name + ) + + +def capture_skill_archive(archive_bytes: bytes, /) -> SkillPackageSnapshot: + """Validate an untrusted ZIP and rewrite it into canonical package bytes.""" + + if not archive_bytes or len(archive_bytes) > MAX_SKILL_ARCHIVE_BYTES: + raise SkillPackageError("Agent Skill archive exceeds the supported size") + try: + files = _archive_files(archive_bytes) + except (OSError, RuntimeError, zipfile.BadZipFile, zipfile.LargeZipFile) as error: + raise SkillPackageError("Agent Skill archive is invalid") from error + return _canonical_snapshot(files) + + +def package_file(snapshot: SkillPackageSnapshot, path: str, /) -> bytes: + """Read one exact regular file from a verified canonical snapshot.""" + + canonical = _validate_relative_path(path) + if canonical not in {entry.path for entry in snapshot.entries}: + raise SkillPackageError(f"Agent Skill package file does not exist: {canonical}") + try: + with zipfile.ZipFile(io.BytesIO(snapshot.archive_bytes), "r") as archive: + return archive.read(canonical) + except (KeyError, OSError, RuntimeError, zipfile.BadZipFile) as error: + raise SkillPackageError("stored Agent Skill archive is invalid") from error + + +def materialize_skill_package(snapshot: SkillPackageSnapshot, destination: Path, /) -> None: + """Write exact package files into a new destination without following links.""" + + if destination.exists() or destination.is_symlink(): + raise FileExistsError(destination) + destination.mkdir(parents=True) + try: + with zipfile.ZipFile(io.BytesIO(snapshot.archive_bytes), "r") as archive: + for entry in snapshot.entries: + target = destination.joinpath(*PurePosixPath(entry.path).parts) + target.parent.mkdir(parents=True, exist_ok=True) + content = archive.read(entry.path) + if hashlib.sha256(content).hexdigest() != entry.digest: + raise SkillPackageError(f"stored Agent Skill file digest does not match: {entry.path}") # noqa: TRY301 + target.write_bytes(content) + target.chmod(entry.mode) + except BaseException: + _remove_partial_tree(destination) + raise + + +def build_instruction_skill_package(content: SkillContent, /) -> SkillPackageSnapshot: + """Convert legacy or generated instruction content into a one-file standard package.""" + + if content.package is not None: + raise SkillPackageError("package-backed Skill content cannot be rebuilt from cached fields") + frontmatter: dict[str, object] = { + "name": content.name, + "description": content.description, + } + if content.license is not None: + frontmatter["license"] = content.license + if content.compatibility is not None: + frontmatter["compatibility"] = content.compatibility + if content.metadata: + frontmatter["metadata"] = content.metadata + if content.allowed_tools is not None: + frontmatter["allowed-tools"] = content.allowed_tools + body = content.instructions.rstrip() + if content.validation: + validation = "\n".join(f"- {item}" for item in content.validation) + body = f"{body}\n\n## Validation\n\n{validation}" if body else f"## Validation\n\n{validation}" + manifest = yaml.safe_dump(frontmatter, allow_unicode=True, sort_keys=False).rstrip() + skill_markdown = f"---\n{manifest}\n---\n\n{body}\n".encode() + return _canonical_snapshot((_PackageFile(SKILL_ENTRYPOINT, skill_markdown, 0o644),), expected_name=content.name) + + +def _directory_files(root: Path) -> tuple[_PackageFile, ...]: + files: list[_PackageFile] = [] + seen_inodes: set[tuple[int, int]] = set() + for path in sorted(root.rglob("*"), key=lambda value: value.relative_to(root).as_posix()): + relative = _validate_relative_path(path.relative_to(root).as_posix()) + file_stat = path.lstat() + if stat.S_ISLNK(file_stat.st_mode): + raise SkillPackageError(f"Agent Skill package contains a symbolic link: {relative}") + if stat.S_ISDIR(file_stat.st_mode): + continue + if not stat.S_ISREG(file_stat.st_mode): + raise SkillPackageError(f"Agent Skill package contains a special file: {relative}") + inode = (file_stat.st_dev, file_stat.st_ino) + if file_stat.st_nlink > 1 or inode in seen_inodes: + raise SkillPackageError(f"Agent Skill package contains a hard link: {relative}") + seen_inodes.add(inode) + try: + with path.open("rb") as stream: + before = os.fstat(stream.fileno()) + content = stream.read(MAX_SKILL_PACKAGE_BYTES + 1) + after = os.fstat(stream.fileno()) + except OSError as error: + raise SkillPackageError(f"Agent Skill package file is unreadable: {relative}") from error + if _changed_during_read(before, after): + raise SkillPackageError(f"Agent Skill package changed during capture: {relative}") + files.append(_PackageFile(relative, content, _normalized_mode(file_stat.st_mode))) + return tuple(files) + + +def _archive_files(archive_bytes: bytes) -> tuple[_PackageFile, ...]: + files: list[_PackageFile] = [] + seen_paths: set[str] = set() + with zipfile.ZipFile(io.BytesIO(archive_bytes), "r") as archive: + for info in archive.infolist(): + path = _validate_relative_path(info.filename.rstrip("/") if info.is_dir() else info.filename) + collision_key = _path_collision_key(path) + if collision_key in seen_paths: + raise SkillPackageError(f"Agent Skill archive contains duplicate or colliding paths: {path}") + seen_paths.add(collision_key) + if info.flag_bits & 0x1: + raise SkillPackageError(f"Agent Skill archive contains an encrypted entry: {path}") + mode = info.external_attr >> 16 + if info.is_dir(): + continue + file_type = stat.S_IFMT(mode) + if file_type not in {0, stat.S_IFREG}: + raise SkillPackageError(f"Agent Skill archive contains a non-regular entry: {path}") + if info.file_size > MAX_SKILL_PACKAGE_BYTES: + raise SkillPackageError(f"Agent Skill archive entry exceeds the supported size: {path}") + with archive.open(info, "r") as stream: + content = stream.read(MAX_SKILL_PACKAGE_BYTES + 1) + if len(content) != info.file_size: + raise SkillPackageError(f"Agent Skill archive entry size does not match: {path}") + files.append(_PackageFile(path, content, _normalized_mode(mode))) + return tuple(files) + + +def _canonical_snapshot( + values: Iterable[_PackageFile], + *, + expected_name: str | None = None, +) -> SkillPackageSnapshot: + files = tuple(sorted(values, key=lambda value: value.path)) + if not files or len(files) > MAX_SKILL_PACKAGE_FILES: + raise SkillPackageError("Agent Skill package has an unsupported file count") + paths: dict[str, str] = {} + total_bytes = 0 + entries: list[SkillPackageEntry] = [] + for value in files: + path = _validate_relative_path(value.path) + collision_key = _path_collision_key(path) + if collision_key in paths: + raise SkillPackageError(f"Agent Skill package contains colliding paths: {paths[collision_key]} and {path}") + paths[collision_key] = path + total_bytes += len(value.content) + if total_bytes > MAX_SKILL_PACKAGE_BYTES: + raise SkillPackageError("Agent Skill package exceeds the supported uncompressed size") + entries.append( + SkillPackageEntry( + path=path, + digest=hashlib.sha256(value.content).hexdigest(), + size=len(value.content), + media_type=mimetypes.guess_type(path)[0] or "application/octet-stream", + mode=value.mode, + ) + ) + try: + entrypoint = files[[value.path for value in files].index(SKILL_ENTRYPOINT)].content + except ValueError: + raise SkillPackageError("Agent Skill package must contain SKILL.md at its root") from None + metadata, instructions = _parse_skill_markdown(entrypoint, expected_name=expected_name) + tree_digest = _tree_digest(tuple(entries)) + archive_bytes = _canonical_archive(files) + if len(archive_bytes) > MAX_SKILL_ARCHIVE_BYTES: + raise SkillPackageError("canonical Agent Skill archive exceeds the supported size") + reference = SkillPackageRef( + tree_digest=tree_digest, + archive_digest=hashlib.sha256(archive_bytes).hexdigest(), + file_count=len(files), + uncompressed_size=total_bytes, + archive_size=len(archive_bytes), + ) + snapshot = SkillPackageSnapshot( + reference=reference, + entries=tuple(entries), + metadata=metadata, + instructions=instructions, + archive_bytes=archive_bytes, + ) + if len(snapshot.manifest_bytes) > MAX_SKILL_MANIFEST_BYTES: + raise SkillPackageError("Agent Skill package manifest exceeds the supported size") + return snapshot + + +def _parse_skill_markdown( # noqa: C901 + content: bytes, + *, + expected_name: str | None, +) -> tuple[SkillPackageMetadata, str]: + if not content or len(content) > MAX_SKILL_ENTRYPOINT_BYTES: + raise SkillPackageError("Agent Skill SKILL.md exceeds the supported size") + try: + text = content.decode("utf-8") + except UnicodeDecodeError as error: + raise SkillPackageError("Agent Skill SKILL.md must be UTF-8") from error + lines = text.splitlines(keepends=True) + if not lines or lines[0].strip() != "---": + raise SkillPackageError("Agent Skill SKILL.md is missing YAML frontmatter") + closing = next((index for index, line in enumerate(lines[1:], start=1) if line.strip() == "---"), None) + if closing is None: + raise SkillPackageError("Agent Skill SKILL.md frontmatter is not terminated") + try: + parsed = yaml.safe_load("".join(lines[1:closing])) + except yaml.YAMLError as error: + raise SkillPackageError("Agent Skill SKILL.md frontmatter is invalid YAML") from error + if not isinstance(parsed, Mapping) or any(not isinstance(key, str) for key in parsed): + raise SkillPackageError("Agent Skill SKILL.md frontmatter must be a string-keyed mapping") + name = _required_string(parsed, "name", maximum=64) + if _SKILL_NAME.fullmatch(name) is None: + raise SkillPackageError("Agent Skill name must contain lowercase letters, digits, and single hyphens") + if expected_name is not None and name != expected_name: + raise SkillPackageError("Agent Skill name must match its package directory") + description = _required_string(parsed, "description", maximum=1_024) + license_name = _optional_string(parsed, "license", maximum=512) + compatibility = _optional_string(parsed, "compatibility", maximum=500) + allowed_tools = _optional_string(parsed, "allowed-tools", maximum=2_000) + raw_metadata = parsed.get("metadata", {}) + if not isinstance(raw_metadata, Mapping) or any( + not isinstance(key, str) or not isinstance(value, str) for key, value in raw_metadata.items() + ): + raise SkillPackageError("Agent Skill metadata must map strings to strings") + metadata = dict(raw_metadata) + if len(metadata) > 64: + raise SkillPackageError("Agent Skill metadata must not exceed 64 entries") + for key, value in metadata.items(): + if not key.strip() or key != key.strip() or len(key) > 128: + raise SkillPackageError("Agent Skill metadata keys must be trimmed and at most 128 characters") + if value != value.strip() or len(value) > 2_000: + raise SkillPackageError("Agent Skill metadata values must be trimmed and at most 2000 characters") + instructions = "".join(lines[closing + 1 :]).lstrip("\r\n").rstrip() + return ( + SkillPackageMetadata( + name=name, + description=description, + license=license_name, + compatibility=compatibility, + metadata=metadata, + allowed_tools=allowed_tools, + ), + instructions, + ) + + +def _required_string(values: Mapping[str, object], field: str, *, maximum: int) -> str: + value = values.get(field) + if not isinstance(value, str) or not value.strip() or value != value.strip() or len(value) > maximum: + raise SkillPackageError( + f"Agent Skill {field} must be a non-empty trimmed string of at most {maximum} characters" + ) + return value + + +def _optional_string(values: Mapping[str, object], field: str, *, maximum: int) -> str | None: + value = values.get(field) + if value is None: + return None + if not isinstance(value, str) or not value.strip() or value != value.strip() or len(value) > maximum: + raise SkillPackageError( + f"Agent Skill {field} must be a non-empty trimmed string of at most {maximum} characters" + ) + return value + + +def _validate_relative_path(value: str) -> str: + if not value or "\x00" in value or "\\" in value: + raise SkillPackageError("Agent Skill package contains an invalid path") + path = PurePosixPath(value) + if path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts): + raise SkillPackageError(f"Agent Skill package path is not relative: {value}") + if any(part in _FORBIDDEN_COMPONENTS for part in path.parts): + raise SkillPackageError(f"Agent Skill package contains a forbidden path: {value}") + canonical = path.as_posix() + if unicodedata.normalize("NFC", canonical) != canonical: + raise SkillPackageError(f"Agent Skill package path must use NFC Unicode normalization: {value}") + if len(canonical.encode("utf-8")) > MAX_SKILL_PATH_BYTES: + raise SkillPackageError(f"Agent Skill package path exceeds the supported size: {value}") + return canonical + + +def _path_collision_key(value: str) -> str: + return unicodedata.normalize("NFC", value).casefold() + + +def _normalized_mode(value: int) -> int: + executable = value & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return 0o755 if executable else 0o644 + + +def _tree_digest(entries: tuple[SkillPackageEntry, ...]) -> str: + digest = hashlib.sha256(_CANONICAL_TREE_DOMAIN) + for entry in entries: + path = entry.path.encode("utf-8") + digest.update(len(path).to_bytes(4, "big")) + digest.update(path) + digest.update(entry.mode.to_bytes(4, "big")) + digest.update(entry.size.to_bytes(8, "big")) + digest.update(bytes.fromhex(entry.digest)) + return digest.hexdigest() + + +def _canonical_archive(files: tuple[_PackageFile, ...]) -> bytes: + output = io.BytesIO() + with zipfile.ZipFile( + output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9, strict_timestamps=True + ) as archive: + for value in files: + info = zipfile.ZipInfo(value.path, date_time=_ZIP_TIMESTAMP) + info.compress_type = zipfile.ZIP_DEFLATED + info.create_system = 3 + info.external_attr = (stat.S_IFREG | value.mode) << 16 + info.flag_bits |= 0x800 + archive.writestr(info, value.content, compress_type=zipfile.ZIP_DEFLATED, compresslevel=9) + return output.getvalue() + + +def _changed_during_read(before: os.stat_result, after: os.stat_result) -> bool: + return ( + before.st_dev, + before.st_ino, + before.st_size, + before.st_mtime_ns, + before.st_ctime_ns, + ) != ( + after.st_dev, + after.st_ino, + after.st_size, + after.st_mtime_ns, + after.st_ctime_ns, + ) + + +def _remove_partial_tree(path: Path) -> None: + for child in sorted(path.rglob("*"), key=lambda value: len(value.parts), reverse=True): + if child.is_dir() and not child.is_symlink(): + child.rmdir() + else: + child.unlink(missing_ok=True) + path.rmdir() + + +__all__ = [ + "MAX_SKILL_ARCHIVE_BYTES", + "MAX_SKILL_ENTRYPOINT_BYTES", + "MAX_SKILL_MANIFEST_BYTES", + "MAX_SKILL_PACKAGE_BYTES", + "MAX_SKILL_PACKAGE_FILES", + "MAX_SKILL_PATH_BYTES", + "SKILL_ENTRYPOINT", + "SkillPackageEntry", + "SkillPackageError", + "SkillPackageMetadata", + "SkillPackageSnapshot", + "build_instruction_skill_package", + "capture_skill_archive", + "capture_skill_directory", + "materialize_skill_package", + "package_file", +] diff --git a/src/powercontext/builtin/artifacts/skill/projection.py b/src/powercontext/builtin/artifacts/skill/projection.py index 9aa3bfac2..c598ad21a 100644 --- a/src/powercontext/builtin/artifacts/skill/projection.py +++ b/src/powercontext/builtin/artifacts/skill/projection.py @@ -43,8 +43,10 @@ class AgentSkillProjectionState(StrEnum): """Observable state of one managed Skill in a configured Agent target.""" UNPUBLISHED = "unpublished" + PENDING = "pending" CURRENT = "current" UPDATE_AVAILABLE = "update_available" + DELIVERY_FAILED = "delivery_failed" CONFLICT = "conflict" DRIFTED = "drifted" INCOMPATIBLE = "incompatible" @@ -266,6 +268,14 @@ def _validate_agent_projection(content: SkillContent, destination: Path, agent_k ) +def validate_skill_projection_target(content: SkillContent, target: AgentSkillTarget, /) -> Path: + """Validate host-specific package constraints and return the exact destination.""" + + destination = target.path.expanduser().resolve(strict=False) / content.name + _validate_agent_projection(content, destination, target.agent_kind) + return destination + + def _agent_label(agent_kind: AgentKind) -> str: return "Codex" if agent_kind == "codex" else "Claude Code" @@ -347,4 +357,5 @@ def _manifest_matches_agent(manifest: dict[str, object], agent_kind: AgentKind) "inspect_skill_projection", "project_skill", "publish_skill_projection", + "validate_skill_projection_target", ] diff --git a/src/powercontext/builtin/artifacts/skill/provenance.py b/src/powercontext/builtin/artifacts/skill/provenance.py new file mode 100644 index 000000000..41b2a44f8 --- /dev/null +++ b/src/powercontext/builtin/artifacts/skill/provenance.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. + +"""User-visible provenance for one managed Skill lineage.""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, model_validator + +from powercontext.builtin.artifacts.skill.external import ExternalSkillRegistration +from powercontext.sources import SourceRef + + +class SkillOriginKind(StrEnum): + """The creation boundary that can be proven from immutable Skill lineage.""" + + POWERCONTEXT = "powercontext" + EXTERNAL_IMPORT = "external_import" + EXTERNAL_FORK = "external_fork" + + +class SkillOrigin(BaseModel): + """A compact Skill origin projection backed by exact persisted evidence.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: SkillOriginKind + registration: ExternalSkillRegistration | None = None + source: SourceRef | None = None + + @model_validator(mode="after") + def require_external_evidence(self) -> SkillOrigin: + external = self.kind in {SkillOriginKind.EXTERNAL_IMPORT, SkillOriginKind.EXTERNAL_FORK} + if external != (self.registration is not None and self.source is not None): + raise ValueError("external Skill origins require registration and Source evidence") # noqa: TRY003 + return self + + +__all__ = ["SkillOrigin", "SkillOriginKind"] diff --git a/src/powercontext/builtin/artifacts/skill/publication.py b/src/powercontext/builtin/artifacts/skill/publication.py new file mode 100644 index 000000000..33cf8d5bd --- /dev/null +++ b/src/powercontext/builtin/artifacts/skill/publication.py @@ -0,0 +1,564 @@ +# 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. + +"""Safe standard-package publication to configured host-local Agent targets.""" + +from __future__ import annotations + +import asyncio +import shutil +import tempfile +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path + +from powercontext.artifacts import ArtifactRef +from powercontext.builtin.artifacts.skill.compatibility import ( + SkillCompatibilityState, + assess_skill_compatibility, + target_environment_fingerprint, +) +from powercontext.builtin.artifacts.skill.external import AgentSkillTarget +from powercontext.builtin.artifacts.skill.models import Skill +from powercontext.builtin.artifacts.skill.package import ( + SkillPackageError, + SkillPackageSnapshot, + capture_skill_directory, + materialize_skill_package, +) +from powercontext.builtin.artifacts.skill.projection import ( + AgentSkillProjectionConflictError, + AgentSkillProjectionState, + AgentSkillProjectionStatus, + validate_skill_projection_target, +) +from powercontext.builtin.persistence.artifact_governance import ( + ArtifactGovernance, + ArtifactGovernanceRepository, + ArtifactLifecycleState, +) +from powercontext.builtin.persistence.artifacts import ArtifactRepository +from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.persistence.skill_packages import SkillPackageRepository +from powercontext.builtin.persistence.skill_publications import ( + SkillPublication, + SkillPublicationDesiredState, + SkillPublicationRepository, +) + + +@dataclass(frozen=True) +class ManagedSkillPublicationStatus: + """Database-backed publication status exposed to the Runtime and UI.""" + + state: AgentSkillProjectionState + destination: Path + published_destination: Path | None = None + published_artifact: ArtifactRef | None = None + published_tree_digest: str | None = None + reason: str | None = None + generation: int | None = None + + +class ManagedSkillPublicationService: + """Coordinate package storage, publication CAS, and exact local filesystem state.""" + + def __init__( + self, + *, + database: AsyncDatabase, + scope_id: str, + artifacts: ArtifactRepository, + governance: ArtifactGovernanceRepository, + packages: SkillPackageRepository, + publications: SkillPublicationRepository, + lock: asyncio.Lock, + ) -> None: + self._database = database + self._scope_id = scope_id + self._artifacts = artifacts + self._governance = governance + self._packages = packages + self._publications = publications + self._lock = lock + + async def inspect(self, artifact: ArtifactRef, target: AgentSkillTarget, /) -> ManagedSkillPublicationStatus: + async with self._lock: + skill, package, publication, _governance = await self._load(artifact, target) + status = await asyncio.to_thread(_inspect_local, skill, package, target, publication) + if publication is not None: + publication = await self._persist_observation(publication, skill, package, target, status) + status = _with_generation(status, publication.generation) + return status + + async def publish( + self, + artifact: ArtifactRef, + target: AgentSkillTarget, + /, + *, + allow_deprecated: bool = False, + ) -> ManagedSkillPublicationStatus: + if not target.allow_managed_publish: + raise ValueError("managed publication is not enabled for this Agent target") # noqa: TRY003 + async with self._lock: + skill, package, publication, governance = await self._load(artifact, target) + compatibility = assess_skill_compatibility(skill.content, package, target) + if compatibility.state is SkillCompatibilityState.INCOMPATIBLE: + raise ValueError("managed Skill is incompatible with this Agent target") # noqa: TRY003 + status = await asyncio.to_thread(_inspect_local, skill, package, target, publication) + if status.state is AgentSkillProjectionState.CURRENT: + if publication is None: + raise AgentSkillProjectionConflictError(_legacy_status(status)) + publication = await self._persist_observation(publication, skill, package, target, status) + return _with_generation(status, publication.generation) + if governance.lifecycle_state is ArtifactLifecycleState.RETIRED: + raise ValueError("retired managed Skills cannot be published or updated") # noqa: TRY003 + if governance.lifecycle_state is ArtifactLifecycleState.DEPRECATED and not allow_deprecated: + raise ValueError("deprecated managed Skills require an explicit publication override") # noqa: TRY003 + if status.state not in { + AgentSkillProjectionState.UNPUBLISHED, + AgentSkillProjectionState.UPDATE_AVAILABLE, + }: + raise AgentSkillProjectionConflictError(_legacy_status(status)) + + publication = await self._record_intent(publication, skill, package, target, status) + await asyncio.to_thread(_publish_local, skill, package, target, publication) + observed = publication.model_copy( + update={ + "desired_state": SkillPublicationDesiredState.PUBLISHED, + "desired_revision": skill.revision, + "desired_tree_digest": package.reference.tree_digest, + "observed_revision": skill.revision, + "observed_tree_digest": package.reference.tree_digest, + "destination": str(_destination(skill, target)), + "state": AgentSkillProjectionState.CURRENT, + "selected_runtime_variant": compatibility.selected_runtime_variant, + "environment_fingerprint": target_environment_fingerprint(target), + "observed_generation": publication.generation, + "observed_at": datetime.now(UTC), + "last_error_code": None, + } + ) + async with self._database.transaction() as connection: + publication = await self._publications.observe( + connection, + observed, + publication.generation, + preserve_success=False, + ) + return _with_generation( + await asyncio.to_thread(_inspect_local, skill, package, target, publication), + publication.generation, + ) + + async def unpublish(self, artifact: ArtifactRef, target: AgentSkillTarget, /) -> ManagedSkillPublicationStatus: + if not target.allow_managed_publish: + raise ValueError("managed publication is not enabled for this Agent target") # noqa: TRY003 + async with self._lock: + skill, package, publication, _governance = await self._load(artifact, target) + if publication is None: + return await asyncio.to_thread(_inspect_local, skill, package, target, None) + status = await asyncio.to_thread(_inspect_local, skill, package, target, publication) + if status.state is AgentSkillProjectionState.UNPUBLISHED: + return _with_generation(status, publication.generation) + if status.state not in { + AgentSkillProjectionState.CURRENT, + AgentSkillProjectionState.UPDATE_AVAILABLE, + }: + raise AgentSkillProjectionConflictError(_legacy_status(status)) + if status.published_destination is None: + raise AgentSkillProjectionConflictError(_legacy_status(status)) + + backup_root, backup = await asyncio.to_thread(_stage_unpublish, status.published_destination, target) + revised = publication.model_copy( + update={ + "desired_state": SkillPublicationDesiredState.UNPUBLISHED, + "desired_revision": skill.revision, + "desired_tree_digest": package.reference.tree_digest, + "observed_revision": None, + "observed_tree_digest": None, + "destination": str(_destination(skill, target)), + "state": AgentSkillProjectionState.UNPUBLISHED, + "selected_runtime_variant": _selected_runtime_variant(skill, package, target), + "environment_fingerprint": target_environment_fingerprint(target), + "observed_generation": publication.generation + 1, + "observed_at": datetime.now(UTC), + "last_error_code": None, + } + ) + try: + async with self._database.transaction() as connection: + publication = await self._publications.replace(connection, revised, publication.generation) + except BaseException: + await asyncio.to_thread(_restore_unpublish, backup, status.published_destination, backup_root) + raise + await asyncio.to_thread(shutil.rmtree, backup_root, True) + return ManagedSkillPublicationStatus( + state=AgentSkillProjectionState.UNPUBLISHED, + destination=_destination(skill, target), + generation=publication.generation, + ) + + async def _load( + self, + artifact: ArtifactRef, + target: AgentSkillTarget, + ) -> tuple[Skill, SkillPackageSnapshot, SkillPublication | None, ArtifactGovernance]: + async with self._database.transaction() as connection: + value = await self._artifacts.get(connection, self._scope_id, artifact) + if not isinstance(value, Skill) or value.content.package is None: + raise ValueError("managed publication requires a package-backed Skill Revision") # noqa: TRY003 + package = await self._packages.get(connection, self._scope_id, value.content.package) + publication = await self._publications.find( + connection, self._scope_id, target.target_id, artifact.artifact_id + ) + governance = await self._governance.get(connection, self._scope_id, Skill.family, artifact.artifact_id) + return value, package, publication, governance + + async def _persist_observation( + self, + publication: SkillPublication, + skill: Skill, + package: SkillPackageSnapshot, + target: AgentSkillTarget, + status: ManagedSkillPublicationStatus, + ) -> SkillPublication: + observed = publication.model_copy( + update={ + "observed_revision": ( + None if status.published_artifact is None else status.published_artifact.revision + ), + "observed_tree_digest": status.published_tree_digest, + "state": status.state, + "selected_runtime_variant": _selected_runtime_variant(skill, package, target), + "environment_fingerprint": target_environment_fingerprint(target), + "observed_generation": publication.generation, + "observed_at": datetime.now(UTC), + "last_error_code": None, + } + ) + if observed.model_dump(exclude={"generation", "updated_at"}) == publication.model_dump( + exclude={"generation", "updated_at"} + ): + return publication + async with self._database.transaction() as connection: + return await self._publications.observe( + connection, + observed, + publication.generation, + preserve_success=status.state + not in {AgentSkillProjectionState.CURRENT, AgentSkillProjectionState.UNPUBLISHED}, + ) + + async def _record_intent( + self, + publication: SkillPublication | None, + skill: Skill, + package: SkillPackageSnapshot, + target: AgentSkillTarget, + status: ManagedSkillPublicationStatus, + ) -> SkillPublication: + now = datetime.now(UTC) + if publication is None: + intent = SkillPublication( + scope_id=self._scope_id, + target_id=target.target_id, + artifact_id=skill.artifact_id, + desired_state=SkillPublicationDesiredState.PUBLISHED, + desired_revision=skill.revision, + desired_tree_digest=package.reference.tree_digest, + observed_revision=skill.revision, + observed_tree_digest=package.reference.tree_digest, + destination=str(_destination(skill, target)), + state=AgentSkillProjectionState.UNPUBLISHED, + selected_runtime_variant=_selected_runtime_variant(skill, package, target), + environment_fingerprint=target_environment_fingerprint(target), + generation=0, + updated_at=now, + ) + async with self._database.transaction() as connection: + return await self._publications.create(connection, intent) + intent = publication.model_copy( + update={ + "desired_state": SkillPublicationDesiredState.PUBLISHED, + "desired_revision": skill.revision, + "desired_tree_digest": package.reference.tree_digest, + "state": status.state, + "selected_runtime_variant": _selected_runtime_variant(skill, package, target), + "environment_fingerprint": target_environment_fingerprint(target), + } + ) + if intent.model_dump(exclude={"generation", "updated_at"}) == publication.model_dump( + exclude={"generation", "updated_at"} + ): + return publication + async with self._database.transaction() as connection: + return await self._publications.replace(connection, intent, publication.generation) + + +def _selected_runtime_variant(skill: Skill, package: SkillPackageSnapshot, target: AgentSkillTarget) -> str | None: + return assess_skill_compatibility(skill.content, package, target).selected_runtime_variant + + +def _inspect_local( # noqa: C901 + skill: Skill, + package: SkillPackageSnapshot, + target: AgentSkillTarget, + publication: SkillPublication | None, +) -> ManagedSkillPublicationStatus: + desired = _destination(skill, target) + try: + validate_skill_projection_target(skill.content, target) + except ValueError as error: + return ManagedSkillPublicationStatus( + state=AgentSkillProjectionState.INCOMPATIBLE, + destination=desired, + reason=str(error), + generation=None if publication is None else publication.generation, + ) + if publication is None: + if desired.exists() or desired.is_symlink(): + return ManagedSkillPublicationStatus( + state=AgentSkillProjectionState.CONFLICT, + destination=desired, + reason="the target Skill directory is already occupied", + ) + return ManagedSkillPublicationStatus(state=AgentSkillProjectionState.UNPUBLISHED, destination=desired) + + if publication.destination is None: + return ManagedSkillPublicationStatus( + state=AgentSkillProjectionState.CONFLICT, + destination=desired, + reason="the stored local publication destination is missing", + generation=publication.generation, + ) + published = Path(publication.destination).expanduser().resolve(strict=False) + root = target.path.expanduser().resolve(strict=False) + if published.parent != root: + return ManagedSkillPublicationStatus( + state=AgentSkillProjectionState.CONFLICT, + destination=desired, + reason="the stored publication destination is outside the configured Agent target", + generation=publication.generation, + ) + if publication.observed_revision is None or publication.observed_tree_digest is None: + if published.exists() or published.is_symlink(): + return ManagedSkillPublicationStatus( + state=AgentSkillProjectionState.CONFLICT, + destination=desired, + reason="the target Skill directory is occupied outside managed publication authority", + generation=publication.generation, + ) + return ManagedSkillPublicationStatus( + state=AgentSkillProjectionState.UNPUBLISHED, + destination=desired, + generation=publication.generation, + ) + if not published.exists() or published.is_symlink(): + state = ( + AgentSkillProjectionState.UNPUBLISHED + if publication.state is AgentSkillProjectionState.UNPUBLISHED + else AgentSkillProjectionState.DRIFTED + ) + return ManagedSkillPublicationStatus( + state=state, + destination=desired, + published_destination=published, + published_artifact=_observed_artifact(skill, publication), + published_tree_digest=publication.observed_tree_digest, + reason=None if state is AgentSkillProjectionState.UNPUBLISHED else "the published package is missing", + generation=publication.generation, + ) + try: + actual = capture_skill_directory(published) + except (OSError, SkillPackageError): + return _drifted(skill, desired, published, publication, "the published package is not a valid standard Skill") + + if ( + published == desired + and actual.reference.tree_digest == publication.desired_tree_digest + and publication.state in {AgentSkillProjectionState.UNPUBLISHED, AgentSkillProjectionState.UPDATE_AVAILABLE} + ): + return ManagedSkillPublicationStatus( + state=AgentSkillProjectionState.CURRENT, + destination=desired, + published_destination=published, + published_artifact=ArtifactRef( + family="skill", artifact_id=skill.artifact_id, revision=publication.desired_revision + ), + published_tree_digest=publication.desired_tree_digest, + generation=publication.generation, + ) + if actual.reference.tree_digest != publication.observed_tree_digest: + return _drifted(skill, desired, published, publication, "the published package was modified locally") + observed = _observed_artifact(skill, publication) + if published != desired and (desired.exists() or desired.is_symlink()): + return ManagedSkillPublicationStatus( + state=AgentSkillProjectionState.CONFLICT, + destination=desired, + published_destination=published, + published_artifact=observed, + published_tree_digest=publication.observed_tree_digest, + reason="the renamed target Skill directory is already occupied", + generation=publication.generation, + ) + if publication.observed_revision > skill.revision: + return ManagedSkillPublicationStatus( + state=AgentSkillProjectionState.CONFLICT, + destination=desired, + published_destination=published, + published_artifact=observed, + published_tree_digest=publication.observed_tree_digest, + reason="a newer managed Skill Revision is already published", + generation=publication.generation, + ) + if publication.observed_revision == skill.revision: + if published == desired and publication.observed_tree_digest == package.reference.tree_digest: + return ManagedSkillPublicationStatus( + state=AgentSkillProjectionState.CURRENT, + destination=desired, + published_destination=published, + published_artifact=observed, + published_tree_digest=publication.observed_tree_digest, + generation=publication.generation, + ) + return ManagedSkillPublicationStatus( + state=AgentSkillProjectionState.CONFLICT, + destination=desired, + published_destination=published, + published_artifact=observed, + published_tree_digest=publication.observed_tree_digest, + reason="the same managed Revision has a different package identity or destination", + generation=publication.generation, + ) + return ManagedSkillPublicationStatus( + state=AgentSkillProjectionState.UPDATE_AVAILABLE, + destination=desired, + published_destination=published, + published_artifact=observed, + published_tree_digest=publication.observed_tree_digest, + generation=publication.generation, + ) + + +def _publish_local( + skill: Skill, + package: SkillPackageSnapshot, + target: AgentSkillTarget, + publication: SkillPublication, +) -> None: + current = _inspect_local(skill, package, target, publication) + if current.state is AgentSkillProjectionState.CURRENT: + return + if current.state not in { + AgentSkillProjectionState.UNPUBLISHED, + AgentSkillProjectionState.UPDATE_AVAILABLE, + }: + raise AgentSkillProjectionConflictError(_legacy_status(current)) + root = target.path.expanduser().resolve(strict=False) + root.mkdir(parents=True, exist_ok=True) + desired = _destination(skill, target) + temporary = Path(tempfile.mkdtemp(prefix=".powercontext-publish-", dir=root)) + backup = temporary / "previous" + try: + staged = temporary / "staged" / skill.content.name + materialize_skill_package(package, staged) + existing = current.published_destination + if existing is not None and existing.exists(): + existing.rename(backup) + try: + staged.rename(desired) + except BaseException: + if existing is not None and backup.exists() and not existing.exists(): + backup.rename(existing) + raise + finally: + shutil.rmtree(temporary, ignore_errors=True) + + +def _stage_unpublish(destination: Path, target: AgentSkillTarget) -> tuple[Path, Path]: + root = target.path.expanduser().resolve(strict=False) + if destination.parent != root or not destination.is_dir() or destination.is_symlink(): + raise AgentSkillProjectionConflictError( + AgentSkillProjectionStatus( + state=AgentSkillProjectionState.DRIFTED, + destination=destination, + reason="the managed publication cannot be removed safely", + ) + ) + temporary = Path(tempfile.mkdtemp(prefix=".powercontext-unpublish-", dir=root)) + backup = temporary / "package" + destination.rename(backup) + return temporary, backup + + +def _restore_unpublish(backup: Path, destination: Path, temporary: Path) -> None: + try: + if backup.exists() and not destination.exists(): + backup.rename(destination) + finally: + shutil.rmtree(temporary, ignore_errors=True) + + +def _destination(skill: Skill, target: AgentSkillTarget) -> Path: + return target.path.expanduser().resolve(strict=False) / skill.content.name + + +def _observed_artifact(skill: Skill, publication: SkillPublication) -> ArtifactRef: + if publication.observed_revision is None: + raise ValueError("Skill publication has no observed Revision") # noqa: TRY003 + return ArtifactRef(family="skill", artifact_id=skill.artifact_id, revision=publication.observed_revision) + + +def _drifted( + skill: Skill, + desired: Path, + published: Path, + publication: SkillPublication, + reason: str, +) -> ManagedSkillPublicationStatus: + return ManagedSkillPublicationStatus( + state=AgentSkillProjectionState.DRIFTED, + destination=desired, + published_destination=published, + published_artifact=_observed_artifact(skill, publication), + published_tree_digest=publication.observed_tree_digest, + reason=reason, + generation=publication.generation, + ) + + +def _with_generation(status: ManagedSkillPublicationStatus, generation: int) -> ManagedSkillPublicationStatus: + return ManagedSkillPublicationStatus( + state=status.state, + destination=status.destination, + published_destination=status.published_destination, + published_artifact=status.published_artifact, + published_tree_digest=status.published_tree_digest, + reason=status.reason, + generation=generation, + ) + + +def _legacy_status(status: ManagedSkillPublicationStatus) -> AgentSkillProjectionStatus: + return AgentSkillProjectionStatus( + state=status.state, + destination=status.destination, + published_artifact=status.published_artifact, + reason=status.reason, + ) + + +__all__ = ["ManagedSkillPublicationService", "ManagedSkillPublicationStatus"] diff --git a/src/powercontext/builtin/artifacts/skill/registry.py b/src/powercontext/builtin/artifacts/skill/registry.py index 75b3db77d..c7d7944a1 100644 --- a/src/powercontext/builtin/artifacts/skill/registry.py +++ b/src/powercontext/builtin/artifacts/skill/registry.py @@ -17,17 +17,18 @@ from __future__ import annotations import asyncio +from pathlib import Path from powercontext.builtin.artifacts.skill.external import ( - MAX_EXTERNAL_SKILL_MANIFEST_BYTES, + CapturedExternalSkillPackage, ExternalSkillNotFoundError, ExternalSkillProvider, ExternalSkillProviderScan, ExternalSkillResolution, ExternalSkillResolutionStatus, - ExternalSkillSnapshot, ExternalSkillSnapshotUnavailableError, ) +from powercontext.builtin.artifacts.skill.package import SkillPackageError, capture_skill_directory from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.errors import RepositoryNotFoundError from powercontext.builtin.persistence.external_skills import ExternalSkillRepository @@ -101,28 +102,20 @@ async def snapshot( external_skill_id: str, fingerprint: str, /, - ) -> ExternalSkillSnapshot: - """Capture exact primary content only while the whole package fingerprint is stable.""" + ) -> CapturedExternalSkillPackage: + """Capture a complete canonical package while the external fingerprint is stable.""" resolution = await self.resolve(external_skill_id, fingerprint) if resolution.status is not ExternalSkillResolutionStatus.AVAILABLE or resolution.entrypoint is None: raise ExternalSkillSnapshotUnavailableError(external_skill_id) try: - manifest = await asyncio.to_thread(_read_manifest, resolution.entrypoint) - except (OSError, UnicodeError, ValueError): + package = await asyncio.to_thread(capture_skill_directory, Path(resolution.entrypoint).parent) + except (OSError, UnicodeError, SkillPackageError): raise ExternalSkillSnapshotUnavailableError(external_skill_id) from None confirmed = await self.resolve(external_skill_id, fingerprint) if confirmed.status is not ExternalSkillResolutionStatus.AVAILABLE: raise ExternalSkillSnapshotUnavailableError(external_skill_id) - return ExternalSkillSnapshot(registration=resolution.registration, manifest=manifest) - - -def _read_manifest(entrypoint: str) -> str: - with open(entrypoint, "rb") as stream: - content = stream.read(MAX_EXTERNAL_SKILL_MANIFEST_BYTES + 1) - if len(content) > MAX_EXTERNAL_SKILL_MANIFEST_BYTES: - raise ValueError("external Skill manifest exceeds the snapshot bound") # noqa: TRY003 - return content.decode("utf-8") + return CapturedExternalSkillPackage(registration=resolution.registration, package=package) __all__ = ["ExternalSkillRegistryService"] diff --git a/src/powercontext/builtin/artifacts/skill/search.py b/src/powercontext/builtin/artifacts/skill/search.py new file mode 100644 index 000000000..ba18d2dff --- /dev/null +++ b/src/powercontext/builtin/artifacts/skill/search.py @@ -0,0 +1,74 @@ +# 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. + +"""Search projection owned by the managed Skill Artifact Family.""" + +from __future__ import annotations + +from pydantic import BaseModel + +from powercontext.artifacts import ArtifactRef +from powercontext.builtin.artifacts.search import analyze_text +from powercontext.builtin.artifacts.skill.models import SkillContent +from powercontext.builtin.artifacts.skill.package import SkillPackageSnapshot, package_file + +_MAX_INDEXED_FILE_BYTES = 128 * 1024 + + +class SkillSearchHit(BaseModel): + """One relevant approved, active managed Skill head.""" + + artifact_ref: ArtifactRef + content: SkillContent + + +def skill_search_text(content: SkillContent, package: SkillPackageSnapshot | None = None, /) -> str: + """Return deterministic user-authored metadata and bounded textual package content.""" + + values = [ + content.name, + content.description, + content.instructions, + *(content.validation), + *(f"{key} {value}" for key, value in sorted(content.metadata.items())), + ] + if content.license is not None: + values.append(content.license) + if content.compatibility is not None: + values.append(content.compatibility) + if content.allowed_tools is not None: + values.append(content.allowed_tools) + if package is not None: + for entry in package.entries: + values.append(entry.path) + if entry.size > _MAX_INDEXED_FILE_BYTES or not _is_indexed_text(entry.path): + continue + try: + values.append(package_file(package, entry.path).decode("utf-8")) + except UnicodeDecodeError: + continue + return "\n".join(values) + + +def skill_searchable_text(content: SkillContent, package: SkillPackageSnapshot | None = None, /) -> str: + """Build the normalized lexical projection for one managed Skill Revision.""" + + return analyze_text(skill_search_text(content, package)) + + +def _is_indexed_text(path: str) -> bool: + return path == "SKILL.md" or (path.startswith("references/") and path.endswith((".md", ".txt"))) + + +__all__ = ["SkillSearchHit", "skill_search_text", "skill_searchable_text"] diff --git a/src/powercontext/builtin/inference/pydantic_ai.py b/src/powercontext/builtin/inference/pydantic_ai.py index 5bf569303..13094d07e 100644 --- a/src/powercontext/builtin/inference/pydantic_ai.py +++ b/src/powercontext/builtin/inference/pydantic_ai.py @@ -282,7 +282,7 @@ async def probe_pydantic_ai_model( await asyncio.wait_for( model.request( [ModelRequest(parts=[UserPromptPart("Reply with one token.")])], - merge_model_settings(model_settings, ModelSettings(max_tokens=1)), + merge_model_settings(model_settings, ModelSettings(max_tokens=16)), ModelRequestParameters(), ), timeout=timeout_seconds, diff --git a/src/powercontext/builtin/persistence/__init__.py b/src/powercontext/builtin/persistence/__init__.py index 9f75b5f30..62cc0fd3a 100644 --- a/src/powercontext/builtin/persistence/__init__.py +++ b/src/powercontext/builtin/persistence/__init__.py @@ -14,6 +14,11 @@ """SQLAlchemy-backed relational persistence building blocks.""" +from powercontext.builtin.persistence.agent_skill_targets import ( + RemoteAgentSkillTarget, + RemoteAgentSkillTargetRepository, + RemoteAgentSkillTargetState, +) from powercontext.builtin.persistence.candidates import CandidateRepository from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.errors import ( @@ -29,6 +34,12 @@ StoredPayloadConflictError, ) from powercontext.builtin.persistence.external_skills import ExternalSkillRepository +from powercontext.builtin.persistence.skill_packages import SkillPackageRepository +from powercontext.builtin.persistence.skill_publications import ( + SkillPublication, + SkillPublicationDesiredState, + SkillPublicationRepository, +) from powercontext.builtin.persistence.statistics import ( StatisticsRepository, StoredInventoryCounts, @@ -47,8 +58,15 @@ "InvalidStoredColumnError", "InvalidStoredPayloadError", "PersistenceError", + "RemoteAgentSkillTarget", + "RemoteAgentSkillTargetRepository", + "RemoteAgentSkillTargetState", "RepositoryError", "RepositoryNotFoundError", + "SkillPackageRepository", + "SkillPublication", + "SkillPublicationDesiredState", + "SkillPublicationRepository", "StatisticsRepository", "StoredInventoryCounts", "StoredModelUsage", diff --git a/src/powercontext/builtin/persistence/agent_skill_targets.py b/src/powercontext/builtin/persistence/agent_skill_targets.py new file mode 100644 index 000000000..dc8b6e3d4 --- /dev/null +++ b/src/powercontext/builtin/persistence/agent_skill_targets.py @@ -0,0 +1,274 @@ +# 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. + +"""CAS persistence for credential-bound remote Agent Skill targets.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from enum import StrEnum +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator +from sqlalchemy import insert, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.builtin.artifacts.skill.external import AgentKind +from powercontext.builtin.persistence.errors import RepositoryNotFoundError, StoredPayloadConflictError +from powercontext.builtin.persistence.tables import AGENT_SKILL_TARGETS_TABLE + + +class RemoteAgentSkillTargetState(StrEnum): + """Enrollment lifecycle for one remote Receiver installation.""" + + PENDING = "pending" + ACTIVE = "active" + REVOKED = "revoked" + + +class RemoteAgentSkillTarget(BaseModel): + """Durable remote Receiver identity without a Server-interpreted path.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + scope_id: str + target_id: str = Field(min_length=1, max_length=64, pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + display_name: str = Field(min_length=1, max_length=128, pattern=r".*\S.*") + agent_kind: AgentKind + installation_scope: Literal["project"] = "project" + delivery_mode: Literal["agent_pull"] = "agent_pull" + installation_id: str | None = Field(default=None, min_length=1, max_length=128) + state: RemoteAgentSkillTargetState + enrollment_token_digest: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$") + enrollment_expires_at: datetime | None = None + credential_subject: str | None = Field(default=None, min_length=1, max_length=128) + credential_verifier: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$") + receiver_version: str | None = Field(default=None, min_length=1, max_length=64) + environment_fingerprint: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$") + machine_hostname: str | None = Field(default=None, min_length=1, max_length=255, pattern=r".*\S.*") + workspace_name: str | None = Field(default=None, min_length=1, max_length=128, pattern=r".*\S.*") + last_seen_at: datetime | None = None + generation: int = Field(ge=0) + created_at: datetime + updated_at: datetime + + @model_validator(mode="after") + def validate_state_payload(self) -> RemoteAgentSkillTarget: + if self.state is RemoteAgentSkillTargetState.PENDING: + if self.enrollment_token_digest is None or self.enrollment_expires_at is None: + raise ValueError("pending remote target requires an enrollment token and expiry") # noqa: TRY003 + if ( + self.installation_id is not None + or self.credential_subject is not None + or self.credential_verifier is not None + ): + raise ValueError("pending remote target cannot have an installation credential") # noqa: TRY003 + elif self.state is RemoteAgentSkillTargetState.ACTIVE: + if self.installation_id is None or self.credential_subject is None or self.credential_verifier is None: + raise ValueError("active remote target requires an installation credential") # noqa: TRY003 + if self.enrollment_token_digest is not None or self.enrollment_expires_at is not None: + raise ValueError("active remote target cannot retain enrollment credentials") # noqa: TRY003 + elif self.enrollment_token_digest is not None or self.credential_verifier is not None: + raise ValueError("revoked remote target cannot retain usable credentials") # noqa: TRY003 + return self + + +class RemoteAgentSkillTargetRepository: + """Create and advance remote target enrollment with generation CAS.""" + + async def list_for_scope( + self, + connection: AsyncConnection, + scope_id: str, + /, + *, + limit: int, + ) -> tuple[RemoteAgentSkillTarget, ...]: + rows = ( + ( + await connection.execute( + select(AGENT_SKILL_TARGETS_TABLE) + .where(AGENT_SKILL_TARGETS_TABLE.c.scope_id == scope_id) + .order_by( + AGENT_SKILL_TARGETS_TABLE.c.created_at, + AGENT_SKILL_TARGETS_TABLE.c.target_id, + ) + .limit(limit) + ) + ) + .mappings() + .all() + ) + return tuple(RemoteAgentSkillTarget.model_validate(row) for row in rows) + + async def find( + self, + connection: AsyncConnection, + scope_id: str, + target_id: str, + /, + ) -> RemoteAgentSkillTarget | None: + row = ( + ( + await connection.execute( + select(AGENT_SKILL_TARGETS_TABLE).where( + AGENT_SKILL_TARGETS_TABLE.c.scope_id == scope_id, + AGENT_SKILL_TARGETS_TABLE.c.target_id == target_id, + ) + ) + ) + .mappings() + .one_or_none() + ) + return None if row is None else RemoteAgentSkillTarget.model_validate(row) + + async def find_by_enrollment_token( + self, + connection: AsyncConnection, + token_digest: str, + /, + ) -> RemoteAgentSkillTarget | None: + row = ( + ( + await connection.execute( + select(AGENT_SKILL_TARGETS_TABLE).where( + AGENT_SKILL_TARGETS_TABLE.c.enrollment_token_digest == token_digest + ) + ) + ) + .mappings() + .one_or_none() + ) + return None if row is None else RemoteAgentSkillTarget.model_validate(row) + + async def find_by_credential( + self, + connection: AsyncConnection, + credential_verifier: str, + /, + ) -> RemoteAgentSkillTarget | None: + row = ( + ( + await connection.execute( + select(AGENT_SKILL_TARGETS_TABLE).where( + AGENT_SKILL_TARGETS_TABLE.c.credential_verifier == credential_verifier + ) + ) + ) + .mappings() + .one_or_none() + ) + return None if row is None else RemoteAgentSkillTarget.model_validate(row) + + async def create( + self, + connection: AsyncConnection, + target: RemoteAgentSkillTarget, + /, + ) -> RemoteAgentSkillTarget: + if target.generation != 0: + raise ValueError("new remote target generation must be zero") # noqa: TRY003 + try: + await connection.execute(insert(AGENT_SKILL_TARGETS_TABLE).values(**_values(target))) + except IntegrityError as error: + raise StoredPayloadConflictError("agent-skill-target", (target.scope_id, target.target_id)) from error + return target + + async def replace( + self, + connection: AsyncConnection, + target: RemoteAgentSkillTarget, + expected_generation: int, + /, + ) -> RemoteAgentSkillTarget: + revised = target.model_copy(update={"generation": expected_generation + 1}) + revised = revised.model_copy(update={"updated_at": datetime.now(UTC)}) + values = _values(revised, exclude_identity=True) + try: + result = await connection.execute( + update(AGENT_SKILL_TARGETS_TABLE) + .where( + AGENT_SKILL_TARGETS_TABLE.c.scope_id == target.scope_id, + AGENT_SKILL_TARGETS_TABLE.c.target_id == target.target_id, + AGENT_SKILL_TARGETS_TABLE.c.generation == expected_generation, + ) + .values(**values) + ) + except IntegrityError as error: + raise StoredPayloadConflictError( + "agent-skill-target", + (target.scope_id, target.target_id, expected_generation), + ) from error + if result.rowcount == 1: + return revised + current = await self.find(connection, target.scope_id, target.target_id) + if current is None: + raise RepositoryNotFoundError("agent-skill-target", (target.scope_id, target.target_id)) + raise StoredPayloadConflictError( + "agent-skill-target", + (target.scope_id, target.target_id, expected_generation), + ) + + async def observe( + self, + connection: AsyncConnection, + target: RemoteAgentSkillTarget, + /, + *, + receiver_version: str, + environment_fingerprint: str | None, + observed_at: datetime, + ) -> RemoteAgentSkillTarget: + """Refresh liveness metadata without changing credential lifecycle generation.""" + + result = await connection.execute( + update(AGENT_SKILL_TARGETS_TABLE) + .where( + AGENT_SKILL_TARGETS_TABLE.c.scope_id == target.scope_id, + AGENT_SKILL_TARGETS_TABLE.c.target_id == target.target_id, + AGENT_SKILL_TARGETS_TABLE.c.state == RemoteAgentSkillTargetState.ACTIVE.value, + AGENT_SKILL_TARGETS_TABLE.c.credential_verifier == target.credential_verifier, + ) + .values( + receiver_version=receiver_version, + environment_fingerprint=environment_fingerprint, + last_seen_at=observed_at, + updated_at=observed_at, + ) + ) + if result.rowcount != 1: + raise RepositoryNotFoundError("active-agent-skill-target", (target.scope_id, target.target_id)) + return target.model_copy( + update={ + "receiver_version": receiver_version, + "environment_fingerprint": environment_fingerprint, + "last_seen_at": observed_at, + "updated_at": observed_at, + } + ) + + +def _values(target: RemoteAgentSkillTarget, *, exclude_identity: bool = False) -> dict[str, object]: + excluded = {"scope_id", "target_id"} if exclude_identity else set() + values = target.model_dump(mode="python", exclude=excluded) + values["state"] = target.state.value + return values + + +__all__ = [ + "RemoteAgentSkillTarget", + "RemoteAgentSkillTargetRepository", + "RemoteAgentSkillTargetState", +] diff --git a/src/powercontext/builtin/persistence/artifact_governance.py b/src/powercontext/builtin/persistence/artifact_governance.py new file mode 100644 index 000000000..6a3a403d5 --- /dev/null +++ b/src/powercontext/builtin/persistence/artifact_governance.py @@ -0,0 +1,166 @@ +# 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. + +"""Mutable governance state on authoritative Artifact heads.""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, Field, model_validator +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.artifacts import ArtifactRef +from powercontext.builtin.persistence.errors import RepositoryNotFoundError, StoredPayloadConflictError +from powercontext.builtin.persistence.tables import ARTIFACT_HEADS_TABLE +from powercontext.errors import PowerContextError + + +class InvalidArtifactLifecycleError(PowerContextError, ValueError): + """Raised when an explicit governance transition is not valid.""" + + +class ArtifactLifecycleState(StrEnum): + """Working-set governance independent of immutable Artifact Revisions.""" + + ACTIVE = "active" + DEPRECATED = "deprecated" + RETIRED = "retired" + + +class ArtifactGovernance(BaseModel): + """Current logical Artifact head plus its lifecycle CAS generation.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + artifact: ArtifactRef + lifecycle_state: ArtifactLifecycleState + replacement_artifact_id: str | None = None + governance_generation: int = Field(ge=0) + + @model_validator(mode="after") + def validate_replacement(self) -> ArtifactGovernance: + if self.replacement_artifact_id is not None and self.lifecycle_state is not ArtifactLifecycleState.DEPRECATED: + raise ValueError("only a deprecated Artifact may name a replacement") # noqa: TRY003 + return self + + +class ArtifactGovernanceRepository: + """Read and transition governance state without changing the head Revision.""" + + async def get( + self, + connection: AsyncConnection, + scope_id: str, + family: str, + artifact_id: str, + /, + ) -> ArtifactGovernance: + row = ( + ( + await connection.execute( + select(ARTIFACT_HEADS_TABLE).where( + ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, + ARTIFACT_HEADS_TABLE.c.family == family, + ARTIFACT_HEADS_TABLE.c.artifact_id == artifact_id, + ) + ) + ) + .mappings() + .one_or_none() + ) + if row is None: + raise RepositoryNotFoundError("artifact-head", (scope_id, family, artifact_id)) + return _governance(row) + + async def transition( + self, + connection: AsyncConnection, + scope_id: str, + family: str, + artifact_id: str, + expected_generation: int, + lifecycle_state: ArtifactLifecycleState, + replacement_artifact_id: str | None, + /, + ) -> ArtifactGovernance: + current = await self.get(connection, scope_id, family, artifact_id) + _validate_transition(current.lifecycle_state, lifecycle_state) + if replacement_artifact_id is not None: + if replacement_artifact_id == artifact_id: + raise InvalidArtifactLifecycleError("an Artifact cannot replace itself") # noqa: TRY003 + replacement = await self.get(connection, scope_id, family, replacement_artifact_id) + if replacement.lifecycle_state is ArtifactLifecycleState.RETIRED: + raise InvalidArtifactLifecycleError("a retired Artifact cannot be a replacement") # noqa: TRY003 + requested = ArtifactGovernance( + artifact=current.artifact, + lifecycle_state=lifecycle_state, + replacement_artifact_id=replacement_artifact_id, + governance_generation=expected_generation + 1, + ) + result = await connection.execute( + update(ARTIFACT_HEADS_TABLE) + .where( + ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, + ARTIFACT_HEADS_TABLE.c.family == family, + ARTIFACT_HEADS_TABLE.c.artifact_id == artifact_id, + ARTIFACT_HEADS_TABLE.c.governance_generation == expected_generation, + ) + .values( + lifecycle_state=lifecycle_state.value, + replacement_artifact_id=replacement_artifact_id, + governance_generation=expected_generation + 1, + ) + ) + if result.rowcount != 1: + raise StoredPayloadConflictError( + "artifact-governance", (scope_id, family, artifact_id, expected_generation) + ) + return requested + + +def _validate_transition(current: ArtifactLifecycleState, requested: ArtifactLifecycleState) -> None: + if current is ArtifactLifecycleState.RETIRED and requested is not ArtifactLifecycleState.RETIRED: + raise InvalidArtifactLifecycleError("retired Artifact lifecycle is irreversible") # noqa: TRY003 + if requested is ArtifactLifecycleState.ACTIVE and current not in { + ArtifactLifecycleState.ACTIVE, + ArtifactLifecycleState.DEPRECATED, + }: + raise InvalidArtifactLifecycleError( # noqa: TRY003 + "requested Artifact lifecycle transition is not allowed" + ) + + +def _governance(row) -> ArtifactGovernance: + return ArtifactGovernance( + artifact=ArtifactRef( + family=str(row["family"]), + artifact_id=str(row["artifact_id"]), + revision=int(row["revision"]), + ), + lifecycle_state=ArtifactLifecycleState(str(row["lifecycle_state"])), + replacement_artifact_id=( + None if row["replacement_artifact_id"] is None else str(row["replacement_artifact_id"]) + ), + governance_generation=int(row["governance_generation"]), + ) + + +__all__ = [ + "ArtifactGovernance", + "ArtifactGovernanceRepository", + "ArtifactLifecycleState", + "InvalidArtifactLifecycleError", +] diff --git a/src/powercontext/builtin/persistence/cursors.py b/src/powercontext/builtin/persistence/cursors.py index 9f9eac7e7..28c58d05b 100644 --- a/src/powercontext/builtin/persistence/cursors.py +++ b/src/powercontext/builtin/persistence/cursors.py @@ -20,8 +20,9 @@ from typing import Any from pydantic import BaseModel -from sqlalchemy import insert, select, update -from sqlalchemy.exc import IntegrityError +from sqlalchemy import select, update +from sqlalchemy.dialects.mysql import insert as mysql_insert +from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.ext.asyncio import AsyncConnection from powercontext.builtin.persistence.codec import dump_model, load_model, stored_bytes @@ -86,17 +87,14 @@ async def save( if existing is not None: raise GenerationConflictError(binding_name, None, existing.generation) generation = 1 - try: - async with connection.begin_nested(): - await connection.execute( - insert(SOURCE_CURSORS_TABLE).values( - scope_id=scope_id, - binding_name=binding_name, - cursor=payload, - generation=generation, - ) - ) - except IntegrityError: + created = await _insert_if_absent( + connection, + scope_id=scope_id, + binding_name=binding_name, + cursor=payload, + generation=generation, + ) + if not created: # Another runtime may have inserted the same cursor after our # initial read. Normalize that database race to the same CAS # conflict used for concurrent updates. @@ -107,7 +105,7 @@ async def save( for_update=True, ) if existing is None: - raise + raise GenerationConflictError(binding_name, None, None) raise GenerationConflictError(binding_name, None, existing.generation) from None else: generation = expected_generation + 1 @@ -135,6 +133,34 @@ async def save( ) +async def _insert_if_absent( + connection: AsyncConnection, + *, + scope_id: str, + binding_name: str, + cursor: bytes, + generation: int, +) -> bool: + values = { + "scope_id": scope_id, + "binding_name": binding_name, + "cursor": cursor, + "generation": generation, + } + if connection.dialect.name == "sqlite": + statement = sqlite_insert(SOURCE_CURSORS_TABLE).values(**values).on_conflict_do_nothing() + elif connection.dialect.name == "mysql": + # OceanBase MySQL mode accepts SAVEPOINT but does not retain it for + # RELEASE/ROLLBACK. INSERT IGNORE keeps first creation atomic without + # relying on a nested transaction; all values are validated first, so + # the only ignored error is the table's cursor identity conflict. + statement = mysql_insert(SOURCE_CURSORS_TABLE).values(**values).prefix_with("IGNORE") + else: + raise InvalidRepositoryArgumentError("dialect", f"{connection.dialect.name!r} does not support source cursors") + result = await connection.execute(statement) + return result.rowcount == 1 + + def _decode_row(row: Mapping[Any, Any]) -> StoredSourceCursor: return StoredSourceCursor( scope_id=str(row["scope_id"]), diff --git a/src/powercontext/builtin/persistence/experience_index.py b/src/powercontext/builtin/persistence/experience_index.py index d91624cd3..d3c397a8f 100644 --- a/src/powercontext/builtin/persistence/experience_index.py +++ b/src/powercontext/builtin/persistence/experience_index.py @@ -31,9 +31,18 @@ experience_searchable_text, ) from powercontext.builtin.artifacts.search import admits_fts_text +from powercontext.builtin.artifacts.skill import ( + Skill, + SkillContent, + SkillPackageSnapshot, + SkillSearchHit, + skill_search_text, + skill_searchable_text, +) +from powercontext.builtin.artifacts.skill.package import capture_skill_archive from powercontext.builtin.persistence.codec import load_model, stored_bytes from powercontext.builtin.persistence.errors import RepositoryNotFoundError -from powercontext.builtin.persistence.tables import ARTIFACT_HEADS_TABLE, ARTIFACTS_TABLE +from powercontext.builtin.persistence.tables import ARTIFACT_HEADS_TABLE, ARTIFACTS_TABLE, SKILL_PACKAGES_TABLE _SQLITE_SEARCHABLE_TEXT_EXISTS_SQL = text( """ @@ -53,6 +62,14 @@ ) _ADD_SQLITE_SEARCHABLE_TEXT_SQL = "ALTER TABLE pc_artifact_heads ADD COLUMN searchable_text TEXT NULL" _ADD_MYSQL_SEARCHABLE_TEXT_SQL = "ALTER TABLE pc_artifact_heads ADD COLUMN searchable_text MEDIUMTEXT NULL" +_GOVERNANCE_COLUMNS = { + "lifecycle_state": ( + "VARCHAR(16) NOT NULL DEFAULT 'active'", + "VARCHAR(16) NOT NULL DEFAULT 'active'", + ), + "replacement_artifact_id": ("VARCHAR(128) NULL", "VARCHAR(128) NULL"), + "governance_generation": ("BIGINT NOT NULL DEFAULT 0", "BIGINT NOT NULL DEFAULT 0"), +} class ExperienceIndex(Protocol): @@ -77,6 +94,24 @@ async def search( /, ) -> tuple[ExperienceSearchHit, ...]: ... + async def replace_skill( + self, + connection: AsyncConnection, + scope_id: str, + skill: Skill, + package: SkillPackageSnapshot, + /, + ) -> None: ... + + async def search_skills( + self, + connection: AsyncConnection, + scope_id: str, + query: str, + limit: int, + /, + ) -> tuple[SkillSearchHit, ...]: ... + class NoExperienceIndex: """Allow exact Experience reads when no recall projection is configured.""" @@ -103,6 +138,26 @@ async def search( ) -> tuple[ExperienceSearchHit, ...]: return () + async def replace_skill( + self, + _connection: AsyncConnection, + _scope_id: str, + _skill: Skill, + _package: SkillPackageSnapshot, + /, + ) -> None: + pass + + async def search_skills( + self, + _connection: AsyncConnection, + _scope_id: str, + _query: str, + _limit: int, + /, + ) -> tuple[SkillSearchHit, ...]: + return () + async def ensure_artifact_head_searchable_text(connection: AsyncConnection, /) -> None: """Upgrade a pre-Experience Artifact head table with its rebuildable search projection.""" @@ -119,6 +174,18 @@ async def ensure_artifact_head_searchable_text(connection: AsyncConnection, /) - if int(await connection.scalar(exists_sql) or 0) == 0: await connection.exec_driver_sql(migration_sql) + for column, definitions in _GOVERNANCE_COLUMNS.items(): + column_sql = text( + "SELECT COUNT(*) FROM pragma_table_info('pc_artifact_heads') WHERE name = :column" + if dialect == "sqlite" + else "SELECT COUNT(*) FROM information_schema.columns " + "WHERE table_schema = DATABASE() AND table_name = 'pc_artifact_heads' " + "AND column_name = :column" + ) + exists = await connection.scalar(column_sql, {"column": column}) + if int(exists or 0) == 0: + definition = definitions[0 if dialect == "sqlite" else 1] + await connection.exec_driver_sql(f"ALTER TABLE pc_artifact_heads ADD COLUMN {column} {definition}") async def rebuild_experience_projections(connection: AsyncConnection, /) -> None: @@ -155,6 +222,43 @@ async def rebuild_experience_projections(connection: AsyncConnection, /) -> None ) +async def rebuild_skill_projections(connection: AsyncConnection, /) -> None: + """Rebuild searchable text on managed Skill heads from durable content caches.""" + + rows = tuple( + ( + await connection.execute( + select( + ARTIFACT_HEADS_TABLE.c.scope_id, + ARTIFACT_HEADS_TABLE.c.artifact_id, + ARTIFACT_HEADS_TABLE.c.revision, + ARTIFACTS_TABLE.c.content, + ) + .join( + ARTIFACTS_TABLE, + (ARTIFACTS_TABLE.c.scope_id == ARTIFACT_HEADS_TABLE.c.scope_id) + & (ARTIFACTS_TABLE.c.family == ARTIFACT_HEADS_TABLE.c.family) + & (ARTIFACTS_TABLE.c.artifact_id == ARTIFACT_HEADS_TABLE.c.artifact_id) + & (ARTIFACTS_TABLE.c.revision == ARTIFACT_HEADS_TABLE.c.revision), + ) + .where(ARTIFACT_HEADS_TABLE.c.family == Skill.family) + .order_by(ARTIFACT_HEADS_TABLE.c.scope_id, ARTIFACT_HEADS_TABLE.c.artifact_id) + ) + ).mappings() + ) + for row in rows: + content = _skill_content(row["content"]) + package = await _load_skill_package(connection, str(row["scope_id"]), content) + await _update_searchable_text( + connection, + scope_id=str(row["scope_id"]), + family=Skill.family, + artifact_id=str(row["artifact_id"]), + revision=int(row["revision"]), + searchable_text=skill_searchable_text(content, package), + ) + + async def replace_experience_projection( connection: AsyncConnection, scope_id: str, @@ -172,6 +276,25 @@ async def replace_experience_projection( ) +async def replace_skill_projection( + connection: AsyncConnection, + scope_id: str, + skill: Skill, + package: SkillPackageSnapshot, + /, +) -> None: + """Replace searchable text on one newly approved exact Skill head.""" + + await _update_searchable_text( + connection, + scope_id=scope_id, + family=Skill.family, + artifact_id=skill.artifact_id, + revision=skill.revision, + searchable_text=skill_searchable_text(skill.content, package), + ) + + def experience_search_hits( rows: Iterable[Mapping[Any, Any]], query: str, @@ -200,10 +323,40 @@ def experience_search_hits( return tuple(hits) +def skill_search_hits( + rows: Iterable[Mapping[Any, Any]], + query: str, + limit: int, + /, +) -> tuple[SkillSearchHit, ...]: + """Decode backend-ordered Skill rows and apply shared lexical admission.""" + + hits: list[SkillSearchHit] = [] + for row in rows: + content = _skill_content(row["content"]) + searchable = row.get("searchable_text") or skill_search_text(content) + if not admits_fts_text(query, str(searchable)): + continue + hits.append( + SkillSearchHit( + artifact_ref=ArtifactRef( + family=Skill.family, + artifact_id=str(row["artifact_id"]), + revision=int(row["revision"]), + ), + content=content, + ) + ) + if len(hits) >= limit: + break + return tuple(hits) + + async def _update_searchable_text( connection: AsyncConnection, *, scope_id: str, + family: str = Experience.family, artifact_id: str, revision: int, searchable_text: str, @@ -212,7 +365,7 @@ async def _update_searchable_text( update(ARTIFACT_HEADS_TABLE) .where( ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, - ARTIFACT_HEADS_TABLE.c.family == Experience.family, + ARTIFACT_HEADS_TABLE.c.family == family, ARTIFACT_HEADS_TABLE.c.artifact_id == artifact_id, ARTIFACT_HEADS_TABLE.c.revision == revision, ) @@ -221,7 +374,7 @@ async def _update_searchable_text( if result.rowcount != 1: raise RepositoryNotFoundError( # noqa: TRY003 "artifact head", - ArtifactRef(family=Experience.family, artifact_id=artifact_id, revision=revision), + ArtifactRef(family=family, artifact_id=artifact_id, revision=revision), ) @@ -234,11 +387,44 @@ def _content(value: object) -> ExperienceContent: ) +def _skill_content(value: object) -> SkillContent: + return load_model( + SkillContent, + stored_bytes(value, column="content"), + kind="artifact", + name=Skill.family, + ) + + +async def _load_skill_package( + connection: AsyncConnection, + scope_id: str, + content: SkillContent, +) -> SkillPackageSnapshot | None: + if content.package is None: + return None + archive = await connection.scalar( + select(SKILL_PACKAGES_TABLE.c.archive_bytes).where( + SKILL_PACKAGES_TABLE.c.scope_id == scope_id, + SKILL_PACKAGES_TABLE.c.tree_digest == content.package.tree_digest, + ) + ) + if archive is None: + raise RepositoryNotFoundError("skill-package", (scope_id, content.package.tree_digest)) + snapshot = capture_skill_archive(stored_bytes(archive, column="archive_bytes")) + if snapshot.reference != content.package: + raise RepositoryNotFoundError("skill-package", (scope_id, content.package.tree_digest)) + return snapshot + + __all__ = [ "ExperienceIndex", "NoExperienceIndex", "ensure_artifact_head_searchable_text", "experience_search_hits", "rebuild_experience_projections", + "rebuild_skill_projections", "replace_experience_projection", + "replace_skill_projection", + "skill_search_hits", ] diff --git a/src/powercontext/builtin/persistence/oceanbase/experience_index.py b/src/powercontext/builtin/persistence/oceanbase/experience_index.py index 56966abe0..1736e6571 100644 --- a/src/powercontext/builtin/persistence/oceanbase/experience_index.py +++ b/src/powercontext/builtin/persistence/oceanbase/experience_index.py @@ -23,11 +23,15 @@ from powercontext.builtin.artifacts.experience import Experience, ExperienceSearchHit from powercontext.builtin.artifacts.memory import CapabilityNotSupportedError from powercontext.builtin.artifacts.search import analyze_text +from powercontext.builtin.artifacts.skill import Skill, SkillPackageSnapshot, SkillSearchHit from powercontext.builtin.persistence.experience_index import ( ensure_artifact_head_searchable_text, experience_search_hits, rebuild_experience_projections, + rebuild_skill_projections, replace_experience_projection, + replace_skill_projection, + skill_search_hits, ) from powercontext.builtin.persistence.tables import ARTIFACT_HEADS_TABLE, ARTIFACTS_TABLE @@ -55,6 +59,7 @@ async def initialize(self, connection: AsyncConnection, /) -> None: raise CapabilityNotSupportedError("oceanbase-experience-fts") await ensure_artifact_head_searchable_text(connection) await rebuild_experience_projections(connection) + await rebuild_skill_projections(connection) count = await connection.scalar( _OCEANBASE_FTS_INDEX_EXISTS_SQL, {"index_name": _OCEANBASE_FTS_INDEX_NAME}, @@ -102,6 +107,7 @@ async def search( .where( ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, ARTIFACT_HEADS_TABLE.c.family == Experience.family, + ARTIFACT_HEADS_TABLE.c.lifecycle_state == "active", score, ) .order_by(score.desc(), ARTIFACT_HEADS_TABLE.c.artifact_id, ARTIFACT_HEADS_TABLE.c.revision) @@ -110,5 +116,54 @@ async def search( ).mappings() return experience_search_hits(rows, query, limit) + async def replace_skill( + self, + connection: AsyncConnection, + scope_id: str, + skill: Skill, + package: SkillPackageSnapshot, + /, + ) -> None: + await replace_skill_projection(connection, scope_id, skill, package) + + async def search_skills( + self, + connection: AsyncConnection, + scope_id: str, + query: str, + limit: int, + /, + ) -> tuple[SkillSearchHit, ...]: + analyzed = analyze_text(query) + if not analyzed: + return () + score = match(ARTIFACT_HEADS_TABLE.c.searchable_text, against=analyzed) + rows = ( + await connection.execute( + select( + ARTIFACT_HEADS_TABLE.c.artifact_id, + ARTIFACT_HEADS_TABLE.c.revision, + ARTIFACT_HEADS_TABLE.c.searchable_text, + ARTIFACTS_TABLE.c.content, + ) + .join( + ARTIFACTS_TABLE, + (ARTIFACTS_TABLE.c.scope_id == ARTIFACT_HEADS_TABLE.c.scope_id) + & (ARTIFACTS_TABLE.c.family == ARTIFACT_HEADS_TABLE.c.family) + & (ARTIFACTS_TABLE.c.artifact_id == ARTIFACT_HEADS_TABLE.c.artifact_id) + & (ARTIFACTS_TABLE.c.revision == ARTIFACT_HEADS_TABLE.c.revision), + ) + .where( + ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, + ARTIFACT_HEADS_TABLE.c.family == Skill.family, + ARTIFACT_HEADS_TABLE.c.lifecycle_state == "active", + score, + ) + .order_by(score.desc(), ARTIFACT_HEADS_TABLE.c.artifact_id, ARTIFACT_HEADS_TABLE.c.revision) + .limit(limit * 4) + ) + ).mappings() + return skill_search_hits(rows, query, limit) + __all__ = ["OceanBaseExperienceFTSIndex"] diff --git a/src/powercontext/builtin/persistence/skill_distribution_schema.py b/src/powercontext/builtin/persistence/skill_distribution_schema.py new file mode 100644 index 000000000..902a350ec --- /dev/null +++ b/src/powercontext/builtin/persistence/skill_distribution_schema.py @@ -0,0 +1,209 @@ +# 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. + +"""Startup migration for remote Skill desired/observed publication state.""" + +from __future__ import annotations + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.builtin.persistence.tables import SKILL_PUBLICATIONS_TABLE + +_REMOTE_COLUMNS = frozenset({"desired_state", "observed_generation", "last_error_code", "observed_at"}) +_MYSQL_IDENTITY = "CHARACTER SET utf8mb4 COLLATE utf8mb4_bin" + + +async def ensure_skill_distribution_schema(connection: AsyncConnection, /) -> None: + """Upgrade host-local publication rows for remote desired-state reconciliation.""" + + if connection.dialect.name == "sqlite": + await _ensure_sqlite_schema(connection) + return + if connection.dialect.name == "mysql": + await _ensure_mysql_schema(connection) + return + raise ValueError(f"unsupported Skill distribution migration dialect: {connection.dialect.name}") # noqa: TRY003 + + +async def _ensure_sqlite_schema(connection: AsyncConnection) -> None: + columns = tuple((await connection.exec_driver_sql("PRAGMA table_info('pc_skill_publications')")).mappings()) + by_name = {str(column["name"]): column for column in columns} + if not (by_name.keys() >= _REMOTE_COLUMNS and int(by_name["destination"]["notnull"]) == 0): + await connection.exec_driver_sql( + "ALTER TABLE pc_skill_publications RENAME TO pc_skill_publications_remote_v1_legacy" + ) + await connection.run_sync(lambda sync_connection: SKILL_PUBLICATIONS_TABLE.create(sync_connection)) + await connection.exec_driver_sql( + """ + INSERT INTO pc_skill_publications ( + scope_id, target_id, artifact_id, desired_state, desired_revision, desired_tree_digest, + observed_revision, observed_tree_digest, observed_generation, destination, state, + selected_runtime_variant, environment_fingerprint, last_error_code, observed_at, + generation, updated_at + ) + SELECT + scope_id, + target_id, + artifact_id, + CASE WHEN state = 'unpublished' THEN 'unpublished' ELSE 'published' END, + desired_revision, + desired_tree_digest, + observed_revision, + observed_tree_digest, + generation, + destination, + state, + selected_runtime_variant, + environment_fingerprint, + NULL, + updated_at, + generation, + updated_at + FROM pc_skill_publications_remote_v1_legacy + """ + ) + await connection.exec_driver_sql("DROP TABLE pc_skill_publications_remote_v1_legacy") + + target_columns = { + str(column["name"]) + for column in (await connection.exec_driver_sql("PRAGMA table_info('pc_agent_skill_targets')")).mappings() + } + if "display_name" not in target_columns: + await connection.exec_driver_sql("ALTER TABLE pc_agent_skill_targets ADD COLUMN display_name VARCHAR(128)") + await connection.exec_driver_sql( + "UPDATE pc_agent_skill_targets SET display_name = target_id WHERE display_name IS NULL" + ) + if "machine_hostname" not in target_columns: + await connection.exec_driver_sql("ALTER TABLE pc_agent_skill_targets ADD COLUMN machine_hostname VARCHAR(255)") + if "workspace_name" not in target_columns: + await connection.exec_driver_sql("ALTER TABLE pc_agent_skill_targets ADD COLUMN workspace_name VARCHAR(128)") + + +async def _ensure_mysql_schema(connection: AsyncConnection) -> None: + columns = tuple( + ( + await connection.execute( + text( + "SELECT column_name, is_nullable FROM information_schema.columns " + "WHERE table_schema = DATABASE() AND table_name = :table_name" + ), + {"table_name": "pc_skill_publications"}, + ) + ).mappings() + ) + by_name = {str(column["column_name"]): column for column in columns} + + if "desired_state" not in by_name: + await connection.exec_driver_sql( + f"ALTER TABLE pc_skill_publications ADD COLUMN desired_state VARCHAR(16) {_MYSQL_IDENTITY} NULL" + ) + await connection.exec_driver_sql( + "UPDATE pc_skill_publications SET desired_state = " + "CASE WHEN state = 'unpublished' THEN 'unpublished' ELSE 'published' END" + ) + await connection.exec_driver_sql( + "ALTER TABLE pc_skill_publications MODIFY COLUMN desired_state " + f"VARCHAR(16) {_MYSQL_IDENTITY} NOT NULL DEFAULT 'published'" + ) + if "observed_generation" not in by_name: + await connection.exec_driver_sql("ALTER TABLE pc_skill_publications ADD COLUMN observed_generation BIGINT NULL") + await connection.exec_driver_sql("UPDATE pc_skill_publications SET observed_generation = generation") + if "last_error_code" not in by_name: + await connection.exec_driver_sql( + f"ALTER TABLE pc_skill_publications ADD COLUMN last_error_code VARCHAR(128) {_MYSQL_IDENTITY} NULL" + ) + if "observed_at" not in by_name: + await connection.exec_driver_sql("ALTER TABLE pc_skill_publications ADD COLUMN observed_at DATETIME(6) NULL") + await connection.exec_driver_sql("UPDATE pc_skill_publications SET observed_at = updated_at") + if str(by_name.get("destination", {}).get("is_nullable", "NO")).upper() != "YES": + await connection.exec_driver_sql("ALTER TABLE pc_skill_publications MODIFY COLUMN destination MEDIUMTEXT NULL") + + target_columns = tuple( + ( + await connection.execute( + text( + "SELECT column_name, is_nullable FROM information_schema.columns " + "WHERE table_schema = DATABASE() AND table_name = :table_name" + ), + {"table_name": "pc_agent_skill_targets"}, + ) + ).mappings() + ) + target_by_name = {str(column["column_name"]): column for column in target_columns} + if "display_name" not in target_by_name: + await connection.exec_driver_sql( + f"ALTER TABLE pc_agent_skill_targets ADD COLUMN display_name VARCHAR(128) {_MYSQL_IDENTITY} NULL" + ) + if str(target_by_name.get("display_name", {}).get("is_nullable", "YES")).upper() == "YES": + await connection.exec_driver_sql( + "UPDATE pc_agent_skill_targets SET display_name = target_id WHERE display_name IS NULL" + ) + await connection.exec_driver_sql( + f"ALTER TABLE pc_agent_skill_targets MODIFY COLUMN display_name VARCHAR(128) {_MYSQL_IDENTITY} NOT NULL" + ) + if "machine_hostname" not in target_by_name: + await connection.exec_driver_sql( + f"ALTER TABLE pc_agent_skill_targets ADD COLUMN machine_hostname VARCHAR(255) {_MYSQL_IDENTITY} NULL" + ) + if "workspace_name" not in target_by_name: + await connection.exec_driver_sql( + f"ALTER TABLE pc_agent_skill_targets ADD COLUMN workspace_name VARCHAR(128) {_MYSQL_IDENTITY} NULL" + ) + + await _replace_mysql_check( + connection, + name="ck_pc_skill_publications_desired_state", + expression="desired_state IN ('published', 'unpublished')", + required_marker="unpublished", + ) + await _replace_mysql_check( + connection, + name="ck_pc_skill_publications_state", + expression=( + "state IN ('unpublished', 'pending', 'current', 'update_available', " + "'delivery_failed', 'conflict', 'drifted', 'incompatible')" + ), + required_marker="delivery_failed", + ) + await _replace_mysql_check( + connection, + name="ck_pc_skill_publications_observed_generation_nonnegative", + expression="observed_generation IS NULL OR observed_generation >= 0", + required_marker="observed_generation", + ) + + +async def _replace_mysql_check( + connection: AsyncConnection, + *, + name: str, + expression: str, + required_marker: str, +) -> None: + clause = await connection.scalar( + text( + "SELECT check_clause FROM information_schema.check_constraints " + "WHERE constraint_schema = DATABASE() AND constraint_name = :constraint_name" + ), + {"constraint_name": name}, + ) + if clause is not None and required_marker in str(clause): + return + if clause is not None: + await connection.exec_driver_sql(f"ALTER TABLE pc_skill_publications DROP CHECK {name}") + await connection.exec_driver_sql(f"ALTER TABLE pc_skill_publications ADD CONSTRAINT {name} CHECK ({expression})") + + +__all__ = ["ensure_skill_distribution_schema"] diff --git a/src/powercontext/builtin/persistence/skill_packages.py b/src/powercontext/builtin/persistence/skill_packages.py new file mode 100644 index 000000000..ebb09fd1e --- /dev/null +++ b/src/powercontext/builtin/persistence/skill_packages.py @@ -0,0 +1,169 @@ +# 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. + +"""Content-addressed persistence for canonical Agent Skill packages.""" + +from __future__ import annotations + +from collections.abc import Mapping +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import insert, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.builtin.artifacts.skill.models import SkillPackageRef +from powercontext.builtin.artifacts.skill.package import SkillPackageError, SkillPackageSnapshot, capture_skill_archive +from powercontext.builtin.persistence.codec import stored_bytes +from powercontext.builtin.persistence.errors import ( + InvalidRepositoryArgumentError, + InvalidStoredPayloadError, + RepositoryNotFoundError, + StoredPayloadConflictError, +) +from powercontext.builtin.persistence.tables import SKILL_PACKAGES_TABLE +from powercontext.limits import MAX_SCOPE_ID_LENGTH + + +class SkillPackageRepository: + """Store immutable canonical packages and reject identity reuse with different bytes.""" + + async def add( + self, + connection: AsyncConnection, + scope_id: str, + snapshot: SkillPackageSnapshot, + /, + ) -> SkillPackageRef: + """Insert a package or return an identical content-addressed record.""" + + _require_scope(scope_id) + existing = await self._find_row(connection, scope_id, snapshot.reference.tree_digest) + if existing is not None: + self._require_same(scope_id, existing, snapshot) + return snapshot.reference + try: + await connection.execute( + insert(SKILL_PACKAGES_TABLE).values( + scope_id=scope_id, + tree_digest=snapshot.reference.tree_digest, + archive_digest=snapshot.reference.archive_digest, + archive_bytes=snapshot.archive_bytes, + manifest=snapshot.manifest_bytes, + file_count=snapshot.reference.file_count, + uncompressed_size=snapshot.reference.uncompressed_size, + archive_size=snapshot.reference.archive_size, + created_at=datetime.now(UTC), + ) + ) + except IntegrityError: + existing = await self._find_row(connection, scope_id, snapshot.reference.tree_digest) + if existing is None: + raise + self._require_same(scope_id, existing, snapshot) + return snapshot.reference + + async def get( + self, + connection: AsyncConnection, + scope_id: str, + reference: SkillPackageRef, + /, + ) -> SkillPackageSnapshot: + """Load and fully verify one exact package reference.""" + + _require_scope(scope_id) + row = await self._find_row(connection, scope_id, reference.tree_digest) + if row is None: + raise RepositoryNotFoundError("skill-package", (scope_id, reference.tree_digest)) + snapshot = _decode_row(row) + if snapshot.reference != reference: + raise InvalidStoredPayloadError( + "skill-package", reference.tree_digest, "indexed package reference mismatch" + ) + return snapshot + + async def _find_row( + self, + connection: AsyncConnection, + scope_id: str, + tree_digest: str, + ) -> Mapping[Any, Any] | None: + return ( + ( + await connection.execute( + select(SKILL_PACKAGES_TABLE).where( + SKILL_PACKAGES_TABLE.c.scope_id == scope_id, + SKILL_PACKAGES_TABLE.c.tree_digest == tree_digest, + ) + ) + ) + .mappings() + .one_or_none() + ) + + @staticmethod + def _require_same( + scope_id: str, + row: Mapping[Any, Any], + snapshot: SkillPackageSnapshot, + ) -> None: + if ( + str(row["archive_digest"]) != snapshot.reference.archive_digest + or stored_bytes(row["archive_bytes"], column="archive_bytes") != snapshot.archive_bytes + or stored_bytes(row["manifest"], column="manifest") != snapshot.manifest_bytes + or int(row["file_count"]) != snapshot.reference.file_count + or int(row["uncompressed_size"]) != snapshot.reference.uncompressed_size + or int(row["archive_size"]) != snapshot.reference.archive_size + ): + raise StoredPayloadConflictError("skill-package", (scope_id, snapshot.reference.tree_digest)) + + +def _decode_row(row: Mapping[Any, Any]) -> SkillPackageSnapshot: + archive_bytes = stored_bytes(row["archive_bytes"], column="archive_bytes") + try: + snapshot = capture_skill_archive(archive_bytes) + except SkillPackageError as error: + raise InvalidStoredPayloadError( + "skill-package", + str(row["tree_digest"]), + "canonical archive is invalid", + ) from error + indexed = SkillPackageRef( + tree_digest=str(row["tree_digest"]), + archive_digest=str(row["archive_digest"]), + file_count=int(row["file_count"]), + uncompressed_size=int(row["uncompressed_size"]), + archive_size=int(row["archive_size"]), + ) + if snapshot.reference != indexed: + raise InvalidStoredPayloadError( + "skill-package", + indexed.tree_digest, + "archive digest or bounds do not match indexed columns", + ) + if snapshot.manifest_bytes != stored_bytes(row["manifest"], column="manifest"): + raise InvalidStoredPayloadError("skill-package", indexed.tree_digest, "manifest does not match archive") + return snapshot + + +def _require_scope(value: str) -> None: + if not isinstance(value, str) or not value.strip() or value != value.strip(): + raise InvalidRepositoryArgumentError("scope_id", "must be a non-empty trimmed string") + if len(value) > MAX_SCOPE_ID_LENGTH: + raise InvalidRepositoryArgumentError("scope_id", f"must not exceed {MAX_SCOPE_ID_LENGTH} characters") + + +__all__ = ["SkillPackageRepository"] diff --git a/src/powercontext/builtin/persistence/skill_publications.py b/src/powercontext/builtin/persistence/skill_publications.py new file mode 100644 index 000000000..3fb1586c1 --- /dev/null +++ b/src/powercontext/builtin/persistence/skill_publications.py @@ -0,0 +1,228 @@ +# 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. + +"""CAS persistence for host-local managed Skill publications.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import insert, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.builtin.artifacts.skill.projection import AgentSkillProjectionState +from powercontext.builtin.persistence.errors import RepositoryNotFoundError, StoredPayloadConflictError +from powercontext.builtin.persistence.tables import SKILL_PUBLICATIONS_TABLE + + +class SkillPublicationDesiredState(StrEnum): + """Server-owned desired presence for one target binding.""" + + PUBLISHED = "published" + UNPUBLISHED = "unpublished" + + +class SkillPublication(BaseModel): + """Authoritative publication intent plus the last exact local observation.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + scope_id: str + target_id: str + artifact_id: str + desired_state: SkillPublicationDesiredState = SkillPublicationDesiredState.PUBLISHED + desired_revision: int = Field(ge=1) + desired_tree_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + observed_revision: int | None = Field(default=None, ge=1) + observed_tree_digest: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$") + observed_generation: int | None = Field(default=None, ge=0) + destination: str | None = None + state: AgentSkillProjectionState + selected_runtime_variant: str | None = None + environment_fingerprint: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$") + last_error_code: str | None = Field(default=None, min_length=1, max_length=128) + observed_at: datetime | None = None + generation: int = Field(ge=0) + updated_at: datetime + + +class SkillPublicationRepository: + """Create and advance publication observations with generation CAS.""" + + async def find( + self, + connection: AsyncConnection, + scope_id: str, + target_id: str, + artifact_id: str, + /, + ) -> SkillPublication | None: + row = ( + ( + await connection.execute( + select(SKILL_PUBLICATIONS_TABLE).where( + SKILL_PUBLICATIONS_TABLE.c.scope_id == scope_id, + SKILL_PUBLICATIONS_TABLE.c.target_id == target_id, + SKILL_PUBLICATIONS_TABLE.c.artifact_id == artifact_id, + ) + ) + ) + .mappings() + .one_or_none() + ) + return None if row is None else SkillPublication.model_validate(row) + + async def list_for_target( + self, + connection: AsyncConnection, + scope_id: str, + target_id: str, + /, + ) -> tuple[SkillPublication, ...]: + rows = ( + ( + await connection.execute( + select(SKILL_PUBLICATIONS_TABLE) + .where( + SKILL_PUBLICATIONS_TABLE.c.scope_id == scope_id, + SKILL_PUBLICATIONS_TABLE.c.target_id == target_id, + ) + .order_by(SKILL_PUBLICATIONS_TABLE.c.artifact_id) + ) + ) + .mappings() + .all() + ) + return tuple(SkillPublication.model_validate(row) for row in rows) + + async def create( + self, + connection: AsyncConnection, + publication: SkillPublication, + /, + ) -> SkillPublication: + if publication.generation != 0: + raise ValueError("new Skill publication generation must be zero") # noqa: TRY003 + values = publication.model_dump(mode="python") + values["desired_state"] = publication.desired_state.value + values["state"] = publication.state.value + try: + await connection.execute(insert(SKILL_PUBLICATIONS_TABLE).values(**values)) + except IntegrityError as error: + raise StoredPayloadConflictError( + "skill-publication", + (publication.scope_id, publication.target_id, publication.artifact_id), + ) from error + return publication + + async def replace( + self, + connection: AsyncConnection, + publication: SkillPublication, + expected_generation: int, + /, + ) -> SkillPublication: + revised = publication.model_copy( + update={"generation": expected_generation + 1, "updated_at": datetime.now(UTC)} + ) + values = revised.model_dump(mode="python", exclude={"scope_id", "target_id", "artifact_id"}) + values["desired_state"] = revised.desired_state.value + values["state"] = revised.state.value + result = await connection.execute( + update(SKILL_PUBLICATIONS_TABLE) + .where( + SKILL_PUBLICATIONS_TABLE.c.scope_id == publication.scope_id, + SKILL_PUBLICATIONS_TABLE.c.target_id == publication.target_id, + SKILL_PUBLICATIONS_TABLE.c.artifact_id == publication.artifact_id, + SKILL_PUBLICATIONS_TABLE.c.generation == expected_generation, + ) + .values(**values) + ) + if result.rowcount != 1: + current = await self.find( + connection, + publication.scope_id, + publication.target_id, + publication.artifact_id, + ) + if current is None: + raise RepositoryNotFoundError( + "skill-publication", + (publication.scope_id, publication.target_id, publication.artifact_id), + ) + raise StoredPayloadConflictError( + "skill-publication", + (publication.scope_id, publication.target_id, publication.artifact_id, expected_generation), + ) + return revised + + async def observe( + self, + connection: AsyncConnection, + publication: SkillPublication, + expected_generation: int, + /, + *, + preserve_success: bool, + ) -> SkillPublication: + """Write an observation without advancing Server-owned desired generation. + + ``preserve_success`` protects an accepted success from a later out-of-order + failure Receipt or a nonterminal local inspection. An authenticated remote + drift observation passes ``False`` so it can replace stale success state. + """ + + revised = publication.model_copy(update={"generation": expected_generation, "updated_at": datetime.now(UTC)}) + values = revised.model_dump(mode="python", exclude={"scope_id", "target_id", "artifact_id", "generation"}) + values["desired_state"] = revised.desired_state.value + values["state"] = revised.state.value + statement = update(SKILL_PUBLICATIONS_TABLE).where( + SKILL_PUBLICATIONS_TABLE.c.scope_id == publication.scope_id, + SKILL_PUBLICATIONS_TABLE.c.target_id == publication.target_id, + SKILL_PUBLICATIONS_TABLE.c.artifact_id == publication.artifact_id, + SKILL_PUBLICATIONS_TABLE.c.generation == expected_generation, + ) + if preserve_success: + statement = statement.where( + ~( + (SKILL_PUBLICATIONS_TABLE.c.observed_generation == expected_generation) + & SKILL_PUBLICATIONS_TABLE.c.state.in_({"current", "unpublished"}) + ) + ) + result = await connection.execute(statement.values(**values)) + if result.rowcount == 1: + return revised + current = await self.find( + connection, + publication.scope_id, + publication.target_id, + publication.artifact_id, + ) + if current is None: + raise RepositoryNotFoundError( + "skill-publication", + (publication.scope_id, publication.target_id, publication.artifact_id), + ) + if current.generation == expected_generation and preserve_success: + return current + raise StoredPayloadConflictError( + "skill-publication", + (publication.scope_id, publication.target_id, publication.artifact_id, expected_generation), + ) + + +__all__ = ["SkillPublication", "SkillPublicationDesiredState", "SkillPublicationRepository"] diff --git a/src/powercontext/builtin/persistence/sqlite/experience_index.py b/src/powercontext/builtin/persistence/sqlite/experience_index.py index acfdbe074..7e4baf0d3 100644 --- a/src/powercontext/builtin/persistence/sqlite/experience_index.py +++ b/src/powercontext/builtin/persistence/sqlite/experience_index.py @@ -25,44 +25,58 @@ from powercontext.builtin.artifacts.experience import Experience, ExperienceSearchHit, experience_searchable_text from powercontext.builtin.artifacts.memory import CapabilityNotSupportedError from powercontext.builtin.artifacts.search import fts_match_query +from powercontext.builtin.artifacts.skill import Skill, SkillPackageSnapshot, SkillSearchHit, skill_searchable_text from powercontext.builtin.persistence.experience_index import ( ensure_artifact_head_searchable_text, experience_search_hits, rebuild_experience_projections, + rebuild_skill_projections, replace_experience_projection, + replace_skill_projection, + skill_search_hits, ) from powercontext.builtin.persistence.tables import ARTIFACT_HEADS_TABLE _CREATE_FTS_SQL = """ -CREATE VIRTUAL TABLE IF NOT EXISTS pc_experience_fts USING fts5( +CREATE VIRTUAL TABLE IF NOT EXISTS pc_artifact_fts USING fts5( scope_id UNINDEXED, + family UNINDEXED, artifact_id UNINDEXED, revision UNINDEXED, searchable_text, tokenize='unicode61' ) """ -_DELETE_ALL_FTS_SQL = "DELETE FROM pc_experience_fts" -_PROBE_FTS_SQL = "SELECT rowid FROM pc_experience_fts WHERE pc_experience_fts MATCH 'powercontext'" -_DELETE_FTS_SQL = text("DELETE FROM pc_experience_fts WHERE scope_id = :scope_id AND artifact_id = :artifact_id") +_DELETE_ALL_FTS_SQL = "DELETE FROM pc_artifact_fts" +_PROBE_FTS_SQL = "SELECT rowid FROM pc_artifact_fts WHERE pc_artifact_fts MATCH 'powercontext'" +_DELETE_FTS_SQL = text( + "DELETE FROM pc_artifact_fts WHERE scope_id = :scope_id AND family = :family AND artifact_id = :artifact_id" +) _INSERT_FTS_SQL = text( """ - INSERT INTO pc_experience_fts (scope_id, artifact_id, revision, searchable_text) - VALUES (:scope_id, :artifact_id, :revision, :searchable_text) + INSERT INTO pc_artifact_fts (scope_id, family, artifact_id, revision, searchable_text) + VALUES (:scope_id, :family, :artifact_id, :revision, :searchable_text) """ ) _SEARCH_FTS_SQL = text( """ - SELECT f.artifact_id, f.revision, a.content - FROM pc_experience_fts AS f + SELECT f.artifact_id, f.revision, f.searchable_text, a.content + FROM pc_artifact_fts AS f JOIN pc_artifacts AS a ON a.scope_id = f.scope_id - AND a.family = 'experience' + AND a.family = f.family AND a.artifact_id = f.artifact_id AND a.revision = f.revision - WHERE pc_experience_fts MATCH :query + JOIN pc_artifact_heads AS h + ON h.scope_id = f.scope_id + AND h.family = f.family + AND h.artifact_id = f.artifact_id + AND h.revision = f.revision + WHERE pc_artifact_fts MATCH :query AND f.scope_id = :scope_id - ORDER BY bm25(pc_experience_fts), f.artifact_id, f.revision + AND f.family = :family + AND h.lifecycle_state = 'active' + ORDER BY bm25(pc_artifact_fts), f.artifact_id, f.revision LIMIT :candidate_limit """ ) @@ -76,17 +90,20 @@ async def initialize(self, connection: AsyncConnection, /) -> None: raise CapabilityNotSupportedError("sqlite-experience-fts") await ensure_artifact_head_searchable_text(connection) await rebuild_experience_projections(connection) + await rebuild_skill_projections(connection) await connection.exec_driver_sql(_CREATE_FTS_SQL) await connection.exec_driver_sql(_DELETE_ALL_FTS_SQL) rows = ( await connection.execute( select( ARTIFACT_HEADS_TABLE.c.scope_id, + ARTIFACT_HEADS_TABLE.c.family, ARTIFACT_HEADS_TABLE.c.artifact_id, ARTIFACT_HEADS_TABLE.c.revision, ARTIFACT_HEADS_TABLE.c.searchable_text, ).where( - ARTIFACT_HEADS_TABLE.c.family == Experience.family, + ARTIFACT_HEADS_TABLE.c.family.in_((Experience.family, Skill.family)), + ARTIFACT_HEADS_TABLE.c.lifecycle_state == "active", ARTIFACT_HEADS_TABLE.c.searchable_text.is_not(None), ) ) @@ -105,12 +122,13 @@ async def replace( await replace_experience_projection(connection, scope_id, experience) await connection.execute( _DELETE_FTS_SQL, - {"scope_id": scope_id, "artifact_id": experience.artifact_id}, + {"scope_id": scope_id, "family": Experience.family, "artifact_id": experience.artifact_id}, ) await self._insert_row( connection, { "scope_id": scope_id, + "family": Experience.family, "artifact_id": experience.artifact_id, "revision": experience.revision, "searchable_text": experience_searchable_text(experience.content), @@ -134,17 +152,66 @@ async def search( { "query": match_query, "scope_id": scope_id, + "family": Experience.family, "candidate_limit": limit * 4, }, ) ).mappings() return experience_search_hits(rows, query, limit) + async def replace_skill( + self, + connection: AsyncConnection, + scope_id: str, + skill: Skill, + package: SkillPackageSnapshot, + /, + ) -> None: + await replace_skill_projection(connection, scope_id, skill, package) + await connection.execute( + _DELETE_FTS_SQL, + {"scope_id": scope_id, "family": Skill.family, "artifact_id": skill.artifact_id}, + ) + await self._insert_row( + connection, + { + "scope_id": scope_id, + "family": Skill.family, + "artifact_id": skill.artifact_id, + "revision": skill.revision, + "searchable_text": skill_searchable_text(skill.content, package), + }, + ) + + async def search_skills( + self, + connection: AsyncConnection, + scope_id: str, + query: str, + limit: int, + /, + ) -> tuple[SkillSearchHit, ...]: + match_query = fts_match_query(query) + if match_query is None: + return () + rows = ( + await connection.execute( + _SEARCH_FTS_SQL, + { + "query": match_query, + "scope_id": scope_id, + "family": Skill.family, + "candidate_limit": limit * 4, + }, + ) + ).mappings() + return skill_search_hits(rows, query, limit) + @staticmethod async def _insert_row(connection: AsyncConnection, row: Mapping[Any, Any]) -> None: await connection.execute( _INSERT_FTS_SQL, - {field: row[field] for field in ("scope_id", "artifact_id", "revision", "searchable_text")}, + {field: row[field] for field in ("scope_id", "family", "artifact_id", "revision", "searchable_text")}, ) diff --git a/src/powercontext/builtin/persistence/tables.py b/src/powercontext/builtin/persistence/tables.py index 99e1013a6..4972f3110 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -20,6 +20,7 @@ CheckConstraint, Column, Date, + DateTime, ForeignKeyConstraint, Integer, LargeBinary, @@ -112,6 +113,9 @@ def _entry_text_type(): Column("artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH), primary_key=True), Column("revision", Integer, nullable=False), Column("searchable_text", _entry_text_type()), + Column("lifecycle_state", identity_string(16), nullable=False, server_default="active"), + Column("replacement_artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH)), + Column("governance_generation", BigInteger, nullable=False, server_default="0"), ForeignKeyConstraint( ("scope_id", "family", "artifact_id", "revision"), ( @@ -123,6 +127,18 @@ def _entry_text_type(): ondelete="RESTRICT", ), CheckConstraint("revision > 0", name="ck_pc_artifact_heads_revision_positive"), + CheckConstraint( + "lifecycle_state IN ('active', 'deprecated', 'retired')", + name="ck_pc_artifact_heads_lifecycle_state", + ), + CheckConstraint( + "governance_generation >= 0", + name="ck_pc_artifact_heads_governance_generation_nonnegative", + ), + CheckConstraint( + "replacement_artifact_id IS NULL OR lifecycle_state = 'deprecated'", + name="ck_pc_artifact_heads_replacement_deprecated", + ), ) @@ -302,6 +318,122 @@ def _entry_text_type(): ), ) +SKILL_PACKAGES_TABLE = Table( + "pc_skill_packages", + SHARED_METADATA, + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), + Column("tree_digest", identity_string(64), primary_key=True), + Column("archive_digest", identity_string(64), nullable=False), + Column("archive_bytes", _canonical_payload_type(), nullable=False), + Column("manifest", _canonical_payload_type(), nullable=False), + Column("file_count", Integer, nullable=False), + Column("uncompressed_size", BigInteger, nullable=False), + Column("archive_size", BigInteger, nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + CheckConstraint("file_count > 0 AND file_count <= 256", name="ck_pc_skill_packages_file_count"), + CheckConstraint( + "uncompressed_size > 0 AND uncompressed_size <= 4194304", + name="ck_pc_skill_packages_uncompressed_size", + ), + CheckConstraint( + "archive_size > 0 AND archive_size <= 5242880", + name="ck_pc_skill_packages_archive_size", + ), +) + +AGENT_SKILL_TARGETS_TABLE = Table( + "pc_agent_skill_targets", + SHARED_METADATA, + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), + Column("target_id", identity_string(64), primary_key=True), + Column("display_name", identity_string(128), nullable=False), + Column("agent_kind", identity_string(32), nullable=False), + Column("installation_scope", identity_string(16), nullable=False), + Column("delivery_mode", identity_string(16), nullable=False), + Column("installation_id", identity_string(128)), + Column("state", identity_string(16), nullable=False), + Column("enrollment_token_digest", identity_string(64)), + Column("enrollment_expires_at", DateTime(timezone=True)), + Column("credential_subject", identity_string(128)), + Column("credential_verifier", identity_string(64)), + Column("receiver_version", identity_string(64)), + Column("environment_fingerprint", identity_string(64)), + Column("machine_hostname", identity_string(255)), + Column("workspace_name", identity_string(128)), + Column("last_seen_at", DateTime(timezone=True)), + Column("generation", BigInteger, nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), + UniqueConstraint( + "scope_id", + "agent_kind", + "installation_scope", + "installation_id", + name="uq_pc_agent_skill_targets_installation", + ), + UniqueConstraint("enrollment_token_digest", name="uq_pc_agent_skill_targets_enrollment_token"), + UniqueConstraint("credential_subject", name="uq_pc_agent_skill_targets_credential_subject"), + UniqueConstraint("credential_verifier", name="uq_pc_agent_skill_targets_credential_verifier"), + CheckConstraint("agent_kind IN ('codex', 'claude_code')", name="ck_pc_agent_skill_targets_agent_kind"), + CheckConstraint( + "installation_scope IN ('project')", + name="ck_pc_agent_skill_targets_installation_scope", + ), + CheckConstraint("delivery_mode = 'agent_pull'", name="ck_pc_agent_skill_targets_delivery_mode"), + CheckConstraint("state IN ('pending', 'active', 'revoked')", name="ck_pc_agent_skill_targets_state"), + CheckConstraint( + "(state = 'pending' AND enrollment_token_digest IS NOT NULL AND enrollment_expires_at IS NOT NULL " + "AND installation_id IS NULL AND credential_subject IS NULL AND credential_verifier IS NULL) OR " + "(state = 'active' AND enrollment_token_digest IS NULL AND enrollment_expires_at IS NULL " + "AND installation_id IS NOT NULL AND credential_subject IS NOT NULL AND credential_verifier IS NOT NULL) OR " + "(state = 'revoked' AND enrollment_token_digest IS NULL AND enrollment_expires_at IS NULL " + "AND credential_verifier IS NULL)", + name="ck_pc_agent_skill_targets_state_payload", + ), + CheckConstraint("generation >= 0", name="ck_pc_agent_skill_targets_generation_nonnegative"), +) + +SKILL_PUBLICATIONS_TABLE = Table( + "pc_skill_publications", + SHARED_METADATA, + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), + Column("target_id", identity_string(64), primary_key=True), + Column("artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH), primary_key=True), + Column("desired_state", identity_string(16), nullable=False, server_default="published"), + Column("desired_revision", Integer, nullable=False), + Column("desired_tree_digest", identity_string(64), nullable=False), + Column("observed_revision", Integer), + Column("observed_tree_digest", identity_string(64)), + Column("observed_generation", BigInteger), + Column("destination", _entry_text_type()), + Column("state", identity_string(32), nullable=False), + Column("selected_runtime_variant", identity_string(128)), + Column("environment_fingerprint", identity_string(64)), + Column("last_error_code", identity_string(128)), + Column("observed_at", DateTime(timezone=True)), + Column("generation", BigInteger, nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), + CheckConstraint("desired_revision > 0", name="ck_pc_skill_publications_desired_revision_positive"), + CheckConstraint( + "observed_revision IS NULL OR observed_revision > 0", + name="ck_pc_skill_publications_observed_revision_positive", + ), + CheckConstraint( + "desired_state IN ('published', 'unpublished')", + name="ck_pc_skill_publications_desired_state", + ), + CheckConstraint( + "state IN ('unpublished', 'pending', 'current', 'update_available', " + "'delivery_failed', 'conflict', 'drifted', 'incompatible')", + name="ck_pc_skill_publications_state", + ), + CheckConstraint( + "observed_generation IS NULL OR observed_generation >= 0", + name="ck_pc_skill_publications_observed_generation_nonnegative", + ), + CheckConstraint("generation >= 0", name="ck_pc_skill_publications_generation_nonnegative"), +) + MODEL_USAGE_DAILY_TABLE = Table( "pc_model_usage_daily", SHARED_METADATA, @@ -358,6 +490,9 @@ def _entry_text_type(): ARTIFACT_CANDIDATE_HEADS_TABLE, SOURCE_CURSORS_TABLE, EXTERNAL_SKILL_REGISTRATIONS_TABLE, + SKILL_PACKAGES_TABLE, + AGENT_SKILL_TARGETS_TABLE, + SKILL_PUBLICATIONS_TABLE, ) diff --git a/src/powercontext/builtin/review/service.py b/src/powercontext/builtin/review/service.py index ed40d26c5..230a51a13 100644 --- a/src/powercontext/builtin/review/service.py +++ b/src/powercontext/builtin/review/service.py @@ -23,12 +23,13 @@ from powercontext.artifacts import Artifact, ArtifactRef from powercontext.builtin.artifacts.experience import Experience, ExperienceContent, ExperienceDraft -from powercontext.builtin.artifacts.skill import Skill, SkillContent, SkillDraft +from powercontext.builtin.artifacts.skill import Skill, SkillContent, SkillDraft, build_instruction_skill_package from powercontext.builtin.persistence.artifacts import ArtifactRepository from powercontext.builtin.persistence.candidates import CandidateRepository from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.errors import RepositoryNotFoundError from powercontext.builtin.persistence.experience_index import ExperienceIndex +from powercontext.builtin.persistence.skill_packages import SkillPackageRepository from powercontext.builtin.persistence.sources import SourceRepository from powercontext.builtin.review.errors import ArtifactTargetConflictError, InvalidCandidateError from powercontext.builtin.review.models import ( @@ -59,6 +60,7 @@ def __init__( candidates: CandidateRepository, artifacts: ArtifactRepository, experience_index: ExperienceIndex, + skill_packages: SkillPackageRepository, sources: SourceRepository, id_factory: IdFactory, connection: AsyncConnection | None = None, @@ -68,6 +70,7 @@ def __init__( self._candidates = candidates self._artifacts = artifacts self._experience_index = experience_index + self._skill_packages = skill_packages self._sources = sources self._id_factory = id_factory self._bound_connection = connection @@ -106,14 +109,20 @@ async def propose_skill( ) -> ArtifactCandidate[SkillContent]: """Persist a human or integration supplied managed Skill proposal.""" - candidate = await self._propose( - Skill.family, - proposal, - sources=sources, - artifacts=artifacts, - target=target, - reason=reason, - ) + canonical_sources = _unique_sources(sources) + canonical_artifacts = _unique_artifacts(artifacts) + _validate_reason(reason) + async with self._database.connection(self._bound_connection) as connection: + proposal = await self._canonical_skill_proposal(connection, proposal) + candidate = await self._propose_with_connection( + connection, + Skill.family, + proposal, + sources=canonical_sources, + artifacts=canonical_artifacts, + target=target, + reason=reason, + ) return _skill_candidate(candidate) async def _propose( @@ -131,12 +140,8 @@ async def _propose( canonical_artifacts = _unique_artifacts(artifacts) _validate_reason(reason) async with self._database.connection(self._bound_connection) as connection: - await self._validate_evidence(connection, canonical_sources, canonical_artifacts) - await self._validate_target(connection, family, target, canonical_artifacts) - candidate = await self._candidates.create( + candidate = await self._propose_with_connection( connection, - self._scope_id, - self._id_factory("candidate"), family, proposal, sources=canonical_sources, @@ -146,6 +151,33 @@ async def _propose( ) return _reviewed_candidate(candidate) + async def _propose_with_connection( + self, + connection: AsyncConnection, + family: str, + proposal: ReviewedProposal, + /, + *, + sources: tuple[SourceRef, ...], + artifacts: tuple[ArtifactRef, ...], + target: ArtifactRef | None, + reason: str | None, + ) -> ReviewedCandidate: + await self._validate_evidence(connection, sources, artifacts) + await self._validate_target(connection, family, target, artifacts) + candidate = await self._candidates.create( + connection, + self._scope_id, + self._id_factory("candidate"), + family, + proposal, + sources=sources, + artifacts=artifacts, + target=target, + reason=reason, + ) + return _reviewed_candidate(candidate) + async def get_candidate(self, candidate_id: str, /) -> ReviewedCandidate: async with self._database.connection(self._bound_connection) as connection: candidate = await self._candidates.get(connection, self._scope_id, candidate_id) @@ -198,6 +230,8 @@ async def revise( ) reviewed = _reviewed_candidate(current) _validate_proposal_family(reviewed.family, proposal) + if isinstance(proposal, SkillContent): + proposal = await self._canonical_skill_proposal(connection, proposal) if target != current.target: raise InvalidCandidateError("target", "cannot change across Candidate versions") await self._validate_evidence(connection, canonical_sources, canonical_artifacts) @@ -251,6 +285,8 @@ async def approve( ) ) _validate_approval_lineage(candidate) + if isinstance(candidate.proposal, SkillContent) and candidate.proposal.package is not None: + await self._canonical_skill_proposal(connection, candidate.proposal) draft = _candidate_draft(candidate) if candidate.target is None: artifact = await self._artifacts.create( @@ -270,6 +306,9 @@ async def approve( raise ArtifactTargetConflictError(candidate.target, current.as_ref()) from error if isinstance(artifact, Experience): await self._experience_index.replace(connection, self._scope_id, artifact) + elif isinstance(artifact, Skill) and artifact.content.package is not None: + package = await self._skill_packages.get(connection, self._scope_id, artifact.content.package) + await self._experience_index.replace_skill(connection, self._scope_id, artifact, package) approved = await self._candidates.mark_approved( connection, self._scope_id, @@ -345,6 +384,25 @@ async def _validate_target( if current.as_ref() != target: raise ArtifactTargetConflictError(target, current.as_ref()) + async def _canonical_skill_proposal( + self, + connection: AsyncConnection, + proposal: SkillContent, + /, + ) -> SkillContent: + if proposal.package is None: + snapshot = build_instruction_skill_package(proposal) + await self._skill_packages.add(connection, self._scope_id, snapshot) + else: + try: + snapshot = await self._skill_packages.get(connection, self._scope_id, proposal.package) + except RepositoryNotFoundError as error: + raise InvalidCandidateError("package", "exact Skill package is not available in this scope") from error + canonical = snapshot.as_skill_content() + if proposal.package is not None and canonical != proposal: + raise InvalidCandidateError("package", "cached Skill fields do not match the exact package") + return canonical + def _reviewed_candidate(candidate: ArtifactCandidate[Any]) -> ReviewedCandidate: _validate_proposal_family(candidate.family, candidate.proposal) diff --git a/src/powercontext/builtin/runtime/__init__.py b/src/powercontext/builtin/runtime/__init__.py index ef7eba4e3..bac2fa7c4 100644 --- a/src/powercontext/builtin/runtime/__init__.py +++ b/src/powercontext/builtin/runtime/__init__.py @@ -45,6 +45,7 @@ ExternalSkillApplication, HandoffApplication, MemoryApplication, + RemoteSkillApplication, ReviewApplication, ScheduledExperienceProcessor, ScheduledSourceProcessor, @@ -245,6 +246,7 @@ "RecallTokenValue", "RejectArtifactCandidateRequest", "RememberMemoryRequest", + "RemoteSkillApplication", "ResolveExternalSkillRequest", "ResolvedUsagePeriod", "RetireMemoryEntryRequest", diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index 750b7d20e..e4643a6f2 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -63,14 +63,40 @@ MemoryEntryNotFoundError, ) from powercontext.builtin.artifacts.skill import ( + AgentKind, + AgentSkillTarget, ExternalSkillRegistryUnavailableError, ExternalSkillResolution, Skill, + SkillOrigin, + SkillPackageRef, + SkillPackageSnapshot, + SkillSearchHit, +) +from powercontext.builtin.artifacts.skill.distribution import ( + RemoteSkillDistributionService, + RemoteSkillObservation, + RemoteSkillReceipt, + RemoteSkillReceiptResult, + RemoteSkillReconcileResult, + RemoteSkillTargetStatus, + RemoteTargetCredential, + RemoteTargetEnrollment, +) +from powercontext.builtin.artifacts.skill.publication import ( + ManagedSkillPublicationService, + ManagedSkillPublicationStatus, ) from powercontext.builtin.artifacts.skill.registry import ExternalSkillRegistryService 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.persistence.agent_skill_targets import RemoteAgentSkillTarget +from powercontext.builtin.persistence.artifact_governance import ( + ArtifactGovernance, + ArtifactLifecycleState, +) +from powercontext.builtin.persistence.skill_publications import SkillPublication from powercontext.builtin.review.generation import GeneratedCandidateResult, ReviewedGenerationService from powercontext.builtin.review.service import ReviewService from powercontext.builtin.runtime._scope_cache import ( @@ -137,6 +163,7 @@ ContentCapture, ContentSource, ExternalSkillImportMode, + SkillUsageCapture, SourceCursor, validate_scope_id, ) @@ -190,12 +217,22 @@ ReviewServiceFactory = Callable[[str], ReviewService] GenerationServiceFactory = Callable[[str], ReviewedGenerationService] ExternalSkillRegistryFactory = Callable[[str], ExternalSkillRegistryService] +SkillPublicationServiceFactory = Callable[[str, str, str], ManagedSkillPublicationService] ExternalSkillImporter = Callable[ [str, str, str, ExternalSkillImportMode, str | None], Awaitable[GeneratedCandidateResult], ] ExperienceIncubator = Callable[[str, int], Awaitable[ExperienceIncubationResult]] ExperienceRecall = Callable[[str, str, int], Awaitable[tuple[ExperienceSearchHit, ...]]] +SkillRecall = Callable[[str, str, int], Awaitable[tuple[SkillSearchHit, ...]]] +SkillLister = Callable[[str, bool, int], Awaitable[tuple[tuple[Skill, ArtifactGovernance], ...]]] +SkillOriginReader = Callable[[str, tuple[Skill, ...]], Awaitable[tuple[SkillOrigin, ...]]] +SkillGovernanceReader = Callable[[str, str], Awaitable[ArtifactGovernance]] +SkillGovernanceUpdater = Callable[[str, str, int, ArtifactLifecycleState, str | None], Awaitable[ArtifactGovernance]] +SkillPackageResolver = Callable[[str, ArtifactRef], Awaitable[SkillPackageSnapshot]] +PackageSnapshotResolver = Callable[[str, SkillPackageRef], Awaitable[SkillPackageSnapshot]] +SkillPackageUploader = Callable[[str, bytes, str | None, ArtifactRef | None], Awaitable[SkillCandidate]] +SkillUsageRecorder = Callable[[str, SkillUsageCapture], Awaitable[SourceReceipt]] StatisticsServiceFactory = Callable[[str], RelationalScopedStatistics] RecallTokenEstimator = Callable[[str, PreparedContextBuild], Awaitable[RecallTokenMeasurement | None]] Clock = Callable[[], datetime] @@ -215,7 +252,13 @@ def __init__(self, code: str) -> None: "experience-incubation": "Experience incubation is not configured", "external-skill-registry": "External Skill Registry is not configured", "review": "Candidate Review services are not configured", + "remote-skill-distribution": "Remote Skill distribution services are not configured", "scheduler": "Built-in Runtime scheduler is already started", + "skill-publication": "Managed Skill publication services are not configured", + "skill-provenance": "Managed Skill provenance services are not configured", + "skill-governance": "Managed Skill governance services are not configured", + "skill-package": "Managed Skill package services are not configured", + "skill-usage": "Managed Skill usage recording is not configured", "statistics": "Statistics services are not configured", } super().__init__(messages[code]) @@ -551,6 +594,127 @@ async def get(self, request: GetSkillRequest, /) -> Skill: async with self._runtime._scoped_operation(self.scope_id): return await self._runtime._review(self.scope_id).get_skill(request.artifact) + async def search(self, query: str, limit: int, /) -> tuple[SkillSearchHit, ...]: + recall = self._runtime._skill_recall + if recall is None: + return () + async with self._runtime._scoped_operation(self.scope_id): + return await recall(self.scope_id, query, limit) + + async def list( + self, + *, + include_deprecated: bool = False, + limit: int = 100, + ) -> tuple[tuple[Skill, ArtifactGovernance], ...]: + lister = self._runtime._skill_lister + if lister is None: + return () + async with self._runtime._scoped_operation(self.scope_id): + return await lister(self.scope_id, include_deprecated, limit) + + async def origins(self, skills: tuple[Skill, ...], /) -> tuple[SkillOrigin, ...]: + """Resolve display provenance for current Skill revisions in one bounded read.""" + + reader = self._runtime._skill_origin_reader + if reader is None: + raise _RuntimeStateError("skill-provenance") + async with self._runtime._scoped_operation(self.scope_id): + return await reader(self.scope_id, skills) + + async def package(self, artifact: ArtifactRef, /) -> SkillPackageSnapshot: + resolver = self._runtime._skill_package_resolver + if resolver is None: + raise _RuntimeStateError("skill-package") + async with self._runtime._scoped_operation(self.scope_id): + return await resolver(self.scope_id, artifact) + + async def package_snapshot(self, package: SkillPackageRef, /) -> SkillPackageSnapshot: + resolver = self._runtime._package_snapshot_resolver + if resolver is None: + raise _RuntimeStateError("skill-package") + async with self._runtime._scoped_operation(self.scope_id): + return await resolver(self.scope_id, package) + + async def upload_package( + self, + archive_bytes: bytes, + reason: str | None, + target: ArtifactRef | None, + /, + ) -> SkillCandidate: + uploader = self._runtime._skill_package_uploader + if uploader is None: + raise _RuntimeStateError("skill-package") + async with self._runtime._scoped_operation(self.scope_id), self._runtime._locked(self.scope_id): + return await uploader(self.scope_id, archive_bytes, reason, target) + + async def record_usage(self, observation: SkillUsageCapture, /) -> SourceReceipt: + recorder = self._runtime._skill_usage_recorder + if recorder is None: + raise _RuntimeStateError("skill-usage") + async with self._runtime._scoped_operation(self.scope_id), self._runtime._locked(self.scope_id): + return await recorder(self.scope_id, observation) + + async def governance(self, artifact_id: str, /) -> ArtifactGovernance: + reader = self._runtime._skill_governance_reader + if reader is None: + raise _RuntimeStateError("skill-governance") + async with self._runtime._scoped_operation(self.scope_id): + return await reader(self.scope_id, artifact_id) + + async def update_lifecycle( + self, + artifact_id: str, + expected_generation: int, + lifecycle_state: ArtifactLifecycleState, + replacement_artifact_id: str | None, + /, + ) -> ArtifactGovernance: + updater = self._runtime._skill_governance_updater + if updater is None: + raise _RuntimeStateError("skill-governance") + async with self._runtime._scoped_operation(self.scope_id), self._runtime._locked(self.scope_id): + return await updater( + self.scope_id, + artifact_id, + expected_generation, + lifecycle_state, + replacement_artifact_id, + ) + + async def inspect_publication( + self, + artifact: ArtifactRef, + target: AgentSkillTarget, + /, + ) -> ManagedSkillPublicationStatus: + async with self._runtime._scoped_operation(self.scope_id): + service = self._runtime._skill_publications(self.scope_id, target, artifact) + return await service.inspect(artifact, target) + + async def publish( + self, + artifact: ArtifactRef, + target: AgentSkillTarget, + /, + *, + allow_deprecated: bool = False, + ) -> ManagedSkillPublicationStatus: + async with self._runtime._scoped_operation(self.scope_id): + service = self._runtime._skill_publications(self.scope_id, target, artifact) + return await service.publish(artifact, target, allow_deprecated=allow_deprecated) + + async def unpublish( + self, + artifact: ArtifactRef, + target: AgentSkillTarget, + /, + ) -> ManagedSkillPublicationStatus: + async with self._runtime._scoped_operation(self.scope_id): + service = self._runtime._skill_publications(self.scope_id, target, artifact) + return await service.unpublish(artifact, target) + class SkillApplication: """Select a scoped managed Skill application service.""" @@ -562,6 +726,151 @@ def for_scope(self, scope_id: str, /) -> ScopedSkillApplication: return ScopedSkillApplication(self._runtime, scope_id) +class RemoteSkillApplication: + """Expose remote target lifecycle and Receiver reconciliation through the Runtime boundary.""" + + def __init__(self, runtime: BuiltinRuntime) -> None: + self._runtime = runtime + + def _service(self) -> RemoteSkillDistributionService: + service = self._runtime._remote_skill_distribution + if service is None: + raise _RuntimeStateError("remote-skill-distribution") + return service + + async def list_targets( + self, + scope_id: str, + /, + *, + target_id: str | None = None, + limit: int = 100, + ) -> tuple[RemoteSkillTargetStatus, ...]: + async with self._runtime._scoped_operation(scope_id): + return await self._service().list_targets( + scope_id, + target_id=target_id, + limit=limit, + ) + + async def create_target( + self, + scope_id: str, + agent_kind: AgentKind, + display_name: str, + /, + ) -> RemoteTargetEnrollment: + async with self._runtime._scoped_operation(scope_id): + return await self._service().create_target(scope_id, agent_kind, display_name) + + async def enroll( + self, + enrollment_code: str, + installation_id: str, + receiver_version: str, + environment_fingerprint: str | None, + machine_hostname: str | None = None, + workspace_name: str | None = None, + /, + ) -> RemoteTargetCredential: + async with self._runtime._operation(): + return await self._service().enroll( + enrollment_code, + installation_id, + receiver_version, + environment_fingerprint, + machine_hostname, + workspace_name, + ) + + async def rename_target( + self, + scope_id: str, + target_id: str, + expected_generation: int, + display_name: str, + /, + ) -> RemoteAgentSkillTarget: + async with self._runtime._scoped_operation(scope_id): + return await self._service().rename_target(scope_id, target_id, expected_generation, display_name) + + async def revoke_target( + self, + scope_id: str, + target_id: str, + expected_generation: int, + /, + ) -> RemoteAgentSkillTarget: + async with self._runtime._scoped_operation(scope_id): + return await self._service().revoke_target(scope_id, target_id, expected_generation) + + async def publish( + self, + scope_id: str, + target_id: str, + artifact: ArtifactRef, + expected_generation: int | None, + /, + *, + allow_deprecated: bool = False, + ) -> SkillPublication: + async with self._runtime._scoped_operation(scope_id): + return await self._service().publish( + scope_id, + target_id, + artifact, + expected_generation, + allow_deprecated=allow_deprecated, + ) + + async def unpublish( + self, + scope_id: str, + target_id: str, + artifact_id: str, + expected_generation: int, + /, + ) -> SkillPublication: + async with self._runtime._scoped_operation(scope_id): + return await self._service().unpublish(scope_id, target_id, artifact_id, expected_generation) + + async def reconcile( + self, + credential: str, + observations: tuple[RemoteSkillObservation, ...], + receiver_version: str, + environment_fingerprint: str | None, + /, + ) -> RemoteSkillReconcileResult: + async with self._runtime._operation(): + return await self._service().reconcile( + credential, + observations, + receiver_version, + environment_fingerprint, + ) + + async def download( + self, + credential: str, + generation: int, + artifact: ArtifactRef, + package: SkillPackageRef, + /, + ) -> SkillPackageSnapshot: + async with self._runtime._operation(): + return await self._service().download(credential, generation, artifact, package) + + async def receipt( + self, + credential: str, + receipt: RemoteSkillReceipt, + /, + ) -> RemoteSkillReceiptResult: + async with self._runtime._operation(): + return await self._service().receipt(credential, receipt) + + class ScopedHandoffApplication: """Operate temporary and committed Handoffs for one scope.""" @@ -1222,9 +1531,20 @@ def __init__( review_service: ReviewServiceFactory | None = None, generation_service: GenerationServiceFactory | None = None, experience_recall: ExperienceRecall | None = None, + skill_recall: SkillRecall | None = None, + skill_lister: SkillLister | None = None, + skill_origin_reader: SkillOriginReader | None = None, + skill_governance_reader: SkillGovernanceReader | None = None, + skill_governance_updater: SkillGovernanceUpdater | None = None, + skill_package_resolver: SkillPackageResolver | None = None, + package_snapshot_resolver: PackageSnapshotResolver | None = None, + skill_package_uploader: SkillPackageUploader | None = None, + skill_usage_recorder: SkillUsageRecorder | None = None, experience_incubator: ExperienceIncubator | None = None, external_skill_registry: ExternalSkillRegistryFactory | None = None, external_skill_importer: ExternalSkillImporter | None = None, + skill_publication_service: SkillPublicationServiceFactory | None = None, + remote_skill_distribution: RemoteSkillDistributionService | None = None, statistics_service: StatisticsServiceFactory | None = None, recall_token_estimator: RecallTokenEstimator | None = None, readiness: RuntimeReadinessChecks | None = None, @@ -1240,9 +1560,20 @@ def __init__( self._review_service = review_service self._generation_service = generation_service self._experience_recall = experience_recall + self._skill_recall = skill_recall + self._skill_lister = skill_lister + self._skill_origin_reader = skill_origin_reader + self._skill_governance_reader = skill_governance_reader + self._skill_governance_updater = skill_governance_updater + self._skill_package_resolver = skill_package_resolver + self._package_snapshot_resolver = package_snapshot_resolver + self._skill_package_uploader = skill_package_uploader + self._skill_usage_recorder = skill_usage_recorder self._experience_incubator = experience_incubator self._external_skill_registry = external_skill_registry self._external_skill_importer = external_skill_importer + self._skill_publication_service = skill_publication_service + self._remote_skill_distribution = remote_skill_distribution self._statistics_service = statistics_service self._recall_token_estimator = recall_token_estimator self._readiness = RuntimeReadinessChecks() if readiness is None else readiness @@ -1272,6 +1603,7 @@ def __init__( self.memory = MemoryApplication(self) self.review = ReviewApplication(self) self.skill = SkillApplication(self) + self.remote_skills = RemoteSkillApplication(self) self.statistics = StatisticsApplication(self) self.handoff_report: HandoffReportApplication | None = None self.processor = None if scope_ids is None else ScheduledSourceProcessor(self, scope_ids) @@ -1512,6 +1844,16 @@ def _external_skills(self, scope_id: str) -> ExternalSkillRegistryService: raise ExternalSkillRegistryUnavailableError return self._external_skill_registry(validate_scope_id(scope_id)) + def _skill_publications( + self, + scope_id: str, + target: AgentSkillTarget, + artifact: ArtifactRef, + ) -> ManagedSkillPublicationService: + if self._skill_publication_service is None: + raise _RuntimeStateError("skill-publication") + return self._skill_publication_service(validate_scope_id(scope_id), target.target_id, artifact.artifact_id) + def _statistics(self, scope_id: str) -> RelationalScopedStatistics: if self._statistics_service is None: raise _RuntimeStateError("statistics") diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 3867a28e9..f2b8e42d5 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -54,6 +54,7 @@ ) from powercontext.builtin.persistence.oceanbase.profile import OceanBaseConfig, OceanBaseProfile from powercontext.builtin.persistence.seekdb.profile import SeekDBConfig, SeekDBProfile +from powercontext.builtin.persistence.skill_distribution_schema import ensure_skill_distribution_schema from powercontext.builtin.persistence.sqlite.experience_index import SQLiteExperienceFTSIndex from powercontext.builtin.persistence.sqlite.memory_index import SQLiteMemoryFTSIndex, SQLiteMemoryVectorIndex from powercontext.builtin.persistence.sqlite.profile import SQLiteConfig, SQLiteProfile @@ -64,7 +65,6 @@ from powercontext.builtin.runtime.models import MemorySearchMode, RuntimeCapabilities from powercontext.builtin.runtime.protocols import RuntimeTracing from powercontext.builtin.runtime.readiness import ( - READINESS_PROBE_TIMEOUT_SECONDS, CachedReadinessProbe, ReadinessProbe, ReadinessProbeDefinition, @@ -274,9 +274,20 @@ async def open_builtin_runtime( review_service=contexts.review, generation_service=contexts.generation, experience_recall=contexts.search_experience, + skill_recall=contexts.search_skills, + skill_lister=contexts.list_skills, + skill_origin_reader=contexts.get_skill_origins, + skill_governance_reader=contexts.get_skill_governance, + skill_governance_updater=contexts.update_skill_lifecycle, + skill_package_resolver=contexts.skill_package, + package_snapshot_resolver=contexts.package_snapshot, + skill_package_uploader=contexts.upload_skill_package, + skill_usage_recorder=contexts.record_skill_usage, experience_incubator=contexts.incubate_experience if contexts.experience_incubation else None, external_skill_registry=contexts.external_skills if contexts.external_skill_registry else None, external_skill_importer=contexts.import_external_skill if contexts.external_skill_registry else None, + skill_publication_service=contexts.skill_publications, + remote_skill_distribution=contexts.remote_skill_distribution(), statistics_service=contexts.statistics, recall_token_estimator=contexts.estimate_recall_tokens, readiness=RuntimeReadinessChecks(readiness_probes), @@ -336,6 +347,7 @@ async def open_builtin_contexts( load_vector_extension=embedding_model is not None, ) as profile: async with profile.database.transaction() as connection: + await ensure_skill_distribution_schema(connection) await index.initialize(connection) await experience_index.initialize(connection) yield RelationalContexts( @@ -368,6 +380,7 @@ async def open_builtin_contexts( raise BuiltinConfigurationError("database") async with profile_context as profile: async with profile.database.transaction() as connection: + await ensure_skill_distribution_schema(connection) await index.initialize(connection) await experience_index.initialize(connection) yield RelationalContexts( @@ -453,7 +466,7 @@ async def probe_generation() -> None: # Readiness probing runs outside any operation span; keep it out of traces. await probe_pydantic_ai_model( provider_model, - timeout_seconds=READINESS_PROBE_TIMEOUT_SECONDS, + timeout_seconds=settings.generation_timeout_seconds, model_settings=model_settings, ) @@ -532,7 +545,9 @@ async def probe_generation() -> None: evidence_projector=_ContentHandoffEvidenceProjector(), ), (None if rerank_generator is None else LLMMemoryReranker(UsageReportingStructuredGenerator(rerank_generator))), - CachedReadinessProbe(dependency_readiness_probe(probe_generation)), + CachedReadinessProbe( + dependency_readiness_probe(probe_generation, timeout_seconds=settings.generation_timeout_seconds) + ), ) diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index 534a828f5..0d1c097bb 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -25,7 +25,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncConnection -from powercontext.artifacts import Artifact +from powercontext.artifacts import Artifact, ArtifactRef from powercontext.builtin.artifacts.experience import ( EXPERIENCE_INCUBATION_CURSOR_NAME, Experience, @@ -57,10 +57,24 @@ Skill, SkillContent, SkillGenerator, + SkillOrigin, + SkillOriginKind, + SkillPackageRef, + SkillPackageSnapshot, + SkillSearchHit, + capture_skill_archive, ) +from powercontext.builtin.artifacts.skill.distribution import RemoteSkillDistributionService +from powercontext.builtin.artifacts.skill.publication import ManagedSkillPublicationService from powercontext.builtin.artifacts.skill.registry import ExternalSkillRegistryService from powercontext.builtin.context import BuiltinArtifacts, BuiltinSources from powercontext.builtin.inference import EmbeddingModel, InvalidInferenceOutputError, TokenEstimator +from powercontext.builtin.persistence.agent_skill_targets import RemoteAgentSkillTargetRepository +from powercontext.builtin.persistence.artifact_governance import ( + ArtifactGovernance, + ArtifactGovernanceRepository, + ArtifactLifecycleState, +) from powercontext.builtin.persistence.artifacts import ArtifactRepository from powercontext.builtin.persistence.candidates import CandidateRepository from powercontext.builtin.persistence.cursors import SourceCursorRepository @@ -74,6 +88,8 @@ ) from powercontext.builtin.persistence.memory import RelationalMemoryBackend from powercontext.builtin.persistence.memory_index import MemoryIndex, NoMemoryIndex +from powercontext.builtin.persistence.skill_packages import SkillPackageRepository +from powercontext.builtin.persistence.skill_publications import SkillPublicationRepository 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 @@ -83,8 +99,9 @@ ReviewedGenerationService, SkillGenerationOrigin, ) +from powercontext.builtin.review.models import ArtifactCandidate from powercontext.builtin.review.service import ReviewService -from powercontext.builtin.runtime.models import ExperienceIncubationResult, MemoryFlushResult +from powercontext.builtin.runtime.models import ExperienceIncubationResult, MemoryFlushResult, SourceReceipt from powercontext.builtin.runtime.prepared_context import PreparedContextBuild from powercontext.builtin.runtime.protocols import BuiltinTriggers from powercontext.builtin.runtime.recall import RelationalRecallTokenEstimator @@ -92,8 +109,13 @@ from powercontext.builtin.sources import ( CONTENT_SOURCE_ADAPTER, EXTERNAL_SKILL_SNAPSHOT_SOURCE_ADAPTER, + SKILL_PACKAGE_UPLOAD_SOURCE_ADAPTER, + SKILL_USAGE_SOURCE_ADAPTER, ExternalSkillImportMode, ExternalSkillSnapshotCapture, + ExternalSkillSnapshotSource, + SkillPackageUploadCapture, + SkillUsageCapture, SourceCursor, SourceJournalEntry, validate_scope_id, @@ -121,18 +143,28 @@ _SOURCE_ADAPTERS: tuple[SourceAdapter[Any, Any, Any], ...] = ( CONTENT_SOURCE_ADAPTER, EXTERNAL_SKILL_SNAPSHOT_SOURCE_ADAPTER, + SKILL_PACKAGE_UPLOAD_SOURCE_ADAPTER, + SKILL_USAGE_SOURCE_ADAPTER, ) +def _artifact_identity(ref: ArtifactRef) -> tuple[str, str, int]: + return ref.family, ref.artifact_id, ref.revision + + @dataclass(frozen=True, slots=True) class _Repositories: """Repositories shared by every scoped context.""" sources: SourceRepository artifacts: ArtifactRepository + governance: ArtifactGovernanceRepository candidates: CandidateRepository cursors: SourceCursorRepository external_skills: ExternalSkillRepository + skill_packages: SkillPackageRepository + agent_skill_targets: RemoteAgentSkillTargetRepository + skill_publications: SkillPublicationRepository statistics: StatisticsRepository @@ -207,6 +239,7 @@ def review(self, connection: AsyncConnection | None = None) -> ReviewService: candidates=self.repositories.candidates, artifacts=self.repositories.artifacts, experience_index=self.experience_index, + skill_packages=self.repositories.skill_packages, sources=self.repositories.sources, id_factory=self.id_factory, connection=connection, @@ -310,12 +343,16 @@ def __init__( self.repositories = _Repositories( sources=SourceRepository(_SOURCE_ADAPTERS), artifacts=ArtifactRepository((Handoff, Memory, Experience, Skill)), + governance=ArtifactGovernanceRepository(), candidates=CandidateRepository({ Experience.family: ExperienceContent, Skill.family: SkillContent, }), cursors=SourceCursorRepository(), external_skills=ExternalSkillRepository(), + skill_packages=SkillPackageRepository(), + agent_skill_targets=RemoteAgentSkillTargetRepository(), + skill_publications=SkillPublicationRepository(), statistics=StatisticsRepository(), ) self._candidate_pipeline = candidate_pipeline @@ -344,6 +381,7 @@ def __init__( self._source_locks: dict[str, asyncio.Lock] = {} self._activation_locks: dict[str, asyncio.Lock] = {} self._experience_locks: dict[str, asyncio.Lock] = {} + self._skill_publication_locks: dict[tuple[str, str, str], asyncio.Lock] = {} def evict(self, scope_id: str, /) -> None: """Discard inactive scope-local compositions and serialization locks.""" @@ -393,6 +431,237 @@ async def search_experience( async with self.database.transaction() as connection: return await self.experience_index.search(connection, scope, query, limit) + async def search_skills( + self, + scope_id: str, + query: str, + limit: int, + /, + ) -> tuple[SkillSearchHit, ...]: + """Recall relevant active managed Skill heads in one scope.""" + + if limit < 1: + raise ValueError("Skill search limit must be positive") # noqa: TRY003 + scope = validate_scope_id(scope_id) + async with self.database.transaction() as connection: + return await self.experience_index.search_skills(connection, scope, query, limit) + + async def get_skill_governance( + self, + scope_id: str, + artifact_id: str, + /, + ) -> ArtifactGovernance: + scope = validate_scope_id(scope_id) + async with self.database.transaction() as connection: + return await self.repositories.governance.get(connection, scope, Skill.family, artifact_id) + + async def get_skill_origins(self, scope_id: str, skills: tuple[Skill, ...], /) -> tuple[SkillOrigin, ...]: + """Project exact external takeover evidence through later Skill revisions.""" + + scope = validate_scope_id(scope_id) + async with self.database.transaction() as connection: + origins: list[SkillOrigin] = [] + for skill in skills: + origins.append( + await self._skill_origin( + connection, + scope, + skill, + visited={_artifact_identity(skill.as_ref())}, + ) + ) + return tuple(origins) + + async def _skill_origin( + self, + connection: AsyncConnection, + scope_id: str, + skill: Skill, + *, + visited: set[tuple[str, str, int]], + ) -> SkillOrigin: + for source_ref in skill.lineage.sources: + if source_ref.source_type != EXTERNAL_SKILL_SNAPSHOT_SOURCE_ADAPTER.name: + continue + stored = await self.repositories.sources.get(connection, scope_id, source_ref) + if isinstance(stored.value, ExternalSkillSnapshotSource): + kind = ( + SkillOriginKind.EXTERNAL_IMPORT + if stored.value.mode is ExternalSkillImportMode.IMPORT + else SkillOriginKind.EXTERNAL_FORK + ) + return SkillOrigin(kind=kind, registration=stored.value.snapshot.registration, source=source_ref) + + for artifact_ref in skill.lineage.artifacts: + identity = _artifact_identity(artifact_ref) + if artifact_ref.family != Skill.family or identity in visited: + continue + visited.add(identity) + upstream = await self.repositories.artifacts.get(connection, scope_id, artifact_ref) + if isinstance(upstream, Skill): + origin = await self._skill_origin(connection, scope_id, upstream, visited=visited) + if origin.kind is not SkillOriginKind.POWERCONTEXT: + return origin + return SkillOrigin(kind=SkillOriginKind.POWERCONTEXT) + + async def list_skills( + self, + scope_id: str, + include_deprecated: bool, + limit: int, + /, + ) -> tuple[tuple[Skill, ArtifactGovernance], ...]: + """List current managed Skill heads with mutable governance state.""" + + if limit < 1: + raise ValueError("Skill Library limit must be positive") # noqa: TRY003 + scope = validate_scope_id(scope_id) + states = ( + (ArtifactLifecycleState.ACTIVE.value, ArtifactLifecycleState.DEPRECATED.value) + if include_deprecated + else (ArtifactLifecycleState.ACTIVE.value,) + ) + async with self.database.transaction() as connection: + rows = tuple( + ( + await connection.execute( + select( + ARTIFACT_HEADS_TABLE.c.artifact_id, + ARTIFACT_HEADS_TABLE.c.revision, + ) + .where( + ARTIFACT_HEADS_TABLE.c.scope_id == scope, + ARTIFACT_HEADS_TABLE.c.family == Skill.family, + ARTIFACT_HEADS_TABLE.c.lifecycle_state.in_(states), + ) + .order_by(ARTIFACT_HEADS_TABLE.c.artifact_id) + .limit(limit) + ) + ).mappings() + ) + values = [] + for row in rows: + artifact_id = str(row["artifact_id"]) + skill = await self.repositories.artifacts.get( + connection, + scope, + ArtifactRef( + family=Skill.family, + artifact_id=artifact_id, + revision=int(row["revision"]), + ), + ) + governance = await self.repositories.governance.get(connection, scope, Skill.family, artifact_id) + values.append((cast(Skill, skill), governance)) + return tuple(values) + + async def update_skill_lifecycle( + self, + scope_id: str, + artifact_id: str, + expected_generation: int, + lifecycle_state: ArtifactLifecycleState, + replacement_artifact_id: str | None, + /, + ) -> ArtifactGovernance: + scope = validate_scope_id(scope_id) + async with self.database.transaction() as connection: + return await self.repositories.governance.transition( + connection, + scope, + Skill.family, + artifact_id, + expected_generation, + lifecycle_state, + replacement_artifact_id, + ) + + async def skill_package( + self, + scope_id: str, + artifact: ArtifactRef, + /, + ) -> SkillPackageSnapshot: + """Resolve and verify the exact package owned by an approved Skill Revision.""" + + scope = validate_scope_id(scope_id) + async with self.database.transaction() as connection: + value = await self.repositories.artifacts.get(connection, scope, artifact) + if not isinstance(value, Skill) or value.content.package is None: + raise ValueError("the Skill Revision is not package-backed") # noqa: TRY003 + return await self.repositories.skill_packages.get(connection, scope, value.content.package) + + async def package_snapshot( + self, + scope_id: str, + package: SkillPackageRef, + /, + ) -> SkillPackageSnapshot: + """Resolve one exact package reference for inert Review inspection.""" + + scope = validate_scope_id(scope_id) + async with self.database.transaction() as connection: + return await self.repositories.skill_packages.get(connection, scope, package) + + async def upload_skill_package( + self, + scope_id: str, + archive_bytes: bytes, + reason: str | None, + target: ArtifactRef | None, + /, + ) -> ArtifactCandidate[SkillContent]: + """Canonicalize an explicit upload and create a pending exact-import Candidate.""" + + scope = validate_scope_id(scope_id) + package = await asyncio.to_thread(capture_skill_archive, archive_bytes) + source = await SKILL_PACKAGE_UPLOAD_SOURCE_ADAPTER.resolve( + SkillPackageUploadCapture( + package=package.reference, + name=package.metadata.name, + description=package.metadata.description, + ) + ) + async with self.database.transaction() as connection: + await self.repositories.skill_packages.add(connection, scope, package) + stored = await self.repositories.sources.add(connection, scope, source) + artifacts = () if target is None else (target,) + return ( + await self + ._services_for(scope) + .review(connection) + .propose_skill( + package.as_skill_content(), + sources=(stored.ref,), + artifacts=artifacts, + target=target, + reason=reason, + ) + ) + + async def record_skill_usage( + self, + scope_id: str, + observation: SkillUsageCapture, + /, + ) -> SourceReceipt: + """Validate and capture one exact, bounded Skill usage observation.""" + + scope = validate_scope_id(scope_id) + source = await SKILL_USAGE_SOURCE_ADAPTER.resolve(observation) + async with self.database.transaction() as connection: + value = await self.repositories.artifacts.get(connection, scope, observation.skill_ref) + if not isinstance(value, Skill) or value.content.package is None: + raise ValueError("usage must reference a package-backed Skill Revision") # noqa: TRY003 + expected_digest = f"sha256:{value.content.package.tree_digest}" + if observation.package_digest != expected_digest: + raise ValueError("usage package digest does not match the Skill Revision") # noqa: TRY003 + if observation.task_source is not None: + await self.repositories.sources.get(connection, scope, observation.task_source) + stored = await self.repositories.sources.add(connection, scope, source) + return SourceReceipt(source_ref=stored.ref, sequence=stored.journal_position) + def external_skills(self, scope_id: str, /) -> ExternalSkillRegistryService: """Return the host-local external Skill Registry bound to one scope.""" @@ -405,6 +674,38 @@ def external_skills(self, scope_id: str, /) -> ExternalSkillRegistryService: provider=self._external_skill_provider, ) + def skill_publications( + self, + scope_id: str, + target_id: str, + artifact_id: str, + /, + ) -> ManagedSkillPublicationService: + """Return safe package publication operations serialized for one target binding.""" + + scope = validate_scope_id(scope_id) + return ManagedSkillPublicationService( + database=self.database, + scope_id=scope, + artifacts=self.repositories.artifacts, + governance=self.repositories.governance, + packages=self.repositories.skill_packages, + publications=self.repositories.skill_publications, + lock=self._skill_publication_locks.setdefault((scope, target_id, artifact_id), asyncio.Lock()), + ) + + def remote_skill_distribution(self) -> RemoteSkillDistributionService: + """Return credential-bound remote target desired-state operations.""" + + return RemoteSkillDistributionService( + database=self.database, + targets=self.repositories.agent_skill_targets, + artifacts=self.repositories.artifacts, + governance=self.repositories.governance, + packages=self.repositories.skill_packages, + publications=self.repositories.skill_publications, + ) + async def import_external_skill( self, scope_id: str, @@ -414,17 +715,32 @@ async def import_external_skill( reason: str | None, /, ) -> GeneratedCandidateResult: - """Snapshot an exact external package only for an explicit managed import or fork.""" + """Snapshot an exact package, preserving import bytes or using LLM only for a fork.""" - if self._skill_generator is None: + if mode is ExternalSkillImportMode.FORK and self._skill_generator is None: raise GenerationCapabilityUnavailableError(Skill.family) scope = validate_scope_id(scope_id) - snapshot = await self.external_skills(scope).snapshot(external_skill_id, fingerprint) + capture = await self.external_skills(scope).snapshot(external_skill_id, fingerprint) source = await EXTERNAL_SKILL_SNAPSHOT_SOURCE_ADAPTER.resolve( - ExternalSkillSnapshotCapture(snapshot=snapshot, mode=mode) + ExternalSkillSnapshotCapture(snapshot=capture.as_source_snapshot(), mode=mode) ) async with self.database.transaction() as connection: + await self.repositories.skill_packages.add(connection, scope, capture.package) stored = await self.repositories.sources.add(connection, scope, source) + if mode is ExternalSkillImportMode.IMPORT: + candidate = ( + await self + ._services_for(scope) + .review(connection) + .propose_skill( + capture.package.as_skill_content(), + sources=(stored.ref,), + artifacts=(), + target=None, + reason=reason, + ) + ) + return GeneratedCandidateResult(candidate=candidate) return await self.generation(scope).skill( origin=SkillGenerationOrigin.SOURCE, sources=(stored.ref,), diff --git a/src/powercontext/builtin/sources/__init__.py b/src/powercontext/builtin/sources/__init__.py index d596a4588..6d1e5bc2d 100644 --- a/src/powercontext/builtin/sources/__init__.py +++ b/src/powercontext/builtin/sources/__init__.py @@ -35,12 +35,33 @@ SourceJournalEntry, validate_scope_id, ) +from powercontext.builtin.sources.skill_package import ( + SKILL_PACKAGE_UPLOAD_SOURCE_ADAPTER, + SKILL_PACKAGE_UPLOAD_SOURCE_NAME, + SkillPackageUploadCapture, + SkillPackageUploadSource, + SkillPackageUploadSourceAdapter, +) +from powercontext.builtin.sources.skill_usage import ( + SKILL_USAGE_SOURCE_ADAPTER, + SKILL_USAGE_SOURCE_NAME, + ObservedInvocation, + ObservedOutcome, + ObservedValidation, + SkillUsageCapture, + SkillUsageSource, + SkillUsageSourceAdapter, +) __all__ = [ "CONTENT_SOURCE_ADAPTER", "CONTENT_SOURCE_NAME", "EXTERNAL_SKILL_SNAPSHOT_SOURCE_ADAPTER", "EXTERNAL_SKILL_SNAPSHOT_SOURCE_NAME", + "SKILL_PACKAGE_UPLOAD_SOURCE_ADAPTER", + "SKILL_PACKAGE_UPLOAD_SOURCE_NAME", + "SKILL_USAGE_SOURCE_ADAPTER", + "SKILL_USAGE_SOURCE_NAME", "ContentCapture", "ContentSource", "ContentSourceAdapter", @@ -48,6 +69,15 @@ "ExternalSkillSnapshotCapture", "ExternalSkillSnapshotSource", "ExternalSkillSnapshotSourceAdapter", + "ObservedInvocation", + "ObservedOutcome", + "ObservedValidation", + "SkillPackageUploadCapture", + "SkillPackageUploadSource", + "SkillPackageUploadSourceAdapter", + "SkillUsageCapture", + "SkillUsageSource", + "SkillUsageSourceAdapter", "SourceCursor", "SourceJournal", "SourceJournalEntry", diff --git a/src/powercontext/builtin/sources/skill_package.py b/src/powercontext/builtin/sources/skill_package.py new file mode 100644 index 000000000..2f00b8bfc --- /dev/null +++ b/src/powercontext/builtin/sources/skill_package.py @@ -0,0 +1,76 @@ +# 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. + +"""Bounded Source evidence for caller-uploaded standard Skill packages.""" + +from __future__ import annotations + +from pydantic import BaseModel + +from powercontext.builtin.artifacts.skill.models import SkillPackageRef +from powercontext.sources import Source, SourceMaterialization + +SKILL_PACKAGE_UPLOAD_SOURCE_NAME = "skill-package-upload" + + +class SkillPackageUploadCapture(BaseModel): + """Exact package identity selected by an explicit caller upload.""" + + package: SkillPackageRef + name: str + description: str + + +class SkillPackageUploadSource(Source): + """Durable upload evidence that refers to package bytes stored once.""" + + package: SkillPackageRef + skill_name: str + skill_description: str + + +class SkillPackageUploadSourceAdapter: + """Materialize package upload metadata without duplicating archive bytes.""" + + input_class = SkillPackageUploadCapture + name = SKILL_PACKAGE_UPLOAD_SOURCE_NAME + source_class = SkillPackageUploadSource + + async def resolve(self, value: SkillPackageUploadCapture, /) -> SkillPackageUploadSource: + return SkillPackageUploadSource( + name=f"skill_pkg_{value.package.tree_digest}", + materialization=SourceMaterialization.CAPTURED, + description="Exact standard Skill package captured by an explicit caller upload.", + package=value.package, + skill_name=value.name, + skill_description=value.description, + ) + + async def read(self, source: SkillPackageUploadSource, /) -> SkillPackageUploadCapture: + return SkillPackageUploadCapture( + package=source.package, + name=source.skill_name, + description=source.skill_description, + ) + + +SKILL_PACKAGE_UPLOAD_SOURCE_ADAPTER = SkillPackageUploadSourceAdapter() + +__all__ = [ + "SKILL_PACKAGE_UPLOAD_SOURCE_ADAPTER", + "SKILL_PACKAGE_UPLOAD_SOURCE_NAME", + "SkillPackageUploadCapture", + "SkillPackageUploadSource", + "SkillPackageUploadSourceAdapter", +] diff --git a/src/powercontext/builtin/sources/skill_usage.py b/src/powercontext/builtin/sources/skill_usage.py new file mode 100644 index 000000000..5d0cfd4f4 --- /dev/null +++ b/src/powercontext/builtin/sources/skill_usage.py @@ -0,0 +1,133 @@ +# 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. + +"""Bounded evidence captured when an Agent integration observes Skill use.""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, Field + +from powercontext.artifacts import ArtifactRef +from powercontext.sources import Source, SourceMaterialization, SourceRef + +SKILL_USAGE_SOURCE_NAME = "skill-usage" + + +class ObservedInvocation(StrEnum): + """Whether the integration actually observed invocation.""" + + TRUE = "true" + FALSE = "false" + UNKNOWN = "unknown" + + +class ObservedValidation(StrEnum): + """Bounded validation result reported by the owning integration.""" + + PASSED = "passed" + FAILED = "failed" + UNKNOWN = "unknown" + + +class ObservedOutcome(StrEnum): + """Bounded task outcome reported by the owning integration.""" + + SUCCESS = "success" + FAILURE = "failure" + UNKNOWN = "unknown" + + +class SkillUsageCapture(BaseModel): + """Caller-stable, exact usage observation with no prompt or command body.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + observation_id: str = Field(min_length=1, max_length=256) + skill_ref: ArtifactRef + package_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + target_id: str = Field(min_length=1, max_length=128) + selected: bool + invoked: ObservedInvocation = ObservedInvocation.UNKNOWN + validation: ObservedValidation = ObservedValidation.UNKNOWN + outcome: ObservedOutcome = ObservedOutcome.UNKNOWN + task_source: SourceRef | None = None + environment_fingerprint: str | None = Field(default=None, pattern=r"^sha256:[0-9a-f]{64}$") + + +class SkillUsageSource(Source): + """Immutable Source representation of one bounded usage observation.""" + + skill_ref: ArtifactRef + package_digest: str + target_id: str + selected: bool + invoked: ObservedInvocation + validation: ObservedValidation + outcome: ObservedOutcome + task_source: SourceRef | None = None + environment_fingerprint: str | None = None + + +class SkillUsageSourceAdapter: + """Materialize and read bounded Skill usage evidence.""" + + input_class = SkillUsageCapture + name = SKILL_USAGE_SOURCE_NAME + source_class = SkillUsageSource + + async def resolve(self, value: SkillUsageCapture, /) -> SkillUsageSource: + return SkillUsageSource( + name=value.observation_id, + materialization=SourceMaterialization.CAPTURED, + description="Bounded Skill usage observed by the owning Agent integration.", + skill_ref=value.skill_ref, + package_digest=value.package_digest, + target_id=value.target_id, + selected=value.selected, + invoked=value.invoked, + validation=value.validation, + outcome=value.outcome, + task_source=value.task_source, + environment_fingerprint=value.environment_fingerprint, + ) + + async def read(self, source: SkillUsageSource, /) -> SkillUsageCapture: + return SkillUsageCapture( + observation_id=source.name, + skill_ref=source.skill_ref, + package_digest=source.package_digest, + target_id=source.target_id, + selected=source.selected, + invoked=source.invoked, + validation=source.validation, + outcome=source.outcome, + task_source=source.task_source, + environment_fingerprint=source.environment_fingerprint, + ) + + +SKILL_USAGE_SOURCE_ADAPTER = SkillUsageSourceAdapter() + +__all__ = [ + "SKILL_USAGE_SOURCE_ADAPTER", + "SKILL_USAGE_SOURCE_NAME", + "ObservedInvocation", + "ObservedOutcome", + "ObservedValidation", + "SkillUsageCapture", + "SkillUsageSource", + "SkillUsageSourceAdapter", +] diff --git a/src/powercontext/cli/openclaw.py b/src/powercontext/cli/openclaw.py index 7ba2943d6..4370811fd 100644 --- a/src/powercontext/cli/openclaw.py +++ b/src/powercontext/cli/openclaw.py @@ -213,8 +213,14 @@ def build_openclaw_plugin(plugin_dir: Path) -> None: """Install plugin dependencies and produce the runtime bundle.""" executable = pnpm_executable() - run_process([executable, "--dir", str(plugin_dir), "install", "--frozen-lockfile"], timeout=600) - run_process([executable, "--dir", str(plugin_dir), "run", "build"], timeout=600) + environment = os.environ.copy() + environment["CI"] = "true" + run_process( + [executable, "--dir", str(plugin_dir), "install", "--frozen-lockfile"], + timeout=600, + env=environment, + ) + run_process([executable, "--dir", str(plugin_dir), "run", "build"], timeout=600, env=environment) if not (plugin_dir / "dist" / "index.js").is_file(): raise SetupError.unbuilt_openclaw_plugin(plugin_dir) @@ -299,7 +305,13 @@ def run_openclaw(executable: str, *arguments: str) -> subprocess.CompletedProces return run_process([executable, *arguments], timeout=180) -def run_process(command: list[str], *, timeout: int, check: bool = True) -> subprocess.CompletedProcess[str]: +def run_process( + command: list[str], + *, + timeout: int, + check: bool = True, + env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: """Run a setup subprocess with captured, bounded output.""" try: @@ -311,6 +323,7 @@ def run_process(command: list[str], *, timeout: int, check: bool = True) -> subp encoding="utf-8", errors="replace", timeout=timeout, + env=env, ) except (OSError, subprocess.SubprocessError) as error: raise SetupError.command_unavailable(command, error) from error @@ -338,7 +351,8 @@ def run_openclaw_diagnostics() -> dict[str, Diagnostic]: } try: output = run_openclaw(executable, "plugins", "list", "--enabled", "--json").stdout or "" - installed = openclaw_plugin_installed(output) + active_memory_plugin = read_config_value(executable, "plugins.slots.memory") + installed = openclaw_plugin_installed(output, active_memory_plugin=active_memory_plugin) except SetupError as error: return { "openclaw": Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)), @@ -360,7 +374,7 @@ def run_openclaw_diagnostics() -> dict[str, Diagnostic]: } -def openclaw_plugin_installed(output: str) -> bool: +def openclaw_plugin_installed(output: str, *, active_memory_plugin: object | None = None) -> bool: """Return whether OpenClaw reports the PowerContext plugin as the active memory plugin.""" command = ["openclaw", "plugins", "list", "--enabled", "--json"] @@ -376,11 +390,10 @@ def openclaw_plugin_installed(output: str) -> bool: raise SetupError.invalid_command_output(command, "an invalid plugin entry") if plugin.get("id") != OPENCLAW_PLUGIN_NAME: continue - return ( - plugin.get("enabled") is True - and plugin.get("status") == "loaded" - and plugin.get("memorySlotSelected") is True - ) + memory_slot_selected = plugin.get("memorySlotSelected") + if memory_slot_selected is None: + memory_slot_selected = active_memory_plugin == OPENCLAW_PLUGIN_NAME + return plugin.get("enabled") is True and plugin.get("status") == "loaded" and memory_slot_selected is True return False diff --git a/src/powercontext/client/__init__.py b/src/powercontext/client/__init__.py index 55ca5bd9e..76bfadcc7 100644 --- a/src/powercontext/client/__init__.py +++ b/src/powercontext/client/__init__.py @@ -16,11 +16,29 @@ from powercontext.client.client import PowerContextClient from powercontext.client.errors import ClientError, InvalidResponseError, ServerResponseError, TransportError +from powercontext.client.skill_receiver import ( + RECEIVER_VERSION, + ReceiverSyncResult, + RemoteSkillReceiver, + RemoteSkillReceiverConfig, + SkillReceiverConflictError, + SkillReceiverError, + SkillReceiverStateError, + require_remote_skill_server_url, +) __all__ = [ + "RECEIVER_VERSION", "ClientError", "InvalidResponseError", "PowerContextClient", + "ReceiverSyncResult", + "RemoteSkillReceiver", + "RemoteSkillReceiverConfig", "ServerResponseError", + "SkillReceiverConflictError", + "SkillReceiverError", + "SkillReceiverStateError", "TransportError", + "require_remote_skill_server_url", ] diff --git a/src/powercontext/client/cli.py b/src/powercontext/client/cli.py index 811f4e25b..b96ea248a 100644 --- a/src/powercontext/client/cli.py +++ b/src/powercontext/client/cli.py @@ -17,19 +17,46 @@ from __future__ import annotations import asyncio +import base64 +import binascii +import json +import os +import socket +import tempfile from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass from pathlib import Path from typing import Annotated, Never, TypeAlias +from uuid import uuid4 import typer from pydantic import SecretStr, ValidationError from powercontext.artifacts import ArtifactRef +from powercontext.builtin.artifacts.skill import ( + AgentSkillTarget, + SkillContent, + capture_skill_archive, + materialize_skill_package, +) +from powercontext.builtin.artifacts.skill.projection import validate_skill_projection_target from powercontext.client.client import PowerContextClient -from powercontext.client.errors import ClientError +from powercontext.client.errors import ClientError, ServerResponseError from powercontext.client.projections import SkillExportTarget, export_skill +from powercontext.client.receiver_service import ( + ReceiverServiceError, + ReceiverServiceInstallation, + install_systemd_user_service, + uninstall_systemd_user_service, +) from powercontext.client.settings import ClientSettings +from powercontext.client.skill_receiver import ( + RECEIVER_VERSION, + ReceiverSyncResult, + RemoteSkillReceiver, + RemoteSkillReceiverConfig, + require_remote_skill_server_url, +) from powercontext.http import ( ApproveArtifactCandidateRequest, ArtifactCandidate, @@ -38,6 +65,8 @@ CandidateFamily, CandidateStatus, Capabilities, + CreateRemoteSkillTargetRequest, + EnrollRemoteSkillTargetRequest, ExperienceProposal, ExternalSkillImportMode, ExternalSkillResolution, @@ -46,6 +75,7 @@ GenerateExperienceRequest, GenerateSkillRequest, GetArtifactCandidateRequest, + GetSkillPackageRequest, GetSkillRequest, GetStatsRequest, HealthResponse, @@ -53,11 +83,20 @@ ListArtifactCandidatesRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, + ListRemoteSkillTargetsRequest, + ListRemoteSkillTargetsResponse, ModelUsageValue, + PublishRemoteSkillRequest, ReadinessResponse, RejectArtifactCandidateRequest, + RemoteAgentKind, + RemoteSkillPublication, + RemoteSkillTarget, + RemoteSkillTargetStatus, + RenameRemoteSkillTargetRequest, ResolveExternalSkillRequest, ReviseArtifactCandidateRequest, + RevokeRemoteSkillTargetRequest, ScanExternalSkillsRequest, ScanExternalSkillsResponse, ScopedStats, @@ -67,6 +106,7 @@ SkillValidationItem, SourceReference, StatsPeriod, + UnpublishRemoteSkillRequest, ) HELP_OPTION_NAMES = ("-h", "--help") @@ -78,7 +118,10 @@ | GeneratedCandidateResponse | HealthResponse | ListExternalSkillsResponse + | ListRemoteSkillTargetsResponse | ReadinessResponse + | RemoteSkillPublication + | RemoteSkillTarget | ScanExternalSkillsResponse | SkillArtifact | ScopedStats @@ -533,6 +576,629 @@ def export_managed_skill( asyncio.run(_export_managed_skill(context, request, target, destination)) +@skill_app.command("remote-target-create") +def create_remote_skill_target( + context: typer.Context, + scope_id: Annotated[str, typer.Option(help="Application scope authorized for the remote target.")], + agent_kind: Annotated[RemoteAgentKind, typer.Option(help="Remote Agent integration kind.")], + name: Annotated[str, typer.Option(help="Human-readable remote machine name shown in the Dashboard.")], +) -> None: + """Create a pending target and print its short-lived enrollment code once.""" + + asyncio.run(_create_remote_skill_target(context, scope_id, agent_kind, name)) + + +@skill_app.command("remote-status") +def list_remote_skill_targets( + context: typer.Context, + scope_id: Annotated[str, typer.Option(help="Application scope containing the remote targets.")], + target_id: Annotated[ + str | None, + typer.Option(help="Optional exact target identity; omit to list the scope."), + ] = None, + limit: Annotated[int, typer.Option(min=1, max=200, help="Maximum targets to return.")] = 100, +) -> None: + """Show remote target enrollment, liveness, desired state, and delivery state.""" + + request = ListRemoteSkillTargetsRequest(scope_id=scope_id, target_id=target_id, limit=limit) + asyncio.run(_execute(context, lambda client: client.list_remote_skill_targets(request))) + + +@skill_app.command("remote-publish") +def publish_remote_skill( + context: typer.Context, + artifact_id: Annotated[str, typer.Argument(help="Approved managed Skill Artifact identity.")], + scope_id: Annotated[str, typer.Option(help="Application scope containing the Skill and target.")], + target_id: Annotated[str, typer.Option(help="Enrolled remote target identity.")], + revision: Annotated[int, typer.Option(min=1, help="Exact approved managed Skill Revision.")], + expected_generation: Annotated[ + int | None, + typer.Option(min=0, help="Optional publication CAS generation; resolved automatically when omitted."), + ] = None, + allow_deprecated: Annotated[ + bool, + typer.Option(help="Explicitly allow publishing a deprecated managed Skill."), + ] = False, +) -> None: + """Publish or update one exact Skill Revision for a remote target.""" + + asyncio.run( + _publish_remote_skill( + context, + scope_id, + target_id, + artifact_id, + revision, + expected_generation, + allow_deprecated=allow_deprecated, + ) + ) + + +@skill_app.command("remote-unpublish") +def unpublish_remote_skill( + context: typer.Context, + artifact_id: Annotated[str, typer.Argument(help="Managed Skill Artifact identity to remove remotely.")], + scope_id: Annotated[str, typer.Option(help="Application scope containing the publication.")], + target_id: Annotated[str, typer.Option(help="Enrolled remote target identity.")], + expected_generation: Annotated[ + int | None, + typer.Option(min=0, help="Optional publication CAS generation; resolved automatically when omitted."), + ] = None, +) -> None: + """Request safe removal of one Receiver-managed remote Skill.""" + + asyncio.run(_unpublish_remote_skill(context, scope_id, target_id, artifact_id, expected_generation)) + + +@skill_app.command("remote-target-revoke") +def revoke_remote_skill_target( + context: typer.Context, + target_id: Annotated[str, typer.Argument(help="Remote target identity to revoke.")], + scope_id: Annotated[str, typer.Option(help="Application scope containing the remote target.")], + expected_generation: Annotated[ + int | None, + typer.Option(min=0, help="Optional target CAS generation; resolved automatically when omitted."), + ] = None, +) -> None: + """Revoke a remote Receiver credential while retaining status history.""" + + asyncio.run(_revoke_remote_skill_target(context, scope_id, target_id, expected_generation)) + + +@skill_app.command("remote-target-rename") +def rename_remote_skill_target( + context: typer.Context, + target_id: Annotated[str, typer.Argument(help="Remote target identity to rename.")], + name: Annotated[str, typer.Option(help="New human-readable remote machine name.")], + scope_id: Annotated[str, typer.Option(help="Application scope containing the remote target.")], + expected_generation: Annotated[ + int | None, + typer.Option(min=0, help="Optional target CAS generation; resolved automatically when omitted."), + ] = None, +) -> None: + """Rename a remote machine without changing its durable target identity.""" + + asyncio.run(_rename_remote_skill_target(context, scope_id, target_id, name, expected_generation)) + + +@skill_app.command("remote-enroll") +def enroll_remote_skill_target( + context: typer.Context, + workspace: Annotated[Path, typer.Option(help="Local project workspace owned by the Agent Receiver.")] = Path("."), + enrollment_code: Annotated[ + str | None, + typer.Option(help="One-time enrollment code; omit to enter it without terminal echo."), + ] = None, + config_file: Annotated[ + Path | None, + typer.Option(help="Credential file to create with owner-only permissions."), + ] = None, + environment_fingerprint: Annotated[ + str | None, + typer.Option(help="Optional target environment SHA-256 fingerprint."), + ] = None, + install_service: Annotated[ + bool, + typer.Option("--install-service", help="Install and start a Linux systemd user service after enrollment."), + ] = False, + watch_interval: Annotated[ + float, + typer.Option(min=1, max=3600, help="Seconds between automatic Pull checks when installing the service."), + ] = 5, + allow_insecure_http: Annotated[ + bool, + typer.Option( + "--allow-insecure-http", + help="Allow cleartext remote HTTP on a protected private test network.", + ), + ] = False, +) -> None: + """Enroll this project Receiver without installing a full PowerContext Server.""" + + code = enrollment_code or typer.prompt("Enrollment code", hide_input=True) + asyncio.run( + _enroll_remote_skill_target( + context, + workspace, + code, + config_file, + environment_fingerprint, + install_service=install_service, + watch_interval=watch_interval, + allow_insecure_http=allow_insecure_http, + ) + ) + + +@skill_app.command("remote-sync") +def sync_remote_skills( + context: typer.Context, + config_file: Annotated[ + Path, + typer.Option(help="Owner-only Receiver credential file created by remote-enroll."), + ] = Path(".powercontext/remote-skill-target.json"), +) -> None: + """Reconcile and safely install or unpublish latest remote desired state.""" + + asyncio.run(_sync_remote_skills(context, config_file)) + + +@skill_app.command("remote-watch") +def watch_remote_skills( + context: typer.Context, + config_file: Annotated[ + Path, + typer.Option(help="Owner-only Receiver credential file created by remote-enroll."), + ] = Path(".powercontext/remote-skill-target.json"), + interval: Annotated[ + float, + typer.Option(min=1, max=3600, help="Seconds between successful Pull reconciliations."), + ] = 5, + max_backoff: Annotated[ + float, + typer.Option(min=1, max=3600, help="Maximum retry delay after incomplete or failed reconciliation."), + ] = 60, +) -> None: + """Continuously Pull and apply the latest remote desired state.""" + + try: + asyncio.run(_watch_remote_skills(context, config_file, interval, max_backoff)) + except KeyboardInterrupt: + typer.echo("Remote Skill watch stopped.") + + +@skill_app.command("remote-service-install") +def install_remote_skill_service( + config_file: Annotated[ + Path, + typer.Option(help="Owner-only Receiver credential file created by remote-enroll."), + ] = Path(".powercontext/remote-skill-target.json"), + interval: Annotated[ + float, + typer.Option(min=1, max=3600, help="Seconds between automatic Pull reconciliations."), + ] = 5, +) -> None: + """Install and start this Receiver as a Linux systemd user service.""" + + _install_remote_skill_service(config_file, interval) + + +@skill_app.command("remote-service-uninstall") +def uninstall_remote_skill_service( + config_file: Annotated[ + Path, + typer.Option(help="Receiver credential file identifying the target-scoped user service."), + ] = Path(".powercontext/remote-skill-target.json"), +) -> None: + """Stop and remove this Receiver's PowerContext-managed systemd user service.""" + + _uninstall_remote_skill_service(config_file) + + +async def _create_remote_skill_target( + context: typer.Context, + scope_id: str, + agent_kind: RemoteAgentKind, + name: str, +) -> None: + options = _options(context) + token = None if options.api_token is None else options.api_token.get_secret_value() + try: + async with PowerContextClient(options.server_url, token=token, timeout=options.timeout) as client: + enrollment = await client.create_remote_skill_target( + CreateRemoteSkillTargetRequest(scope_id=scope_id, agent_kind=agent_kind, display_name=name) + ) + except ClientError as error: + typer.echo(_error_message(error), err=True) + raise typer.Exit(code=1) from error + if options.json_output: + typer.echo(enrollment.model_dump_json(indent=2)) + return + typer.echo(f"Machine: {enrollment.target.display_name}") + typer.echo(f"Target ID: {enrollment.target.target_id}") + typer.echo(f"Expires: {enrollment.enrollment_expires_at.isoformat()}") + typer.echo(f"Enrollment code: {enrollment.enrollment_code}") + typer.echo("Next: run remote-enroll on the target project using the public HTTPS Server URL.") + + +async def _publish_remote_skill( + context: typer.Context, + scope_id: str, + target_id: str, + artifact_id: str, + revision: int, + expected_generation: int | None, + *, + allow_deprecated: bool, +) -> None: + async def publish(client: PowerContextClient) -> RemoteSkillPublication: + resolved_generation = expected_generation + if resolved_generation is None: + status = await _remote_target_status(client, scope_id, target_id) + current = next( + (publication for publication in status.publications if publication.artifact_id == artifact_id), + None, + ) + resolved_generation = None if current is None else current.generation + return await client.publish_remote_skill( + PublishRemoteSkillRequest( + scope_id=scope_id, + target_id=target_id, + artifact=ArtifactReference(family="skill", artifact_id=artifact_id, revision=revision), + expected_generation=resolved_generation, + allow_deprecated=allow_deprecated, + ) + ) + + await _execute(context, publish) + + +async def _unpublish_remote_skill( + context: typer.Context, + scope_id: str, + target_id: str, + artifact_id: str, + expected_generation: int | None, +) -> None: + async def unpublish(client: PowerContextClient) -> RemoteSkillPublication: + resolved_generation = expected_generation + if resolved_generation is None: + status = await _remote_target_status(client, scope_id, target_id) + current = next( + (publication for publication in status.publications if publication.artifact_id == artifact_id), + None, + ) + if current is None: + message = f"remote publication {artifact_id!r} was not found for target {target_id!r}" + raise typer.BadParameter( + message, + param_hint="artifact_id", + ) + resolved_generation = current.generation + return await client.unpublish_remote_skill( + UnpublishRemoteSkillRequest( + scope_id=scope_id, + target_id=target_id, + artifact_id=artifact_id, + expected_generation=resolved_generation, + ) + ) + + await _execute(context, unpublish) + + +async def _revoke_remote_skill_target( + context: typer.Context, + scope_id: str, + target_id: str, + expected_generation: int | None, +) -> None: + async def revoke(client: PowerContextClient) -> RemoteSkillTarget: + resolved_generation = expected_generation + if resolved_generation is None: + status = await _remote_target_status(client, scope_id, target_id) + resolved_generation = status.target.generation + return await client.revoke_remote_skill_target( + RevokeRemoteSkillTargetRequest( + scope_id=scope_id, + target_id=target_id, + expected_generation=resolved_generation, + ) + ) + + await _execute(context, revoke) + + +async def _rename_remote_skill_target( + context: typer.Context, + scope_id: str, + target_id: str, + name: str, + expected_generation: int | None, +) -> None: + async def rename(client: PowerContextClient) -> RemoteSkillTarget: + resolved_generation = expected_generation + if resolved_generation is None: + status = await _remote_target_status(client, scope_id, target_id) + resolved_generation = status.target.generation + return await client.rename_remote_skill_target( + RenameRemoteSkillTargetRequest( + scope_id=scope_id, + target_id=target_id, + display_name=name, + expected_generation=resolved_generation, + ) + ) + + await _execute(context, rename) + + +async def _remote_target_status( + client: PowerContextClient, + scope_id: str, + target_id: str, +) -> RemoteSkillTargetStatus: + response = await client.list_remote_skill_targets( + ListRemoteSkillTargetsRequest(scope_id=scope_id, target_id=target_id, limit=1) + ) + if not response.targets: + message = f"remote target {target_id!r} was not found" + raise typer.BadParameter(message, param_hint="--target-id") + return response.targets[0] + + +async def _enroll_remote_skill_target( + context: typer.Context, + workspace: Path, + enrollment_code: str, + config_file: Path | None, + environment_fingerprint: str | None, + *, + install_service: bool, + watch_interval: float, + allow_insecure_http: bool, +) -> None: + options = _options(context) + try: + insecure_http = require_remote_skill_server_url( + options.server_url, + allow_insecure_http=allow_insecure_http, + ) + except ValueError as error: + typer.echo(f"Cannot enroll remote Skill Receiver: {error}", err=True) + raise typer.Exit(code=2) from error + resolved_workspace, destination = _remote_receiver_paths(workspace, config_file) + request = EnrollRemoteSkillTargetRequest( + enrollment_code=enrollment_code, + installation_id=f"project-{uuid4().hex}", + receiver_version=RECEIVER_VERSION, + environment_fingerprint=environment_fingerprint, + machine_hostname=socket.gethostname(), + workspace_name=resolved_workspace.name, + ) + credential_saved = False + installation: ReceiverServiceInstallation | None = None + try: + async with PowerContextClient( + options.server_url, + timeout=options.timeout, + allow_insecure_http=insecure_http, + ) as client: + enrolled = await client.enroll_remote_skill_target(request) + _write_receiver_config( + destination, + { + "schema": "powercontext.remote-skill-receiver-config.v1", + "server_url": options.server_url, + "target_id": enrolled.target_id, + "credential": enrolled.credential, + "agent_kind": enrolled.agent_kind.value, + "workspace": str(resolved_workspace), + "state_root": None, + "receiver_version": RECEIVER_VERSION, + "environment_fingerprint": environment_fingerprint, + "allow_insecure_http": insecure_http, + }, + ) + credential_saved = True + if install_service: + installation = install_systemd_user_service( + destination, + _read_receiver_config(destination), + interval_seconds=watch_interval, + ) + except ClientError as error: + typer.echo(_error_message(error), err=True) + raise typer.Exit(code=1) from error + except (OSError, ReceiverServiceError, ValueError) as error: + if credential_saved: + typer.echo( + f"Receiver credential was saved at {destination}, but automatic sync could not start: {error}", err=True + ) + else: + typer.echo(f"Cannot save Receiver credential: {error}", err=True) + raise typer.Exit(code=2) from error + if options.json_output: + typer.echo( + json.dumps( + { + "target_id": enrolled.target_id, + "agent_kind": enrolled.agent_kind.value, + "workspace": str(resolved_workspace), + "config_file": str(destination), + "allow_insecure_http": insecure_http, + "service": None + if installation is None + else {"unit_name": installation.unit_name, "unit_path": str(installation.unit_path)}, + }, + ensure_ascii=False, + indent=2, + sort_keys=True, + ) + ) + return + typer.echo(f"Enrolled target {enrolled.target_id} for {enrolled.agent_kind.value}.") + typer.echo(f"Credential saved with owner-only permissions at {destination}.") + if insecure_http: + typer.echo( + "WARNING: Receiver credentials and Skill packages will use cleartext HTTP. " + "Use this only on a protected private test network.", + err=True, + ) + if installation is None: + typer.echo(f"Next: cd {resolved_workspace} && powercontext skill remote-service-install") + else: + typer.echo(f"Automatic remote Skill sync is active in {installation.unit_name}.") + + +def _remote_receiver_paths(workspace: Path, config_file: Path | None) -> tuple[Path, Path]: + resolved_workspace = workspace.expanduser().resolve(strict=False) + destination = ( + resolved_workspace / ".powercontext" / "remote-skill-target.json" + if config_file is None + else config_file.expanduser().resolve(strict=False) + ) + return resolved_workspace, destination + + +async def _sync_remote_skills(context: typer.Context, config_file: Path) -> None: + try: + config = _read_receiver_config(config_file) + async with RemoteSkillReceiver(config) as receiver: + result = await receiver.sync() + except ClientError as error: + typer.echo(_error_message(error), err=True) + raise typer.Exit(code=1) from error + except (OSError, ValueError, RuntimeError) as error: + typer.echo(f"Cannot sync remote Skills: {error}", err=True) + raise typer.Exit(code=2) from error + json_output = _options(context).json_output + if json_output: + typer.echo( + json.dumps( + { + "requested": result.requested, + "succeeded": result.succeeded, + "failed": result.failed, + "receipt_pending": result.receipt_pending, + }, + indent=2, + sort_keys=True, + ) + ) + elif result.requested == 0: + typer.echo("Remote Skills are already current; no actions were needed.") + else: + typer.echo( + f"Remote Skill sync: {result.succeeded} succeeded, {result.failed} failed, " + f"{result.receipt_pending} Receipts pending ({result.requested} actions)." + ) + if result.succeeded: + typer.echo("Changes are discoverable on the next Agent session or discovery cycle.") + if result.failed: + typer.echo("One or more remote Skill actions failed; inspect remote-status before retrying.", err=True) + if result.receipt_pending: + typer.echo("Run remote-sync again to finish pending delivery Receipts.", err=True) + if result.failed or result.receipt_pending: + raise typer.Exit(code=1) + + +async def _watch_remote_skills( + context: typer.Context, + config_file: Path, + interval: float, + max_backoff: float, +) -> None: + if max_backoff < interval: + typer.echo("Cannot watch remote Skills: --max-backoff must not be shorter than --interval", err=True) + raise typer.Exit(code=2) + try: + config = _read_receiver_config(config_file) + typer.echo(f"Watching remote Skills for {config.target_id} every {interval:g} seconds. Press Ctrl+C to stop.") + async with RemoteSkillReceiver(config) as receiver: + await receiver.watch( + interval_seconds=interval, + max_backoff_seconds=max_backoff, + on_result=_print_receiver_watch_result, + on_error=_print_receiver_watch_error, + ) + except ServerResponseError as error: + if error.status_code in {401, 403}: + typer.echo("Receiver credential was rejected; automatic sync is stopping.", err=True) + raise typer.Exit(code=3) from error + typer.echo(_error_message(error), err=True) + raise typer.Exit(code=1) from error + except (OSError, ValueError, RuntimeError) as error: + typer.echo(f"Cannot watch remote Skills: {error}", err=True) + raise typer.Exit(code=2) from error + + +def _print_receiver_watch_result(result: ReceiverSyncResult) -> None: + if result.requested == 0: + return + typer.echo( + f"Remote Skill sync: {result.succeeded} succeeded, {result.failed} failed, " + f"{result.receipt_pending} Receipts pending ({result.requested} actions)." + ) + + +def _print_receiver_watch_error(error: Exception, retry_delay: float) -> None: + typer.echo(f"Remote Skill sync failed; retrying in {retry_delay:g} seconds: {error}", err=True) + + +def _install_remote_skill_service(config_file: Path, interval: float) -> ReceiverServiceInstallation: + try: + config = _read_receiver_config(config_file) + installation = install_systemd_user_service(config_file, config, interval_seconds=interval) + except (OSError, ReceiverServiceError, ValueError) as error: + typer.echo(f"Cannot install automatic remote Skill sync: {error}", err=True) + raise typer.Exit(code=2) from error + typer.echo(f"Automatic remote Skill sync is active in {installation.unit_name}.") + typer.echo(f"Unit: {installation.unit_path}") + return installation + + +def _uninstall_remote_skill_service(config_file: Path) -> ReceiverServiceInstallation: + try: + config = _read_receiver_config(config_file) + installation = uninstall_systemd_user_service(config.target_id) + except (OSError, ReceiverServiceError, ValueError) as error: + typer.echo(f"Cannot uninstall automatic remote Skill sync: {error}", err=True) + raise typer.Exit(code=2) from error + typer.echo(f"Automatic remote Skill sync is stopped for {config.target_id}.") + return installation + + +def _write_receiver_config(path: Path, value: dict[str, object]) -> None: + if path.exists() or path.is_symlink(): + raise FileExistsError(path) + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump(value, stream, ensure_ascii=False, indent=2, sort_keys=True) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + temporary.chmod(0o600) + os.replace(temporary, path) + except BaseException: + temporary.unlink(missing_ok=True) + raise + + +def _read_receiver_config(path: Path) -> RemoteSkillReceiverConfig: + resolved = path.expanduser().resolve(strict=True) + if os.name != "nt" and resolved.stat().st_mode & 0o077: + raise ValueError("Receiver credential file must not be accessible by group or other users") # noqa: TRY003 + value = json.loads(resolved.read_text(encoding="utf-8")) + if not isinstance(value, dict) or value.pop("schema", None) != "powercontext.remote-skill-receiver-config.v1": + raise ValueError("Receiver credential file schema is invalid") # noqa: TRY003 + return RemoteSkillReceiverConfig.model_validate(value) + + def _options(context: typer.Context) -> _ClientOptions: overrides = context.meta.get("powercontext.client.overrides", _ClientOverrides()) settings = ClientSettings() @@ -700,16 +1366,53 @@ async def _export_managed_skill( token = None if options.api_token is None else options.api_token.get_secret_value() async with PowerContextClient(options.server_url, token=token, timeout=options.timeout) as client: response = await client.get_skill(request) - exported = export_skill( - ArtifactRef( - family=response.artifact.family, - artifact_id=response.artifact.artifact_id, - revision=response.artifact.revision, - ), - response.content, - target, - destination, - ) + if response.content.package is None: + exported = export_skill( + ArtifactRef( + family=response.artifact.family, + artifact_id=response.artifact.artifact_id, + revision=response.artifact.revision, + ), + response.content, + target, + destination, + ) + else: + download = await client.download_skill_package( + GetSkillPackageRequest(scope_id=request.scope_id, artifact=request.artifact) + ) + try: + archive_bytes = base64.b64decode(download.archive_base64, validate=True) + except (binascii.Error, ValueError) as error: + raise ValueError("Server returned an invalid Skill package archive") from error # noqa: TRY003 + package = capture_skill_archive(archive_bytes) + if package.reference.model_dump(mode="json") != response.content.package.model_dump(mode="json"): + raise ValueError( # noqa: TRY003, TRY301 + "downloaded Skill package does not match the Artifact" + ) + runtime_content = SkillContent( + name=response.content.name, + description=response.content.description, + instructions=response.content.instructions, + validation=tuple(item.root for item in response.content.validation), + package=package.reference, + license=response.content.license, + compatibility=response.content.compatibility, + metadata=response.content.metadata or {}, + allowed_tools=response.content.allowed_tools, + ) + validate_skill_projection_target( + runtime_content, + AgentSkillTarget( + target_id="client", + agent_kind=target.value, + installation_scope="project", + path=destination.parent, + allow_managed_publish=True, + ), + ) + materialize_skill_package(package, destination) + exported = destination except ClientError as error: typer.echo(_error_message(error), err=True) raise typer.Exit(code=1) from error @@ -728,6 +1431,9 @@ def _error_message(error: ClientError) -> str: def _print_human_response(response: _ClientResponse) -> None: + if isinstance(response, (ListRemoteSkillTargetsResponse, RemoteSkillPublication, RemoteSkillTarget)): + _print_remote_response(response) + return match response: case Capabilities(): typer.echo(f"Source types: {_items(response.source_types)}") @@ -760,6 +1466,62 @@ def _print_human_response(response: _ClientResponse) -> None: typer.echo(response.model_dump_json(indent=2)) +def _print_remote_response( + response: ListRemoteSkillTargetsResponse | RemoteSkillPublication | RemoteSkillTarget, +) -> None: + match response: + case ListRemoteSkillTargetsResponse(): + _print_remote_skill_targets(response) + case RemoteSkillPublication(): + typer.echo( + f"Remote publication {response.artifact_id}@{response.desired_revision} -> {response.target_id}: " + f"desired={response.desired_state.value}, state={response.state.value}, generation={response.generation}" + ) + typer.echo("The target applies this desired state on its next remote-sync.") + case RemoteSkillTarget(): + typer.echo( + f"Remote target {response.display_name} ({response.target_id}): state={response.state.value}, " + f"agent={response.agent_kind.value}, generation={response.generation}" + ) + + +def _print_remote_skill_targets(response: ListRemoteSkillTargetsResponse) -> None: + if not response.targets: + typer.echo("No remote Skill targets found.") + return + for index, status in enumerate(response.targets): + if index: + typer.echo("") + target = status.target + last_seen = "never" if target.last_seen_at is None else target.last_seen_at.isoformat() + installation = "not enrolled" if target.installation_id is None else target.installation_id + typer.echo( + f"Target {target.display_name} ({target.target_id}): state={target.state.value}, " + f"agent={target.agent_kind.value}, " + f"generation={target.generation}" + ) + typer.echo(f" Installation: {installation}; last seen: {last_seen}") + environment = " / ".join(value for value in (target.machine_hostname, target.workspace_name) if value) + typer.echo(f" Environment: {environment or 'not reported'}") + if not status.publications: + typer.echo(" Publications: none") + continue + typer.echo(" Publications:") + for publication in status.publications: + if publication.observed_revision is not None: + observed = f"revision {publication.observed_revision}" + elif publication.state.value == "unpublished": + observed = "absent" + else: + observed = "not reported" + error = "" if publication.last_error_code is None else f", error={publication.last_error_code}" + typer.echo( + f" {publication.artifact_id}: desired={publication.desired_state.value} " + f"revision {publication.desired_revision}, observed={observed}, " + f"state={publication.state.value}, generation={publication.generation}{error}" + ) + + def _print_stats(response: ScopedStats) -> None: inventory = response.inventory typer.echo(f"Scope: {response.scope_id}") diff --git a/src/powercontext/client/client.py b/src/powercontext/client/client.py index 2c1618f2f..2072da104 100644 --- a/src/powercontext/client/client.py +++ b/src/powercontext/client/client.py @@ -39,8 +39,11 @@ CommittedHandoff, ContinueHandoffRequest, CreateHandoffReportProjectRequest, + CreateRemoteSkillTargetRequest, CreateWorkContractRequest, DetachHandoffReportWorkspaceRequest, + DownloadRemoteSkillPackageRequest, + EnrollRemoteSkillTargetRequest, ErrorResponse, ExperienceArtifact, ExternalSkillResolution, @@ -56,6 +59,7 @@ GetHandoffReportRequest, GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, + GetSkillPackageRequest, GetSkillRequest, GetStatsRequest, HandoffAcknowledgement, @@ -76,10 +80,14 @@ ListHandoffReportKnownScopesRequest, ListHandoffReportProjectsRequest, ListHandoffReportWorkstreamsRequest, + ListManagedSkillsRequest, + ListManagedSkillsResponse, ListMemoryChangesRequest, ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, + ListRemoteSkillTargetsRequest, + ListRemoteSkillTargetsResponse, MemoryEntry, MemoryMutationResponse, PrepareContextRequest, @@ -90,28 +98,46 @@ ProjectDescriptor, ProjectPage, ProposeExperienceRequest, + ProposeSkillPackageRequest, ProposeSkillRequest, + PublishRemoteSkillRequest, PurgeHandoffReportActivitiesRequest, PurgeHandoffReportActivitiesResponse, ReadinessResponse, + ReconcileRemoteSkillsRequest, + ReconcileRemoteSkillsResponse, RecordHandoffReportActivityRequest, + RecordRemoteSkillReceiptRequest, + RecordSkillUsageRequest, RecordTaskOutcomeRequest, RegisterHandoffReportWorkstreamRequest, RejectArtifactCandidateRequest, RememberMemoryRequest, + RemoteSkillPublication, + RemoteSkillReceiptResponse, + RemoteSkillTarget, + RemoteSkillTargetCredential, + RemoteSkillTargetEnrollment, + RenameRemoteSkillTargetRequest, ResolveExternalSkillRequest, RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, ReviseMemoryEntryRequest, + RevokeRemoteSkillTargetRequest, ScanExternalSkillsRequest, ScanExternalSkillsResponse, ScopedStats, SearchMemoryRequest, SearchMemoryResponse, SkillArtifact, + SkillGovernance, + SkillPackageDownload, + SkillPackageManifest, StoredHandoffReportActivity, + UnpublishRemoteSkillRequest, UpdateHandoffReportProjectRequest, UpdateHandoffReportWorkstreamRequest, + UpdateSkillLifecycleRequest, WorkSourceReceipt, WorkstreamDescriptor, WorkstreamPage, @@ -125,8 +151,12 @@ COMMIT_HANDOFF, CONTINUE_HANDOFF, CREATE_HANDOFF_REPORT_PROJECT, + CREATE_REMOTE_SKILL_TARGET, CREATE_WORK_CONTRACT, DETACH_HANDOFF_REPORT_WORKSPACE, + DOWNLOAD_REMOTE_SKILL_PACKAGE, + DOWNLOAD_SKILL_PACKAGE, + ENROLL_REMOTE_SKILL_TARGET, FINALIZE_HANDOFF, FLUSH_MEMORY, GENERATE_EXPERIENCE, @@ -141,6 +171,7 @@ GET_MEMORY_ENTRY, GET_READINESS, GET_SKILL, + GET_SKILL_PACKAGE_MANIFEST, GET_STATS, HANDOFF_CURRENT_WORK, IMPORT_EXTERNAL_SKILL, @@ -150,26 +181,37 @@ LIST_HANDOFF_REPORT_KNOWN_SCOPES, LIST_HANDOFF_REPORT_PROJECTS, LIST_HANDOFF_REPORT_WORKSTREAMS, + LIST_MANAGED_SKILLS, LIST_MEMORY_CHANGES, LIST_MEMORY_ENTRIES, + LIST_REMOTE_SKILL_TARGETS, PREPARE_CONTEXT, PREPARE_HANDOFF, PROPOSE_EXPERIENCE, PROPOSE_SKILL, + PROPOSE_SKILL_PACKAGE, + PUBLISH_REMOTE_SKILL, PURGE_HANDOFF_REPORT_ACTIVITIES, + RECONCILE_REMOTE_SKILLS, RECORD_HANDOFF_REPORT_ACTIVITY, + RECORD_REMOTE_SKILL_RECEIPT, + RECORD_SKILL_USAGE, RECORD_TASK_OUTCOME, REGISTER_HANDOFF_REPORT_WORKSTREAM, REJECT_ARTIFACT_CANDIDATE, REMEMBER_MEMORY, + RENAME_REMOTE_SKILL_TARGET, RESOLVE_EXTERNAL_SKILL, RETIRE_MEMORY_ENTRY, REVISE_ARTIFACT_CANDIDATE, REVISE_MEMORY_ENTRY, + REVOKE_REMOTE_SKILL_TARGET, SCAN_EXTERNAL_SKILLS, SEARCH_MEMORY, + UNPUBLISH_REMOTE_SKILL, UPDATE_HANDOFF_REPORT_PROJECT, UPDATE_HANDOFF_REPORT_WORKSTREAM, + UPDATE_SKILL_LIFECYCLE, Operation, ) from powercontext.transport import is_plaintext_non_loopback @@ -190,6 +232,7 @@ def __init__( timeout: float = 10.0, http_client: httpx.AsyncClient | None = None, trust_transport_security: bool = False, + allow_insecure_http: bool = False, ) -> None: self._base_url = base_url.rstrip("/") # Plaintext HTTP is only trusted on loopback -- for *any* request, not just an authenticated @@ -202,9 +245,11 @@ def __init__( # exactly as exposed as one we would open ourselves. Supplying a transport is therefore not # evidence of safety: the guard stays on for caller-supplied transports too, and a caller that # knows its transport is secure must say so explicitly via ``trust_transport_security`` rather - # than have safety inferred from the argument being set. + # than have safety inferred from the argument being set. ``allow_insecure_http`` is the + # separate, explicit cleartext escape hatch used by a remote Skill Receiver after its own + # protected-network consent check; it does not claim that the transport is secure. transport_trusted = http_client is not None and trust_transport_security - if not transport_trusted and is_plaintext_non_loopback(self._base_url): + if not transport_trusted and not allow_insecure_http and is_plaintext_non_loopback(self._base_url): raise ValueError("refusing to send requests over unencrypted non-loopback HTTP") # noqa: TRY003 self._headers = {"Authorization": f"Bearer {token}"} if token else None self._owned_http_client: httpx.AsyncClient | None = None @@ -543,6 +588,110 @@ async def get_skill(self, request: GetSkillRequest) -> SkillArtifact: return await self._request(GET_SKILL, request) + async def list_managed_skills(self, request: ListManagedSkillsRequest) -> ListManagedSkillsResponse: + """List or search current governed managed Skill heads.""" + + return await self._request(LIST_MANAGED_SKILLS, request) + + async def update_skill_lifecycle(self, request: UpdateSkillLifecycleRequest) -> SkillGovernance: + """Apply one governance generation CAS lifecycle transition.""" + + return await self._request(UPDATE_SKILL_LIFECYCLE, request) + + async def get_skill_package_manifest(self, request: GetSkillPackageRequest) -> SkillPackageManifest: + """Read verified exact package metadata and file inventory.""" + + return await self._request(GET_SKILL_PACKAGE_MANIFEST, request) + + async def download_skill_package(self, request: GetSkillPackageRequest) -> SkillPackageDownload: + """Read canonical exact package ZIP bytes as bounded base64.""" + + return await self._request(DOWNLOAD_SKILL_PACKAGE, request) + + async def propose_skill_package(self, request: ProposeSkillPackageRequest) -> ArtifactCandidate: + """Create a pending exact package Candidate without LLM rewriting.""" + + return await self._request(PROPOSE_SKILL_PACKAGE, request) + + async def record_skill_usage(self, request: RecordSkillUsageRequest) -> CaptureContentSourceResponse: + """Capture one bounded exact Skill usage observation as immutable Source evidence.""" + + return await self._request(RECORD_SKILL_USAGE, request) + + async def create_remote_skill_target( + self, + request: CreateRemoteSkillTargetRequest, + ) -> RemoteSkillTargetEnrollment: + """Create a pending remote target and one-time enrollment code.""" + + return await self._request(CREATE_REMOTE_SKILL_TARGET, request) + + async def list_remote_skill_targets( + self, + request: ListRemoteSkillTargetsRequest, + ) -> ListRemoteSkillTargetsResponse: + """List credential-free target and publication status for one scope.""" + + return await self._request(LIST_REMOTE_SKILL_TARGETS, request) + + async def enroll_remote_skill_target( + self, + request: EnrollRemoteSkillTargetRequest, + ) -> RemoteSkillTargetCredential: + """Consume one enrollment code and receive a per-target credential.""" + + return await self._request(ENROLL_REMOTE_SKILL_TARGET, request) + + async def revoke_remote_skill_target( + self, + request: RevokeRemoteSkillTargetRequest, + ) -> RemoteSkillTarget: + """Revoke one remote target credential using generation CAS.""" + + return await self._request(REVOKE_REMOTE_SKILL_TARGET, request) + + async def rename_remote_skill_target( + self, + request: RenameRemoteSkillTargetRequest, + ) -> RemoteSkillTarget: + """Rename one remote target using generation CAS.""" + + return await self._request(RENAME_REMOTE_SKILL_TARGET, request) + + async def publish_remote_skill(self, request: PublishRemoteSkillRequest) -> RemoteSkillPublication: + """Set an exact approved package as remote desired state.""" + + return await self._request(PUBLISH_REMOTE_SKILL, request) + + async def unpublish_remote_skill(self, request: UnpublishRemoteSkillRequest) -> RemoteSkillPublication: + """Set desired absence for one remote publication.""" + + return await self._request(UNPUBLISH_REMOTE_SKILL, request) + + async def reconcile_remote_skills( + self, + request: ReconcileRemoteSkillsRequest, + ) -> ReconcileRemoteSkillsResponse: + """Read latest-generation actions using this client's target credential.""" + + return await self._request(RECONCILE_REMOTE_SKILLS, request) + + async def download_remote_skill_package( + self, + request: DownloadRemoteSkillPackageRequest, + ) -> SkillPackageDownload: + """Download an exact package authorized for this target generation.""" + + return await self._request(DOWNLOAD_REMOTE_SKILL_PACKAGE, request) + + async def record_remote_skill_receipt( + self, + request: RecordRemoteSkillReceiptRequest, + ) -> RemoteSkillReceiptResponse: + """Record target-local delivery evidence for one exact generation.""" + + return await self._request(RECORD_REMOTE_SKILL_RECEIPT, request) + async def scan_external_skills(self, request: ScanExternalSkillsRequest) -> ScanExternalSkillsResponse: """Refresh the configured host-local external Skill Registry.""" diff --git a/src/powercontext/client/receiver_service.py b/src/powercontext/client/receiver_service.py new file mode 100644 index 000000000..677db598c --- /dev/null +++ b/src/powercontext/client/receiver_service.py @@ -0,0 +1,206 @@ +# 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. + +"""Linux user-service lifecycle for one enrolled remote Skill Receiver.""" + +# User-service errors retain target-local diagnostics needed to recover safely. +# ruff: noqa: TRY003 + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from powercontext.client.skill_receiver import RemoteSkillReceiverConfig + +_MANAGED_HEADER = "# Managed by PowerContext remote Skill Receiver." + + +class ReceiverServiceError(RuntimeError): + """Reject an unavailable or unsafe user-service operation.""" + + +@dataclass(frozen=True, slots=True) +class ReceiverServiceInstallation: + """One deterministic systemd user unit installed for an enrolled target.""" + + unit_name: str + unit_path: Path + + +def install_systemd_user_service( + config_file: Path, + config: RemoteSkillReceiverConfig, + *, + interval_seconds: float, +) -> ReceiverServiceInstallation: + """Install and start a target-scoped systemd user service without copying its credential.""" + + _require_linux() + resolved_config = config_file.expanduser().resolve(strict=True) + powercontext = _powercontext_executable() + systemctl = _required_executable("systemctl") + installation = _installation(config.target_id) + contents = render_systemd_user_service( + resolved_config, + config, + powercontext=powercontext, + interval_seconds=interval_seconds, + ) + if installation.unit_path.exists(): + current = installation.unit_path.read_text(encoding="utf-8") + if not current.startswith(_MANAGED_HEADER): + raise ReceiverServiceError(f"refusing to replace unmanaged systemd unit: {installation.unit_path}") + _atomic_write(installation.unit_path, contents) + _run_systemctl(systemctl, "daemon-reload") + _run_systemctl(systemctl, "enable", "--now", installation.unit_name) + return installation + + +def uninstall_systemd_user_service(target_id: str) -> ReceiverServiceInstallation: + """Stop and remove only the deterministic PowerContext-managed user unit.""" + + _require_linux() + systemctl = _required_executable("systemctl") + installation = _installation(target_id) + if not installation.unit_path.exists(): + raise ReceiverServiceError(f"managed systemd unit does not exist: {installation.unit_path}") + current = installation.unit_path.read_text(encoding="utf-8") + if not current.startswith(_MANAGED_HEADER): + raise ReceiverServiceError(f"refusing to remove unmanaged systemd unit: {installation.unit_path}") + _run_systemctl(systemctl, "disable", "--now", installation.unit_name) + installation.unit_path.unlink() + _run_systemctl(systemctl, "daemon-reload") + return installation + + +def render_systemd_user_service( + config_file: Path, + config: RemoteSkillReceiverConfig, + *, + powercontext: Path, + interval_seconds: float, +) -> str: + """Render a secret-free unit that runs the same authenticated Pull Receiver.""" + + if interval_seconds < 1: + raise ValueError("remote watch interval must be at least one second") + command = " ".join( + _systemd_quote(value) + for value in ( + str(powercontext), + "skill", + "remote-watch", + "--config-file", + str(config_file), + "--interval", + f"{interval_seconds:g}", + ) + ) + return f"""{_MANAGED_HEADER} +[Unit] +Description=PowerContext remote Skill Receiver ({config.target_id}) + +[Service] +Type=simple +ExecStart={command} +Restart=on-failure +RestartSec=5s +RestartPreventExitStatus=2 3 +UMask=0077 +NoNewPrivileges=true +PrivateTmp=true +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=default.target +""" + + +def _installation(target_id: str) -> ReceiverServiceInstallation: + root = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")).expanduser().resolve(strict=False) + unit_name = f"powercontext-skill-receiver-{target_id}.service" + return ReceiverServiceInstallation(unit_name=unit_name, unit_path=root / "systemd" / "user" / unit_name) + + +def _required_executable(name: str) -> Path: + resolved = shutil.which(name) + if resolved is None: + raise ReceiverServiceError(f"cannot install the Receiver service because {name!r} is not available") + return Path(resolved).resolve(strict=True) + + +def _powercontext_executable() -> Path: + invoked = Path(sys.argv[0]).expanduser() + if invoked.name == "powercontext" and invoked.is_absolute(): + try: + return invoked.resolve(strict=True) + except OSError: + pass + return _required_executable("powercontext") + + +def _atomic_write(path: Path, contents: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + stream.write(contents) + stream.flush() + os.fsync(stream.fileno()) + temporary.chmod(0o644) + os.replace(temporary, path) + except BaseException: + temporary.unlink(missing_ok=True) + raise + + +def _run_systemctl(systemctl: Path, *arguments: str) -> None: + try: + subprocess.run( # noqa: S603 - the executable and every argument are resolved without a shell. + [str(systemctl), "--user", *arguments], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as error: + detail = (error.stderr or error.stdout or "systemctl failed").strip() + raise ReceiverServiceError(detail) from error + + +def _systemd_quote(value: str) -> str: + if "\n" in value or "\0" in value: + raise ValueError("systemd unit values must remain on one line") + escaped = value.replace("%", "%%").replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + +def _require_linux() -> None: + if sys.platform != "linux": + raise ReceiverServiceError("systemd user-service installation is supported on Linux only") + + +__all__ = [ + "ReceiverServiceError", + "ReceiverServiceInstallation", + "install_systemd_user_service", + "render_systemd_user_service", + "uninstall_systemd_user_service", +] diff --git a/src/powercontext/client/skill_receiver.py b/src/powercontext/client/skill_receiver.py new file mode 100644 index 000000000..078a48479 --- /dev/null +++ b/src/powercontext/client/skill_receiver.py @@ -0,0 +1,767 @@ +# 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. + +"""Lightweight Codex/Claude Code Receiver for remote desired-state Skill delivery.""" + +# Trust-boundary failures retain precise local diagnostics, and the install +# transaction is deliberately linear so its rename ordering stays auditable. +# ruff: noqa: TRY003, TRY203, TRY301 + +from __future__ import annotations + +import asyncio +import base64 +import binascii +import hashlib +import hmac +import ipaddress +import json +import os +import shutil +import tempfile +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, Protocol +from urllib.parse import urlsplit + +from pydantic import BaseModel, ConfigDict, Field, SecretStr + +from powercontext.builtin.artifacts.skill.package import ( + SkillPackageError, + capture_skill_archive, + capture_skill_directory, + materialize_skill_package, +) +from powercontext.client.client import PowerContextClient +from powercontext.client.errors import ClientError, ServerResponseError +from powercontext.http import ( + DownloadRemoteSkillPackageRequest, + ReconcileRemoteSkillsRequest, + ReconcileRemoteSkillsResponse, + RecordRemoteSkillReceiptRequest, + RemoteSkillAction, + RemoteSkillFailureState, + RemoteSkillObservation, + RemoteSkillOperation, + RemoteSkillReceiptOutcome, + SkillPackageDownload, +) + +RECEIVER_VERSION = "0.1.0" +_CHECKPOINT_SCHEMA = "powercontext.remote-skill-checkpoint.v1" +_JOURNAL_SCHEMA = "powercontext.remote-skill-pending-action.v1" + + +class SkillReceiverError(RuntimeError): + """Base failure for target-local Receiver validation or filesystem convergence.""" + + +class SkillReceiverConflictError(SkillReceiverError): + """Refuse to replace or remove content outside exact checkpoint authority.""" + + +class SkillReceiverStateError(SkillReceiverError): + """Reject corrupt or credential-mismatched Receiver-private state.""" + + +class RemoteSkillReceiverConfig(BaseModel): + """One enrolled Receiver identity and its target-local project roots.""" + + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid", frozen=True) + + server_url: str = Field(min_length=1, max_length=2048) + target_id: str = Field(min_length=1, max_length=64, pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + credential: SecretStr + agent_kind: Literal["codex", "claude_code"] + workspace: Path + state_root: Path | None = None + receiver_version: str = Field(default=RECEIVER_VERSION, min_length=1, max_length=64) + environment_fingerprint: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$") + allow_insecure_http: bool = False + + +class RemoteSkillReceiverClient(Protocol): + """Narrow transport surface needed by the filesystem Receiver.""" + + async def reconcile_remote_skills(self, request: ReconcileRemoteSkillsRequest) -> ReconcileRemoteSkillsResponse: ... + + async def download_remote_skill_package( + self, + request: DownloadRemoteSkillPackageRequest, + ) -> SkillPackageDownload: ... + + async def record_remote_skill_receipt( + self, + request: RecordRemoteSkillReceiptRequest, + ) -> object | None: ... + + async def aclose(self) -> None: ... + + +class ReceiverCheckpoint(BaseModel): + """Credential-bound ownership of one complete installed package.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + schema_name: Literal["powercontext.remote-skill-checkpoint.v1"] = _CHECKPOINT_SCHEMA + target_id: str + artifact: dict[str, object] + tree_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + skill_name: str = Field(min_length=1, max_length=64, pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + applied_generation: int = Field(ge=0) + + +class ReceiverJournal(BaseModel): + """Crash-recovery record written before any package-directory rename.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + schema_name: Literal["powercontext.remote-skill-pending-action.v1"] = _JOURNAL_SCHEMA + target_id: str + action: dict[str, object] + staging_name: str | None = None + quarantine_name: str | None = None + previous: ReceiverCheckpoint | None = None + + +@dataclass(frozen=True) +class ReceiverSyncResult: + """Bounded outcome of one explicit or Agent-hook sync.""" + + requested: int + succeeded: int + failed: int + receipt_pending: int + + +@dataclass(frozen=True) +class _AppliedAction: + receipt: RecordRemoteSkillReceiptRequest + journal_path: Path | None = None + quarantine: Path | None = None + + +class RemoteSkillReceiver: + """Reconcile one target without running a PowerContext Server or database remotely.""" + + def __init__( + self, + config: RemoteSkillReceiverConfig, + *, + client: RemoteSkillReceiverClient | None = None, + ) -> None: + require_remote_skill_server_url( + config.server_url, + allow_insecure_http=config.allow_insecure_http, + ) + self.config = config + self.workspace = config.workspace.expanduser().resolve(strict=False) + self.skill_root = self.workspace / (".agents/skills" if config.agent_kind == "codex" else ".claude/skills") + configured_state = ( + self.workspace / ".powercontext" / "skill-receiver" / config.target_id + if config.state_root is None + else config.state_root + ) + self.state_root = configured_state.expanduser().resolve(strict=False) + if _is_relative_to(self.state_root, self.skill_root): + raise ValueError("Receiver state root must remain outside the Agent Skill package root") + self._credential = config.credential.get_secret_value() + self._mac_key = hashlib.sha256(f"powercontext.receiver.v1\0{self._credential}".encode()).digest() + self._owned_client = client is None + self._client = ( + PowerContextClient( + config.server_url, + token=self._credential, + allow_insecure_http=config.allow_insecure_http, + ) + if client is None + else client + ) + + async def __aenter__(self) -> RemoteSkillReceiver: + return self + + async def __aexit__(self, exc_type: object, exc_value: object, traceback: object) -> None: + if self._owned_client: + await self._client.aclose() + + async def sync(self) -> ReceiverSyncResult: + """Reconcile, apply safe local actions, and report exact bounded Receipts.""" + + self._prepare_private_roots() + observations = self._observations() + response = await self._client.reconcile_remote_skills( + ReconcileRemoteSkillsRequest( + observations=observations, + receiver_version=self.config.receiver_version, + environment_fingerprint=self.config.environment_fingerprint, + ) + ) + if response.target_id != self.config.target_id: + raise SkillReceiverStateError("reconcile response target does not match enrolled Receiver") + succeeded = 0 + failed = 0 + receipt_pending = 0 + for action in response.actions: + try: + download = ( + await self._client.download_remote_skill_package( + DownloadRemoteSkillPackageRequest( + generation=action.generation, + artifact=action.artifact, + package=action.package, + ) + ) + if action.operation is RemoteSkillOperation.INSTALL + and action.package is not None + and action.blocked_error_code is None + else None + ) + applied = self._apply(action, download) + except Exception as error: + failed += 1 + failure = _failure_receipt(action, error, self.config) + try: + await self._client.record_remote_skill_receipt(failure) + except ( + Exception + ): # The desired generation remains retryable; never turn transport loss into a local mutation. + receipt_pending += 1 + continue + try: + await self._client.record_remote_skill_receipt(applied.receipt) + except Exception: + receipt_pending += 1 + continue + self._finish(applied) + succeeded += 1 + return ReceiverSyncResult( + requested=len(response.actions), + succeeded=succeeded, + failed=failed, + receipt_pending=receipt_pending, + ) + + async def watch( + self, + *, + interval_seconds: float = 5, + max_backoff_seconds: float = 60, + on_result: Callable[[ReceiverSyncResult], None] | None = None, + on_error: Callable[[Exception, float], None] | None = None, + ) -> None: + """Continuously reconcile, backing off transient failures while preserving Pull semantics.""" + + if interval_seconds < 1: + raise ValueError("remote watch interval must be at least one second") + if max_backoff_seconds < interval_seconds: + raise ValueError("remote watch max backoff must not be shorter than the sync interval") + retry_delay = interval_seconds + while True: + try: + result = await self.sync() + except ServerResponseError as error: + if error.status_code in {401, 403}: + raise + if on_error is not None: + on_error(error, retry_delay) + await asyncio.sleep(retry_delay) + retry_delay = min(retry_delay * 2, max_backoff_seconds) + continue + except (ClientError, OSError, ValueError, RuntimeError) as error: + if on_error is not None: + on_error(error, retry_delay) + await asyncio.sleep(retry_delay) + retry_delay = min(retry_delay * 2, max_backoff_seconds) + continue + if on_result is not None: + on_result(result) + incomplete = result.failed > 0 or result.receipt_pending > 0 + delay = retry_delay if incomplete else interval_seconds + retry_delay = min(retry_delay * 2, max_backoff_seconds) if incomplete else interval_seconds + await asyncio.sleep(delay) + + def _prepare_private_roots(self) -> None: + self.skill_root.parent.mkdir(parents=True, exist_ok=True) + self.state_root.mkdir(parents=True, exist_ok=True, mode=0o700) + self.state_root.chmod(0o700) + self._checkpoints_root.mkdir(mode=0o700, exist_ok=True) + self._journals_root.mkdir(mode=0o700, exist_ok=True) + + @property + def _checkpoints_root(self) -> Path: + return self.state_root / "checkpoints" + + @property + def _journals_root(self) -> Path: + return self.state_root / "journals" + + def _observations(self) -> list[RemoteSkillObservation]: + observations: list[RemoteSkillObservation] = [] + for path in sorted(self._checkpoints_root.glob("*.json")): + checkpoint = self._read_signed(path, ReceiverCheckpoint) + self._require_target(checkpoint.target_id) + destination = self._destination(checkpoint.skill_name) + actual_digest = _directory_digest(destination) + observations.append( + RemoteSkillObservation( + artifact=checkpoint.artifact, + tree_digest=checkpoint.tree_digest, + actual_tree_digest=actual_digest, + skill_name=checkpoint.skill_name, + applied_generation=checkpoint.applied_generation, + ) + ) + return observations + + def _apply(self, action: RemoteSkillAction, download: SkillPackageDownload | None) -> _AppliedAction: + if action.blocked_error_code is not None: + if action.blocked_error_code == "drifted": + raise SkillReceiverConflictError("the managed Skill directory has drifted") + raise SkillReceiverConflictError("the local ownership checkpoint is not authorized") + return ( + self._install(action, download) + if action.operation is RemoteSkillOperation.INSTALL + else self._unpublish(action) + ) + + def _install( # noqa: C901 + self, + action: RemoteSkillAction, + download: SkillPackageDownload | None, + ) -> _AppliedAction: + if action.package is None or download is None: + raise SkillReceiverStateError("install action is missing its exact package reference") + checkpoint_path = self._checkpoint_path(action.artifact.artifact_id) + current = self._read_optional_checkpoint(checkpoint_path) + journal_path = self._journal_path(action.artifact.artifact_id) + pending = self._read_optional_journal(journal_path) + if pending is not None: + resumed = self._resume_install(action, pending, checkpoint_path, journal_path) + if resumed is not None: + return resumed + current = self._read_optional_checkpoint(checkpoint_path) + destination = self._destination(action.skill_name) + if current is not None: + current_path = self._destination(current.skill_name) + actual = _directory_digest(current_path) + if actual != current.tree_digest: + raise SkillReceiverConflictError("the managed Skill directory no longer matches its checkpoint") + if ( + current.artifact == action.artifact.model_dump(mode="json") + and current.tree_digest == action.tree_digest + and current.skill_name == action.skill_name + ): + revised = _checkpoint(self.config.target_id, action) + self._write_signed(checkpoint_path, revised) + return _success_receipt(action, self.config) + if action.expected_local is None or not _checkpoint_matches_observation(current, action.expected_local): + raise SkillReceiverConflictError("the install action does not authorize replacing the local checkpoint") + if destination != current_path and (destination.exists() or destination.is_symlink()): + raise SkillReceiverConflictError("the desired Skill directory is occupied by foreign content") + elif destination.exists() or destination.is_symlink(): + raise SkillReceiverConflictError("the desired Skill directory is occupied by foreign content") + + try: + archive_bytes = base64.b64decode(download.archive_base64, validate=True) + except (binascii.Error, ValueError) as error: + raise SkillReceiverStateError("downloaded Skill archive encoding is invalid") from error + package = capture_skill_archive(archive_bytes) + if package.reference.model_dump(mode="json") != action.package.model_dump(mode="json"): + raise SkillReceiverStateError("downloaded Skill package reference does not match the action") + if package.reference.tree_digest != action.tree_digest: + raise SkillReceiverStateError("downloaded Skill package tree digest does not match the action") + + staging = Path(tempfile.mkdtemp(prefix=".powercontext-stage-", dir=self.skill_root.parent)) + staged_package = staging / action.skill_name + quarantine = self.skill_root.parent / _quarantine_name(action.artifact.artifact_id) + try: + materialize_skill_package(package, staged_package) + if _directory_digest(staged_package) != action.tree_digest: + raise SkillReceiverStateError("staged Skill package tree digest is invalid") + journal = ReceiverJournal( + target_id=self.config.target_id, + action=action.model_dump(mode="json"), + staging_name=staging.name, + quarantine_name=quarantine.name, + previous=current, + ) + self._write_signed(journal_path, journal) + if current is not None: + if quarantine.exists() or quarantine.is_symlink(): + raise SkillReceiverStateError("Receiver quarantine is already occupied") + os.replace(self._destination(current.skill_name), quarantine) + self.skill_root.mkdir(parents=True, exist_ok=True) + os.replace(staged_package, destination) + if _directory_digest(destination) != action.tree_digest: + raise SkillReceiverStateError("installed Skill package tree digest changed during rename") + self._write_signed(checkpoint_path, _checkpoint(self.config.target_id, action)) + self._require_quarantine_owned(quarantine, current) + journal_path.unlink(missing_ok=True) + shutil.rmtree(quarantine, ignore_errors=True) + shutil.rmtree(staging, ignore_errors=True) + except BaseException: + # Signed journal plus exact staging/quarantine state is intentionally retained for inspection/recovery. + raise + return _success_receipt(action, self.config) + + def _resume_install( + self, + action: RemoteSkillAction, + journal: ReceiverJournal, + checkpoint_path: Path, + journal_path: Path, + ) -> _AppliedAction | None: + self._require_target(journal.target_id) + pending_action = RemoteSkillAction.model_validate(journal.action) + if ( + not _same_action_intent(pending_action, action) + or pending_action.operation is not RemoteSkillOperation.INSTALL + ): + raise SkillReceiverConflictError("pending action journal does not match latest install intent") + if journal.staging_name is None or journal.quarantine_name is None: + raise SkillReceiverStateError("pending install journal is incomplete") + staging = self._private_sibling(journal.staging_name) + staged_package = staging / action.skill_name + quarantine = self._private_sibling(journal.quarantine_name) + destination = self._destination(action.skill_name) + if _directory_digest(destination) == action.tree_digest: + self._write_signed(checkpoint_path, _checkpoint(self.config.target_id, action)) + self._require_quarantine_owned(quarantine, journal.previous) + journal_path.unlink(missing_ok=True) + shutil.rmtree(quarantine, ignore_errors=True) + shutil.rmtree(staging, ignore_errors=True) + return _success_receipt(action, self.config) + if not destination.exists() and _directory_digest(staged_package) == action.tree_digest: + self.skill_root.mkdir(parents=True, exist_ok=True) + os.replace(staged_package, destination) + self._write_signed(checkpoint_path, _checkpoint(self.config.target_id, action)) + self._require_quarantine_owned(quarantine, journal.previous) + journal_path.unlink(missing_ok=True) + shutil.rmtree(quarantine, ignore_errors=True) + shutil.rmtree(staging, ignore_errors=True) + return _success_receipt(action, self.config) + previous = journal.previous + if previous is not None and _directory_digest(self._destination(previous.skill_name)) == previous.tree_digest: + shutil.rmtree(staging, ignore_errors=True) + journal_path.unlink(missing_ok=True) + return None + if previous is None and not destination.exists() and not quarantine.exists(): + shutil.rmtree(staging, ignore_errors=True) + journal_path.unlink(missing_ok=True) + return None + raise SkillReceiverConflictError("pending install filesystem state is ambiguous") + + def _unpublish(self, action: RemoteSkillAction) -> _AppliedAction: + checkpoint_path = self._checkpoint_path(action.artifact.artifact_id) + journal_path = self._journal_path(action.artifact.artifact_id) + pending = self._read_optional_journal(journal_path) + if pending is not None: + return self._resume_unpublish(action, pending, checkpoint_path, journal_path) + current = self._read_optional_checkpoint(checkpoint_path) + if action.expected_local is None: + if current is not None: + raise SkillReceiverConflictError("unpublish action omitted the Receiver-owned checkpoint") + destination = self._destination(action.skill_name) + if destination.exists() or destination.is_symlink(): + raise SkillReceiverConflictError("foreign content occupies the desired Skill directory") + return _success_receipt(action, self.config) + if current is None or not _checkpoint_matches_observation(current, action.expected_local): + raise SkillReceiverConflictError("unpublish action does not match the Receiver-owned checkpoint") + destination = self._destination(current.skill_name) + if _directory_digest(destination) != current.tree_digest: + raise SkillReceiverConflictError("the managed Skill directory no longer matches its checkpoint") + quarantine = self.skill_root.parent / _quarantine_name(action.artifact.artifact_id) + if quarantine.exists() or quarantine.is_symlink(): + raise SkillReceiverStateError("Receiver quarantine is already occupied") + journal = ReceiverJournal( + target_id=self.config.target_id, + action=action.model_dump(mode="json"), + quarantine_name=quarantine.name, + previous=current, + ) + self._write_signed(journal_path, journal) + os.replace(destination, quarantine) + checkpoint_path.unlink() + return _AppliedAction( + receipt=_success_receipt(action, self.config).receipt, + journal_path=journal_path, + quarantine=quarantine, + ) + + def _resume_unpublish( + self, + action: RemoteSkillAction, + journal: ReceiverJournal, + checkpoint_path: Path, + journal_path: Path, + ) -> _AppliedAction: + self._require_target(journal.target_id) + pending_action = RemoteSkillAction.model_validate(journal.action) + if ( + not _same_action_intent(pending_action, action) + or pending_action.operation is not RemoteSkillOperation.UNPUBLISH + ): + raise SkillReceiverConflictError("pending action journal does not match latest unpublish intent") + if journal.quarantine_name is None: + raise SkillReceiverStateError("pending unpublish journal is incomplete") + quarantine = self._private_sibling(journal.quarantine_name) + if checkpoint_path.exists(): + raise SkillReceiverConflictError("pending unpublish retained an unexpected ownership checkpoint") + if ( + journal.previous is None + or _directory_digest(quarantine, expected_name=journal.previous.skill_name) != journal.previous.tree_digest + ): + raise SkillReceiverConflictError("pending unpublish quarantine no longer matches the authorized package") + return _AppliedAction( + receipt=_success_receipt(action, self.config).receipt, + journal_path=journal_path, + quarantine=quarantine, + ) + + def _finish(self, applied: _AppliedAction) -> None: + if applied.journal_path is not None: + applied.journal_path.unlink(missing_ok=True) + if applied.quarantine is not None: + shutil.rmtree(applied.quarantine, ignore_errors=True) + + def _destination(self, skill_name: str) -> Path: + destination = (self.skill_root / skill_name).resolve(strict=False) + if destination.parent != self.skill_root.resolve(strict=False): + raise SkillReceiverStateError("Skill name escapes the Agent package root") + return destination + + def _private_sibling(self, name: str) -> Path: + if not name.startswith(".powercontext-") or Path(name).name != name: + raise SkillReceiverStateError("pending action path is invalid") + return self.skill_root.parent / name + + @staticmethod + def _require_quarantine_owned(quarantine: Path, previous: ReceiverCheckpoint | None) -> None: + if previous is None: + if quarantine.exists() or quarantine.is_symlink(): + raise SkillReceiverConflictError("unexpected content occupies the Receiver quarantine") + return + if _directory_digest(quarantine, expected_name=previous.skill_name) != previous.tree_digest: + raise SkillReceiverConflictError("Receiver quarantine no longer matches the replaced managed package") + + def _checkpoint_path(self, artifact_id: str) -> Path: + return self._state_path(self._checkpoints_root, artifact_id) + + def _journal_path(self, artifact_id: str) -> Path: + return self._state_path(self._journals_root, artifact_id) + + @staticmethod + def _state_path(root: Path, artifact_id: str) -> Path: + name = hashlib.sha256(artifact_id.encode()).hexdigest() + return root / f"{name}.json" + + def _read_optional_checkpoint(self, path: Path) -> ReceiverCheckpoint | None: + return None if not path.exists() else self._read_signed(path, ReceiverCheckpoint) + + def _read_optional_journal(self, path: Path) -> ReceiverJournal | None: + return None if not path.exists() else self._read_signed(path, ReceiverJournal) + + def _read_signed(self, path: Path, model_type): + try: + envelope = json.loads(path.read_text(encoding="utf-8")) + payload = envelope["payload"] + signature = str(envelope["hmac_sha256"]) + canonical = _canonical_json(payload) + except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as error: + raise SkillReceiverStateError("Receiver-private state is invalid") from error + expected = hmac.new(self._mac_key, canonical, hashlib.sha256).hexdigest() + if not hmac.compare_digest(signature, expected): + raise SkillReceiverStateError("Receiver-private state credential binding is invalid") + try: + return model_type.model_validate(payload) + except ValueError as error: + raise SkillReceiverStateError("Receiver-private state payload is invalid") from error + + def _write_signed(self, path: Path, payload: BaseModel) -> None: + value = payload.model_dump(mode="json") + canonical = _canonical_json(value) + envelope = { + "payload": value, + "hmac_sha256": hmac.new(self._mac_key, canonical, hashlib.sha256).hexdigest(), + } + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump(envelope, stream, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + temporary.chmod(0o600) + os.replace(temporary, path) + except BaseException: + temporary.unlink(missing_ok=True) + raise + + def _require_target(self, target_id: str) -> None: + if not hmac.compare_digest(target_id, self.config.target_id): + raise SkillReceiverStateError("Receiver-private state belongs to another target") + + +def _checkpoint(target_id: str, action: RemoteSkillAction) -> ReceiverCheckpoint: + return ReceiverCheckpoint( + target_id=target_id, + artifact=action.artifact.model_dump(mode="json"), + tree_digest=action.tree_digest, + skill_name=action.skill_name, + applied_generation=action.generation, + ) + + +def _checkpoint_matches_observation( + checkpoint: ReceiverCheckpoint, + observation: RemoteSkillObservation, +) -> bool: + return ( + checkpoint.artifact == observation.artifact.model_dump(mode="json") + and checkpoint.tree_digest == observation.tree_digest + and checkpoint.skill_name == observation.skill_name + and checkpoint.applied_generation == observation.applied_generation + and observation.actual_tree_digest == observation.tree_digest + ) + + +def _same_action_intent(left: RemoteSkillAction, right: RemoteSkillAction) -> bool: + return ( + left.operation is right.operation + and left.generation == right.generation + and left.artifact == right.artifact + and left.tree_digest == right.tree_digest + and left.skill_name == right.skill_name + and left.package == right.package + ) + + +def _success_receipt(action: RemoteSkillAction, config: RemoteSkillReceiverConfig) -> _AppliedAction: + observed = action.tree_digest if action.operation is RemoteSkillOperation.INSTALL else None + return _AppliedAction( + receipt=RecordRemoteSkillReceiptRequest( + operation=action.operation, + generation=action.generation, + artifact=action.artifact, + expected_tree_digest=action.tree_digest, + observed_tree_digest=observed, + outcome=RemoteSkillReceiptOutcome.SUCCEEDED, + failure_state=None, + error_code=None, + receiver_version=config.receiver_version, + environment_fingerprint=config.environment_fingerprint, + ) + ) + + +def _failure_receipt( + action: RemoteSkillAction, + error: Exception, + config: RemoteSkillReceiverConfig, +) -> RecordRemoteSkillReceiptRequest: + if isinstance(error, SkillReceiverConflictError): + state = ( + RemoteSkillFailureState.DRIFTED if "drift" in str(error).casefold() else RemoteSkillFailureState.CONFLICT + ) + elif isinstance(error, SkillPackageError | ValueError): + state = RemoteSkillFailureState.INCOMPATIBLE + else: + state = RemoteSkillFailureState.DELIVERY_FAILED + return RecordRemoteSkillReceiptRequest( + operation=action.operation, + generation=action.generation, + artifact=action.artifact, + expected_tree_digest=action.tree_digest, + observed_tree_digest=None, + outcome=RemoteSkillReceiptOutcome.FAILED, + failure_state=state, + error_code=_error_code(error), + receiver_version=config.receiver_version, + environment_fingerprint=config.environment_fingerprint, + ) + + +def _error_code(error: Exception) -> str: + if isinstance(error, SkillReceiverConflictError): + return "drifted" if "drift" in str(error).casefold() else "local_conflict" + if isinstance(error, SkillPackageError | ValueError): + return "incompatible_package" + return "local_delivery_failed" + + +def _directory_digest(path: Path, *, expected_name: str | None = None) -> str | None: + if not path.exists() or path.is_symlink(): + return None + try: + return capture_skill_directory(path, expected_name=expected_name).reference.tree_digest + except (OSError, SkillPackageError): + return None + + +def _canonical_json(value: object) -> bytes: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8") + + +def require_remote_skill_server_url(value: str, *, allow_insecure_http: bool = False) -> bool: + """Validate one Receiver Server URL and report whether it uses cleartext remote HTTP.""" + + parsed = urlsplit(value) + if parsed.scheme.casefold() == "https": + return False + if parsed.scheme.casefold() == "http" and parsed.hostname is not None: + if _loopback_host(parsed.hostname): + return False + if allow_insecure_http: + return True + raise ValueError( + "remote Skill Receiver requires HTTPS except for loopback development; " + "use --allow-insecure-http only on a protected private test network" + ) + + +def _loopback_host(host: str) -> bool: + if host.casefold() == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +def _is_relative_to(path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + except ValueError: + return False + return True + + +def _quarantine_name(artifact_id: str) -> str: + return f".powercontext-quarantine-{hashlib.sha256(artifact_id.encode()).hexdigest()[:24]}" + + +__all__ = [ + "RECEIVER_VERSION", + "ReceiverSyncResult", + "RemoteSkillReceiver", + "RemoteSkillReceiverClient", + "RemoteSkillReceiverConfig", + "SkillReceiverConflictError", + "SkillReceiverError", + "SkillReceiverStateError", +] diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index 56c05bf89..25a51fa69 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -35,9 +35,12 @@ CommittedHandoff, ContinueHandoffRequest, CreateHandoffReportProjectRequest, + CreateRemoteSkillTargetRequest, CreateWorkContractRequest, CurrentWorkHandoff, DetachHandoffReportWorkspaceRequest, + DownloadRemoteSkillPackageRequest, + EnrollRemoteSkillTargetRequest, EntryChange, EntryChangeOperation, ErrorDetail, @@ -64,6 +67,7 @@ GetHandoffReportRequest, GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, + GetSkillPackageRequest, GetSkillRequest, GetStatsRequest, HandoffAcknowledgement, @@ -108,10 +112,15 @@ ListHandoffReportKnownScopesRequest, ListHandoffReportProjectsRequest, ListHandoffReportWorkstreamsRequest, + ListManagedSkillsRequest, + ListManagedSkillsResponse, ListMemoryChangesRequest, ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, + ListRemoteSkillTargetsRequest, + ListRemoteSkillTargetsResponse, + ManagedSkillLibraryEntry, MemoryCitation, MemoryEntry, MemoryEntryInventoryStatistics, @@ -138,7 +147,9 @@ ProjectDescriptor, ProjectPage, ProposeExperienceRequest, + ProposeSkillPackageRequest, ProposeSkillRequest, + PublishRemoteSkillRequest, PurgeHandoffReportActivitiesRequest, PurgeHandoffReportActivitiesResponse, ReadinessResponse, @@ -146,11 +157,31 @@ RecallTokenDay, RecallTokenStatistics, RecallTokenValue, + ReconcileRemoteSkillsRequest, + ReconcileRemoteSkillsResponse, RecordHandoffReportActivityRequest, + RecordRemoteSkillReceiptRequest, + RecordSkillUsageRequest, RecordTaskOutcomeRequest, RegisterHandoffReportWorkstreamRequest, RejectArtifactCandidateRequest, RememberMemoryRequest, + RemoteAgentKind, + RemoteSkillAction, + RemoteSkillDesiredState, + RemoteSkillFailureState, + RemoteSkillObservation, + RemoteSkillOperation, + RemoteSkillPublication, + RemoteSkillPublicationState, + RemoteSkillReceiptOutcome, + RemoteSkillReceiptResponse, + RemoteSkillTarget, + RemoteSkillTargetCredential, + RemoteSkillTargetEnrollment, + RemoteSkillTargetState, + RemoteSkillTargetStatus, + RenameRemoteSkillTargetRequest, ReportActivitySource, ReportCatalogState, ReportFormat, @@ -161,6 +192,7 @@ RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, ReviseMemoryEntryRequest, + RevokeRemoteSkillTargetRequest, ScanExternalSkillsRequest, ScanExternalSkillsResponse, ScopedStats, @@ -169,6 +201,12 @@ SearchMemoryResponse, SkillArtifact, SkillGenerationOrigin, + SkillGovernance, + SkillLifecycleState, + SkillPackageDownload, + SkillPackageFile, + SkillPackageManifest, + SkillPackageReference, SkillProposal, SkillValidationItem, SourceInventoryStatistics, @@ -180,8 +218,10 @@ TaskOutcome, TaskOutcomeStatus, TokenEstimatorProfile, + UnpublishRemoteSkillRequest, UpdateHandoffReportProjectRequest, UpdateHandoffReportWorkstreamRequest, + UpdateSkillLifecycleRequest, UsageStatistics, WorkClaim, WorkClaimBasis, @@ -214,9 +254,12 @@ "CommittedHandoff", "ContinueHandoffRequest", "CreateHandoffReportProjectRequest", + "CreateRemoteSkillTargetRequest", "CreateWorkContractRequest", "CurrentWorkHandoff", "DetachHandoffReportWorkspaceRequest", + "DownloadRemoteSkillPackageRequest", + "EnrollRemoteSkillTargetRequest", "EntryChange", "EntryChangeOperation", "ErrorDetail", @@ -243,6 +286,7 @@ "GetHandoffReportRequest", "GetHandoffReportWorkspaceRequest", "GetMemoryEntryRequest", + "GetSkillPackageRequest", "GetSkillRequest", "GetStatsRequest", "HandoffAcknowledgement", @@ -287,10 +331,15 @@ "ListHandoffReportKnownScopesRequest", "ListHandoffReportProjectsRequest", "ListHandoffReportWorkstreamsRequest", + "ListManagedSkillsRequest", + "ListManagedSkillsResponse", "ListMemoryChangesRequest", "ListMemoryChangesResponse", "ListMemoryEntriesRequest", "ListMemoryEntriesResponse", + "ListRemoteSkillTargetsRequest", + "ListRemoteSkillTargetsResponse", + "ManagedSkillLibraryEntry", "MemoryCitation", "MemoryEntry", "MemoryEntryInventoryStatistics", @@ -317,7 +366,9 @@ "ProjectDescriptor", "ProjectPage", "ProposeExperienceRequest", + "ProposeSkillPackageRequest", "ProposeSkillRequest", + "PublishRemoteSkillRequest", "PurgeHandoffReportActivitiesRequest", "PurgeHandoffReportActivitiesResponse", "ReadinessResponse", @@ -325,11 +376,31 @@ "RecallTokenDay", "RecallTokenStatistics", "RecallTokenValue", + "ReconcileRemoteSkillsRequest", + "ReconcileRemoteSkillsResponse", "RecordHandoffReportActivityRequest", + "RecordRemoteSkillReceiptRequest", + "RecordSkillUsageRequest", "RecordTaskOutcomeRequest", "RegisterHandoffReportWorkstreamRequest", "RejectArtifactCandidateRequest", "RememberMemoryRequest", + "RemoteAgentKind", + "RemoteSkillAction", + "RemoteSkillDesiredState", + "RemoteSkillFailureState", + "RemoteSkillObservation", + "RemoteSkillOperation", + "RemoteSkillPublication", + "RemoteSkillPublicationState", + "RemoteSkillReceiptOutcome", + "RemoteSkillReceiptResponse", + "RemoteSkillTarget", + "RemoteSkillTargetCredential", + "RemoteSkillTargetEnrollment", + "RemoteSkillTargetState", + "RemoteSkillTargetStatus", + "RenameRemoteSkillTargetRequest", "ReportActivitySource", "ReportCatalogState", "ReportFormat", @@ -340,6 +411,7 @@ "RetireMemoryEntryRequest", "ReviseArtifactCandidateRequest", "ReviseMemoryEntryRequest", + "RevokeRemoteSkillTargetRequest", "ScanExternalSkillsRequest", "ScanExternalSkillsResponse", "ScopedStats", @@ -348,6 +420,12 @@ "SearchMemoryResponse", "SkillArtifact", "SkillGenerationOrigin", + "SkillGovernance", + "SkillLifecycleState", + "SkillPackageDownload", + "SkillPackageFile", + "SkillPackageManifest", + "SkillPackageReference", "SkillProposal", "SkillValidationItem", "SourceInventoryStatistics", @@ -359,8 +437,10 @@ "TaskOutcome", "TaskOutcomeStatus", "TokenEstimatorProfile", + "UnpublishRemoteSkillRequest", "UpdateHandoffReportProjectRequest", "UpdateHandoffReportWorkstreamRequest", + "UpdateSkillLifecycleRequest", "UsageStatistics", "WorkClaim", "WorkClaimBasis", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index ad2a598c1..49c4d8539 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -319,6 +319,387 @@ class ExperienceProposal(BaseModel): lesson: Annotated[StrictStr, Field(max_length=8000, min_length=1, pattern=".*\\S.*")] +class SkillLifecycleState(StrEnum): + ACTIVE = "active" + DEPRECATED = "deprecated" + RETIRED = "retired" + + +class SkillGovernance(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + artifact: ArtifactReference + lifecycle_state: SkillLifecycleState + replacement_artifact_id: Annotated[StrictStr | None, Field(max_length=128, min_length=1)] + governance_generation: Annotated[StrictInt, Field(ge=0)] + + +class ListManagedSkillsRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + query: Annotated[StrictStr | None, Field(max_length=2000, min_length=1)] = None + include_deprecated: StrictBool = False + limit: Annotated[StrictInt, Field(ge=1, le=200)] = 100 + + +class UpdateSkillLifecycleRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + artifact_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + expected_generation: Annotated[StrictInt, Field(ge=0)] + lifecycle_state: SkillLifecycleState + replacement_artifact_id: Annotated[ + StrictStr | None, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$") + ] = None + + +class SkillPackageReference(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + tree_digest: Annotated[StrictStr, Field(pattern="^[0-9a-f]{64}$")] + archive_digest: Annotated[StrictStr, Field(pattern="^[0-9a-f]{64}$")] + file_count: Annotated[StrictInt, Field(ge=1, le=256)] + uncompressed_size: Annotated[StrictInt, Field(ge=1, le=4194304)] + archive_size: Annotated[StrictInt, Field(ge=1, le=5242880)] + + +class SkillPackageFile(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + path: Annotated[StrictStr, Field(max_length=512, min_length=1)] + digest: Annotated[StrictStr, Field(pattern="^[0-9a-f]{64}$")] + size: Annotated[StrictInt, Field(ge=0, le=4194304)] + media_type: Annotated[StrictStr, Field(max_length=255, min_length=1)] + executable: StrictBool + + +class SkillPackageManifest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + package: SkillPackageReference + name: Annotated[StrictStr, Field(max_length=64, min_length=1)] + description: Annotated[StrictStr, Field(max_length=1024, min_length=1)] + license: Annotated[StrictStr | None, Field(max_length=512, min_length=1)] = None + compatibility: Annotated[StrictStr | None, Field(max_length=500, min_length=1)] = None + metadata: Annotated[dict[str, StrictStr], Field(max_length=64)] + allowed_tools: Annotated[StrictStr | None, Field(max_length=2000, min_length=1)] = None + files: Annotated[list[SkillPackageFile], Field(max_length=256, min_length=1)] + + +class GetSkillPackageRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + artifact: ArtifactReference + + +class SkillPackageDownload(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + package: SkillPackageReference + archive_base64: Annotated[StrictStr, Field(max_length=6990508, min_length=1, pattern="^[A-Za-z0-9+/]*={0,2}$")] + + +class RemoteAgentKind(StrEnum): + CODEX = "codex" + CLAUDE_CODE = "claude_code" + + +class RemoteSkillTargetState(StrEnum): + PENDING = "pending" + ACTIVE = "active" + REVOKED = "revoked" + + +class InstallationScope(StrEnum): + PROJECT = "project" + + +class DeliveryMode(StrEnum): + AGENT_PULL = "agent_pull" + + +class RemoteSkillTarget(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + target_id: Annotated[StrictStr, Field(max_length=64, min_length=1, pattern="^[a-z0-9]+(?:-[a-z0-9]+)*$")] + display_name: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern=".*\\S.*")] + agent_kind: RemoteAgentKind + installation_scope: InstallationScope + delivery_mode: DeliveryMode + installation_id: Annotated[StrictStr | None, Field(max_length=128, min_length=1)] + state: RemoteSkillTargetState + receiver_version: Annotated[StrictStr | None, Field(max_length=64, min_length=1)] + environment_fingerprint: Annotated[StrictStr | None, Field(pattern="^[0-9a-f]{64}$")] + machine_hostname: Annotated[StrictStr | None, Field(max_length=255, min_length=1, pattern=".*\\S.*")] + workspace_name: Annotated[StrictStr | None, Field(max_length=128, min_length=1, pattern=".*\\S.*")] + last_seen_at: Annotated[AwareDatetime | None, Field(...)] + generation: Annotated[StrictInt, Field(ge=0)] + + +class ListRemoteSkillTargetsRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + target_id: Annotated[StrictStr | None, Field(max_length=64, min_length=1, pattern="^[a-z0-9]+(?:-[a-z0-9]+)*$")] = ( + None + ) + limit: Annotated[StrictInt, Field(ge=1, le=200)] = 100 + + +class CreateRemoteSkillTargetRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + agent_kind: RemoteAgentKind + display_name: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern=".*\\S.*")] + + +class RemoteSkillTargetEnrollment(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + target: RemoteSkillTarget + enrollment_code: Annotated[StrictStr, Field(max_length=256, min_length=32)] + enrollment_expires_at: AwareDatetime + + +class EnrollRemoteSkillTargetRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + enrollment_code: Annotated[StrictStr, Field(max_length=256, min_length=32)] + installation_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + receiver_version: Annotated[StrictStr, Field(max_length=64, min_length=1, pattern="^[\\x21-\\x7E]+$")] + environment_fingerprint: Annotated[StrictStr | None, Field(pattern="^[0-9a-f]{64}$")] = None + machine_hostname: Annotated[StrictStr | None, Field(max_length=255, min_length=1, pattern=".*\\S.*")] = None + workspace_name: Annotated[StrictStr | None, Field(max_length=128, min_length=1, pattern=".*\\S.*")] = None + + +class RemoteSkillTargetCredential(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + target_id: Annotated[StrictStr, Field(max_length=64, min_length=1)] + agent_kind: RemoteAgentKind + credential: Annotated[StrictStr, Field(max_length=256, min_length=32)] + + +class RevokeRemoteSkillTargetRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + target_id: Annotated[StrictStr, Field(max_length=64, min_length=1, pattern="^[a-z0-9]+(?:-[a-z0-9]+)*$")] + expected_generation: Annotated[StrictInt, Field(ge=0)] + + +class RenameRemoteSkillTargetRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + target_id: Annotated[StrictStr, Field(max_length=64, min_length=1, pattern="^[a-z0-9]+(?:-[a-z0-9]+)*$")] + display_name: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern=".*\\S.*")] + expected_generation: Annotated[StrictInt, Field(ge=0)] + + +class PublishRemoteSkillRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + target_id: Annotated[StrictStr, Field(max_length=64, min_length=1, pattern="^[a-z0-9]+(?:-[a-z0-9]+)*$")] + artifact: ArtifactReference + expected_generation: Annotated[StrictInt | None, Field(ge=0)] + allow_deprecated: StrictBool = False + + +class UnpublishRemoteSkillRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + target_id: Annotated[StrictStr, Field(max_length=64, min_length=1, pattern="^[a-z0-9]+(?:-[a-z0-9]+)*$")] + artifact_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + expected_generation: Annotated[StrictInt, Field(ge=0)] + + +class RemoteSkillDesiredState(StrEnum): + PUBLISHED = "published" + UNPUBLISHED = "unpublished" + + +class RemoteSkillPublicationState(StrEnum): + UNPUBLISHED = "unpublished" + PENDING = "pending" + CURRENT = "current" + UPDATE_AVAILABLE = "update_available" + DELIVERY_FAILED = "delivery_failed" + CONFLICT = "conflict" + DRIFTED = "drifted" + INCOMPATIBLE = "incompatible" + + +class RemoteSkillPublication(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: StrictStr + target_id: StrictStr + artifact_id: StrictStr + desired_state: RemoteSkillDesiredState + desired_revision: Annotated[StrictInt, Field(ge=1)] + desired_tree_digest: Annotated[StrictStr, Field(pattern="^[0-9a-f]{64}$")] + observed_revision: Annotated[StrictInt | None, Field(ge=1)] + observed_tree_digest: Annotated[StrictStr | None, Field(pattern="^[0-9a-f]{64}$")] + observed_generation: Annotated[StrictInt | None, Field(ge=0)] + state: RemoteSkillPublicationState + last_error_code: Annotated[StrictStr | None, Field(max_length=128, min_length=1)] + observed_at: Annotated[AwareDatetime | None, Field(...)] + generation: Annotated[StrictInt, Field(ge=0)] + + +class RemoteSkillObservation(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + artifact: ArtifactReference + tree_digest: Annotated[StrictStr, Field(pattern="^[0-9a-f]{64}$")] + actual_tree_digest: Annotated[StrictStr | None, Field(pattern="^[0-9a-f]{64}$")] + skill_name: Annotated[StrictStr, Field(max_length=64, min_length=1, pattern="^[a-z0-9]+(?:-[a-z0-9]+)*$")] + applied_generation: Annotated[StrictInt, Field(ge=0)] + + +class ReconcileRemoteSkillsRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + observations: Annotated[list[RemoteSkillObservation], Field(max_length=256)] + receiver_version: Annotated[StrictStr, Field(max_length=64, min_length=1, pattern="^[\\x21-\\x7E]+$")] + environment_fingerprint: Annotated[StrictStr | None, Field(pattern="^[0-9a-f]{64}$")] = None + + +class RemoteSkillOperation(StrEnum): + INSTALL = "install" + UNPUBLISH = "unpublish" + + +class RemoteSkillAction(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + operation: RemoteSkillOperation + generation: Annotated[StrictInt, Field(ge=0)] + artifact: ArtifactReference + tree_digest: Annotated[StrictStr, Field(pattern="^[0-9a-f]{64}$")] + skill_name: Annotated[StrictStr, Field(max_length=64, min_length=1)] + package: Annotated[SkillPackageReference | None, Field(...)] + expected_local: Annotated[RemoteSkillObservation | None, Field(...)] + blocked_error_code: Annotated[StrictStr | None, Field(max_length=128, min_length=1)] + + +class ReconcileRemoteSkillsResponse(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: StrictStr + target_id: StrictStr + actions: Annotated[list[RemoteSkillAction], Field(max_length=256)] + + +class DownloadRemoteSkillPackageRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + generation: Annotated[StrictInt, Field(ge=0)] + artifact: ArtifactReference + package: SkillPackageReference + + +class RemoteSkillReceiptOutcome(StrEnum): + SUCCEEDED = "succeeded" + FAILED = "failed" + + +class RemoteSkillFailureState(StrEnum): + DELIVERY_FAILED = "delivery_failed" + CONFLICT = "conflict" + DRIFTED = "drifted" + INCOMPATIBLE = "incompatible" + + +class RecordRemoteSkillReceiptRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + operation: RemoteSkillOperation + generation: Annotated[StrictInt, Field(ge=0)] + artifact: ArtifactReference + expected_tree_digest: Annotated[StrictStr, Field(pattern="^[0-9a-f]{64}$")] + observed_tree_digest: Annotated[StrictStr | None, Field(pattern="^[0-9a-f]{64}$")] + outcome: RemoteSkillReceiptOutcome + failure_state: Annotated[RemoteSkillFailureState | None, Field(...)] + error_code: Annotated[StrictStr | None, Field(max_length=128, min_length=1)] + receiver_version: Annotated[StrictStr, Field(max_length=64, min_length=1, pattern="^[\\x21-\\x7E]+$")] + environment_fingerprint: Annotated[StrictStr | None, Field(pattern="^[0-9a-f]{64}$")] + + +class RemoteSkillReceiptResponse(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + accepted: StrictBool + stale: StrictBool + publication: RemoteSkillPublication + + +class ProposeSkillPackageRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + archive_base64: Annotated[StrictStr, Field(max_length=6990508, min_length=1, pattern="^[A-Za-z0-9+/]*={0,2}$")] + reason: Annotated[StrictStr | None, Field(max_length=2000, min_length=1)] = None + target: Annotated[ + ArtifactReference | None, + Field(description="Exact managed Skill Revision replaced by this complete package Candidate."), + ] = None + + +class Invoked(StrEnum): + TRUE = "true" + FALSE = "false" + UNKNOWN = "unknown" + + +class Validation(StrEnum): + PASSED = "passed" + FAILED = "failed" + UNKNOWN = "unknown" + + +class Outcome(StrEnum): + SUCCESS = "success" + FAILURE = "failure" + UNKNOWN = "unknown" + + class SkillValidationItem(RootModel[StrictStr]): root: Annotated[StrictStr, Field(max_length=2000, min_length=1, pattern="^\\S(?:.*\\S)?$")] @@ -1079,8 +1460,45 @@ class SkillProposal(BaseModel): ) name: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^\\S(?:.*\\S)?$")] description: Annotated[StrictStr, Field(max_length=2000, min_length=1, pattern="^\\S(?:.*\\S)?$")] - instructions: Annotated[StrictStr, Field(max_length=32000, min_length=1, pattern=".*\\S.*")] - validation: Annotated[list[SkillValidationItem], Field(max_length=32, min_length=1)] + instructions: Annotated[StrictStr, Field(max_length=131072)] + validation: Annotated[list[SkillValidationItem], Field(max_length=32)] + package: SkillPackageReference | None = None + license: Annotated[StrictStr | None, Field(max_length=512, min_length=1)] = None + compatibility: Annotated[StrictStr | None, Field(max_length=500, min_length=1)] = None + metadata: Annotated[dict[str, StrictStr] | None, Field(max_length=64)] = None + allowed_tools: Annotated[StrictStr | None, Field(max_length=2000, min_length=1)] = None + + +class RemoteSkillTargetStatus(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + target: RemoteSkillTarget + publications: Annotated[list[RemoteSkillPublication], Field(max_length=256)] + + +class ListRemoteSkillTargetsResponse(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + targets: Annotated[list[RemoteSkillTargetStatus], Field(max_length=200)] + + +class RecordSkillUsageRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + observation_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + skill_ref: ArtifactReference + package_digest: Annotated[StrictStr, Field(pattern="^sha256:[0-9a-f]{64}$")] + target_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern=".*\\S.*")] + selected: StrictBool + invoked: Invoked + validation: Validation + outcome: Outcome + task_source: SourceReference | None = None + environment_fingerprint: Annotated[StrictStr | None, Field(pattern="^sha256:[0-9a-f]{64}$")] = None class ExternalSkillRegistration(BaseModel): @@ -1652,6 +2070,24 @@ class SkillArtifact(BaseModel): artifact_refs: list[ArtifactReference] +class ManagedSkillLibraryEntry(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + artifact: ArtifactReference + content: SkillProposal + source_refs: list[SourceReference] + artifact_refs: list[ArtifactReference] + governance: SkillGovernance + + +class ListManagedSkillsResponse(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + skills: Annotated[list[ManagedSkillLibraryEntry], Field(max_length=200)] + + class UpdateHandoffReportProjectRequest(BaseModel): model_config = ConfigDict( extra="forbid", diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index 2044f5352..69be27055 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -20,8 +20,11 @@ CommittedHandoff, ContinueHandoffRequest, CreateHandoffReportProjectRequest, + CreateRemoteSkillTargetRequest, CreateWorkContractRequest, DetachHandoffReportWorkspaceRequest, + DownloadRemoteSkillPackageRequest, + EnrollRemoteSkillTargetRequest, ExperienceArtifact, ExternalSkillResolution, FinalizeHandoffRequest, @@ -36,6 +39,7 @@ GetHandoffReportRequest, GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, + GetSkillPackageRequest, GetSkillRequest, GetStatsRequest, HandoffAcknowledgement, @@ -56,10 +60,14 @@ ListHandoffReportKnownScopesRequest, ListHandoffReportProjectsRequest, ListHandoffReportWorkstreamsRequest, + ListManagedSkillsRequest, + ListManagedSkillsResponse, ListMemoryChangesRequest, ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, + ListRemoteSkillTargetsRequest, + ListRemoteSkillTargetsResponse, MemoryEntry, MemoryMutationResponse, PrepareContextRequest, @@ -70,28 +78,46 @@ ProjectDescriptor, ProjectPage, ProposeExperienceRequest, + ProposeSkillPackageRequest, ProposeSkillRequest, + PublishRemoteSkillRequest, PurgeHandoffReportActivitiesRequest, PurgeHandoffReportActivitiesResponse, ReadinessResponse, + ReconcileRemoteSkillsRequest, + ReconcileRemoteSkillsResponse, RecordHandoffReportActivityRequest, + RecordRemoteSkillReceiptRequest, + RecordSkillUsageRequest, RecordTaskOutcomeRequest, RegisterHandoffReportWorkstreamRequest, RejectArtifactCandidateRequest, RememberMemoryRequest, + RemoteSkillPublication, + RemoteSkillReceiptResponse, + RemoteSkillTarget, + RemoteSkillTargetCredential, + RemoteSkillTargetEnrollment, + RenameRemoteSkillTargetRequest, ResolveExternalSkillRequest, RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, ReviseMemoryEntryRequest, + RevokeRemoteSkillTargetRequest, ScanExternalSkillsRequest, ScanExternalSkillsResponse, ScopedStats, SearchMemoryRequest, SearchMemoryResponse, SkillArtifact, + SkillGovernance, + SkillPackageDownload, + SkillPackageManifest, StoredHandoffReportActivity, + UnpublishRemoteSkillRequest, UpdateHandoffReportProjectRequest, UpdateHandoffReportWorkstreamRequest, + UpdateSkillLifecycleRequest, WorkSourceReceipt, WorkstreamDescriptor, WorkstreamPage, @@ -759,6 +785,329 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, ) +LIST_MANAGED_SKILLS = Operation[ListManagedSkillsRequest, ListManagedSkillsResponse]( + method="POST", + path="/v1/skill/library", + operation_id="list_managed_skills", + request_type=ListManagedSkillsRequest, + request_location="body", + response_type=ListManagedSkillsResponse, + success_status=200, + summary="List or search current managed Skills", + tags=("skill",), + responses={ + 200: { + "description": "Current managed Skill Library rows.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + }, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +UPDATE_SKILL_LIFECYCLE = Operation[UpdateSkillLifecycleRequest, SkillGovernance]( + method="POST", + path="/v1/skill/lifecycle", + operation_id="update_skill_lifecycle", + request_type=UpdateSkillLifecycleRequest, + request_location="body", + response_type=SkillGovernance, + success_status=200, + summary="Update managed Skill lifecycle", + tags=("skill",), + responses={ + 200: { + "description": "Updated managed Skill governance.", + "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"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +GET_SKILL_PACKAGE_MANIFEST = Operation[GetSkillPackageRequest, SkillPackageManifest]( + method="POST", + path="/v1/skill/package/manifest", + operation_id="get_skill_package_manifest", + request_type=GetSkillPackageRequest, + request_location="body", + response_type=SkillPackageManifest, + success_status=200, + summary="Get an exact managed Skill package manifest", + tags=("skill",), + responses={ + 200: {"description": "Verified exact package manifest."}, + 404: {"$ref": "#/components/responses/NotFound"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +DOWNLOAD_SKILL_PACKAGE = Operation[GetSkillPackageRequest, SkillPackageDownload]( + method="POST", + path="/v1/skill/package/download", + operation_id="download_skill_package", + request_type=GetSkillPackageRequest, + request_location="body", + response_type=SkillPackageDownload, + success_status=200, + summary="Download an exact managed Skill package", + tags=("skill",), + responses={ + 200: {"description": "Canonical exact package archive."}, + 404: {"$ref": "#/components/responses/NotFound"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +PROPOSE_SKILL_PACKAGE = Operation[ProposeSkillPackageRequest, ArtifactCandidate]( + method="POST", + path="/v1/skill/package/propose", + operation_id="propose_skill_package", + request_type=ProposeSkillPackageRequest, + request_location="body", + response_type=ArtifactCandidate, + success_status=201, + summary="Propose an uploaded standard Skill package", + tags=("skill",), + responses={ + 201: {"description": "Pending exact package Candidate."}, + 409: {"$ref": "#/components/responses/Conflict"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +RECORD_SKILL_USAGE = Operation[RecordSkillUsageRequest, CaptureContentSourceResponse]( + method="POST", + path="/v1/skill/usage", + operation_id="record_skill_usage", + request_type=RecordSkillUsageRequest, + request_location="body", + response_type=CaptureContentSourceResponse, + success_status=201, + summary="Record a bounded Skill usage observation", + tags=("skill",), + responses={ + 201: {"description": "Accepted immutable usage Source evidence."}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +LIST_REMOTE_SKILL_TARGETS = Operation[ListRemoteSkillTargetsRequest, ListRemoteSkillTargetsResponse]( + method="POST", + path="/v1/skill/remote/targets", + operation_id="list_remote_skill_targets", + request_type=ListRemoteSkillTargetsRequest, + request_location="body", + response_type=ListRemoteSkillTargetsResponse, + success_status=200, + summary="List remote Agent Skill target status", + tags=("skill",), + responses={ + 200: {"description": "Remote target status rows visible to the administrative caller."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +CREATE_REMOTE_SKILL_TARGET = Operation[CreateRemoteSkillTargetRequest, RemoteSkillTargetEnrollment]( + method="POST", + path="/v1/skill/remote/target/create", + operation_id="create_remote_skill_target", + request_type=CreateRemoteSkillTargetRequest, + request_location="body", + response_type=RemoteSkillTargetEnrollment, + success_status=201, + summary="Create a remote Agent Skill target enrollment", + tags=("skill",), + responses={ + 201: {"description": "Pending remote target enrollment."}, + 409: {"$ref": "#/components/responses/Conflict"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +ENROLL_REMOTE_SKILL_TARGET = Operation[EnrollRemoteSkillTargetRequest, RemoteSkillTargetCredential]( + method="POST", + path="/v1/skill/remote/target/enroll", + operation_id="enroll_remote_skill_target", + request_type=EnrollRemoteSkillTargetRequest, + request_location="body", + response_type=RemoteSkillTargetCredential, + success_status=200, + summary="Enroll a remote Agent Skill Receiver", + tags=("skill",), + responses={ + 200: {"description": "Activated remote target credential."}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +RENAME_REMOTE_SKILL_TARGET = Operation[RenameRemoteSkillTargetRequest, RemoteSkillTarget]( + method="POST", + path="/v1/skill/remote/target/rename", + operation_id="rename_remote_skill_target", + request_type=RenameRemoteSkillTargetRequest, + request_location="body", + response_type=RemoteSkillTarget, + success_status=200, + summary="Rename a remote Agent Skill target", + tags=("skill",), + responses={ + 200: {"description": "Renamed remote target."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +REVOKE_REMOTE_SKILL_TARGET = Operation[RevokeRemoteSkillTargetRequest, RemoteSkillTarget]( + method="POST", + path="/v1/skill/remote/target/revoke", + operation_id="revoke_remote_skill_target", + request_type=RevokeRemoteSkillTargetRequest, + request_location="body", + response_type=RemoteSkillTarget, + success_status=200, + summary="Revoke a remote Agent Skill target", + tags=("skill",), + responses={ + 200: {"description": "Revoked remote target."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +PUBLISH_REMOTE_SKILL = Operation[PublishRemoteSkillRequest, RemoteSkillPublication]( + method="POST", + path="/v1/skill/remote/publication/publish", + operation_id="publish_remote_skill", + request_type=PublishRemoteSkillRequest, + request_location="body", + response_type=RemoteSkillPublication, + success_status=200, + summary="Set a remote target Skill desired Revision", + tags=("skill",), + responses={ + 200: {"description": "Latest remote publication desired state."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +UNPUBLISH_REMOTE_SKILL = Operation[UnpublishRemoteSkillRequest, RemoteSkillPublication]( + method="POST", + path="/v1/skill/remote/publication/unpublish", + operation_id="unpublish_remote_skill", + request_type=UnpublishRemoteSkillRequest, + request_location="body", + response_type=RemoteSkillPublication, + success_status=200, + summary="Set remote target Skill desired absence", + tags=("skill",), + responses={ + 200: {"description": "Latest remote publication desired state."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +RECONCILE_REMOTE_SKILLS = Operation[ReconcileRemoteSkillsRequest, ReconcileRemoteSkillsResponse]( + method="POST", + path="/v1/skill/remote/reconcile", + operation_id="reconcile_remote_skills", + request_type=ReconcileRemoteSkillsRequest, + request_location="body", + response_type=ReconcileRemoteSkillsResponse, + success_status=200, + summary="Reconcile a remote Agent Skill target", + tags=("skill",), + responses={ + 200: {"description": "Latest desired-state actions for this target only."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +DOWNLOAD_REMOTE_SKILL_PACKAGE = Operation[DownloadRemoteSkillPackageRequest, SkillPackageDownload]( + method="POST", + path="/v1/skill/remote/package/download", + operation_id="download_remote_skill_package", + request_type=DownloadRemoteSkillPackageRequest, + request_location="body", + response_type=SkillPackageDownload, + success_status=200, + summary="Download the exact package desired by a remote target", + tags=("skill",), + responses={ + 200: {"description": "Canonical exact package archive."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +RECORD_REMOTE_SKILL_RECEIPT = Operation[RecordRemoteSkillReceiptRequest, RemoteSkillReceiptResponse]( + method="POST", + path="/v1/skill/remote/receipt", + operation_id="record_remote_skill_receipt", + request_type=RecordRemoteSkillReceiptRequest, + request_location="body", + response_type=RemoteSkillReceiptResponse, + success_status=200, + summary="Record an exact remote Skill delivery Receipt", + tags=("skill",), + responses={ + 200: {"description": "Receipt acceptance and latest publication observation."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + SCAN_EXTERNAL_SKILLS = Operation[ScanExternalSkillsRequest, ScanExternalSkillsResponse]( method="POST", path="/v1/external-skills/scan", diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index be81ba103..02d8851dc 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -760,27 +760,26 @@ }, } }, - "/v1/external-skills/scan": { + "/v1/skill/library": { "post": { "tags": ["skill"], - "summary": "Scan configured external Skill roots", - "description": "Replace the current host-local " - "Registry projection without " - "copying or rewriting package " - "content.", - "operationId": "scan_external_skills", + "summary": "List or search current managed Skills", + "description": "Return current managed Skill heads with " + "lifecycle governance; retired Skills " + "remain exact-read only.", + "operationId": "list_managed_skills", "requestBody": { "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/ScanExternalSkillsRequest"}} + "application/json": {"schema": {"$ref": "#/components/schemas/ListManagedSkillsRequest"}} }, "required": True, }, "responses": { "200": { - "description": "The rebuildable provider snapshot.", + "description": "Current managed Skill Library rows.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/ScanExternalSkillsResponse"}} + "application/json": {"schema": {"$ref": "#/components/schemas/ListManagedSkillsResponse"}} }, }, "401": {"$ref": "#/components/responses/Unauthorized"}, @@ -790,37 +789,28 @@ }, } }, - "/v1/external-skills/list": { + "/v1/skill/lifecycle": { "post": { "tags": ["skill"], - "summary": "List external Skills visible on this host", - "description": "Return live local resolutions; " - "unavailable registrations are " - "omitted unless explicitly " - "requested.", - "operationId": "list_external_skills", + "summary": "Update managed Skill lifecycle", + "description": "Apply an explicit lifecycle transition " + "using governance generation CAS " + "without changing package bytes.", + "operationId": "update_skill_lifecycle", "requestBody": { "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/ListExternalSkillsRequest"}} + "application/json": {"schema": {"$ref": "#/components/schemas/UpdateSkillLifecycleRequest"}} }, "required": True, }, "responses": { "200": { - "description": "External " - "Skills " - "resolved " - "against the " - "current " - "Agent, " - "host, " - "scope, and " - "fingerprint.", + "description": "Updated managed Skill governance.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/ListExternalSkillsResponse"}} - }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SkillGovernance"}}}, }, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, @@ -828,27 +818,26 @@ }, } }, - "/v1/external-skills/resolve": { + "/v1/skill/package/manifest": { "post": { "tags": ["skill"], - "summary": "Resolve an exact external Skill fingerprint", - "description": "Resolve only the registered " - "local package version " - "requested by the caller; never " - "install or fall back.", - "operationId": "resolve_external_skill", + "summary": "Get an exact managed Skill package manifest", + "description": "Return verified metadata and " + "file inventory without " + "executing or returning file " + "bodies.", + "operationId": "get_skill_package_manifest", "requestBody": { "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/ResolveExternalSkillRequest"}} + "application/json": {"schema": {"$ref": "#/components/schemas/GetSkillPackageRequest"}} }, "required": True, }, "responses": { "200": { - "description": "The live exact-resolution result, which may be unavailable.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "description": "Verified exact package manifest.", "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/ExternalSkillResolution"}} + "application/json": {"schema": {"$ref": "#/components/schemas/SkillPackageManifest"}} }, }, "404": {"$ref": "#/components/responses/NotFound"}, @@ -859,31 +848,26 @@ }, } }, - "/v1/external-skills/import": { + "/v1/skill/package/download": { "post": { "tags": ["skill"], - "summary": "Import or fork an external Skill into Review", - "description": "Capture one exact local " - "snapshot and use the configured " - "model to propose a new managed " - "Skill Candidate.", - "operationId": "import_external_skill", + "summary": "Download an exact managed Skill package", + "description": "Return canonical ZIP bytes as bounded base64 with their content-addressed reference.", + "operationId": "download_skill_package", "requestBody": { "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/ImportExternalSkillRequest"}} + "application/json": {"schema": {"$ref": "#/components/schemas/GetSkillPackageRequest"}} }, "required": True, }, "responses": { "200": { - "description": "A pending managed Skill Candidate or an explicit semantic no-op.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "description": "Canonical exact package archive.", "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/GeneratedCandidateResponse"}} + "application/json": {"schema": {"$ref": "#/components/schemas/SkillPackageDownload"}} }, }, "404": {"$ref": "#/components/responses/NotFound"}, - "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, @@ -891,26 +875,27 @@ }, } }, - "/v1/artifact-candidates/list": { + "/v1/skill/package/propose": { "post": { - "tags": ["review"], - "summary": "List Artifact Candidates", - "description": "Page current Candidate heads; pending is the default Review Inbox view.", - "operationId": "list_artifact_candidates", + "tags": ["skill"], + "summary": "Propose an uploaded standard Skill package", + "description": "Canonicalize exact ZIP bytes, " + "store them once, and create a " + "pending Candidate without LLM " + "rewriting.", + "operationId": "propose_skill_package", "requestBody": { "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/ListArtifactCandidatesRequest"}} + "application/json": {"schema": {"$ref": "#/components/schemas/ProposeSkillPackageRequest"}} }, "required": True, }, "responses": { - "200": { - "description": "The selected current Candidate heads.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/ArtifactCandidatePage"}} - }, + "201": { + "description": "Pending exact package Candidate.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArtifactCandidate"}}}, }, + "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, @@ -918,25 +903,31 @@ }, } }, - "/v1/artifact-candidates/get": { + "/v1/skill/usage": { "post": { - "tags": ["review"], - "summary": "Get an Artifact Candidate", - "description": "Read the current head and exact immutable proposal version.", - "operationId": "get_artifact_candidate", + "tags": ["skill"], + "summary": "Record a bounded Skill usage observation", + "description": "Validate an exact managed Skill Revision " + "and capture immutable bounded usage Source " + "evidence.", + "operationId": "record_skill_usage", "requestBody": { "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/GetArtifactCandidateRequest"}} + "application/json": {"schema": {"$ref": "#/components/schemas/RecordSkillUsageRequest"}} }, "required": True, }, "responses": { - "200": { - "description": "The current Candidate head.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArtifactCandidate"}}}, + "201": { + "description": "Accepted immutable usage Source evidence.", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/CaptureContentSourceResponse"} + } + }, }, "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, @@ -944,26 +935,29 @@ }, } }, - "/v1/artifact-candidates/approve": { + "/v1/skill/remote/targets": { "post": { - "tags": ["review"], - "summary": "Approve an Artifact Candidate", - "description": "Commit the reviewed proposal and mark the Candidate approved in one transaction.", - "operationId": "approve_artifact_candidate", + "tags": ["skill"], + "summary": "List remote Agent Skill target status", + "description": "Return credential-free target " + "metadata and desired/observed " + "publication state for one scope.", + "operationId": "list_remote_skill_targets", "requestBody": { "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/ApproveArtifactCandidateRequest"}} + "application/json": {"schema": {"$ref": "#/components/schemas/ListRemoteSkillTargetsRequest"}} }, "required": True, }, "responses": { "200": { - "description": "The approved Candidate and exact result Artifact.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArtifactCandidate"}}}, + "description": "Remote target status rows visible to the administrative caller.", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ListRemoteSkillTargetsResponse"} + } + }, }, - "404": {"$ref": "#/components/responses/NotFound"}, - "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, @@ -971,28 +965,28 @@ }, } }, - "/v1/artifact-candidates/reject": { + "/v1/skill/remote/target/create": { "post": { - "tags": ["review"], - "summary": "Reject an Artifact Candidate", - "description": "Move the exact pending " - "version to its rejected " - "terminal state without " - "writing an Artifact.", - "operationId": "reject_artifact_candidate", + "tags": ["skill"], + "summary": "Create a remote Agent Skill target enrollment", + "description": "Create a pending project " + "target and return one " + "short-lived enrollment code " + "exactly once.", + "operationId": "create_remote_skill_target", "requestBody": { "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/RejectArtifactCandidateRequest"}} + "application/json": {"schema": {"$ref": "#/components/schemas/CreateRemoteSkillTargetRequest"}} }, "required": True, }, "responses": { - "200": { - "description": "The rejected Candidate.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArtifactCandidate"}}}, + "201": { + "description": "Pending remote target enrollment.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/RemoteSkillTargetEnrollment"}} + }, }, - "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, @@ -1001,1528 +995,2606 @@ }, } }, - "/v1/artifact-candidates/revise": { + "/v1/skill/remote/target/enroll": { "post": { - "tags": ["review"], - "summary": "Revise an Artifact Candidate", - "description": "Append a complete replacement proposal as the next immutable pending version.", - "operationId": "revise_artifact_candidate", + "tags": ["skill"], + "summary": "Enroll a remote Agent Skill Receiver", + "description": "Consume one short-lived " + "enrollment code and return " + "a per-target credential " + "exactly once.", + "operationId": "enroll_remote_skill_target", "requestBody": { "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/ReviseArtifactCandidateRequest"}} + "application/json": {"schema": {"$ref": "#/components/schemas/EnrollRemoteSkillTargetRequest"}} }, "required": True, }, "responses": { "200": { - "description": "The next pending Candidate version.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArtifactCandidate"}}}, + "description": "Activated remote target credential.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/RemoteSkillTargetCredential"}} + }, }, - "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, - "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "security": [], } }, - "/v1/stats": { - "get": { - "tags": ["stats"], - "summary": "Get scoped product statistics", - "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"}, + "/v1/skill/remote/target/rename": { + "post": { + "tags": ["skill"], + "summary": "Rename a remote Agent Skill target", + "description": "Change the human-readable " + "target name with target " + "generation CAS while " + "retaining its durable " + "identity.", + "operationId": "rename_remote_skill_target", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/RenameRemoteSkillTargetRequest"}} }, - ], + "required": True, + }, "responses": { "200": { - "description": "Current inventory, model usage, and recall token estimates for the scope.", - "headers": { - "X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}, - "Cache-Control": { - "description": "Prevent caches from retaining scoped statistics.", - "schema": {"type": "string", "enum": ["no-store"]}, - }, - }, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScopedStats"}}}, + "description": "Renamed remote target.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RemoteSkillTarget"}}}, }, "401": {"$ref": "#/components/responses/Unauthorized"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, - "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, } }, - "/v1/handoff-reports/projects/create": { + "/v1/skill/remote/target/revoke": { "post": { - "tags": ["handoff-reports"], - "summary": "Create a Handoff Report Project", - "operationId": "create_handoff_report_project", + "tags": ["skill"], + "summary": "Revoke a remote Agent Skill target", + "description": "Revoke the per-target " + "credential with target " + "generation CAS while " + "retaining durable identity.", + "operationId": "revoke_remote_skill_target", "requestBody": { "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/CreateHandoffReportProjectRequest"} - } + "application/json": {"schema": {"$ref": "#/components/schemas/RevokeRemoteSkillTargetRequest"}} }, "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"}}}, + "200": { + "description": "Revoked remote target.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RemoteSkillTarget"}}}, }, - "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, } }, - "/v1/handoff-reports/projects/list": { + "/v1/skill/remote/publication/publish": { "post": { - "tags": ["handoff-reports"], - "summary": "List Handoff Report Projects", - "operationId": "list_handoff_report_projects", + "tags": ["skill"], + "summary": "Set a remote target Skill desired Revision", + "description": "Advance only " + "Server-owned desired " + "state; delivery is " + "confirmed later by an " + "exact Receipt.", + "operationId": "publish_remote_skill", "requestBody": { "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/ListHandoffReportProjectsRequest"} - } + "application/json": {"schema": {"$ref": "#/components/schemas/PublishRemoteSkillRequest"}} }, "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"}}}, + "description": "Latest remote publication desired state.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/RemoteSkillPublication"}} + }, }, "401": {"$ref": "#/components/responses/Unauthorized"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, } }, - "/v1/handoff-reports/scopes/list-known": { + "/v1/skill/remote/publication/unpublish": { "post": { - "tags": ["handoff-reports"], - "summary": "List scopes that contain a committed Handoff", - "operationId": "list_handoff_report_known_scopes", + "tags": ["skill"], + "summary": "Set remote target Skill desired absence", + "description": "Advance desired " + "state without " + "claiming that any " + "remote directory " + "has already been " + "removed.", + "operationId": "unpublish_remote_skill", "requestBody": { "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/ListHandoffReportKnownScopesRequest"} - } + "application/json": {"schema": {"$ref": "#/components/schemas/UnpublishRemoteSkillRequest"}} }, "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": "Latest remote publication desired state.", "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/KnownHandoffScopePage"}} + "application/json": {"schema": {"$ref": "#/components/schemas/RemoteSkillPublication"}} }, }, "401": {"$ref": "#/components/responses/Unauthorized"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, } }, - "/v1/handoff-reports/projects/get": { + "/v1/skill/remote/reconcile": { "post": { - "tags": ["handoff-reports"], - "summary": "Get a Handoff Report Project", - "operationId": "get_handoff_report_project", + "tags": ["skill"], + "summary": "Reconcile a remote Agent Skill target", + "description": "Authenticate one target and " + "return only latest-generation " + "idempotent install or unpublish " + "actions.", + "operationId": "reconcile_remote_skills", "requestBody": { "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/GetHandoffReportProjectRequest"}} + "application/json": {"schema": {"$ref": "#/components/schemas/ReconcileRemoteSkillsRequest"}} }, "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"}}}, + "description": "Latest desired-state actions for this target only.", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ReconcileRemoteSkillsResponse"} + } + }, }, - "404": {"$ref": "#/components/responses/NotFound"}, "401": {"$ref": "#/components/responses/Unauthorized"}, + "409": {"$ref": "#/components/responses/Conflict"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "500": {"$ref": "#/components/responses/InternalError"}, }, + "security": [{"TargetBearerAuth": []}], } }, - "/v1/handoff-reports/projects/update": { + "/v1/skill/remote/package/download": { "post": { - "tags": ["handoff-reports"], - "summary": "Update a Handoff Report Project", - "operationId": "update_handoff_report_project", + "tags": ["skill"], + "summary": "Download the exact package desired by a remote target", + "description": "Return canonical ZIP " + "bytes only when target, " + "generation, Artifact " + "Revision, and package " + "reference all match.", + "operationId": "download_remote_skill_package", "requestBody": { "content": { "application/json": { - "schema": {"$ref": "#/components/schemas/UpdateHandoffReportProjectRequest"} + "schema": {"$ref": "#/components/schemas/DownloadRemoteSkillPackageRequest"} } }, "required": True, }, "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"}}}, + "description": "Canonical exact package archive.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SkillPackageDownload"}} + }, }, + "401": {"$ref": "#/components/responses/Unauthorized"}, "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"}, }, + "security": [{"TargetBearerAuth": []}], } }, - "/v1/handoff-reports/workstreams/register": { + "/v1/skill/remote/receipt": { "post": { - "tags": ["handoff-reports"], - "summary": "Register a Handoff Report Workstream", - "operationId": "register_handoff_report_workstream", + "tags": ["skill"], + "summary": "Record an exact remote Skill delivery Receipt", + "description": "Update latest observed state only " + "after credential, generation, " + "Artifact, operation, and digest " + "validation.", + "operationId": "record_remote_skill_receipt", "requestBody": { "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/RegisterHandoffReportWorkstreamRequest"} - } + "application/json": {"schema": {"$ref": "#/components/schemas/RecordRemoteSkillReceiptRequest"}} }, "required": True, }, "responses": { - "201": { - "description": "The registered Report Workstream.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "200": { + "description": "Receipt acceptance and latest publication observation.", "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/WorkstreamDescriptor"}} + "application/json": {"schema": {"$ref": "#/components/schemas/RemoteSkillReceiptResponse"}} }, }, + "401": {"$ref": "#/components/responses/Unauthorized"}, "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"}, }, + "security": [{"TargetBearerAuth": []}], } }, - "/v1/handoff-reports/workstreams/list": { + "/v1/external-skills/scan": { "post": { - "tags": ["handoff-reports"], - "summary": "List Handoff Report Workstreams", - "operationId": "list_handoff_report_workstreams", + "tags": ["skill"], + "summary": "Scan configured external Skill roots", + "description": "Replace the current host-local " + "Registry projection without " + "copying or rewriting package " + "content.", + "operationId": "scan_external_skills", "requestBody": { "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/ListHandoffReportWorkstreamsRequest"} - } + "application/json": {"schema": {"$ref": "#/components/schemas/ScanExternalSkillsRequest"}} }, "required": True, }, "responses": { "200": { - "description": "A cursor-paginated page of Report Workstreams.", + "description": "The rebuildable provider snapshot.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WorkstreamPage"}}}, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ScanExternalSkillsResponse"}} + }, }, - "404": {"$ref": "#/components/responses/NotFound"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, } }, - "/v1/handoff-reports/workstreams/update": { + "/v1/external-skills/list": { "post": { - "tags": ["handoff-reports"], - "summary": "Update a Handoff Report Workstream", - "operationId": "update_handoff_report_workstream", + "tags": ["skill"], + "summary": "List external Skills visible on this host", + "description": "Return live local resolutions; " + "unavailable registrations are " + "omitted unless explicitly " + "requested.", + "operationId": "list_external_skills", "requestBody": { "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/UpdateHandoffReportWorkstreamRequest"} - } + "application/json": {"schema": {"$ref": "#/components/schemas/ListExternalSkillsRequest"}} }, "required": True, }, "responses": { "200": { - "description": "The updated Report Workstream descriptor.", + "description": "External " + "Skills " + "resolved " + "against the " + "current " + "Agent, " + "host, " + "scope, and " + "fingerprint.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/WorkstreamDescriptor"}} + "application/json": {"schema": {"$ref": "#/components/schemas/ListExternalSkillsResponse"}} }, }, - "404": {"$ref": "#/components/responses/NotFound"}, - "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, } }, - "/v1/handoff-reports/get": { + "/v1/external-skills/resolve": { "post": { - "tags": ["handoff-reports"], - "summary": "Generate a Handoff Report", - "operationId": "get_handoff_report", + "tags": ["skill"], + "summary": "Resolve an exact external Skill fingerprint", + "description": "Resolve only the registered " + "local package version " + "requested by the caller; never " + "install or fall back.", + "operationId": "resolve_external_skill", "requestBody": { "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/GetHandoffReportRequest"}} + "application/json": {"schema": {"$ref": "#/components/schemas/ResolveExternalSkillRequest"}} }, "required": True, }, "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"}, - }, - }, + "description": "The live exact-resolution result, which may be unavailable.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/HandoffReportResponse"}}, - "text/markdown": {"schema": {"type": "string"}}, + "application/json": {"schema": {"$ref": "#/components/schemas/ExternalSkillResolution"}} }, }, "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": { + "/v1/external-skills/import": { "post": { - "tags": ["handoff-reports"], - "summary": "Record a Handoff Report Activity", - "operationId": "record_handoff_report_activity", + "tags": ["skill"], + "summary": "Import or fork an external Skill into Review", + "description": "Capture one exact local " + "snapshot and use the configured " + "model to propose a new managed " + "Skill Candidate.", + "operationId": "import_external_skill", "requestBody": { "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/RecordHandoffReportActivityRequest"} - } + "application/json": {"schema": {"$ref": "#/components/schemas/ImportExternalSkillRequest"}} }, "required": True, }, "responses": { - "201": { - "description": "The idempotently recorded Report Activity.", + "200": { + "description": "A pending managed Skill Candidate or an explicit semantic no-op.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/StoredHandoffReportActivity"}} + "application/json": {"schema": {"$ref": "#/components/schemas/GeneratedCandidateResponse"}} }, }, "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, } }, - "/v1/handoff-reports/activities/list": { + "/v1/artifact-candidates/list": { "post": { - "tags": ["handoff-reports"], - "summary": "List Handoff Report Activities", - "operationId": "list_handoff_report_activities", + "tags": ["review"], + "summary": "List Artifact Candidates", + "description": "Page current Candidate heads; pending is the default Review Inbox view.", + "operationId": "list_artifact_candidates", "requestBody": { "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/ListHandoffReportActivitiesRequest"} - } + "application/json": {"schema": {"$ref": "#/components/schemas/ListArtifactCandidatesRequest"}} }, "required": True, }, "responses": { "200": { - "description": "A frozen cursor page of Report Activities.", + "description": "The selected current Candidate heads.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/HandoffReportActivityPage"}} + "application/json": {"schema": {"$ref": "#/components/schemas/ArtifactCandidatePage"}} }, }, - "404": {"$ref": "#/components/responses/NotFound"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, } }, - "/v1/handoff-reports/activities/purge": { + "/v1/artifact-candidates/get": { "post": { - "tags": ["handoff-reports"], - "summary": "Purge Handoff Report Activities", - "operationId": "purge_handoff_report_activities", + "tags": ["review"], + "summary": "Get an Artifact Candidate", + "description": "Read the current head and exact immutable proposal version.", + "operationId": "get_artifact_candidate", "requestBody": { "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/PurgeHandoffReportActivitiesRequest"} - } + "application/json": {"schema": {"$ref": "#/components/schemas/GetArtifactCandidateRequest"}} }, "required": True, }, "responses": { "200": { - "description": "The number of deleted Report-owned Activity rows.", + "description": "The current Candidate head.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/PurgeHandoffReportActivitiesResponse"} - } - }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArtifactCandidate"}}}, }, "404": {"$ref": "#/components/responses/NotFound"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, } }, - "/v1/handoff-reports/workspace-bindings/get": { + "/v1/artifact-candidates/approve": { "post": { - "tags": ["handoff-reports"], - "summary": "Get a Handoff Report Workspace Binding", - "operationId": "get_handoff_report_workspace", + "tags": ["review"], + "summary": "Approve an Artifact Candidate", + "description": "Commit the reviewed proposal and mark the Candidate approved in one transaction.", + "operationId": "approve_artifact_candidate", "requestBody": { "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/GetHandoffReportWorkspaceRequest"} - } + "application/json": {"schema": {"$ref": "#/components/schemas/ApproveArtifactCandidateRequest"}} }, "required": True, }, "responses": { "200": { - "description": "The confirmed Workspace binding.", + "description": "The approved Candidate and exact result Artifact.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/HandoffReportWorkspaceBinding"} - } - }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArtifactCandidate"}}}, }, "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, } }, - "/v1/handoff-reports/workspace-bindings/attach": { + "/v1/artifact-candidates/reject": { "post": { - "tags": ["handoff-reports"], - "summary": "Attach a Handoff Report Workspace Binding", - "operationId": "attach_handoff_report_workspace", + "tags": ["review"], + "summary": "Reject an Artifact Candidate", + "description": "Move the exact pending " + "version to its rejected " + "terminal state without " + "writing an Artifact.", + "operationId": "reject_artifact_candidate", "requestBody": { "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/AttachHandoffReportWorkspaceRequest"} - } + "application/json": {"schema": {"$ref": "#/components/schemas/RejectArtifactCandidateRequest"}} }, "required": True, }, "responses": { "200": { - "description": "The confirmed Workspace binding.", + "description": "The rejected Candidate.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/HandoffReportWorkspaceBinding"} - } - }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArtifactCandidate"}}}, }, "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, } }, - "/v1/handoff-reports/workspace-bindings/detach": { + "/v1/artifact-candidates/revise": { "post": { - "tags": ["handoff-reports"], - "summary": "Detach a Handoff Report Workspace Binding", - "operationId": "detach_handoff_report_workspace", + "tags": ["review"], + "summary": "Revise an Artifact Candidate", + "description": "Append a complete replacement proposal as the next immutable pending version.", + "operationId": "revise_artifact_candidate", "requestBody": { "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/DetachHandoffReportWorkspaceRequest"} - } + "application/json": {"schema": {"$ref": "#/components/schemas/ReviseArtifactCandidateRequest"}} }, "required": True, }, "responses": { "200": { - "description": "The detached Workspace binding record.", + "description": "The next pending Candidate version.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/HandoffReportWorkspaceBinding"} - } - }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArtifactCandidate"}}}, }, "404": {"$ref": "#/components/responses/NotFound"}, "409": {"$ref": "#/components/responses/Conflict"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, } }, - }, - "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": [], - }, - "max_bytes": {"type": "integer", "maximum": 32768.0, "minimum": 512.0, "default": 8000}, - }, - "additionalProperties": False, - "type": "object", - "required": ["scope_id", "boundary_source", "objective"], - }, - "ArtifactReference": { + "/v1/stats": { + "get": { + "tags": ["stats"], + "summary": "Get scoped product statistics", + "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"}, + }, + ], + "responses": { + "200": { + "description": "Current inventory, model usage, and recall token estimates for the scope.", + "headers": { + "X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}, + "Cache-Control": { + "description": "Prevent caches from retaining scoped statistics.", + "schema": {"type": "string", "enum": ["no-store"]}, + }, + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScopedStats"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$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": { + "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": { + "post": { + "tags": ["handoff-reports"], + "summary": "List scopes that contain a committed Handoff", + "operationId": "list_handoff_report_known_scopes", + "requestBody": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ListHandoffReportKnownScopesRequest"} + } + }, + "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"}}, + "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": { + "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"}, + "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"} + } + }, + "required": True, + }, + "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"}, + }, + } + }, + "/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, + }, + "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": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/GetHandoffReportRequest"}} + }, + "required": True, + }, + "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": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/RecordHandoffReportActivityRequest"} + } + }, + "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"}} + }, + }, + "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"} + } + }, + "required": True, + }, + "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": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/PurgeHandoffReportActivitiesRequest"} + } + }, + "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"} + } + }, + }, + "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": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/GetHandoffReportWorkspaceRequest"} + } + }, + "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"} + } + }, + }, + "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"} + } + }, + "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"} + } + }, + }, + "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"} + } + }, + "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"}, + }, + } + }, + }, + "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": [], + }, + "max_bytes": {"type": "integer", "maximum": 32768.0, "minimum": 512.0, "default": 8000}, + }, + "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}, + }, + "additionalProperties": False, + "type": "object", + "required": ["family", "artifact_id", "revision"], + }, + "ArtifactCandidate": { + "properties": { + "candidate_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "version": {"type": "integer", "minimum": 1.0}, + "family": {"$ref": "#/components/schemas/CandidateFamily"}, + "status": {"$ref": "#/components/schemas/CandidateStatus"}, + "proposal": { + "oneOf": [ + {"$ref": "#/components/schemas/ExperienceProposal"}, + {"$ref": "#/components/schemas/SkillProposal"}, + ] + }, + "source_refs": { + "items": {"$ref": "#/components/schemas/SourceReference"}, + "type": "array", + "maxItems": 32, + "description": "Exact " + "Source " + "evidence. " + "Counted " + "with " + "artifact_refs " + "toward " + "a " + "combined " + "maximum " + "of " + "32 " + "references.", + }, + "artifact_refs": { + "items": {"$ref": "#/components/schemas/ArtifactReference"}, + "type": "array", + "maxItems": 32, + "description": "Exact " + "Artifact " + "evidence. " + "Counted " + "with " + "source_refs " + "toward " + "a " + "combined " + "maximum " + "of " + "32 " + "references.", + }, + "target": {"$ref": "#/components/schemas/ArtifactReference", "nullable": True}, + "reason": {"type": "string", "maxLength": 2000, "minLength": 1, "nullable": True}, + "result_artifact": {"$ref": "#/components/schemas/ArtifactReference", "nullable": True}, + "decision_reason": {"type": "string", "maxLength": 2000, "minLength": 1, "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": [ + "candidate_id", + "version", + "family", + "status", + "proposal", + "source_refs", + "artifact_refs", + "target", + "reason", + "result_artifact", + "decision_reason", + ], + }, + "ArtifactCandidatePage": { + "properties": { + "candidates": {"items": {"$ref": "#/components/schemas/ArtifactCandidate"}, "type": "array"}, + "next_cursor": {"type": "string", "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["candidates", "next_cursor"], + }, + "ApproveArtifactCandidateRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "candidate_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "expected_version": {"type": "integer", "minimum": 1.0}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "candidate_id", "expected_version"], + }, + "Capabilities": { + "properties": { + "source_types": {"items": {"type": "string"}, "type": "array"}, + "artifact_families": {"items": {"type": "string"}, "type": "array"}, + "memory_extraction": { + "type": "boolean", + "description": "Whether pending Sources can be extracted into Memory.", + }, + "experience_generation": { + "type": "boolean", + "description": "Whether the configured model can generate reviewed Experience Candidates.", + "default": False, + }, + "managed_skill_generation": { + "type": "boolean", + "description": "Whether the configured model can generate reviewed managed Skill Candidates.", + "default": False, + }, + "external_skill_registry": { + "type": "boolean", + "description": "Whether " + "host-local " + "external " + "Skill " + "discovery " + "and " + "exact " + "resolution " + "are " + "configured.", + "default": False, + }, + "handoff_generation": { + "type": "boolean", + "description": "Whether exact evidence can be generated into an inspectable Handoff Draft.", + }, + "search_modes": {"items": {"$ref": "#/components/schemas/MemorySearchMode"}, "type": "array"}, + "context_versions": { + "items": {"$ref": "#/components/schemas/PreparedContextSchema"}, + "type": "array", + }, + }, + "additionalProperties": False, + "type": "object", + "required": [ + "source_types", + "artifact_families", + "memory_extraction", + "handoff_generation", + "search_modes", + "context_versions", + ], + }, + "FamilyCount": { + "properties": { + "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "total": {"type": "integer", "minimum": 0.0}, + }, + "additionalProperties": False, + "type": "object", + "required": ["family", "total"], + }, + "CandidateFamilyCount": { + "properties": { + "family": {"$ref": "#/components/schemas/CandidateFamily"}, + "total": {"type": "integer", "minimum": 0.0}, + "pending": {"type": "integer", "minimum": 0.0}, + "approved": {"type": "integer", "minimum": 0.0}, + "rejected": {"type": "integer", "minimum": 0.0}, + }, + "additionalProperties": False, + "type": "object", + "required": ["family", "total", "pending", "approved", "rejected"], + }, + "MemoryKindCount": { + "properties": { + "kind": {"type": "string", "maxLength": 128, "minLength": 1}, + "total": {"type": "integer", "minimum": 0.0}, + "active": {"type": "integer", "minimum": 0.0}, + "inactive": {"type": "integer", "minimum": 0.0}, + }, + "additionalProperties": False, + "type": "object", + "required": ["kind", "total", "active", "inactive"], + }, + "SourceInventoryStatistics": { + "properties": { + "total": {"type": "integer", "minimum": 0.0}, + "memory_processed": {"type": "integer", "minimum": 0.0}, + "memory_pending": {"type": "integer", "minimum": 0.0}, + }, + "additionalProperties": False, + "type": "object", + "required": ["total", "memory_processed", "memory_pending"], + }, + "ArtifactInventoryStatistics": { + "properties": { + "total": {"type": "integer", "minimum": 0.0}, + "by_family": {"items": {"$ref": "#/components/schemas/FamilyCount"}, "type": "array"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["total", "by_family"], + }, + "CandidateInventoryStatistics": { + "properties": { + "total": {"type": "integer", "minimum": 0.0}, + "pending": {"type": "integer", "minimum": 0.0}, + "approved": {"type": "integer", "minimum": 0.0}, + "rejected": {"type": "integer", "minimum": 0.0}, + "by_family": {"items": {"$ref": "#/components/schemas/CandidateFamilyCount"}, "type": "array"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["total", "pending", "approved", "rejected", "by_family"], + }, + "MemoryEntryInventoryStatistics": { + "properties": { + "total": {"type": "integer", "minimum": 0.0}, + "active": {"type": "integer", "minimum": 0.0}, + "inactive": {"type": "integer", "minimum": 0.0}, + "by_kind": {"items": {"$ref": "#/components/schemas/MemoryKindCount"}, "type": "array"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["total", "active", "inactive", "by_kind"], + }, + "MemoryInventoryStatistics": { + "properties": {"entries": {"$ref": "#/components/schemas/MemoryEntryInventoryStatistics"}}, + "additionalProperties": False, + "type": "object", + "required": ["entries"], + }, + "InventoryStatistics": { + "properties": { + "sources": {"$ref": "#/components/schemas/SourceInventoryStatistics"}, + "artifacts": {"$ref": "#/components/schemas/ArtifactInventoryStatistics"}, + "candidates": {"$ref": "#/components/schemas/CandidateInventoryStatistics"}, + "memory": {"$ref": "#/components/schemas/MemoryInventoryStatistics"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["sources", "artifacts", "candidates", "memory"], + }, + "ModelUsageValue": { + "properties": { + "requests": {"type": "integer", "minimum": 0.0}, + "input_tokens": {"type": "integer", "minimum": 0.0, "nullable": True}, + "output_tokens": {"type": "integer", "minimum": 0.0, "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["requests", "input_tokens", "output_tokens"], + }, + "ModelUsageStatistics": { + "properties": { + "generation": {"$ref": "#/components/schemas/ModelUsageValue"}, + "embedding": {"$ref": "#/components/schemas/ModelUsageValue"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["generation", "embedding"], + }, + "ModelUsagePurposeBreakdown": { + "properties": { + "purpose": {"type": "string", "maxLength": 64, "minLength": 1}, + "generation": {"$ref": "#/components/schemas/ModelUsageValue"}, + "embedding": {"$ref": "#/components/schemas/ModelUsageValue"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["purpose", "generation", "embedding"], + }, + "ModelUsageDay": { + "properties": { + "date": {"type": "string", "format": "date"}, + "generation": {"$ref": "#/components/schemas/ModelUsageValue"}, + "embedding": {"$ref": "#/components/schemas/ModelUsageValue"}, + "by_purpose": { + "items": {"$ref": "#/components/schemas/ModelUsagePurposeBreakdown"}, + "type": "array", + "maxItems": 16, + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["date", "generation", "embedding", "by_purpose"], + }, + "ResolvedUsagePeriod": { + "properties": { + "preset": {"$ref": "#/components/schemas/StatsPeriod"}, + "start_date": {"type": "string", "format": "date"}, + "end_date": {"type": "string", "format": "date"}, + "timezone": {"type": "string", "enum": ["UTC"]}, + }, + "additionalProperties": False, + "type": "object", + "required": ["preset", "start_date", "end_date", "timezone"], + }, + "UsageStatistics": { + "properties": { + "period": {"$ref": "#/components/schemas/ResolvedUsagePeriod"}, + "totals": {"$ref": "#/components/schemas/ModelUsageStatistics"}, + "by_purpose": { + "items": {"$ref": "#/components/schemas/ModelUsagePurposeBreakdown"}, + "type": "array", + "maxItems": 16, + }, + "daily": {"items": {"$ref": "#/components/schemas/ModelUsageDay"}, "type": "array", "maxItems": 30}, + }, + "additionalProperties": False, + "type": "object", + "required": ["period", "totals", "by_purpose", "daily"], + }, + "TokenEstimatorProfile": { + "properties": { + "estimator_id": {"type": "string", "maxLength": 128, "minLength": 1}, + "version": {"type": "string", "maxLength": 64, "minLength": 1}, + }, + "additionalProperties": False, + "type": "object", + "required": ["estimator_id", "version"], + }, + "RecallTokenValue": { + "properties": { + "preparations": {"type": "integer", "minimum": 0.0}, + "ready_preparations": {"type": "integer", "minimum": 0.0}, + "comparable_preparations": {"type": "integer", "minimum": 0.0}, + "baseline_tokens": {"type": "integer", "minimum": 0.0}, + "recalled_tokens": {"type": "integer", "minimum": 0.0}, + "token_reduction": {"type": "integer"}, + }, + "additionalProperties": False, + "type": "object", + "required": [ + "preparations", + "ready_preparations", + "comparable_preparations", + "baseline_tokens", + "recalled_tokens", + "token_reduction", + ], + }, + "RecallTokenDay": { "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}, + "date": {"type": "string", "format": "date"}, + "preparations": {"type": "integer", "minimum": 0.0}, + "ready_preparations": {"type": "integer", "minimum": 0.0}, + "comparable_preparations": {"type": "integer", "minimum": 0.0}, + "baseline_tokens": {"type": "integer", "minimum": 0.0}, + "recalled_tokens": {"type": "integer", "minimum": 0.0}, + "token_reduction": {"type": "integer"}, }, "additionalProperties": False, "type": "object", - "required": ["family", "artifact_id", "revision"], + "required": [ + "date", + "preparations", + "ready_preparations", + "comparable_preparations", + "baseline_tokens", + "recalled_tokens", + "token_reduction", + ], }, - "ArtifactCandidate": { + "RecallTokenStatistics": { "properties": { - "candidate_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, - "version": {"type": "integer", "minimum": 1.0}, - "family": {"$ref": "#/components/schemas/CandidateFamily"}, - "status": {"$ref": "#/components/schemas/CandidateStatus"}, - "proposal": { - "oneOf": [ - {"$ref": "#/components/schemas/ExperienceProposal"}, - {"$ref": "#/components/schemas/SkillProposal"}, - ] + "period": {"$ref": "#/components/schemas/ResolvedUsagePeriod"}, + "estimator": {"$ref": "#/components/schemas/TokenEstimatorProfile", "nullable": True}, + "totals": {"$ref": "#/components/schemas/RecallTokenValue"}, + "daily": { + "items": {"$ref": "#/components/schemas/RecallTokenDay"}, + "type": "array", + "maxItems": 30, }, - "source_refs": { - "items": {"$ref": "#/components/schemas/SourceReference"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["period", "estimator", "totals", "daily"], + }, + "ScopedStats": { + "properties": { + "scope_id": {"type": "string"}, + "as_of": {"type": "string", "format": "date-time"}, + "inventory": {"$ref": "#/components/schemas/InventoryStatistics"}, + "usage": {"$ref": "#/components/schemas/UsageStatistics"}, + "recall": {"$ref": "#/components/schemas/RecallTokenStatistics"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "as_of", "inventory", "usage", "recall"], + }, + "GetStatsRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "period": {"$ref": "#/components/schemas/StatsPeriod", "default": "30d"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id"], + }, + "WorkClaimBasis": {"type": "string", "enum": ["declared", "verified"]}, + "WorkClaim": { + "properties": { + "text": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, + "basis": {"$ref": "#/components/schemas/WorkClaimBasis"}, + "evidence": { + "items": {"$ref": "#/components/schemas/HandoffCitation"}, "type": "array", - "maxItems": 32, - "description": "Exact " - "Source " - "evidence. " - "Counted " - "with " - "artifact_refs " - "toward " - "a " - "combined " - "maximum " - "of " - "32 " - "references.", + "maxItems": 31, }, - "artifact_refs": { - "items": {"$ref": "#/components/schemas/ArtifactReference"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["text", "basis", "evidence"], + }, + "WorkContract": { + "properties": { + "schema": {"type": "string", "enum": ["powercontext.work-contract.v1"]}, + "trust": {"type": "string", "enum": ["untrusted_input"]}, + "objective": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, + "facts": {"items": {"$ref": "#/components/schemas/WorkClaim"}, "type": "array", "maxItems": 64}, + "in_scope": { + "items": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, "type": "array", - "maxItems": 32, - "description": "Exact " - "Artifact " - "evidence. " - "Counted " - "with " - "source_refs " - "toward " - "a " - "combined " - "maximum " - "of " - "32 " - "references.", + "maxItems": 64, + "minItems": 1, + }, + "exclusions": { + "items": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, + "type": "array", + "maxItems": 64, + }, + "completion_criteria": { + "items": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, + "type": "array", + "maxItems": 64, + "minItems": 1, + }, + "authorization_notes": { + "items": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, + "type": "array", + "maxItems": 64, + }, + "open_questions": { + "items": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, + "type": "array", + "maxItems": 64, + }, + }, + "additionalProperties": False, + "type": "object", + "required": [ + "schema", + "trust", + "objective", + "facts", + "in_scope", + "exclusions", + "completion_criteria", + "authorization_notes", + "open_questions", + ], + }, + "CreateWorkContractRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "source_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "contract": {"$ref": "#/components/schemas/WorkContract"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "source_id", "contract"], + }, + "CurrentWorkHandoff": { + "properties": { + "schema": {"type": "string", "enum": ["powercontext.current-work-handoff.v1"]}, + "trust": {"type": "string", "enum": ["untrusted_input"]}, + "objective": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, + "state": { + "items": {"$ref": "#/components/schemas/WorkClaim"}, + "type": "array", + "maxItems": 64, + "minItems": 1, + }, + "disposition": {"$ref": "#/components/schemas/HandoffDisposition"}, + "next_action": {"$ref": "#/components/schemas/WorkClaim", "nullable": True}, + "omissions": { + "items": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, + "type": "array", + "maxItems": 64, + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["schema", "trust", "objective", "state", "disposition", "next_action", "omissions"], + }, + "HandoffCurrentWorkRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "source_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "handoff": {"$ref": "#/components/schemas/CurrentWorkHandoff"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "source_id", "handoff"], + }, + "WorkSourceKind": { + "type": "string", + "enum": ["work-contract", "handoff-boundary", "handoff-receipt", "task-outcome"], + }, + "WorkSourceReceipt": { + "properties": { + "kind": {"$ref": "#/components/schemas/WorkSourceKind"}, + "source": {"$ref": "#/components/schemas/SourceReference"}, + "position": {"type": "integer", "minimum": 1.0}, + "content_digest": { + "type": "string", + "maxLength": 71, + "minLength": 71, + "pattern": "^sha256:[0-9a-f]{64}$", + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["kind", "source", "position", "content_digest"], + }, + "PreparedWorkHandoff": { + "properties": { + "boundary": {"$ref": "#/components/schemas/WorkSourceReceipt"}, + "handoff": {"$ref": "#/components/schemas/PreparedHandoff"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["boundary", "handoff"], + }, + "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": { + "properties": { + "live_state": {"$ref": "#/components/schemas/LiveStateCheckStatus"}, + "capability": {"$ref": "#/components/schemas/ReceiverReadinessCheckStatus"}, + "authorization": {"$ref": "#/components/schemas/ReceiverReadinessCheckStatus"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["live_state", "capability", "authorization"], + "description": "Untrusted receiver self-attestation " + "kept separate from citation " + "availability. All three values must " + "be confirmed when status is " + "accepted.", + }, + "AcknowledgeHandoffRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "source_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "receiver": {"type": "string", "maxLength": 256, "minLength": 1, "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": { + "type": "string", + "maxLength": 8192, + "minLength": 1, + "pattern": ".*\\S.*", + "nullable": True, }, - "target": {"$ref": "#/components/schemas/ArtifactReference", "nullable": True}, - "reason": {"type": "string", "maxLength": 2000, "minLength": 1, "nullable": True}, - "result_artifact": {"$ref": "#/components/schemas/ArtifactReference", "nullable": True}, - "decision_reason": {"type": "string", "maxLength": 2000, "minLength": 1, "nullable": True}, }, "additionalProperties": False, "type": "object", - "required": [ - "candidate_id", - "version", - "family", - "status", - "proposal", - "source_refs", - "artifact_refs", - "target", - "reason", - "result_artifact", - "decision_reason", - ], + "required": ["scope_id", "source_id", "receiver", "status", "selection"], }, - "ArtifactCandidatePage": { + "HandoffAcknowledgement": { "properties": { - "candidates": {"items": {"$ref": "#/components/schemas/ArtifactCandidate"}, "type": "array"}, - "next_cursor": {"type": "string", "nullable": True}, + "resolution": {"$ref": "#/components/schemas/HandoffResolution"}, + "receipt": {"$ref": "#/components/schemas/WorkSourceReceipt"}, }, "additionalProperties": False, "type": "object", - "required": ["candidates", "next_cursor"], + "required": ["resolution", "receipt"], }, - "ApproveArtifactCandidateRequest": { + "TaskOutcomeStatus": { + "type": "string", + "enum": ["succeeded", "partial", "blocked", "failed", "cancelled", "unknown"], + }, + "TaskCheckStatus": { + "type": "string", + "enum": ["passed", "failed", "skipped", "timed_out", "unavailable", "cancelled", "unknown"], + }, + "TaskCheck": { "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "candidate_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, - "expected_version": {"type": "integer", "minimum": 1.0}, + "name": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, + "status": {"$ref": "#/components/schemas/TaskCheckStatus"}, + "details": { + "type": "string", + "maxLength": 8192, + "minLength": 1, + "pattern": ".*\\S.*", + "nullable": True, + }, + "basis": {"$ref": "#/components/schemas/WorkClaimBasis"}, + "evidence": { + "items": {"$ref": "#/components/schemas/HandoffCitation"}, + "type": "array", + "maxItems": 32, + }, }, "additionalProperties": False, "type": "object", - "required": ["scope_id", "candidate_id", "expected_version"], + "required": ["name", "status", "basis", "evidence"], }, - "Capabilities": { + "TaskOutcome": { "properties": { - "source_types": {"items": {"type": "string"}, "type": "array"}, - "artifact_families": {"items": {"type": "string"}, "type": "array"}, - "memory_extraction": { - "type": "boolean", - "description": "Whether pending Sources can be extracted into Memory.", - }, - "experience_generation": { - "type": "boolean", - "description": "Whether the configured model can generate reviewed Experience Candidates.", - "default": False, - }, - "managed_skill_generation": { - "type": "boolean", - "description": "Whether the configured model can generate reviewed managed Skill Candidates.", - "default": False, - }, - "external_skill_registry": { - "type": "boolean", - "description": "Whether " - "host-local " - "external " - "Skill " - "discovery " - "and " - "exact " - "resolution " - "are " - "configured.", - "default": False, + "schema": {"type": "string", "enum": ["powercontext.task-outcome.v1"]}, + "trust": {"type": "string", "enum": ["untrusted_observation"]}, + "objective": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, + "status": {"$ref": "#/components/schemas/TaskOutcomeStatus"}, + "summary": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, + "handoff_receipt_ref": {"$ref": "#/components/schemas/SourceReference", "nullable": True}, + "observations": { + "items": {"$ref": "#/components/schemas/WorkClaim"}, + "type": "array", + "maxItems": 64, + "minItems": 1, }, - "handoff_generation": { - "type": "boolean", - "description": "Whether exact evidence can be generated into an inspectable Handoff Draft.", + "checks": {"items": {"$ref": "#/components/schemas/TaskCheck"}, "type": "array", "maxItems": 64}, + "produced_artifacts": { + "items": {"$ref": "#/components/schemas/ArtifactReference"}, + "type": "array", + "maxItems": 32, }, - "search_modes": {"items": {"$ref": "#/components/schemas/MemorySearchMode"}, "type": "array"}, - "context_versions": { - "items": {"$ref": "#/components/schemas/PreparedContextSchema"}, + "remaining_work": { + "items": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, "type": "array", + "maxItems": 64, }, }, "additionalProperties": False, "type": "object", "required": [ - "source_types", - "artifact_families", - "memory_extraction", - "handoff_generation", - "search_modes", - "context_versions", + "schema", + "trust", + "objective", + "status", + "summary", + "observations", + "checks", + "produced_artifacts", + "remaining_work", ], }, - "FamilyCount": { + "RecordTaskOutcomeRequest": { "properties": { - "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, - "total": {"type": "integer", "minimum": 0.0}, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "source_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "outcome": {"$ref": "#/components/schemas/TaskOutcome"}, }, "additionalProperties": False, "type": "object", - "required": ["family", "total"], + "required": ["scope_id", "source_id", "outcome"], }, - "CandidateFamilyCount": { + "CaptureContentSourceRequest": { "properties": { - "family": {"$ref": "#/components/schemas/CandidateFamily"}, - "total": {"type": "integer", "minimum": 0.0}, - "pending": {"type": "integer", "minimum": 0.0}, - "approved": {"type": "integer", "minimum": 0.0}, - "rejected": {"type": "integer", "minimum": 0.0}, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "source_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "content": {"type": "string", "maxLength": 200000, "minLength": 1}, + "metadata": {"additionalProperties": True, "type": "object", "nullable": True}, }, "additionalProperties": False, "type": "object", - "required": ["family", "total", "pending", "approved", "rejected"], + "required": ["scope_id", "source_id", "content"], }, - "MemoryKindCount": { + "CaptureContentSourceResponse": { "properties": { - "kind": {"type": "string", "maxLength": 128, "minLength": 1}, - "total": {"type": "integer", "minimum": 0.0}, - "active": {"type": "integer", "minimum": 0.0}, - "inactive": {"type": "integer", "minimum": 0.0}, + "status": {"$ref": "#/components/schemas/CaptureStatus"}, + "source": {"$ref": "#/components/schemas/SourceReference"}, + "position": {"type": "integer", "minimum": 1.0}, }, "additionalProperties": False, "type": "object", - "required": ["kind", "total", "active", "inactive"], + "required": ["status", "source", "position"], }, - "SourceInventoryStatistics": { + "CommitHandoffRequest": { "properties": { - "total": {"type": "integer", "minimum": 0.0}, - "memory_processed": {"type": "integer", "minimum": 0.0}, - "memory_pending": {"type": "integer", "minimum": 0.0}, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "handoff": {"$ref": "#/components/schemas/PreparedHandoff"}, }, "additionalProperties": False, "type": "object", - "required": ["total", "memory_processed", "memory_pending"], + "required": ["scope_id", "handoff"], }, - "ArtifactInventoryStatistics": { + "CommittedHandoff": { "properties": { - "total": {"type": "integer", "minimum": 0.0}, - "by_family": {"items": {"$ref": "#/components/schemas/FamilyCount"}, "type": "array"}, + "reference": {"$ref": "#/components/schemas/ArtifactReference"}, + "content": {"$ref": "#/components/schemas/HandoffContent"}, + "source_refs": {"items": {"$ref": "#/components/schemas/SourceReference"}, "type": "array"}, + "artifact_refs": {"items": {"$ref": "#/components/schemas/ArtifactReference"}, "type": "array"}, }, "additionalProperties": False, "type": "object", - "required": ["total", "by_family"], + "required": ["reference", "content", "source_refs", "artifact_refs"], }, - "CandidateInventoryStatistics": { + "ContinueHandoffRequest": { "properties": { - "total": {"type": "integer", "minimum": 0.0}, - "pending": {"type": "integer", "minimum": 0.0}, - "approved": {"type": "integer", "minimum": 0.0}, - "rejected": {"type": "integer", "minimum": 0.0}, - "by_family": {"items": {"$ref": "#/components/schemas/CandidateFamilyCount"}, "type": "array"}, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "selection": {"$ref": "#/components/schemas/HandoffSelection"}, + "prepared": {"$ref": "#/components/schemas/PreparedHandoff", "nullable": True}, + "revision": {"$ref": "#/components/schemas/ArtifactReference", "nullable": True}, }, "additionalProperties": False, "type": "object", - "required": ["total", "pending", "approved", "rejected", "by_family"], + "required": ["scope_id", "selection"], }, - "MemoryEntryInventoryStatistics": { + "FinalizeHandoffRequest": { "properties": { - "total": {"type": "integer", "minimum": 0.0}, - "active": {"type": "integer", "minimum": 0.0}, - "inactive": {"type": "integer", "minimum": 0.0}, - "by_kind": {"items": {"$ref": "#/components/schemas/MemoryKindCount"}, "type": "array"}, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "draft": {"$ref": "#/components/schemas/HandoffDraft"}, }, "additionalProperties": False, "type": "object", - "required": ["total", "active", "inactive", "by_kind"], + "required": ["scope_id", "draft"], }, - "MemoryInventoryStatistics": { - "properties": {"entries": {"$ref": "#/components/schemas/MemoryEntryInventoryStatistics"}}, + "HandoffArtifactCitation": { + "properties": { + "kind": {"type": "string", "enum": ["artifact"]}, + "artifact_ref": {"$ref": "#/components/schemas/ArtifactReference"}, + }, "additionalProperties": False, "type": "object", - "required": ["entries"], + "required": ["kind", "artifact_ref"], }, - "InventoryStatistics": { + "HandoffActivation": { "properties": { - "sources": {"$ref": "#/components/schemas/SourceInventoryStatistics"}, - "artifacts": {"$ref": "#/components/schemas/ArtifactInventoryStatistics"}, - "candidates": {"$ref": "#/components/schemas/CandidateInventoryStatistics"}, - "memory": {"$ref": "#/components/schemas/MemoryInventoryStatistics"}, + "status": {"$ref": "#/components/schemas/HandoffActivationStatus"}, + "boundary_source": {"$ref": "#/components/schemas/SourceReference"}, + "previous_position": {"type": "integer", "minimum": 0.0}, + "current_position": {"type": "integer", "minimum": 0.0}, + "draft": {"$ref": "#/components/schemas/HandoffDraft", "nullable": True}, }, "additionalProperties": False, "type": "object", - "required": ["sources", "artifacts", "candidates", "memory"], + "required": ["status", "boundary_source", "previous_position", "current_position", "draft"], }, - "ModelUsageValue": { - "properties": { - "requests": {"type": "integer", "minimum": 0.0}, - "input_tokens": {"type": "integer", "minimum": 0.0, "nullable": True}, - "output_tokens": {"type": "integer", "minimum": 0.0, "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", + }, }, - "additionalProperties": False, - "type": "object", - "required": ["requests", "input_tokens", "output_tokens"], }, - "ModelUsageStatistics": { + "HandoffContent": { "properties": { - "generation": {"$ref": "#/components/schemas/ModelUsageValue"}, - "embedding": {"$ref": "#/components/schemas/ModelUsageValue"}, + "schema": {"$ref": "#/components/schemas/HandoffSchema"}, + "objective": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, + "state": { + "items": {"$ref": "#/components/schemas/HandoffStatement"}, + "type": "array", + "maxItems": 64, + "minItems": 1, + }, + "disposition": {"$ref": "#/components/schemas/HandoffDisposition"}, + "next_action": {"$ref": "#/components/schemas/HandoffStatement", "nullable": True}, + "omissions": { + "items": {"$ref": "#/components/schemas/HandoffOmission"}, + "type": "array", + "maxItems": 64, + }, }, "additionalProperties": False, "type": "object", - "required": ["generation", "embedding"], + "required": ["schema", "objective", "state", "disposition", "next_action", "omissions"], }, - "ModelUsagePurposeBreakdown": { + "HandoffDraft": { "properties": { - "purpose": {"type": "string", "maxLength": 64, "minLength": 1}, - "generation": {"$ref": "#/components/schemas/ModelUsageValue"}, - "embedding": {"$ref": "#/components/schemas/ModelUsageValue"}, + "objective": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, + "state": { + "items": {"$ref": "#/components/schemas/HandoffStatement"}, + "type": "array", + "maxItems": 64, + "minItems": 1, + }, + "disposition": {"$ref": "#/components/schemas/HandoffDisposition"}, + "next_action": {"$ref": "#/components/schemas/HandoffStatement", "nullable": True}, + "omissions": { + "items": {"$ref": "#/components/schemas/HandoffOmission"}, + "type": "array", + "maxItems": 64, + }, }, "additionalProperties": False, "type": "object", - "required": ["purpose", "generation", "embedding"], + "required": ["objective", "state", "disposition", "next_action", "omissions"], }, - "ModelUsageDay": { + "HandoffEvidenceCheck": { "properties": { - "date": {"type": "string", "format": "date"}, - "generation": {"$ref": "#/components/schemas/ModelUsageValue"}, - "embedding": {"$ref": "#/components/schemas/ModelUsageValue"}, - "by_purpose": { - "items": {"$ref": "#/components/schemas/ModelUsagePurposeBreakdown"}, + "claim": {"$ref": "#/components/schemas/HandoffClaim"}, + "state_index": {"type": "integer", "minimum": 0.0, "nullable": True}, + "status": {"$ref": "#/components/schemas/HandoffEvidenceStatus"}, + "unavailable_evidence": { + "items": {"$ref": "#/components/schemas/HandoffCitation"}, "type": "array", - "maxItems": 16, + "maxItems": 32, }, }, "additionalProperties": False, "type": "object", - "required": ["date", "generation", "embedding", "by_purpose"], + "required": ["claim", "state_index", "status", "unavailable_evidence"], }, - "ResolvedUsagePeriod": { + "HandoffMemoryCitation": { "properties": { - "preset": {"$ref": "#/components/schemas/StatsPeriod"}, - "start_date": {"type": "string", "format": "date"}, - "end_date": {"type": "string", "format": "date"}, - "timezone": {"type": "string", "enum": ["UTC"]}, + "kind": {"type": "string", "enum": ["memory"]}, + "memory_citation": {"$ref": "#/components/schemas/MemoryCitation"}, }, "additionalProperties": False, "type": "object", - "required": ["preset", "start_date", "end_date", "timezone"], + "required": ["kind", "memory_citation"], }, - "UsageStatistics": { + "HandoffOmission": { "properties": { - "period": {"$ref": "#/components/schemas/ResolvedUsagePeriod"}, - "totals": {"$ref": "#/components/schemas/ModelUsageStatistics"}, - "by_purpose": { - "items": {"$ref": "#/components/schemas/ModelUsagePurposeBreakdown"}, - "type": "array", - "maxItems": 16, - }, - "daily": {"items": {"$ref": "#/components/schemas/ModelUsageDay"}, "type": "array", "maxItems": 30}, + "text": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, + "citation": {"$ref": "#/components/schemas/HandoffCitation", "nullable": True}, }, "additionalProperties": False, "type": "object", - "required": ["period", "totals", "by_purpose", "daily"], + "required": ["text", "citation"], }, - "TokenEstimatorProfile": { + "HandoffResolution": { "properties": { - "estimator_id": {"type": "string", "maxLength": 128, "minLength": 1}, - "version": {"type": "string", "maxLength": 64, "minLength": 1}, + "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": { + "items": {"$ref": "#/components/schemas/HandoffEvidenceCheck"}, + "type": "array", + "maxItems": 65, + }, }, "additionalProperties": False, "type": "object", - "required": ["estimator_id", "version"], + "required": [ + "trust", + "status", + "scope_id", + "content", + "selection", + "selected_revision", + "current_revision", + "evidence_checks", + ], }, - "RecallTokenValue": { + "HandoffSourceCitation": { "properties": { - "preparations": {"type": "integer", "minimum": 0.0}, - "ready_preparations": {"type": "integer", "minimum": 0.0}, - "comparable_preparations": {"type": "integer", "minimum": 0.0}, - "baseline_tokens": {"type": "integer", "minimum": 0.0}, - "recalled_tokens": {"type": "integer", "minimum": 0.0}, - "token_reduction": {"type": "integer"}, + "kind": {"type": "string", "enum": ["source"]}, + "source_ref": {"$ref": "#/components/schemas/SourceReference"}, }, "additionalProperties": False, "type": "object", - "required": [ - "preparations", - "ready_preparations", - "comparable_preparations", - "baseline_tokens", - "recalled_tokens", - "token_reduction", - ], + "required": ["kind", "source_ref"], }, - "RecallTokenDay": { + "HandoffStatement": { "properties": { - "date": {"type": "string", "format": "date"}, - "preparations": {"type": "integer", "minimum": 0.0}, - "ready_preparations": {"type": "integer", "minimum": 0.0}, - "comparable_preparations": {"type": "integer", "minimum": 0.0}, - "baseline_tokens": {"type": "integer", "minimum": 0.0}, - "recalled_tokens": {"type": "integer", "minimum": 0.0}, - "token_reduction": {"type": "integer"}, + "text": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, + "citations": { + "items": {"$ref": "#/components/schemas/HandoffCitation"}, + "type": "array", + "maxItems": 32, + "minItems": 1, + }, }, "additionalProperties": False, "type": "object", - "required": [ - "date", - "preparations", - "ready_preparations", - "comparable_preparations", - "baseline_tokens", - "recalled_tokens", - "token_reduction", - ], + "required": ["text", "citations"], }, - "RecallTokenStatistics": { + "PrepareHandoffRequest": { "properties": { - "period": {"$ref": "#/components/schemas/ResolvedUsagePeriod"}, - "estimator": {"$ref": "#/components/schemas/TokenEstimatorProfile", "nullable": True}, - "totals": {"$ref": "#/components/schemas/RecallTokenValue"}, - "daily": { - "items": {"$ref": "#/components/schemas/RecallTokenDay"}, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "objective": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, + "evidence": { + "items": {"$ref": "#/components/schemas/HandoffCitation"}, "type": "array", - "maxItems": 30, + "maxItems": 32, + "minItems": 1, }, + "max_bytes": {"type": "integer", "maximum": 32768.0, "minimum": 512.0, "default": 8000}, }, "additionalProperties": False, "type": "object", - "required": ["period", "estimator", "totals", "daily"], + "required": ["scope_id", "objective", "evidence"], }, - "ScopedStats": { + "PreparedHandoff": { "properties": { + "schema": {"$ref": "#/components/schemas/PreparedHandoffSchema"}, "scope_id": {"type": "string"}, - "as_of": {"type": "string", "format": "date-time"}, - "inventory": {"$ref": "#/components/schemas/InventoryStatistics"}, - "usage": {"$ref": "#/components/schemas/UsageStatistics"}, - "recall": {"$ref": "#/components/schemas/RecallTokenStatistics"}, + "base": {"$ref": "#/components/schemas/ArtifactReference", "nullable": True}, + "content": {"$ref": "#/components/schemas/HandoffContent"}, }, "additionalProperties": False, "type": "object", - "required": ["scope_id", "as_of", "inventory", "usage", "recall"], + "required": ["schema", "scope_id", "base", "content"], }, - "GetStatsRequest": { + "PreparedContext": { "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "period": {"$ref": "#/components/schemas/StatsPeriod", "default": "30d"}, + "schema": {"$ref": "#/components/schemas/PreparedContextSchema"}, + "status": {"$ref": "#/components/schemas/PreparedContextStatus"}, + "content": {"type": "string", "nullable": True}, + "content_bytes": {"type": "integer", "minimum": 0.0}, }, "additionalProperties": False, "type": "object", - "required": ["scope_id"], + "required": ["schema", "status", "content", "content_bytes"], }, - "WorkClaimBasis": {"type": "string", "enum": ["declared", "verified"]}, - "WorkClaim": { + "EntryChange": { "properties": { - "text": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, - "basis": {"$ref": "#/components/schemas/WorkClaimBasis"}, - "evidence": { - "items": {"$ref": "#/components/schemas/HandoffCitation"}, - "type": "array", - "maxItems": 31, + "op": {"$ref": "#/components/schemas/EntryChangeOperation"}, + "entry_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "from_entry_version_id": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[\\x21-\\x7E]+$", + "nullable": True, + }, + "to_entry_version_id": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[\\x21-\\x7E]+$", + "nullable": True, }, + "reason": {"type": "string", "nullable": True}, }, "additionalProperties": False, "type": "object", - "required": ["text", "basis", "evidence"], + "required": ["op", "entry_id", "from_entry_version_id", "to_entry_version_id", "reason"], }, - "WorkContract": { + "ExperienceArtifact": { "properties": { - "schema": {"type": "string", "enum": ["powercontext.work-contract.v1"]}, - "trust": {"type": "string", "enum": ["untrusted_input"]}, - "objective": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, - "facts": {"items": {"$ref": "#/components/schemas/WorkClaim"}, "type": "array", "maxItems": 64}, - "in_scope": { - "items": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, - "type": "array", - "maxItems": 64, - "minItems": 1, - }, - "exclusions": { - "items": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, - "type": "array", - "maxItems": 64, - }, - "completion_criteria": { - "items": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, - "type": "array", - "maxItems": 64, - "minItems": 1, - }, - "authorization_notes": { - "items": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, - "type": "array", - "maxItems": 64, - }, - "open_questions": { - "items": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, - "type": "array", - "maxItems": 64, - }, + "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, + "content": {"$ref": "#/components/schemas/ExperienceProposal"}, + "source_refs": {"items": {"$ref": "#/components/schemas/SourceReference"}, "type": "array"}, + "artifact_refs": {"items": {"$ref": "#/components/schemas/ArtifactReference"}, "type": "array"}, }, "additionalProperties": False, "type": "object", - "required": [ - "schema", - "trust", - "objective", - "facts", - "in_scope", - "exclusions", - "completion_criteria", - "authorization_notes", - "open_questions", - ], + "required": ["artifact", "content", "source_refs", "artifact_refs"], }, - "CreateWorkContractRequest": { + "ExperienceProposal": { "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "source_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "contract": {"$ref": "#/components/schemas/WorkContract"}, + "situation": {"type": "string", "maxLength": 8000, "minLength": 1, "pattern": ".*\\S.*"}, + "action": {"type": "string", "maxLength": 8000, "minLength": 1, "pattern": ".*\\S.*"}, + "outcome": {"type": "string", "maxLength": 8000, "minLength": 1, "pattern": ".*\\S.*"}, + "lesson": {"type": "string", "maxLength": 8000, "minLength": 1, "pattern": ".*\\S.*"}, }, "additionalProperties": False, "type": "object", - "required": ["scope_id", "source_id", "contract"], + "required": ["situation", "action", "outcome", "lesson"], }, - "CurrentWorkHandoff": { + "SkillArtifact": { "properties": { - "schema": {"type": "string", "enum": ["powercontext.current-work-handoff.v1"]}, - "trust": {"type": "string", "enum": ["untrusted_input"]}, - "objective": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, - "state": { - "items": {"$ref": "#/components/schemas/WorkClaim"}, - "type": "array", - "maxItems": 64, - "minItems": 1, - }, - "disposition": {"$ref": "#/components/schemas/HandoffDisposition"}, - "next_action": {"$ref": "#/components/schemas/WorkClaim", "nullable": True}, - "omissions": { - "items": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, - "type": "array", - "maxItems": 64, - }, + "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, + "content": {"$ref": "#/components/schemas/SkillProposal"}, + "source_refs": {"items": {"$ref": "#/components/schemas/SourceReference"}, "type": "array"}, + "artifact_refs": {"items": {"$ref": "#/components/schemas/ArtifactReference"}, "type": "array"}, }, "additionalProperties": False, "type": "object", - "required": ["schema", "trust", "objective", "state", "disposition", "next_action", "omissions"], + "required": ["artifact", "content", "source_refs", "artifact_refs"], }, - "HandoffCurrentWorkRequest": { + "SkillLifecycleState": {"type": "string", "enum": ["active", "deprecated", "retired"]}, + "SkillGovernance": { "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "source_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "handoff": {"$ref": "#/components/schemas/CurrentWorkHandoff"}, + "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, + "lifecycle_state": {"$ref": "#/components/schemas/SkillLifecycleState"}, + "replacement_artifact_id": {"type": "string", "maxLength": 128, "minLength": 1, "nullable": True}, + "governance_generation": {"type": "integer", "minimum": 0.0}, }, "additionalProperties": False, "type": "object", - "required": ["scope_id", "source_id", "handoff"], + "required": ["artifact", "lifecycle_state", "replacement_artifact_id", "governance_generation"], }, - "WorkSourceKind": { - "type": "string", - "enum": ["work-contract", "handoff-boundary", "handoff-receipt", "task-outcome"], - }, - "WorkSourceReceipt": { + "ManagedSkillLibraryEntry": { "properties": { - "kind": {"$ref": "#/components/schemas/WorkSourceKind"}, - "source": {"$ref": "#/components/schemas/SourceReference"}, - "position": {"type": "integer", "minimum": 1.0}, - "content_digest": { - "type": "string", - "maxLength": 71, - "minLength": 71, - "pattern": "^sha256:[0-9a-f]{64}$", - }, + "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, + "content": {"$ref": "#/components/schemas/SkillProposal"}, + "source_refs": {"items": {"$ref": "#/components/schemas/SourceReference"}, "type": "array"}, + "artifact_refs": {"items": {"$ref": "#/components/schemas/ArtifactReference"}, "type": "array"}, + "governance": {"$ref": "#/components/schemas/SkillGovernance"}, }, "additionalProperties": False, "type": "object", - "required": ["kind", "source", "position", "content_digest"], + "required": ["artifact", "content", "source_refs", "artifact_refs", "governance"], }, - "PreparedWorkHandoff": { + "ListManagedSkillsRequest": { "properties": { - "boundary": {"$ref": "#/components/schemas/WorkSourceReceipt"}, - "handoff": {"$ref": "#/components/schemas/PreparedHandoff"}, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "query": {"type": "string", "maxLength": 2000, "minLength": 1, "nullable": True}, + "include_deprecated": {"type": "boolean", "default": False}, + "limit": {"type": "integer", "maximum": 200.0, "minimum": 1.0, "default": 100}, }, "additionalProperties": False, "type": "object", - "required": ["boundary", "handoff"], + "required": ["scope_id"], }, - "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": { + "ListManagedSkillsResponse": { "properties": { - "live_state": {"$ref": "#/components/schemas/LiveStateCheckStatus"}, - "capability": {"$ref": "#/components/schemas/ReceiverReadinessCheckStatus"}, - "authorization": {"$ref": "#/components/schemas/ReceiverReadinessCheckStatus"}, + "skills": { + "items": {"$ref": "#/components/schemas/ManagedSkillLibraryEntry"}, + "type": "array", + "maxItems": 200, + } }, "additionalProperties": False, "type": "object", - "required": ["live_state", "capability", "authorization"], - "description": "Untrusted receiver self-attestation " - "kept separate from citation " - "availability. All three values must " - "be confirmed when status is " - "accepted.", + "required": ["skills"], }, - "AcknowledgeHandoffRequest": { + "UpdateSkillLifecycleRequest": { "properties": { "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "source_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "receiver": {"type": "string", "maxLength": 256, "minLength": 1, "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": { + "artifact_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "expected_generation": {"type": "integer", "minimum": 0.0}, + "lifecycle_state": {"$ref": "#/components/schemas/SkillLifecycleState"}, + "replacement_artifact_id": { "type": "string", - "maxLength": 8192, + "maxLength": 128, "minLength": 1, - "pattern": ".*\\S.*", + "pattern": "^[\\x21-\\x7E]+$", "nullable": True, }, }, "additionalProperties": False, "type": "object", - "required": ["scope_id", "source_id", "receiver", "status", "selection"], + "required": ["scope_id", "artifact_id", "expected_generation", "lifecycle_state"], }, - "HandoffAcknowledgement": { + "SkillProposal": { "properties": { - "resolution": {"$ref": "#/components/schemas/HandoffResolution"}, - "receipt": {"$ref": "#/components/schemas/WorkSourceReceipt"}, + "name": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^\\S(?:.*\\S)?$"}, + "description": {"type": "string", "maxLength": 2000, "minLength": 1, "pattern": "^\\S(?:.*\\S)?$"}, + "instructions": {"type": "string", "maxLength": 131072}, + "validation": { + "items": {"$ref": "#/components/schemas/SkillValidationItem"}, + "type": "array", + "maxItems": 32, + }, + "package": {"$ref": "#/components/schemas/SkillPackageReference", "nullable": True}, + "license": {"type": "string", "maxLength": 512, "minLength": 1, "nullable": True}, + "compatibility": {"type": "string", "maxLength": 500, "minLength": 1, "nullable": True}, + "metadata": {"additionalProperties": {"type": "string"}, "type": "object", "maxProperties": 64}, + "allowed_tools": {"type": "string", "maxLength": 2000, "minLength": 1, "nullable": True}, }, "additionalProperties": False, "type": "object", - "required": ["resolution", "receipt"], - }, - "TaskOutcomeStatus": { - "type": "string", - "enum": ["succeeded", "partial", "blocked", "failed", "cancelled", "unknown"], - }, - "TaskCheckStatus": { - "type": "string", - "enum": ["passed", "failed", "skipped", "timed_out", "unavailable", "cancelled", "unknown"], + "required": ["name", "description", "instructions", "validation"], }, - "TaskCheck": { + "SkillPackageReference": { "properties": { - "name": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, - "status": {"$ref": "#/components/schemas/TaskCheckStatus"}, - "details": { - "type": "string", - "maxLength": 8192, - "minLength": 1, - "pattern": ".*\\S.*", - "nullable": True, - }, - "basis": {"$ref": "#/components/schemas/WorkClaimBasis"}, - "evidence": { - "items": {"$ref": "#/components/schemas/HandoffCitation"}, - "type": "array", - "maxItems": 32, - }, + "tree_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "archive_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "file_count": {"type": "integer", "maximum": 256.0, "minimum": 1.0}, + "uncompressed_size": {"type": "integer", "maximum": 4194304.0, "minimum": 1.0}, + "archive_size": {"type": "integer", "maximum": 5242880.0, "minimum": 1.0}, }, "additionalProperties": False, "type": "object", - "required": ["name", "status", "basis", "evidence"], + "required": ["tree_digest", "archive_digest", "file_count", "uncompressed_size", "archive_size"], }, - "TaskOutcome": { + "SkillPackageFile": { "properties": { - "schema": {"type": "string", "enum": ["powercontext.task-outcome.v1"]}, - "trust": {"type": "string", "enum": ["untrusted_observation"]}, - "objective": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, - "status": {"$ref": "#/components/schemas/TaskOutcomeStatus"}, - "summary": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, - "handoff_receipt_ref": {"$ref": "#/components/schemas/SourceReference", "nullable": True}, - "observations": { - "items": {"$ref": "#/components/schemas/WorkClaim"}, - "type": "array", - "maxItems": 64, - "minItems": 1, - }, - "checks": {"items": {"$ref": "#/components/schemas/TaskCheck"}, "type": "array", "maxItems": 64}, - "produced_artifacts": { - "items": {"$ref": "#/components/schemas/ArtifactReference"}, - "type": "array", - "maxItems": 32, - }, - "remaining_work": { - "items": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, - "type": "array", - "maxItems": 64, - }, + "path": {"type": "string", "maxLength": 512, "minLength": 1}, + "digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "size": {"type": "integer", "maximum": 4194304.0, "minimum": 0.0}, + "media_type": {"type": "string", "maxLength": 255, "minLength": 1}, + "executable": {"type": "boolean"}, }, "additionalProperties": False, "type": "object", - "required": [ - "schema", - "trust", - "objective", - "status", - "summary", - "observations", - "checks", - "produced_artifacts", - "remaining_work", - ], + "required": ["path", "digest", "size", "media_type", "executable"], }, - "RecordTaskOutcomeRequest": { + "SkillPackageManifest": { "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "source_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "outcome": {"$ref": "#/components/schemas/TaskOutcome"}, + "package": {"$ref": "#/components/schemas/SkillPackageReference"}, + "name": {"type": "string", "maxLength": 64, "minLength": 1}, + "description": {"type": "string", "maxLength": 1024, "minLength": 1}, + "license": {"type": "string", "maxLength": 512, "minLength": 1, "nullable": True}, + "compatibility": {"type": "string", "maxLength": 500, "minLength": 1, "nullable": True}, + "metadata": {"additionalProperties": {"type": "string"}, "type": "object", "maxProperties": 64}, + "allowed_tools": {"type": "string", "maxLength": 2000, "minLength": 1, "nullable": True}, + "files": { + "items": {"$ref": "#/components/schemas/SkillPackageFile"}, + "type": "array", + "maxItems": 256, + "minItems": 1, + }, }, "additionalProperties": False, "type": "object", - "required": ["scope_id", "source_id", "outcome"], + "required": ["package", "name", "description", "metadata", "files"], }, - "CaptureContentSourceRequest": { + "GetSkillPackageRequest": { "properties": { "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "source_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "content": {"type": "string", "maxLength": 200000, "minLength": 1}, - "metadata": {"additionalProperties": True, "type": "object", "nullable": True}, + "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, }, "additionalProperties": False, "type": "object", - "required": ["scope_id", "source_id", "content"], + "required": ["scope_id", "artifact"], }, - "CaptureContentSourceResponse": { + "SkillPackageDownload": { "properties": { - "status": {"$ref": "#/components/schemas/CaptureStatus"}, - "source": {"$ref": "#/components/schemas/SourceReference"}, - "position": {"type": "integer", "minimum": 1.0}, + "package": {"$ref": "#/components/schemas/SkillPackageReference"}, + "archive_base64": { + "type": "string", + "maxLength": 6990508, + "minLength": 1, + "pattern": "^[A-Za-z0-9+/]*={0,2}$", + }, }, "additionalProperties": False, "type": "object", - "required": ["status", "source", "position"], + "required": ["package", "archive_base64"], }, - "CommitHandoffRequest": { + "RemoteAgentKind": {"type": "string", "enum": ["codex", "claude_code"]}, + "RemoteSkillTargetState": {"type": "string", "enum": ["pending", "active", "revoked"]}, + "RemoteSkillTarget": { "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "handoff": {"$ref": "#/components/schemas/PreparedHandoff"}, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "target_id": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + }, + "display_name": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": ".*\\S.*"}, + "agent_kind": {"$ref": "#/components/schemas/RemoteAgentKind"}, + "installation_scope": {"type": "string", "enum": ["project"]}, + "delivery_mode": {"type": "string", "enum": ["agent_pull"]}, + "installation_id": {"type": "string", "maxLength": 128, "minLength": 1, "nullable": True}, + "state": {"$ref": "#/components/schemas/RemoteSkillTargetState"}, + "receiver_version": {"type": "string", "maxLength": 64, "minLength": 1, "nullable": True}, + "environment_fingerprint": {"type": "string", "pattern": "^[0-9a-f]{64}$", "nullable": True}, + "machine_hostname": { + "type": "string", + "maxLength": 255, + "minLength": 1, + "pattern": ".*\\S.*", + "nullable": True, + }, + "workspace_name": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": ".*\\S.*", + "nullable": True, + }, + "last_seen_at": {"type": "string", "format": "date-time", "nullable": True}, + "generation": {"type": "integer", "minimum": 0.0}, }, "additionalProperties": False, "type": "object", - "required": ["scope_id", "handoff"], + "required": [ + "scope_id", + "target_id", + "display_name", + "agent_kind", + "installation_scope", + "delivery_mode", + "installation_id", + "state", + "receiver_version", + "environment_fingerprint", + "machine_hostname", + "workspace_name", + "last_seen_at", + "generation", + ], }, - "CommittedHandoff": { + "ListRemoteSkillTargetsRequest": { "properties": { - "reference": {"$ref": "#/components/schemas/ArtifactReference"}, - "content": {"$ref": "#/components/schemas/HandoffContent"}, - "source_refs": {"items": {"$ref": "#/components/schemas/SourceReference"}, "type": "array"}, - "artifact_refs": {"items": {"$ref": "#/components/schemas/ArtifactReference"}, "type": "array"}, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "target_id": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "nullable": True, + }, + "limit": {"type": "integer", "maximum": 200.0, "minimum": 1.0, "default": 100}, }, "additionalProperties": False, "type": "object", - "required": ["reference", "content", "source_refs", "artifact_refs"], + "required": ["scope_id"], }, - "ContinueHandoffRequest": { + "RemoteSkillTargetStatus": { "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "selection": {"$ref": "#/components/schemas/HandoffSelection"}, - "prepared": {"$ref": "#/components/schemas/PreparedHandoff", "nullable": True}, - "revision": {"$ref": "#/components/schemas/ArtifactReference", "nullable": True}, + "target": {"$ref": "#/components/schemas/RemoteSkillTarget"}, + "publications": { + "items": {"$ref": "#/components/schemas/RemoteSkillPublication"}, + "type": "array", + "maxItems": 256, + }, }, "additionalProperties": False, "type": "object", - "required": ["scope_id", "selection"], + "required": ["target", "publications"], }, - "FinalizeHandoffRequest": { + "ListRemoteSkillTargetsResponse": { "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "draft": {"$ref": "#/components/schemas/HandoffDraft"}, + "targets": { + "items": {"$ref": "#/components/schemas/RemoteSkillTargetStatus"}, + "type": "array", + "maxItems": 200, + } }, "additionalProperties": False, "type": "object", - "required": ["scope_id", "draft"], + "required": ["targets"], }, - "HandoffArtifactCitation": { + "CreateRemoteSkillTargetRequest": { "properties": { - "kind": {"type": "string", "enum": ["artifact"]}, - "artifact_ref": {"$ref": "#/components/schemas/ArtifactReference"}, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "agent_kind": {"$ref": "#/components/schemas/RemoteAgentKind"}, + "display_name": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": ".*\\S.*"}, }, "additionalProperties": False, "type": "object", - "required": ["kind", "artifact_ref"], + "required": ["scope_id", "agent_kind", "display_name"], }, - "HandoffActivation": { + "RemoteSkillTargetEnrollment": { "properties": { - "status": {"$ref": "#/components/schemas/HandoffActivationStatus"}, - "boundary_source": {"$ref": "#/components/schemas/SourceReference"}, - "previous_position": {"type": "integer", "minimum": 0.0}, - "current_position": {"type": "integer", "minimum": 0.0}, - "draft": {"$ref": "#/components/schemas/HandoffDraft", "nullable": True}, + "target": {"$ref": "#/components/schemas/RemoteSkillTarget"}, + "enrollment_code": {"type": "string", "maxLength": 256, "minLength": 32}, + "enrollment_expires_at": {"type": "string", "format": "date-time"}, }, "additionalProperties": False, "type": "object", - "required": ["status", "boundary_source", "previous_position", "current_position", "draft"], + "required": ["target", "enrollment_code", "enrollment_expires_at"], }, - "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", + "EnrollRemoteSkillTargetRequest": { + "properties": { + "enrollment_code": {"type": "string", "maxLength": 256, "minLength": 32}, + "installation_id": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[\\x21-\\x7E]+$", + }, + "receiver_version": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "pattern": "^[\\x21-\\x7E]+$", + }, + "environment_fingerprint": {"type": "string", "pattern": "^[0-9a-f]{64}$", "nullable": True}, + "machine_hostname": { + "type": "string", + "maxLength": 255, + "minLength": 1, + "pattern": ".*\\S.*", + "nullable": True, + }, + "workspace_name": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": ".*\\S.*", + "nullable": True, }, }, + "additionalProperties": False, + "type": "object", + "required": ["enrollment_code", "installation_id", "receiver_version"], }, - "HandoffContent": { + "RemoteSkillTargetCredential": { "properties": { - "schema": {"$ref": "#/components/schemas/HandoffSchema"}, - "objective": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, - "state": { - "items": {"$ref": "#/components/schemas/HandoffStatement"}, - "type": "array", - "maxItems": 64, - "minItems": 1, - }, - "disposition": {"$ref": "#/components/schemas/HandoffDisposition"}, - "next_action": {"$ref": "#/components/schemas/HandoffStatement", "nullable": True}, - "omissions": { - "items": {"$ref": "#/components/schemas/HandoffOmission"}, - "type": "array", - "maxItems": 64, - }, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "target_id": {"type": "string", "maxLength": 64, "minLength": 1}, + "agent_kind": {"$ref": "#/components/schemas/RemoteAgentKind"}, + "credential": {"type": "string", "maxLength": 256, "minLength": 32}, }, "additionalProperties": False, "type": "object", - "required": ["schema", "objective", "state", "disposition", "next_action", "omissions"], + "required": ["scope_id", "target_id", "agent_kind", "credential"], }, - "HandoffDraft": { + "RevokeRemoteSkillTargetRequest": { "properties": { - "objective": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, - "state": { - "items": {"$ref": "#/components/schemas/HandoffStatement"}, - "type": "array", - "maxItems": 64, - "minItems": 1, - }, - "disposition": {"$ref": "#/components/schemas/HandoffDisposition"}, - "next_action": {"$ref": "#/components/schemas/HandoffStatement", "nullable": True}, - "omissions": { - "items": {"$ref": "#/components/schemas/HandoffOmission"}, - "type": "array", - "maxItems": 64, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "target_id": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", }, + "expected_generation": {"type": "integer", "minimum": 0.0}, }, "additionalProperties": False, "type": "object", - "required": ["objective", "state", "disposition", "next_action", "omissions"], + "required": ["scope_id", "target_id", "expected_generation"], }, - "HandoffEvidenceCheck": { + "RenameRemoteSkillTargetRequest": { "properties": { - "claim": {"$ref": "#/components/schemas/HandoffClaim"}, - "state_index": {"type": "integer", "minimum": 0.0, "nullable": True}, - "status": {"$ref": "#/components/schemas/HandoffEvidenceStatus"}, - "unavailable_evidence": { - "items": {"$ref": "#/components/schemas/HandoffCitation"}, - "type": "array", - "maxItems": 32, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "target_id": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", }, + "display_name": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": ".*\\S.*"}, + "expected_generation": {"type": "integer", "minimum": 0.0}, }, "additionalProperties": False, "type": "object", - "required": ["claim", "state_index", "status", "unavailable_evidence"], + "required": ["scope_id", "target_id", "display_name", "expected_generation"], }, - "HandoffMemoryCitation": { + "PublishRemoteSkillRequest": { "properties": { - "kind": {"type": "string", "enum": ["memory"]}, - "memory_citation": {"$ref": "#/components/schemas/MemoryCitation"}, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "target_id": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + }, + "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, + "expected_generation": {"type": "integer", "minimum": 0.0, "nullable": True}, + "allow_deprecated": {"type": "boolean", "default": False}, }, "additionalProperties": False, "type": "object", - "required": ["kind", "memory_citation"], + "required": ["scope_id", "target_id", "artifact", "expected_generation"], }, - "HandoffOmission": { + "UnpublishRemoteSkillRequest": { "properties": { - "text": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, - "citation": {"$ref": "#/components/schemas/HandoffCitation", "nullable": True}, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "target_id": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + }, + "artifact_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "expected_generation": {"type": "integer", "minimum": 0.0}, }, "additionalProperties": False, "type": "object", - "required": ["text", "citation"], + "required": ["scope_id", "target_id", "artifact_id", "expected_generation"], }, - "HandoffResolution": { + "RemoteSkillDesiredState": {"type": "string", "enum": ["published", "unpublished"]}, + "RemoteSkillPublicationState": { + "type": "string", + "enum": [ + "unpublished", + "pending", + "current", + "update_available", + "delivery_failed", + "conflict", + "drifted", + "incompatible", + ], + }, + "RemoteSkillPublication": { "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": { - "items": {"$ref": "#/components/schemas/HandoffEvidenceCheck"}, - "type": "array", - "maxItems": 65, - }, + "target_id": {"type": "string"}, + "artifact_id": {"type": "string"}, + "desired_state": {"$ref": "#/components/schemas/RemoteSkillDesiredState"}, + "desired_revision": {"type": "integer", "minimum": 1.0}, + "desired_tree_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "observed_revision": {"type": "integer", "minimum": 1.0, "nullable": True}, + "observed_tree_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$", "nullable": True}, + "observed_generation": {"type": "integer", "minimum": 0.0, "nullable": True}, + "state": {"$ref": "#/components/schemas/RemoteSkillPublicationState"}, + "last_error_code": {"type": "string", "maxLength": 128, "minLength": 1, "nullable": True}, + "observed_at": {"type": "string", "format": "date-time", "nullable": True}, + "generation": {"type": "integer", "minimum": 0.0}, }, "additionalProperties": False, "type": "object", "required": [ - "trust", - "status", "scope_id", - "content", - "selection", - "selected_revision", - "current_revision", - "evidence_checks", + "target_id", + "artifact_id", + "desired_state", + "desired_revision", + "desired_tree_digest", + "observed_revision", + "observed_tree_digest", + "observed_generation", + "state", + "last_error_code", + "observed_at", + "generation", ], }, - "HandoffSourceCitation": { + "RemoteSkillObservation": { "properties": { - "kind": {"type": "string", "enum": ["source"]}, - "source_ref": {"$ref": "#/components/schemas/SourceReference"}, + "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, + "tree_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "actual_tree_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$", "nullable": True}, + "skill_name": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + }, + "applied_generation": {"type": "integer", "minimum": 0.0}, }, "additionalProperties": False, "type": "object", - "required": ["kind", "source_ref"], + "required": ["artifact", "tree_digest", "actual_tree_digest", "skill_name", "applied_generation"], }, - "HandoffStatement": { + "ReconcileRemoteSkillsRequest": { "properties": { - "text": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, - "citations": { - "items": {"$ref": "#/components/schemas/HandoffCitation"}, + "observations": { + "items": {"$ref": "#/components/schemas/RemoteSkillObservation"}, "type": "array", - "maxItems": 32, - "minItems": 1, + "maxItems": 256, + }, + "receiver_version": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "pattern": "^[\\x21-\\x7E]+$", }, + "environment_fingerprint": {"type": "string", "pattern": "^[0-9a-f]{64}$", "nullable": True}, }, "additionalProperties": False, "type": "object", - "required": ["text", "citations"], + "required": ["observations", "receiver_version"], }, - "PrepareHandoffRequest": { + "RemoteSkillOperation": {"type": "string", "enum": ["install", "unpublish"]}, + "RemoteSkillAction": { "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "objective": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, - "evidence": { - "items": {"$ref": "#/components/schemas/HandoffCitation"}, - "type": "array", - "maxItems": 32, - "minItems": 1, - }, - "max_bytes": {"type": "integer", "maximum": 32768.0, "minimum": 512.0, "default": 8000}, + "operation": {"$ref": "#/components/schemas/RemoteSkillOperation"}, + "generation": {"type": "integer", "minimum": 0.0}, + "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, + "tree_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "skill_name": {"type": "string", "maxLength": 64, "minLength": 1}, + "package": {"$ref": "#/components/schemas/SkillPackageReference", "nullable": True}, + "expected_local": {"$ref": "#/components/schemas/RemoteSkillObservation", "nullable": True}, + "blocked_error_code": {"type": "string", "maxLength": 128, "minLength": 1, "nullable": True}, }, "additionalProperties": False, "type": "object", - "required": ["scope_id", "objective", "evidence"], + "required": [ + "operation", + "generation", + "artifact", + "tree_digest", + "skill_name", + "package", + "expected_local", + "blocked_error_code", + ], }, - "PreparedHandoff": { + "ReconcileRemoteSkillsResponse": { "properties": { - "schema": {"$ref": "#/components/schemas/PreparedHandoffSchema"}, "scope_id": {"type": "string"}, - "base": {"$ref": "#/components/schemas/ArtifactReference", "nullable": True}, - "content": {"$ref": "#/components/schemas/HandoffContent"}, + "target_id": {"type": "string"}, + "actions": { + "items": {"$ref": "#/components/schemas/RemoteSkillAction"}, + "type": "array", + "maxItems": 256, + }, }, "additionalProperties": False, "type": "object", - "required": ["schema", "scope_id", "base", "content"], + "required": ["scope_id", "target_id", "actions"], }, - "PreparedContext": { + "DownloadRemoteSkillPackageRequest": { "properties": { - "schema": {"$ref": "#/components/schemas/PreparedContextSchema"}, - "status": {"$ref": "#/components/schemas/PreparedContextStatus"}, - "content": {"type": "string", "nullable": True}, - "content_bytes": {"type": "integer", "minimum": 0.0}, + "generation": {"type": "integer", "minimum": 0.0}, + "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, + "package": {"$ref": "#/components/schemas/SkillPackageReference"}, }, "additionalProperties": False, "type": "object", - "required": ["schema", "status", "content", "content_bytes"], + "required": ["generation", "artifact", "package"], }, - "EntryChange": { + "RemoteSkillReceiptOutcome": {"type": "string", "enum": ["succeeded", "failed"]}, + "RemoteSkillFailureState": { + "type": "string", + "enum": ["delivery_failed", "conflict", "drifted", "incompatible"], + }, + "RecordRemoteSkillReceiptRequest": { "properties": { - "op": {"$ref": "#/components/schemas/EntryChangeOperation"}, - "entry_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, - "from_entry_version_id": { - "type": "string", - "maxLength": 128, - "minLength": 1, - "pattern": "^[\\x21-\\x7E]+$", - "nullable": True, - }, - "to_entry_version_id": { + "operation": {"$ref": "#/components/schemas/RemoteSkillOperation"}, + "generation": {"type": "integer", "minimum": 0.0}, + "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, + "expected_tree_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "observed_tree_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$", "nullable": True}, + "outcome": {"$ref": "#/components/schemas/RemoteSkillReceiptOutcome"}, + "failure_state": {"$ref": "#/components/schemas/RemoteSkillFailureState", "nullable": True}, + "error_code": {"type": "string", "maxLength": 128, "minLength": 1, "nullable": True}, + "receiver_version": { "type": "string", - "maxLength": 128, + "maxLength": 64, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$", - "nullable": True, }, - "reason": {"type": "string", "nullable": True}, - }, - "additionalProperties": False, - "type": "object", - "required": ["op", "entry_id", "from_entry_version_id", "to_entry_version_id", "reason"], - }, - "ExperienceArtifact": { - "properties": { - "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, - "content": {"$ref": "#/components/schemas/ExperienceProposal"}, - "source_refs": {"items": {"$ref": "#/components/schemas/SourceReference"}, "type": "array"}, - "artifact_refs": {"items": {"$ref": "#/components/schemas/ArtifactReference"}, "type": "array"}, + "environment_fingerprint": {"type": "string", "pattern": "^[0-9a-f]{64}$", "nullable": True}, }, "additionalProperties": False, "type": "object", - "required": ["artifact", "content", "source_refs", "artifact_refs"], + "required": [ + "operation", + "generation", + "artifact", + "expected_tree_digest", + "observed_tree_digest", + "outcome", + "failure_state", + "error_code", + "receiver_version", + "environment_fingerprint", + ], }, - "ExperienceProposal": { + "RemoteSkillReceiptResponse": { "properties": { - "situation": {"type": "string", "maxLength": 8000, "minLength": 1, "pattern": ".*\\S.*"}, - "action": {"type": "string", "maxLength": 8000, "minLength": 1, "pattern": ".*\\S.*"}, - "outcome": {"type": "string", "maxLength": 8000, "minLength": 1, "pattern": ".*\\S.*"}, - "lesson": {"type": "string", "maxLength": 8000, "minLength": 1, "pattern": ".*\\S.*"}, + "accepted": {"type": "boolean"}, + "stale": {"type": "boolean"}, + "publication": {"$ref": "#/components/schemas/RemoteSkillPublication"}, }, "additionalProperties": False, "type": "object", - "required": ["situation", "action", "outcome", "lesson"], + "required": ["accepted", "stale", "publication"], }, - "SkillArtifact": { + "ProposeSkillPackageRequest": { "properties": { - "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, - "content": {"$ref": "#/components/schemas/SkillProposal"}, - "source_refs": {"items": {"$ref": "#/components/schemas/SourceReference"}, "type": "array"}, - "artifact_refs": {"items": {"$ref": "#/components/schemas/ArtifactReference"}, "type": "array"}, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "archive_base64": { + "type": "string", + "maxLength": 6990508, + "minLength": 1, + "pattern": "^[A-Za-z0-9+/]*={0,2}$", + }, + "reason": {"type": "string", "maxLength": 2000, "minLength": 1, "nullable": True}, + "target": { + "$ref": "#/components/schemas/ArtifactReference", + "description": "Exact managed Skill Revision replaced by this complete package Candidate.", + "nullable": True, + }, }, "additionalProperties": False, "type": "object", - "required": ["artifact", "content", "source_refs", "artifact_refs"], + "required": ["scope_id", "archive_base64"], }, - "SkillProposal": { + "RecordSkillUsageRequest": { "properties": { - "name": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^\\S(?:.*\\S)?$"}, - "description": {"type": "string", "maxLength": 2000, "minLength": 1, "pattern": "^\\S(?:.*\\S)?$"}, - "instructions": {"type": "string", "maxLength": 32000, "minLength": 1, "pattern": ".*\\S.*"}, - "validation": { - "items": {"$ref": "#/components/schemas/SkillValidationItem"}, - "type": "array", - "maxItems": 32, - "minItems": 1, - }, + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "observation_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "skill_ref": {"$ref": "#/components/schemas/ArtifactReference"}, + "package_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "target_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": ".*\\S.*"}, + "selected": {"type": "boolean"}, + "invoked": {"type": "string", "enum": ["true", "false", "unknown"]}, + "validation": {"type": "string", "enum": ["passed", "failed", "unknown"]}, + "outcome": {"type": "string", "enum": ["success", "failure", "unknown"]}, + "task_source": {"$ref": "#/components/schemas/SourceReference", "nullable": True}, + "environment_fingerprint": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$", "nullable": True}, }, "additionalProperties": False, "type": "object", - "required": ["name", "description", "instructions", "validation"], + "required": [ + "scope_id", + "observation_id", + "skill_ref", + "package_digest", + "target_id", + "selected", + "invoked", + "validation", + "outcome", + ], }, "SkillValidationItem": {"type": "string", "maxLength": 2000, "minLength": 1, "pattern": "^\\S(?:.*\\S)?$"}, "ExternalSkillRegistration": { @@ -3777,7 +4849,12 @@ "type": "http", "description": "Static bearer token used when local Server authentication is enabled.", "scheme": "bearer", - } + }, + "TargetBearerAuth": { + "type": "http", + "description": "Per-target credential issued once during remote Receiver enrollment.", + "scheme": "bearer", + }, }, }, "security": [{"BearerAuth": []}, {}], diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index ebe042b4f..0ca84f40c 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -17,6 +17,9 @@ from __future__ import annotations import asyncio +import base64 +import binascii +import ipaddress import json import logging from collections.abc import Awaitable, Callable, Sequence @@ -56,14 +59,49 @@ MemoryEntryNotFoundError, ) from powercontext.builtin.artifacts.skill import ( + AgentKind, + AgentSkillTarget, ExternalSkillNotFoundError, ExternalSkillRegistryUnavailableError, ExternalSkillSnapshotUnavailableError, Skill, + SkillPackageRef, + SkillPackageSnapshot, + SkillSearchHit, ) from powercontext.builtin.artifacts.skill import ( ExternalSkillResolution as RuntimeExternalSkillResolution, ) +from powercontext.builtin.artifacts.skill.distribution import ( + RemotePublicationGenerationError, + RemoteSkillDistributionError, + RemoteSkillLifecycleError, + RemoteTargetAuthenticationError, + RemoteTargetEnrollmentError, + RemoteTargetStateError, +) +from powercontext.builtin.artifacts.skill.distribution import ( + RemoteSkillObservation as DomainRemoteSkillObservation, +) +from powercontext.builtin.artifacts.skill.distribution import ( + RemoteSkillReceipt as DomainRemoteSkillReceipt, +) +from powercontext.builtin.artifacts.skill.distribution import ( + RemoteSkillReceiptResult as DomainRemoteSkillReceiptResult, +) +from powercontext.builtin.artifacts.skill.distribution import ( + RemoteSkillReconcileResult as DomainRemoteSkillReconcileResult, +) +from powercontext.builtin.artifacts.skill.distribution import ( + RemoteSkillTargetStatus as DomainRemoteSkillTargetStatus, +) +from powercontext.builtin.artifacts.skill.distribution import ( + RemoteTargetCredential as DomainRemoteTargetCredential, +) +from powercontext.builtin.artifacts.skill.distribution import ( + RemoteTargetEnrollment as DomainRemoteTargetEnrollment, +) +from powercontext.builtin.artifacts.skill.publication import ManagedSkillPublicationStatus from powercontext.builtin.handoff_report import ( HandoffReportApplication, HandoffReportBusyError, @@ -97,6 +135,18 @@ InvalidActivityRepositoryArgumentError, ) from powercontext.builtin.inference.errors import InferenceTimeoutError, InferenceUnavailableError +from powercontext.builtin.persistence.agent_skill_targets import RemoteAgentSkillTarget +from powercontext.builtin.persistence.artifact_governance import ( + ArtifactGovernance, + ArtifactLifecycleState, + InvalidArtifactLifecycleError, +) +from powercontext.builtin.persistence.errors import ( + PersistenceError, + RepositoryNotFoundError, + StoredPayloadConflictError, +) +from powercontext.builtin.persistence.skill_publications import SkillPublication from powercontext.builtin.review import ( ArtifactTargetConflictError, CandidateConflictError, @@ -193,6 +243,12 @@ from powercontext.builtin.runtime import ( StatisticsPeriod as RuntimeStatisticsPeriod, ) +from powercontext.builtin.sources import ( + ObservedInvocation, + ObservedOutcome, + ObservedValidation, + SkillUsageCapture, +) from powercontext.builtin.work import ( AcknowledgeHandoff as RuntimeAcknowledgeHandoff, ) @@ -228,8 +284,11 @@ CommittedHandoff, ContinueHandoffRequest, CreateHandoffReportProjectRequest, + CreateRemoteSkillTargetRequest, CreateWorkContractRequest, DetachHandoffReportWorkspaceRequest, + DownloadRemoteSkillPackageRequest, + EnrollRemoteSkillTargetRequest, ErrorDetail, ErrorResponse, ExperienceArtifact, @@ -246,6 +305,7 @@ GetHandoffReportRequest, GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, + GetSkillPackageRequest, GetSkillRequest, GetStatsRequest, HandoffAcknowledgement, @@ -266,10 +326,14 @@ ListHandoffReportKnownScopesRequest, ListHandoffReportProjectsRequest, ListHandoffReportWorkstreamsRequest, + ListManagedSkillsRequest, + ListManagedSkillsResponse, ListMemoryChangesRequest, ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, + ListRemoteSkillTargetsRequest, + ListRemoteSkillTargetsResponse, MemoryEntry, MemoryMutationResponse, PrepareContextRequest, @@ -279,29 +343,50 @@ ProjectDescriptor, ProjectPage, ProposeExperienceRequest, + ProposeSkillPackageRequest, ProposeSkillRequest, + PublishRemoteSkillRequest, PurgeHandoffReportActivitiesRequest, PurgeHandoffReportActivitiesResponse, ReadinessResponse, ReadinessStatus, + ReconcileRemoteSkillsRequest, + ReconcileRemoteSkillsResponse, RecordHandoffReportActivityRequest, + RecordRemoteSkillReceiptRequest, + RecordSkillUsageRequest, RecordTaskOutcomeRequest, RegisterHandoffReportWorkstreamRequest, RejectArtifactCandidateRequest, RememberMemoryRequest, + RemoteSkillAction, + RemoteSkillPublication, + RemoteSkillReceiptResponse, + RemoteSkillTarget, + RemoteSkillTargetCredential, + RemoteSkillTargetEnrollment, + RemoteSkillTargetStatus, + RenameRemoteSkillTargetRequest, ResolveExternalSkillRequest, RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, ReviseMemoryEntryRequest, + RevokeRemoteSkillTargetRequest, ScanExternalSkillsRequest, ScanExternalSkillsResponse, ScopedStats, SearchMemoryRequest, SearchMemoryResponse, SkillArtifact, + SkillGovernance, + SkillPackageDownload, + SkillPackageFile, + SkillPackageManifest, StoredHandoffReportActivity, + UnpublishRemoteSkillRequest, UpdateHandoffReportProjectRequest, UpdateHandoffReportWorkstreamRequest, + UpdateSkillLifecycleRequest, WorkSourceReceipt, WorkstreamDescriptor, WorkstreamPage, @@ -330,8 +415,12 @@ COMMIT_HANDOFF, CONTINUE_HANDOFF, CREATE_HANDOFF_REPORT_PROJECT, + CREATE_REMOTE_SKILL_TARGET, CREATE_WORK_CONTRACT, DETACH_HANDOFF_REPORT_WORKSPACE, + DOWNLOAD_REMOTE_SKILL_PACKAGE, + DOWNLOAD_SKILL_PACKAGE, + ENROLL_REMOTE_SKILL_TARGET, FINALIZE_HANDOFF, FLUSH_MEMORY, GENERATE_EXPERIENCE, @@ -346,6 +435,7 @@ GET_MEMORY_ENTRY, GET_READINESS, GET_SKILL, + GET_SKILL_PACKAGE_MANIFEST, GET_STATS, HANDOFF_CURRENT_WORK, IMPORT_EXTERNAL_SKILL, @@ -355,27 +445,38 @@ LIST_HANDOFF_REPORT_KNOWN_SCOPES, LIST_HANDOFF_REPORT_PROJECTS, LIST_HANDOFF_REPORT_WORKSTREAMS, + LIST_MANAGED_SKILLS, LIST_MEMORY_CHANGES, LIST_MEMORY_ENTRIES, + LIST_REMOTE_SKILL_TARGETS, OPENAPI_VERSION, PREPARE_CONTEXT, PREPARE_HANDOFF, PROPOSE_EXPERIENCE, PROPOSE_SKILL, + PROPOSE_SKILL_PACKAGE, + PUBLISH_REMOTE_SKILL, PURGE_HANDOFF_REPORT_ACTIVITIES, + RECONCILE_REMOTE_SKILLS, RECORD_HANDOFF_REPORT_ACTIVITY, + RECORD_REMOTE_SKILL_RECEIPT, + RECORD_SKILL_USAGE, RECORD_TASK_OUTCOME, REGISTER_HANDOFF_REPORT_WORKSTREAM, REJECT_ARTIFACT_CANDIDATE, REMEMBER_MEMORY, + RENAME_REMOTE_SKILL_TARGET, RESOLVE_EXTERNAL_SKILL, RETIRE_MEMORY_ENTRY, REVISE_ARTIFACT_CANDIDATE, REVISE_MEMORY_ENTRY, + REVOKE_REMOTE_SKILL_TARGET, SCAN_EXTERNAL_SKILLS, SEARCH_MEMORY, + UNPUBLISH_REMOTE_SKILL, UPDATE_HANDOFF_REPORT_PROJECT, UPDATE_HANDOFF_REPORT_WORKSTREAM, + UPDATE_SKILL_LIFECYCLE, Operation, ) from powercontext.http._generated.schema import OPENAPI_SCHEMA @@ -439,11 +540,137 @@ async def generate(self, request: RuntimeGenerateSkillRequest, /) -> RuntimeGene async def get(self, request: RuntimeGetSkillRequest, /) -> Skill: ... + async def search(self, query: str, limit: int, /) -> tuple[SkillSearchHit, ...]: ... + + async def list( + self, *, include_deprecated: bool = False, limit: int = 100 + ) -> tuple[tuple[Skill, ArtifactGovernance], ...]: ... + + async def package(self, artifact: ArtifactRef, /) -> SkillPackageSnapshot: ... + + async def package_snapshot(self, package: SkillPackageRef, /) -> SkillPackageSnapshot: ... + + async def upload_package( + self, + archive_bytes: bytes, + reason: str | None, + target: ArtifactRef | None, + /, + ) -> SkillCandidate: ... + + async def record_usage(self, observation: SkillUsageCapture, /) -> SourceReceipt: ... + + async def governance(self, artifact_id: str, /) -> ArtifactGovernance: ... + + async def update_lifecycle( + self, + artifact_id: str, + expected_generation: int, + lifecycle_state: ArtifactLifecycleState, + replacement_artifact_id: str | None, + /, + ) -> ArtifactGovernance: ... + + async def inspect_publication( + self, artifact: ArtifactRef, target: AgentSkillTarget, / + ) -> ManagedSkillPublicationStatus: ... + + async def publish( + self, + artifact: ArtifactRef, + target: AgentSkillTarget, + /, + *, + allow_deprecated: bool = False, + ) -> ManagedSkillPublicationStatus: ... + + async def unpublish(self, artifact: ArtifactRef, target: AgentSkillTarget, /) -> ManagedSkillPublicationStatus: ... + class _SkillApplication(Protocol): def for_scope(self, scope_id: str, /) -> _ScopedSkillApplication: ... +class _RemoteSkillApplication(Protocol): + async def list_targets( + self, + scope_id: str, + /, + *, + target_id: str | None = None, + limit: int = 100, + ) -> tuple[DomainRemoteSkillTargetStatus, ...]: ... + + async def create_target( + self, + scope_id: str, + agent_kind: AgentKind, + display_name: str, + /, + ) -> DomainRemoteTargetEnrollment: ... + + async def enroll( + self, + enrollment_code: str, + installation_id: str, + receiver_version: str, + environment_fingerprint: str | None, + machine_hostname: str | None, + workspace_name: str | None, + /, + ) -> DomainRemoteTargetCredential: ... + + async def rename_target( + self, + scope_id: str, + target_id: str, + expected_generation: int, + display_name: str, + /, + ) -> RemoteAgentSkillTarget: ... + + async def revoke_target( + self, scope_id: str, target_id: str, expected_generation: int, / + ) -> RemoteAgentSkillTarget: ... + + async def publish( + self, + scope_id: str, + target_id: str, + artifact: ArtifactRef, + expected_generation: int | None, + /, + *, + allow_deprecated: bool = False, + ) -> SkillPublication: ... + + async def unpublish( + self, scope_id: str, target_id: str, artifact_id: str, expected_generation: int, / + ) -> SkillPublication: ... + + async def reconcile( + self, + credential: str, + observations: tuple[DomainRemoteSkillObservation, ...], + receiver_version: str, + environment_fingerprint: str | None, + /, + ) -> DomainRemoteSkillReconcileResult: ... + + async def download( + self, + credential: str, + generation: int, + artifact: ArtifactRef, + package: SkillPackageRef, + /, + ) -> SkillPackageSnapshot: ... + + async def receipt( + self, credential: str, receipt: DomainRemoteSkillReceipt, / + ) -> DomainRemoteSkillReceiptResult: ... + + class _ScopedExternalSkillApplication(Protocol): async def scan(self) -> ExternalSkillScanResult: ... @@ -548,6 +775,7 @@ class ServerApplication(Protocol): memory: _MemoryApplication review: _ReviewApplication skill: _SkillApplication + remote_skills: _RemoteSkillApplication statistics: _StatisticsApplication handoff_report: HandoffReportApplication | None @@ -566,6 +794,7 @@ def create_app( metrics: ServerMetrics | None = None, tracing: ServerTracing | None = None, handoff_report_enabled: bool = False, + allow_insecure_remote_http: bool = False, ) -> FastAPI: """Build the HTTP adapter around an optional Runtime application binding.""" @@ -584,6 +813,7 @@ def create_app( app.state.readiness_probe = readiness_probe app.state.metrics = metrics app.state.tracing = tracing + app.state.allow_insecure_remote_http = allow_insecure_remote_http app.state.capabilities = Capabilities( source_types=[], artifact_families=[], @@ -620,9 +850,13 @@ async def invalid_request(request: Request, error: RequestValidationError) -> JS @app.exception_handler(_RuntimeNotReadyError) @app.exception_handler(PowerContextError) + @app.exception_handler(PersistenceError) async def application_error(request: Request, error: Exception) -> JSONResponse: response_status, code, message, details = _map_error(error) - return _error_response(response_status, code=code, message=message, details=details) + response = _error_response(response_status, code=code, message=message, details=details) + if isinstance(error, RemoteTargetAuthenticationError): + response.headers["WWW-Authenticate"] = "Bearer" + return response @app.exception_handler(Exception) async def unexpected_error(request: Request, error: Exception) -> JSONResponse: @@ -681,6 +915,22 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, PROPOSE_SKILL, propose_skill) _add_route(app, GENERATE_SKILL, generate_skill) _add_route(app, GET_SKILL, get_skill) + _add_route(app, LIST_MANAGED_SKILLS, list_managed_skills) + _add_route(app, UPDATE_SKILL_LIFECYCLE, update_skill_lifecycle) + _add_route(app, GET_SKILL_PACKAGE_MANIFEST, get_skill_package_manifest) + _add_route(app, DOWNLOAD_SKILL_PACKAGE, download_skill_package) + _add_route(app, PROPOSE_SKILL_PACKAGE, propose_skill_package) + _add_route(app, RECORD_SKILL_USAGE, record_skill_usage) + _add_route(app, LIST_REMOTE_SKILL_TARGETS, list_remote_skill_targets) + _add_route(app, CREATE_REMOTE_SKILL_TARGET, create_remote_skill_target) + _add_route(app, ENROLL_REMOTE_SKILL_TARGET, enroll_remote_skill_target) + _add_route(app, RENAME_REMOTE_SKILL_TARGET, rename_remote_skill_target) + _add_route(app, REVOKE_REMOTE_SKILL_TARGET, revoke_remote_skill_target) + _add_route(app, PUBLISH_REMOTE_SKILL, publish_remote_skill) + _add_route(app, UNPUBLISH_REMOTE_SKILL, unpublish_remote_skill) + _add_route(app, RECONCILE_REMOTE_SKILLS, reconcile_remote_skills) + _add_route(app, DOWNLOAD_REMOTE_SKILL_PACKAGE, download_remote_skill_package) + _add_route(app, RECORD_REMOTE_SKILL_RECEIPT, record_remote_skill_receipt) _add_route(app, SCAN_EXTERNAL_SKILLS, scan_external_skills) _add_route(app, LIST_EXTERNAL_SKILLS, list_external_skills) _add_route(app, RESOLVE_EXTERNAL_SKILL, resolve_external_skill) @@ -1245,6 +1495,385 @@ async def get_skill( return mapping.skill_response(result) +async def list_managed_skills( + request: ListManagedSkillsRequest, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> ListManagedSkillsResponse: + scoped = application.skill.for_scope(request.scope_id) + values: list[tuple[Skill, ArtifactGovernance]] = [] + query = "" if request.query is None else request.query.strip() + if query: + for hit in await scoped.search(query, request.limit): + skill = await scoped.get(RuntimeGetSkillRequest(artifact=hit.artifact_ref)) + values.append((skill, await scoped.governance(skill.artifact_id))) + else: + values.extend(await scoped.list(include_deprecated=request.include_deprecated, limit=request.limit)) + if query and request.include_deprecated: + seen = {skill.artifact_id for skill, _governance in values} + for skill, governance in await scoped.list(include_deprecated=True, limit=request.limit): + search_text = "\n".join(( + skill.content.name, + skill.content.description, + skill.content.instructions, + *skill.content.metadata.values(), + )) + if ( + governance.lifecycle_state is ArtifactLifecycleState.DEPRECATED + and skill.artifact_id not in seen + and query.casefold() in search_text.casefold() + ): + values.append((skill, governance)) + return ListManagedSkillsResponse( + skills=[mapping.managed_skill_library_entry(skill, governance) for skill, governance in values[: request.limit]] + ) + + +async def update_skill_lifecycle( + request: UpdateSkillLifecycleRequest, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> SkillGovernance: + result = await application.skill.for_scope(request.scope_id).update_lifecycle( + request.artifact_id, + request.expected_generation, + ArtifactLifecycleState(request.lifecycle_state.value), + request.replacement_artifact_id, + ) + return mapping.skill_governance(result) + + +async def get_skill_package_manifest( + request: GetSkillPackageRequest, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> SkillPackageManifest: + package = await application.skill.for_scope(request.scope_id).package( + mapping.runtime_artifact_reference(request.artifact) + ) + return _skill_package_manifest(package) + + +async def download_skill_package( + request: GetSkillPackageRequest, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> SkillPackageDownload: + package = await application.skill.for_scope(request.scope_id).package( + mapping.runtime_artifact_reference(request.artifact) + ) + return SkillPackageDownload( + package=package.reference.model_dump(mode="json"), + archive_base64=base64.b64encode(package.archive_bytes).decode("ascii"), + ) + + +async def propose_skill_package( + request: ProposeSkillPackageRequest, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> ArtifactCandidate: + try: + archive_bytes = base64.b64decode(request.archive_base64, validate=True) + except (binascii.Error, ValueError) as error: + raise InvalidRuntimeRequestError("skill-package-base64") from error + try: + candidate = await application.skill.for_scope(request.scope_id).upload_package( + archive_bytes, + request.reason, + None if request.target is None else mapping.runtime_artifact_reference(request.target), + ) + except ValueError as error: + raise InvalidRuntimeRequestError("skill-package") from error + return mapping.candidate_response(candidate) + + +async def record_skill_usage( + request: RecordSkillUsageRequest, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> CaptureContentSourceResponse: + try: + receipt = await application.skill.for_scope(request.scope_id).record_usage( + SkillUsageCapture( + observation_id=request.observation_id, + skill_ref=mapping.runtime_artifact_reference(request.skill_ref), + package_digest=request.package_digest, + target_id=request.target_id, + selected=request.selected, + invoked=ObservedInvocation(request.invoked.value), + validation=ObservedValidation(request.validation.value), + outcome=ObservedOutcome(request.outcome.value), + task_source=( + None if request.task_source is None else mapping.runtime_source_reference(request.task_source) + ), + environment_fingerprint=request.environment_fingerprint, + ) + ) + except ValueError as error: + raise InvalidRuntimeRequestError("skill-usage") from error + return mapping.capture_response(receipt) + + +async def create_remote_skill_target( + request: CreateRemoteSkillTargetRequest, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> RemoteSkillTargetEnrollment: + enrollment = await application.remote_skills.create_target( + request.scope_id, + request.agent_kind.value, + request.display_name, + ) + expires_at = enrollment.target.enrollment_expires_at + if expires_at is None: + raise RuntimeError("pending remote target is missing enrollment expiry") # noqa: TRY003 + return RemoteSkillTargetEnrollment( + target=_remote_skill_target(enrollment.target), + enrollment_code=enrollment.enrollment_code.get_secret_value(), + enrollment_expires_at=_aware_datetime(expires_at), + ) + + +async def list_remote_skill_targets( + request: ListRemoteSkillTargetsRequest, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> ListRemoteSkillTargetsResponse: + statuses = await application.remote_skills.list_targets( + request.scope_id, + target_id=request.target_id, + limit=request.limit, + ) + return ListRemoteSkillTargetsResponse(targets=[_remote_skill_target_status(value) for value in statuses]) + + +async def enroll_remote_skill_target( + request: EnrollRemoteSkillTargetRequest, + http_request: Request, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> RemoteSkillTargetCredential: + _require_secure_remote_transport(http_request) + credential = await application.remote_skills.enroll( + request.enrollment_code, + request.installation_id, + request.receiver_version, + request.environment_fingerprint, + request.machine_hostname, + request.workspace_name, + ) + return RemoteSkillTargetCredential.model_validate({ + "scope_id": credential.scope_id, + "target_id": credential.target_id, + "agent_kind": credential.agent_kind, + "credential": credential.credential.get_secret_value(), + }) + + +async def rename_remote_skill_target( + request: RenameRemoteSkillTargetRequest, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> RemoteSkillTarget: + target = await application.remote_skills.rename_target( + request.scope_id, + request.target_id, + request.expected_generation, + request.display_name, + ) + return _remote_skill_target(target) + + +async def revoke_remote_skill_target( + request: RevokeRemoteSkillTargetRequest, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> RemoteSkillTarget: + target = await application.remote_skills.revoke_target( + request.scope_id, + request.target_id, + request.expected_generation, + ) + return _remote_skill_target(target) + + +async def publish_remote_skill( + request: PublishRemoteSkillRequest, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> RemoteSkillPublication: + publication = await application.remote_skills.publish( + request.scope_id, + request.target_id, + mapping.runtime_artifact_reference(request.artifact), + request.expected_generation, + allow_deprecated=request.allow_deprecated, + ) + return _remote_skill_publication(publication) + + +async def unpublish_remote_skill( + request: UnpublishRemoteSkillRequest, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> RemoteSkillPublication: + publication = await application.remote_skills.unpublish( + request.scope_id, + request.target_id, + request.artifact_id, + request.expected_generation, + ) + return _remote_skill_publication(publication) + + +async def reconcile_remote_skills( + request: ReconcileRemoteSkillsRequest, + http_request: Request, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> ReconcileRemoteSkillsResponse: + _require_secure_remote_transport(http_request) + credential = _target_credential(http_request) + observations = tuple( + DomainRemoteSkillObservation.model_validate(observation.model_dump(mode="json")) + for observation in request.observations + ) + result = await application.remote_skills.reconcile( + credential, + observations, + request.receiver_version, + request.environment_fingerprint, + ) + return ReconcileRemoteSkillsResponse( + scope_id=result.scope_id, + target_id=result.target_id, + actions=[RemoteSkillAction.model_validate(action.model_dump(mode="json")) for action in result.actions], + ) + + +async def download_remote_skill_package( + request: DownloadRemoteSkillPackageRequest, + http_request: Request, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> SkillPackageDownload: + _require_secure_remote_transport(http_request) + package = await application.remote_skills.download( + _target_credential(http_request), + request.generation, + mapping.runtime_artifact_reference(request.artifact), + SkillPackageRef.model_validate(request.package.model_dump(mode="json")), + ) + return SkillPackageDownload( + package=package.reference.model_dump(mode="json"), + archive_base64=base64.b64encode(package.archive_bytes).decode("ascii"), + ) + + +async def record_remote_skill_receipt( + request: RecordRemoteSkillReceiptRequest, + http_request: Request, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> RemoteSkillReceiptResponse: + _require_secure_remote_transport(http_request) + try: + receipt = DomainRemoteSkillReceipt.model_validate(request.model_dump(mode="json")) + except ValueError as error: + raise InvalidRuntimeRequestError("remote-skill-receipt") from error + result = await application.remote_skills.receipt(_target_credential(http_request), receipt) + return RemoteSkillReceiptResponse( + accepted=result.accepted, + stale=result.stale, + publication=_remote_skill_publication(result.publication), + ) + + +def _remote_skill_target(target: RemoteAgentSkillTarget) -> RemoteSkillTarget: + return RemoteSkillTarget.model_validate({ + "scope_id": target.scope_id, + "target_id": target.target_id, + "display_name": target.display_name, + "agent_kind": target.agent_kind, + "installation_scope": target.installation_scope, + "delivery_mode": target.delivery_mode, + "installation_id": target.installation_id, + "state": target.state.value, + "receiver_version": target.receiver_version, + "environment_fingerprint": target.environment_fingerprint, + "machine_hostname": target.machine_hostname, + "workspace_name": target.workspace_name, + "last_seen_at": None if target.last_seen_at is None else _aware_datetime(target.last_seen_at), + "generation": target.generation, + }) + + +def _remote_skill_target_status(status: DomainRemoteSkillTargetStatus) -> RemoteSkillTargetStatus: + return RemoteSkillTargetStatus( + target=_remote_skill_target(status.target), + publications=[_remote_skill_publication(publication) for publication in status.publications], + ) + + +def _remote_skill_publication(publication: SkillPublication) -> RemoteSkillPublication: + return RemoteSkillPublication.model_validate({ + "scope_id": publication.scope_id, + "target_id": publication.target_id, + "artifact_id": publication.artifact_id, + "desired_state": publication.desired_state.value, + "desired_revision": publication.desired_revision, + "desired_tree_digest": publication.desired_tree_digest, + "observed_revision": publication.observed_revision, + "observed_tree_digest": publication.observed_tree_digest, + "observed_generation": publication.observed_generation, + "state": publication.state.value, + "last_error_code": publication.last_error_code, + "observed_at": None if publication.observed_at is None else _aware_datetime(publication.observed_at), + "generation": publication.generation, + }) + + +def _target_credential(request: Request) -> str: + authorization = request.headers.get("authorization") + if authorization is None: + raise RemoteTargetAuthenticationError("the target credential is missing") # noqa: TRY003 + scheme, separator, credential = authorization.partition(" ") + if not separator or scheme.casefold() != "bearer" or not credential: + raise RemoteTargetAuthenticationError("the target credential is invalid") # noqa: TRY003 + return credential + + +def _require_secure_remote_transport(request: Request) -> None: + if request.url.scheme.casefold() == "https": + return + host = request.url.hostname + if host is not None and _loopback_host(host): + return + if request.app.state.allow_insecure_remote_http: + return + raise InvalidRuntimeRequestError("remote-skill-https") + + +def _loopback_host(host: str) -> bool: + if host.casefold() == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +def _aware_datetime(value: datetime) -> datetime: + return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + + +def _skill_package_manifest(package: SkillPackageSnapshot) -> SkillPackageManifest: + return SkillPackageManifest( + package=package.reference.model_dump(mode="json"), + name=package.metadata.name, + description=package.metadata.description, + license=package.metadata.license, + compatibility=package.metadata.compatibility, + metadata=package.metadata.metadata, + allowed_tools=package.metadata.allowed_tools, + files=[ + SkillPackageFile( + path=entry.path, + digest=entry.digest, + size=entry.size, + media_type=entry.media_type, + executable=bool(entry.mode & 0o111), + ) + for entry in package.entries + ], + ) + + async def scan_external_skills( request: ScanExternalSkillsRequest, application: Annotated[ServerApplication, Depends(_require_application)], @@ -1410,13 +2039,14 @@ async def observed_endpoint(*args: Any, **kwargs: Any) -> _ResponseT | Response: except Exception as error: _observe_application(app, operation, "failure", started_at) response_status, error_code, _, _ = _map_error(error) + traceback_error = None if isinstance(error, RemoteTargetAuthenticationError) else error _log_operation( logging.ERROR if response_status >= status.HTTP_500_INTERNAL_SERVER_ERROR else logging.WARNING, "PowerContext application operation failed", operation=operation.operation_id, outcome="failure", started_at=started_at, - error=error, + error=traceback_error, error_code=error_code, ) _finish_span(span, "failure", error=error) @@ -1519,22 +2149,12 @@ def _validation_error_details(error: RequestValidationError) -> list[Any]: def _map_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None]: if isinstance(error, _RuntimeNotReadyError): return status.HTTP_503_SERVICE_UNAVAILABLE, "runtime_not_ready", "The Runtime is not ready.", None - if isinstance(error, ExternalSkillRegistryUnavailableError): - return ( - status.HTTP_503_SERVICE_UNAVAILABLE, - "external_skill_registry_unavailable", - "The external Skill Registry is unavailable.", - None, - ) - if isinstance(error, ExternalSkillNotFoundError): - return status.HTTP_404_NOT_FOUND, "external_skill_not_found", "The external Skill was not found.", None - if isinstance(error, ExternalSkillSnapshotUnavailableError): - return ( - status.HTTP_409_CONFLICT, - "external_skill_snapshot_unavailable", - "The exact external Skill snapshot is unavailable.", - None, - ) + external_skill_error = _map_external_skill_error(error) + if external_skill_error is not None: + return external_skill_error + remote_skill_error = _map_remote_skill_error(error) + if remote_skill_error is not None: + return remote_skill_error if isinstance(error, GenerationCapabilityUnavailableError): return ( status.HTTP_503_SERVICE_UNAVAILABLE, @@ -1542,6 +2162,9 @@ def _map_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None]: "Artifact generation is not configured.", {"family": error.family}, ) + governance_error = _map_governance_error(error) + if governance_error is not None: + return governance_error candidate_error = _map_candidate_error(error) if candidate_error is not None: return candidate_error @@ -1554,6 +2177,52 @@ def _map_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None]: return _map_domain_error(error) +def _map_external_skill_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: + if isinstance(error, ExternalSkillRegistryUnavailableError): + return ( + status.HTTP_503_SERVICE_UNAVAILABLE, + "external_skill_registry_unavailable", + "The external Skill Registry is unavailable.", + None, + ) + if isinstance(error, ExternalSkillNotFoundError): + return status.HTTP_404_NOT_FOUND, "external_skill_not_found", "The external Skill was not found.", None + if isinstance(error, ExternalSkillSnapshotUnavailableError): + return ( + status.HTTP_409_CONFLICT, + "external_skill_snapshot_unavailable", + "The exact external Skill snapshot is unavailable.", + None, + ) + return None + + +def _map_remote_skill_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: + if isinstance(error, RemoteTargetAuthenticationError): + return status.HTTP_401_UNAUTHORIZED, error.code, "The target credential is invalid or revoked.", None + if isinstance(error, RemoteTargetEnrollmentError): + return status.HTTP_409_CONFLICT, error.code, "The enrollment cannot be completed.", None + if isinstance(error, RemotePublicationGenerationError): + return status.HTTP_409_CONFLICT, error.code, "The remote publication generation is stale.", None + if isinstance(error, RemoteSkillLifecycleError): + return status.HTTP_422_UNPROCESSABLE_CONTENT, error.code, "The Skill lifecycle rejects publication.", None + if isinstance(error, RemoteTargetStateError): + return status.HTTP_409_CONFLICT, error.code, "The remote target state rejects this operation.", None + if isinstance(error, RemoteSkillDistributionError): + return status.HTTP_422_UNPROCESSABLE_CONTENT, error.code, "The remote Skill request is invalid.", None + return None + + +def _map_governance_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: + if isinstance(error, RepositoryNotFoundError): + return status.HTTP_404_NOT_FOUND, "not_found", "The requested value was not found.", None + if isinstance(error, StoredPayloadConflictError): + return status.HTTP_409_CONFLICT, "generation_conflict", "The requested state is stale.", None + if isinstance(error, InvalidArtifactLifecycleError): + return status.HTTP_422_UNPROCESSABLE_CONTENT, "invalid_lifecycle", str(error), None + 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 diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index 563b0ab4c..7011277b0 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -84,6 +84,8 @@ def create_server_app( @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: _log_lifecycle("server.starting", "PowerContext Server is starting") + if resolved.allow_insecure_http: + _log_insecure_remote_http_warning() if isinstance(config.database, SQLiteConfig) and config.database.is_in_memory: _log_in_memory_database_warning() async with open_builtin_runtime( @@ -138,6 +140,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: metrics=metrics, tracing=resolved_tracing, handoff_report_enabled=resolved.handoff_report.enabled, + allow_insecure_remote_http=resolved.allow_insecure_http, ) _mount_optional_web_ui(app, resolved) if metrics is not None: @@ -188,6 +191,8 @@ def _mount_optional_web_ui(app: FastAPI, settings: ServerSettings) -> None: handoff_report_enabled=settings.handoff_report.enabled, authentication_required=settings.auth.enabled, agent_skill_targets=settings.external_skills.agent_targets, + public_server_url=settings.public_url, + allow_insecure_http=settings.allow_insecure_http, ) if settings.dashboard.enabled: app.state.dashboard_started = True @@ -280,6 +285,16 @@ def _log_in_memory_database_warning() -> None: ) +def _log_insecure_remote_http_warning() -> None: + log_safely( + logger, + logging.WARNING, + "PowerContext remote Skill Receiver cleartext HTTP opt-in is enabled; " + "use it only on a protected private test network", + extra={"event": "server.remote_skills.insecure_http_enabled", "unit": "server"}, + ) + + def _http_operations(app: FastAPI) -> dict[tuple[str, str], str]: return { (method, route.path): route.operation_id diff --git a/src/powercontext/server/mapping.py b/src/powercontext/server/mapping.py index c26efc143..dd1a4c9aa 100644 --- a/src/powercontext/server/mapping.py +++ b/src/powercontext/server/mapping.py @@ -27,6 +27,7 @@ ExternalSkillProviderScan, Skill, SkillContent, + SkillPackageRef, ) from powercontext.builtin.artifacts.skill import ( ExternalSkillRegistration as RuntimeExternalSkillRegistration, @@ -34,6 +35,7 @@ from powercontext.builtin.artifacts.skill import ( ExternalSkillResolution as RuntimeExternalSkillResolution, ) +from powercontext.builtin.persistence.artifact_governance import ArtifactGovernance from powercontext.builtin.review import ArtifactCandidate as RuntimeArtifactCandidate from powercontext.builtin.review import ArtifactCandidatePage as RuntimeArtifactCandidatePage from powercontext.builtin.review import CandidateStatus as RuntimeCandidateStatus @@ -197,6 +199,7 @@ ListExternalSkillsResponse, ListMemoryChangesResponse, ListMemoryEntriesResponse, + ManagedSkillLibraryEntry, MemoryEntry, MemoryEntryState, MemoryMatchedBy, @@ -222,6 +225,9 @@ SearchMemoryRequest, SearchMemoryResponse, SkillArtifact, + SkillGovernance, + SkillLifecycleState, + SkillPackageReference, SkillProposal, SkillValidationItem, SourceReference, @@ -727,6 +733,26 @@ def skill_response(value: Skill) -> SkillArtifact: ) +def skill_governance(value: ArtifactGovernance) -> SkillGovernance: + return SkillGovernance( + artifact=artifact_reference(value.artifact), + lifecycle_state=SkillLifecycleState(value.lifecycle_state.value), + replacement_artifact_id=value.replacement_artifact_id, + governance_generation=value.governance_generation, + ) + + +def managed_skill_library_entry(value: Skill, governance: ArtifactGovernance) -> ManagedSkillLibraryEntry: + response = skill_response(value) + return ManagedSkillLibraryEntry( + artifact=response.artifact, + content=response.content, + source_refs=response.source_refs, + artifact_refs=response.artifact_refs, + governance=skill_governance(governance), + ) + + def list_external_skills_request(value: ListExternalSkillsRequest) -> RuntimeListExternalSkillsRequest: return RuntimeListExternalSkillsRequest(include_unavailable=value.include_unavailable) @@ -796,6 +822,11 @@ def skill_content(value: SkillProposal) -> SkillContent: description=value.description, instructions=value.instructions, validation=tuple(item.root for item in value.validation), + package=None if value.package is None else SkillPackageRef.model_validate(value.package.model_dump()), + license=value.license, + compatibility=value.compatibility, + metadata={} if value.metadata is None else value.metadata, + allowed_tools=value.allowed_tools, ) @@ -805,6 +836,11 @@ def skill_proposal(value: SkillContent) -> SkillProposal: description=value.description, instructions=value.instructions, validation=[SkillValidationItem(item) for item in value.validation], + package=(None if value.package is None else SkillPackageReference.model_validate(value.package.model_dump())), + license=value.license, + compatibility=value.compatibility, + metadata=value.metadata, + allowed_tools=value.allowed_tools, ) diff --git a/src/powercontext/server/middleware.py b/src/powercontext/server/middleware.py index 605140f04..17ba9d6bd 100644 --- a/src/powercontext/server/middleware.py +++ b/src/powercontext/server/middleware.py @@ -25,7 +25,19 @@ from powercontext.http import ErrorDetail, ErrorResponse from powercontext.server.context import is_internal_bridge -_PUBLIC_PATHS = frozenset({"/", "/docs", "/handoff-reports", "/reviews", "/skills", "/health/live", "/health/ready"}) +_PUBLIC_PATHS = frozenset({ + "/", + "/docs", + "/handoff-reports", + "/reviews", + "/skills", + "/health/live", + "/health/ready", + "/v1/skill/remote/target/enroll", + "/v1/skill/remote/reconcile", + "/v1/skill/remote/package/download", + "/v1/skill/remote/receipt", +}) _PUBLIC_PATH_PREFIXES = ("/static/",) diff --git a/src/powercontext/server/settings.py b/src/powercontext/server/settings.py index 060730d89..583a2592f 100644 --- a/src/powercontext/server/settings.py +++ b/src/powercontext/server/settings.py @@ -17,11 +17,16 @@ from __future__ import annotations from collections.abc import Mapping +from contextlib import suppress +from ipaddress import ip_address +from pathlib import Path from typing import Literal +from urllib.parse import urlsplit from pydantic import BaseModel, Field, SecretStr, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from powercontext.builtin.artifacts.skill import AgentSkillTarget from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.runtime.config import ( DatabaseConfig, @@ -62,6 +67,28 @@ def _default_database() -> SQLiteConfig: return SQLiteConfig(url=sqlite_url(default_database_path())) +def _default_local_external_skills(workspace: Path) -> ExternalSkillsConfig: + return ExternalSkillsConfig( + host_id="local-workspace", + targets=( + AgentSkillTarget( + target_id="codex-project", + agent_kind="codex", + installation_scope="project", + path=workspace / ".agents" / "skills", + allow_managed_publish=True, + ), + AgentSkillTarget( + target_id="claude-project", + agent_kind="claude_code", + installation_scope="project", + path=workspace / ".claude" / "skills", + allow_managed_publish=True, + ), + ), + ) + + def is_unauthenticated_non_loopback_bind( *, host: str, @@ -176,6 +203,9 @@ class ServerSettings(BaseSettings): ) http: HttpConfig = Field(default_factory=HttpConfig) + workspace: Path = Field(default_factory=Path.cwd) + public_url: str | None = None + allow_insecure_http: bool = False mcp: McpConfig = Field(default_factory=McpConfig) auth: BearerAuthConfig = Field(default_factory=BearerAuthConfig) allow_unauthenticated_non_loopback: bool = False @@ -189,6 +219,59 @@ class ServerSettings(BaseSettings): inference: InferenceConfig = Field(default_factory=InferenceConfig) external_skills: ExternalSkillsConfig = Field(default_factory=ExternalSkillsConfig) + @field_validator("workspace") + @classmethod + def resolve_workspace(cls, value: Path) -> Path: + workspace = value.expanduser().resolve(strict=False) + if not workspace.is_dir(): + raise ValueError("Server workspace must be an existing directory") # noqa: TRY003 + return workspace + + @model_validator(mode="after") + def configure_default_local_skill_targets(self) -> ServerSettings: + if "external_skills" not in self.model_fields_set: + self.external_skills = _default_local_external_skills(self.workspace) + return self + + @field_validator("public_url") + @classmethod + def validate_public_url(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip().rstrip("/") + if not normalized: + return None + try: + parsed = urlsplit(normalized) + hostname = parsed.hostname + _ = parsed.port + except ValueError as error: + raise ValueError("public URL must be a valid absolute HTTP URL") from error # noqa: TRY003 + if ( + parsed.scheme not in {"http", "https"} + or hostname is None + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + raise ValueError("public URL must be an absolute HTTP URL without credentials, query, or fragment") # noqa: TRY003 + return normalized + + @model_validator(mode="after") + def require_secure_public_url_by_default(self) -> ServerSettings: + if self.public_url is None or self.allow_insecure_http: + return self + parsed = urlsplit(self.public_url) + hostname = parsed.hostname + loopback = hostname is not None and hostname.lower() == "localhost" + if hostname is not None: + with suppress(ValueError): + loopback = loopback or ip_address(hostname).is_loopback + if parsed.scheme != "https" and not loopback: + raise ValueError("public URL must use HTTPS unless it points to loopback") # noqa: TRY003 + return self + @field_validator("database", mode="before") @classmethod def default_seekdb_database_path(cls, value: object) -> object: diff --git a/src/powercontext/server/static/review.js b/src/powercontext/server/static/review.js index ee7a4e665..0db3eb06f 100644 --- a/src/powercontext/server/static/review.js +++ b/src/powercontext/server/static/review.js @@ -98,6 +98,7 @@ const translations = { installationPlugin: "Plugin", noPublishTargets: "No writable Skill target is configured.", noPublishTargetsHint: "Enable managed publication on an explicit local Agent target.", + standardPackageRequired: "This approved Skill predates standard package snapshots. Create and approve a package-backed revision before publishing.", publishedRevision: "Published revision", destination: "Destination", discovery: "Discovery", @@ -137,6 +138,11 @@ const translations = { description: "Description", instructions: "Instructions", validation: "Validation", + packageContents: "Package contents", + packageFiles: "Package files", + packageLoading: "Loading exact package files...", + packageBinary: "Binary file preview is unavailable.", + packageLoadFailed: "The exact package could not be loaded.", revise: "Revise", approve: "Approve", reject: "Reject", @@ -250,6 +256,7 @@ const translations = { installationPlugin: "插件级", noPublishTargets: "未配置可写的技能目标。", noPublishTargetsHint: "请在一个明确的本地技能目录上启用受管发布。", + standardPackageRequired: "这项已批准技能创建于标准技能包支持之前。请先创建并批准一个由完整技能包支持的新修订,再进行发布。", publishedRevision: "已发布修订", destination: "目标位置", discovery: "发现状态", @@ -289,6 +296,11 @@ const translations = { description: "说明", instructions: "使用指引", validation: "验证条件", + packageContents: "技能包内容", + packageFiles: "技能包文件", + packageLoading: "正在加载精确技能包文件……", + packageBinary: "二进制文件不提供预览。", + packageLoadFailed: "无法加载精确技能包。", revise: "修订", approve: "批准", reject: "拒绝", @@ -373,6 +385,11 @@ const conflictActions = document.getElementById("review-conflict-actions"); const resumeDraftButton = document.getElementById("review-resume-draft"); const discardDraftButton = document.getElementById("review-discard-draft"); const proposalFields = document.getElementById("review-proposal-fields"); +const packageSection = document.getElementById("review-package"); +const packageStatus = document.getElementById("review-package-status"); +const packageFiles = document.getElementById("review-package-files"); +const packagePath = document.getElementById("review-package-path"); +const packagePreview = document.getElementById("review-package-preview"); const sourceRefs = document.getElementById("review-source-refs"); const artifactRefs = document.getElementById("review-artifact-refs"); const lineageFields = document.getElementById("review-lineage-fields"); @@ -423,6 +440,8 @@ let draft = null; let conflictDraft = null; let projectionView = null; let projectionLoading = false; +let packageManifest = null; +let packageLoadingDigest = ""; let busy = false; let scopeActiveIndex = -1; @@ -433,6 +452,7 @@ const listRequests = createRequestGate(); const detailRequests = createRequestGate(); const actionRequests = createRequestGate(); const projectionRequests = createRequestGate(); +const packageRequests = createRequestGate(); const ui = createPageUi(translations, () => { renderAuthError(); renderPageStatus(); @@ -503,7 +523,7 @@ signOut.addEventListener("click", () => { }); editButton.addEventListener("click", () => { - if (selectedCandidate && isSupportedCandidate(selectedCandidate)) { + if (isEditableCandidate(selectedCandidate)) { startRevision(selectedCandidate.proposal, selectedCandidate.version); } }); @@ -1418,6 +1438,7 @@ function renderDetail() { detailStatus.textContent = translate(candidate.status); detailVersion.textContent = translate("version", {version: candidate.version}); renderProposal(candidate); + renderPackage(candidate); renderEvidence(candidate); renderLineage(candidate); renderPublication(); @@ -1426,6 +1447,7 @@ function renderDetail() { } const decisionEnabled = canDecide(candidate); reviewActions.hidden = !decisionEnabled || !revisionForm.hidden; + editButton.hidden = !isEditableCandidate(candidate); editButton.disabled = busy; approveButton.disabled = busy; rejectButton.disabled = busy; @@ -1442,6 +1464,12 @@ function clearDetail() { detailContent.hidden = true; revisionForm.hidden = true; proposalFields.replaceChildren(); + packageRequests.cancel(); + packageManifest = null; + packageLoadingDigest = ""; + packageSection.hidden = true; + packageFiles.replaceChildren(); + packagePreview.textContent = ""; sourceRefs.replaceChildren(); artifactRefs.replaceChildren(); lineageFields.replaceChildren(); @@ -1467,6 +1495,90 @@ function renderProposal(candidate) { } } +function renderPackage(candidate) { + const reference = candidate.family === "skill" ? candidate.proposal.package : null; + packageSection.hidden = !reference; + if (!reference) { + packageManifest = null; + packageFiles.replaceChildren(); + packagePreview.textContent = ""; + return; + } + if (packageManifest?.package?.tree_digest === reference.tree_digest) { + renderPackageFiles(candidate); + return; + } + if (packageLoadingDigest === reference.tree_digest) { + return; + } + packageLoadingDigest = reference.tree_digest; + packageStatus.textContent = translate("packageLoading"); + packageFiles.replaceChildren(); + packagePreview.textContent = ""; + void loadPackageManifest(candidate, reference); +} + +async function loadPackageManifest(candidate, reference) { + const request = packageRequests.start(); + try { + const manifest = await requestJson("/dashboard/skill-packages/manifest", { + scope_id: currentScopeId, + package: reference + }); + if (!request.isCurrent() || selectedCandidateId !== candidate.candidate_id) { + return; + } + packageManifest = manifest; + packageLoadingDigest = ""; + packageStatus.textContent = ""; + renderPackageFiles(candidate); + if (manifest.files.length) { + void loadPackagePreview(candidate, reference, manifest.files[0].path); + } + } catch (error) { + if (request.isCurrent() && selectedCandidateId === candidate.candidate_id) { + packageLoadingDigest = ""; + packageStatus.textContent = translate("packageLoadFailed"); + } + } +} + +function renderPackageFiles(candidate) { + packageFiles.replaceChildren(); + for (const file of packageManifest?.files || []) { + const item = document.createElement("li"); + const button = document.createElement("button"); + button.type = "button"; + button.textContent = `${file.path} · ${formatNumber(file.size)} B${file.executable ? " · executable" : ""}`; + button.addEventListener("click", () => { + void loadPackagePreview(candidate, candidate.proposal.package, file.path); + }); + item.append(button); + packageFiles.append(item); + } +} + +async function loadPackagePreview(candidate, reference, path) { + const request = packageRequests.start(); + packagePath.textContent = path; + packagePreview.textContent = ""; + try { + const preview = await requestJson("/dashboard/skill-packages/preview", { + scope_id: currentScopeId, + package: reference, + path + }); + if (!request.isCurrent() || selectedCandidateId !== candidate.candidate_id) { + return; + } + packagePreview.textContent = preview.binary ? translate("packageBinary") : (preview.content || ""); + } catch (error) { + if (request.isCurrent() && selectedCandidateId === candidate.candidate_id) { + packagePreview.textContent = translate("packageLoadFailed"); + } + } +} + function renderEvidence(candidate) { renderReferenceList( sourceRefs, @@ -1527,6 +1639,10 @@ function renderPublication() { if (projectionLoading || !projectionView) { return; } + if (projectionView.blocker === "standard_package_required") { + publicationStatus.textContent = translate("standardPackageRequired"); + return; + } if (projectionView.targets.length === 0) { publicationEmpty.hidden = false; return; @@ -1588,8 +1704,10 @@ function isPublishableCandidate(candidate) { } function canPublishProjection(target) { - return ["unpublished", "update_available"].includes(target.state) - || (target.state === "current" && target.discovery !== "available"); + return target.compatibility !== "incompatible" && ( + ["unpublished", "update_available"].includes(target.state) + || (target.state === "current" && target.discovery !== "available") + ); } function publicationActionKey(target) { @@ -1911,6 +2029,13 @@ function canDecide(candidate) { return Boolean(candidate && candidate.status === "pending" && isSupportedCandidate(candidate)); } +function isEditableCandidate(candidate) { + return Boolean( + isSupportedCandidate(candidate) + && !(candidate.family === "skill" && candidate.proposal.package) + ); +} + function isSupportedCandidate(candidate) { if (!candidate || !candidate.proposal) { return false; diff --git a/src/powercontext/server/static/site.css b/src/powercontext/server/static/site.css index 1c8709edf..3e6d1b3ba 100644 --- a/src/powercontext/server/static/site.css +++ b/src/powercontext/server/static/site.css @@ -2898,6 +2898,7 @@ button:disabled { } .review-proposal, +.review-package, .review-evidence, .review-lineage, .review-publication, @@ -2905,6 +2906,7 @@ button:disabled { padding: 24px 28px; } +.review-package, .review-evidence, .review-lineage, .review-publication, @@ -2943,6 +2945,50 @@ button:disabled { overflow-wrap: anywhere; } +.review-package-browser { + display: grid; + grid-template-columns: minmax(180px, 0.38fr) minmax(0, 1fr); + gap: 16px; +} + +.review-package-browser ul { + display: grid; + align-content: start; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; +} + +.review-package-browser button { + width: 100%; + border: 0; + border-radius: 6px; + background: transparent; + padding: 7px 9px; + color: var(--pc-text); + font: inherit; + text-align: left; +} + +.review-package-browser button:hover, +.review-package-browser button:focus-visible { + background: var(--pc-surface-secondary); +} + +.review-package-browser pre { + min-height: 150px; + max-height: 420px; + margin: 8px 0 0; + overflow: auto; + border: 1px solid var(--pc-rule); + border-radius: 8px; + background: var(--pc-surface-secondary); + padding: 14px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + .review-publication-heading { display: flex; align-items: flex-start; @@ -3476,6 +3522,13 @@ button:disabled { gap: 12px; } +.skills-list-labels { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; +} + .skills-authority-label { color: var(--pc-muted); font-size: 11px; @@ -3486,6 +3539,28 @@ button:disabled { color: var(--pc-accent); } +.skills-origin-badge { + min-width: 0; + overflow: hidden; + flex-shrink: 1; + text-overflow: ellipsis; +} + +.skills-origin-powercontext { + background: var(--pc-accent-soft); + color: var(--pc-accent); +} + +.skills-origin-external_import { + background: var(--pc-success-soft); + color: var(--pc-success); +} + +.skills-origin-external_fork { + background: var(--pc-warning-soft); + color: var(--pc-warning); +} + .skills-list-item > strong, .skills-list-summary, .skills-list-item > code { @@ -3622,12 +3697,16 @@ button:disabled { .skills-overview, .skills-instructions, +.skills-package, +.skills-governance, .skills-lineage, .skills-delivery { padding: 24px 28px; } .skills-instructions, +.skills-package, +.skills-governance, .skills-lineage, .skills-delivery { border-top: 1px solid var(--pc-rule); @@ -3705,6 +3784,122 @@ button:disabled { line-height: 1.55; } +.skills-package > p, +.skills-governance > p { + margin: -6px 0 12px; + color: var(--pc-muted); + font-size: 12px; +} + +.skills-package-browser { + display: grid; + grid-template-columns: minmax(180px, 0.36fr) minmax(0, 1fr); + min-height: 220px; + border: 1px solid var(--pc-rule); + border-radius: var(--pc-radius); + background: var(--pc-surface-secondary); + overflow: hidden; +} + +.skills-package-browser ul { + max-height: 420px; + margin: 0; + border-right: 1px solid var(--pc-rule); + overflow: auto; + padding: 8px; + list-style: none; +} + +.skills-package-browser button { + width: 100%; + border: 0; + border-radius: var(--pc-radius-control); + background: transparent; + color: var(--pc-text); + font: 11px/1.5 var(--pc-font-code); + padding: 7px 9px; + text-align: left; + overflow-wrap: anywhere; +} + +.skills-package-browser button:hover, +.skills-package-browser button:focus-visible, +.skills-package-browser button[aria-current="true"] { + background: var(--pc-accent-soft); + color: var(--pc-accent); +} + +.skills-package-browser > div { + min-width: 0; + padding: 12px; +} + +.skills-package-browser > div > code { + color: var(--pc-muted); + font: 11px/1.5 var(--pc-font-code); +} + +.skills-package-browser pre { + min-height: 170px; + max-height: 390px; + margin: 8px 0 0; + overflow: auto; + border: 1px solid var(--pc-rule); + border-radius: var(--pc-radius-control); + background: var(--pc-surface); + color: var(--pc-ink); + font: 12px/1.65 var(--pc-font-code); + padding: 14px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.skills-governance-controls { + display: grid; + grid-template-columns: minmax(160px, 0.6fr) minmax(240px, 1fr) auto; + align-items: end; + gap: 12px; + margin-top: 18px; +} + +.skills-governance-controls label { + display: grid; + gap: 6px; + color: var(--pc-muted); + font-size: 12px; + font-weight: 600; +} + +.skills-governance-controls :is(input, select) { + width: 100%; + min-height: 38px; + border: 1px solid var(--pc-rule-strong); + border-radius: var(--pc-radius-control); + outline: 0; + background: var(--pc-surface); + color: var(--pc-ink); + padding: 7px 10px; +} + +.skills-governance-controls :is(input, select):focus { + border-color: var(--pc-accent); +} + +.skills-status-active { + background: var(--pc-success-soft); + color: var(--pc-success); +} + +.skills-status-deprecated { + background: var(--pc-warning-soft); + color: var(--pc-warning); +} + +.skills-status-retired { + background: var(--pc-danger-soft); + color: var(--pc-danger); +} + .skills-reference-groups { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -3759,6 +3954,36 @@ button:disabled { display: none; } +.skills-delivery-status[data-tone="success"] { + color: var(--pc-success); +} + +.skills-delivery-status[data-tone="error"] { + color: var(--pc-danger); +} + +.skills-delivery-mode-row { + display: flex; + align-items: end; + justify-content: space-between; + gap: 12px; + margin-top: 16px; +} + +.skills-delivery-mode-row label { + display: grid; + width: min(320px, 100%); + gap: 7px; + color: var(--pc-muted); + font-size: 12px; + font-weight: 600; +} + +.skills-delivery-mode-row .secondary-button { + border: 1px solid var(--pc-rule-strong); + padding: 0 14px; +} + .skills-delivery-empty { margin-top: 16px; border: 1px dashed var(--pc-rule-strong); @@ -3774,6 +3999,27 @@ button:disabled { margin-top: 18px; } +#skills-remote-content { + margin-top: 18px; +} + +.skills-remote-target-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 10px; +} + +.skills-remote-target-row .secondary-button { + border: 1px solid var(--pc-rule-strong); + padding: 0 14px; +} + +.skills-remote-target-actions { + display: flex; + gap: 8px; +} + .skills-delivery-target { display: grid; min-width: 0; @@ -3798,6 +4044,11 @@ button:disabled { overflow: hidden; } +.skills-delivery-facts.skills-remote-facts { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: 12px; +} + .skills-delivery-facts > div { min-width: 0; padding: 14px 16px; @@ -3821,6 +4072,16 @@ button:disabled { overflow-wrap: anywhere; } +.skills-compatibility-reasons { + display: grid; + gap: 4px; + margin: 7px 0 0; + padding-left: 16px; + color: var(--pc-muted); + font-size: 11px; + font-weight: 400; +} + .skills-delivery-path { display: grid; grid-column: 1 / -1; @@ -3840,6 +4101,159 @@ button:disabled { white-space: normal; } +.skills-remote-next-step { + display: grid; + gap: 7px; + margin-top: 12px; + border: 1px solid var(--pc-rule); + border-radius: var(--pc-radius); + background: var(--pc-surface-secondary); + padding: 14px 16px; +} + +.skills-remote-next-step strong { + font-size: 12px; +} + +.skills-remote-next-step code, +.skills-copy-row code, +.skills-enrollment-summary code { + color: var(--pc-ink); + font: 11px/1.55 var(--pc-font-code); + overflow-wrap: anywhere; + white-space: normal; +} + +.skills-remote-next-step p { + margin: 0; + color: var(--pc-muted); + font-size: 12px; +} + +.skills-remote-dialog { + width: min(680px, calc(100% - 32px)); +} + +.skills-remote-dialog label { + display: grid; + gap: 6px; + margin-top: 16px; + color: var(--pc-muted); + font-size: 12px; + font-weight: 600; +} + +.skills-remote-dialog :is(input, select) { + width: 100%; + min-height: 38px; + border: 1px solid var(--pc-rule-strong); + border-radius: var(--pc-radius-control); + outline: 0; + background: var(--pc-surface); + color: var(--pc-ink); + padding: 7px 10px; +} + +.skills-remote-dialog :is(input, select):hover, +.skills-remote-dialog :is(input, select):focus { + border-color: var(--pc-accent); +} + +.skills-remote-dialog small { + color: var(--pc-tertiary); + font-weight: 400; +} + +.skills-remote-dialog .review-dialog-error { + min-height: 18px; + margin: 12px 0 0; + color: var(--pc-danger); + font-size: 12px; +} + +.skills-remote-dialog .review-dialog-error:empty { + display: none; +} + +.skills-enrollment-summary { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 6px 14px; + border-top: 1px solid var(--pc-rule); + border-bottom: 1px solid var(--pc-rule); + padding: 14px 0; + color: var(--pc-muted); + font-size: 12px; +} + +.skills-enrollment-summary :is(code, time) { + color: var(--pc-ink); +} + +.skills-enrollment-connection { + display: grid; + gap: 5px; + margin-top: 14px; + border-radius: var(--pc-radius-control); + background: var(--pc-surface-secondary); + padding: 11px 12px; +} + +.skills-enrollment-connection strong { + font-size: 12px; +} + +.skills-enrollment-connection p { + margin: 0; + font-size: 12px; +} + +.skills-enrollment-connection[data-tone="ready"] p { + color: var(--pc-success); +} + +.skills-enrollment-connection[data-tone="warning"] { + background: var(--pc-warning-soft); +} + +.skills-enrollment-connection[data-tone="warning"] p { + color: var(--pc-warning); +} + +.skills-enrollment-step { + display: grid; + gap: 7px; + margin-top: 18px; +} + +.skills-enrollment-step > strong { + font-size: 12px; +} + +.skills-copy-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + border: 1px solid var(--pc-rule); + border-radius: var(--pc-radius-control); + background: var(--pc-surface-secondary); + padding: 8px 8px 8px 12px; +} + +.skills-copy-row .secondary-button { + min-height: 32px; + border: 1px solid var(--pc-rule-strong); + padding: 0 11px; +} + +.skills-copy-status { + min-height: 18px; + margin: 10px 0 0 !important; + color: var(--pc-muted); + font-size: 12px; +} + .skills-detail-actions { display: flex; align-items: center; @@ -4025,16 +4439,55 @@ button:disabled { gap: 10px; } + .skills-delivery-mode-row, + .skills-remote-target-row { + align-items: stretch; + grid-template-columns: 1fr; + } + + .skills-remote-target-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .skills-delivery-mode-row { + flex-direction: column; + } + + .skills-delivery-mode-row label { + width: 100%; + } + .skills-overview dl, .skills-reference-groups, + .skills-package-browser, + .skills-governance-controls, #skills-delivery-content { grid-template-columns: 1fr; } + .skills-package-browser ul { + max-height: 190px; + border-right: 0; + border-bottom: 1px solid var(--pc-rule); + } + .skills-delivery-facts { grid-template-columns: 1fr; } + .skills-delivery-facts.skills-remote-facts { + grid-template-columns: 1fr; + } + + .skills-copy-row { + grid-template-columns: 1fr; + } + + .skills-copy-row .secondary-button { + justify-self: start; + } + .skills-delivery-facts > div + div { border-top: 1px solid var(--pc-rule); border-left: 0; diff --git a/src/powercontext/server/static/skills.js b/src/powercontext/server/static/skills.js index 9d5468f48..8023f5f98 100644 --- a/src/powercontext/server/static/skills.js +++ b/src/powercontext/server/static/skills.js @@ -57,6 +57,15 @@ const translations = { searchSkills: "Search", searchSkillsPlaceholder: "Search by name, description, or identity", authority: "Authority", + origin: "Origin", + originPowerContext: "Generated", + originExternalImport: "Imported", + originExternalFork: "Forked", + originExternal: "Local", + sourceMachine: "Source machine", + sourceAgent: "Source Agent", + originalLocation: "Original location", + externalIdentity: "External Skill ID", allSkills: "All Skills", managedSkill: "Managed", externalSkill: "External", @@ -76,6 +85,9 @@ const translations = { description: "Description", status: "Status", approved: "Approved", + active: "Active", + deprecated: "Deprecated", + retired: "Retired", available: "Available", unavailable: "Unavailable", artifact: "Artifact", @@ -89,33 +101,159 @@ const translations = { entrypoint: "Entrypoint", instructions: "Instructions", validation: "Validation", + packageContents: "Package contents", + packageFiles: "Package files", + packageLoading: "Loading verified package contents...", + packageBinary: "Binary file preview is intentionally unavailable.", + packageTruncated: "Preview is limited to the first 64 KiB.", + packageLoadFailed: "Package contents could not be loaded. HTTP {status}.", + governanceGeneration: "Governance generation", + replacement: "Recommended replacement Skill", + governance: "Governance", + governanceIntro: "Deprecate or retire the logical Skill without changing immutable package bytes.", + lifecycleState: "Lifecycle state", + replacementSkill: "Recommended replacement Skill (optional)", + noReplacementSkill: "No recommended replacement", + applyLifecycle: "Apply lifecycle", + lifecycleUpdating: "Updating lifecycle...", + lifecycleUpdated: "Lifecycle updated to {state}.", + lifecycleFailed: "The lifecycle could not be updated. HTTP {status}.", + lifecycleConflict: "The Skill governance state changed. Refresh before trying again.", + retireSkillTitle: "Retire this Skill?", + retireSkillWarning: "Retirement is irreversible. Existing publication must be removed separately.", + retireSkill: "Retire Skill", lineage: "Lineage", sourceReferences: "Source references", artifactReferences: "Artifact references", noSourceReferences: "No Source references", noArtifactReferences: "No Artifact references", delivery: "Delivery", - deliveryIntro: "Inspect or publish this approved Revision to an explicit Agent Skill target.", - createSkillRevision: "Create revision", - publishTarget: "Publish target", + deliveryIntro: "Install this approved Revision on this machine or distribute it to a connected remote machine.", + deliveryLocation: "Delivery location", + deliveryLocal: "This machine", + deliveryRemote: "Remote machine", + refreshRemoteStatus: "Refresh status", + createSkillRevision: "Upload revision package", + revisionUploading: "Uploading complete successor package...", + revisionUploadFailed: "The successor package could not be proposed. HTTP {status}.", + publishTarget: "Install for", agentCodex: "Codex", agentClaudeCode: "Claude Code", installationUser: "User", installationProject: "Project", installationPlugin: "Plugin", - noPublishTargets: "No writable Skill target is configured.", - noPublishTargetsHint: "Enable managed publication on an explicit local Agent target.", - publishedRevision: "Published revision", - destination: "Destination", + currentProject: "Current project", + noPublishTargets: "No local Skill destination is available.", + noPublishTargetsHint: "Set the Server workspace or configure an advanced local Agent target.", + noRemoteTargets: "No remote machines are connected.", + noRemoteTargetsHint: "Add a Codex or Claude Code project, then complete the one-time enrollment on that machine.", + addRemoteMachine: "Add remote machine", + addRemoteMachineHint: "Give this machine a recognizable name, then choose the Agent used by its project.", + remoteMachineName: "Machine name", + remoteMachineNamePlaceholder: "For example: Build machine - Hangzhou", + remoteMachineNameRequired: "Enter a machine name.", + renameRemoteMachine: "Rename", + saveRemoteMachineName: "Save name", + remoteRenaming: "Saving the machine name...", + remoteRenameFailed: "The machine name could not be saved. HTTP {status}.", + searchRemoteMachines: "Search by name, host, workspace, or ID", + remoteSearchNoMatch: "No remote machine matches this search.", + receiverConnection: "Receiver connection", + receiverConnectionReady: "The remote CLI will connect to {url}.", + receiverConnectionInsecure: "The remote CLI will connect to {url} over cleartext HTTP. Credentials and Skill packages are not encrypted in transit.", + receiverConnectionNeedsSetup: "No remote-safe Server URL is available. Configure the remote CLI's Server URL before running enrollment.", + insecureHttpEnabledTitle: "Cleartext HTTP enabled", + insecureHttpEnabledWarning: "Receiver credentials and Skill packages are not encrypted in transit. Use this only on a protected private test network.", + createRemoteMachine: "Create connection", + connectRemoteMachine: "Connect the remote machine", + remoteAgent: "Agent", + remoteTarget: "Remote machine", + remoteTargetId: "Target ID", + remoteEnvironment: "Reported environment", + remoteEnvironmentPending: "Available after enrollment", + remoteEnrollment: "Connection", + remoteDeliveryState: "Delivery state", + remoteObservedRevision: "Installed revision", + remoteLastSeen: "Last check-in", + remoteNextStep: "Enable automatic sync on the remote machine", + remoteTargetPending: "Waiting for enrollment", + remoteTargetActive: "Connected", + remoteTargetRevoked: "Revoked", + remoteNoPublication: "Not distributed", + remoteStateUnpublished: "Removed", + remoteStatePending: "Waiting for the remote Receiver", + remoteStateCurrent: "Current", + remoteStateUpdateAvailable: "Update available", + remoteStateDeliveryFailed: "Delivery failed", + remoteStateConflict: "Target conflict", + remoteStateDrifted: "Locally modified", + remoteStateIncompatible: "Not compatible", + remoteRevisionNone: "Not installed", + remoteNeverSeen: "Never", + remoteGuidancePending: "Finish enrollment with the one-time code. If it was closed before you saved it, revoke this connection and add the machine again.", + remoteGuidanceReady: "Automatic sync checks this Server every few seconds. Choose Distribute Skill when ready.", + remoteGuidanceSync: "The remote Receiver will automatically apply this requested change.", + remoteGuidanceCurrent: "This revision is installed and automatic sync remains active.", + remoteGuidanceProblem: "Inspect the remote project before retrying. PowerContext will not overwrite local changes.", + remoteGuidanceRemoved: "The managed Skill is absent from this target.", + publishRemoteSkill: "Distribute Skill", + unpublishRemoteSkill: "Request removal", + revokeRemoteMachine: "Revoke machine", + revokeRemoteMachineTitle: "Revoke this remote machine?", + revokeRemoteMachineWarning: "Its credential will stop working. Remove distributed Skills and wait for automatic synchronization before revoking.", + remoteRevokeBlocked: "Remove every distributed Skill and wait for automatic synchronization before revoking this machine.", + remoteTargetsLoading: "Loading remote machine status...", + remoteTargetsFailed: "Remote machine status could not be loaded. HTTP {status}.", + remoteCreating: "Creating the remote connection...", + remoteCreateFailed: "The remote connection could not be created. HTTP {status}.", + remotePublishing: "Creating the remote desired state...", + remotePublishRequested: "Distribution requested. Waiting for the remote Receiver to install revision {revision}.", + remotePublishFailed: "The Skill could not be distributed. HTTP {status}.", + remoteUnpublishing: "Requesting remote removal...", + remoteUnpublishRequested: "Removal requested. Waiting for the remote Receiver to finish.", + remoteUnpublishFailed: "Remote removal could not be requested. HTTP {status}.", + remotePublishSkillTitle: "Distribute this managed Skill to the remote machine?", + remoteUnpublishSkillTitle: "Remove this managed Skill from the remote machine?", + remoteRevoking: "Revoking the remote machine...", + remoteRevoked: "The credential for {target} was revoked.", + remoteRevokeFailed: "The remote machine could not be revoked. HTTP {status}.", + remotePublishConfirmation: "Distribute revision {revision} to {target}. The automatic Receiver will install it.", + remotePublishDeprecatedConfirmation: "This Skill is deprecated. Distribute revision {revision} to {target} anyway?", + remoteUnpublishConfirmation: "Request removal from {target}. The automatic Receiver safely removes it if the managed package is intact.", + enrollmentCodeWarning: "This enrollment code is shown once and expires soon. Complete these steps before closing.", + expiresAt: "Expires", + installReceiver: "Install the lightweight Receiver", + runEnrollment: "Run enrollment in the remote project", + enterEnrollmentCode: "Enter this one-time code when prompted", + copy: "Copy", + copyCode: "Copy code", + copied: "Copied.", + copyFailed: "Copy failed. Select the text and copy it manually.", + done: "I finished or saved these steps", + standardPackageRequired: "This approved Skill predates standard package snapshots. Upload and approve a complete package as its next revision before publishing.", + publishedRevision: "Installed revision", + destination: "Installation path", discovery: "Discovery", - publishSkill: "Publish Skill", - updateSkill: "Publish update", + compatibility: "Compatibility", + compatible: "Compatible", + incompatible: "Incompatible", + unknown: "Unknown", + manual_review_required: "Manual review required", + publishSkill: "Install on this machine", + unpublishSkill: "Remove from this machine", + unpublishSkillTitle: "Remove this managed Skill from this machine?", + unpublishConfirmation: "Remove the exact intact package from {target}. The approved Revision and package history remain available.", + unpublishing: "Removing Skill from this machine...", + unpublicationSucceeded: "The managed package was safely removed from this machine.", + unpublicationFailed: "The managed package could not be safely removed. HTTP {status}.", + publishDeprecatedConfirmation: "This Skill is deprecated. Install revision {revision} for {target} anyway? Existing PowerContext-managed content may be safely updated; foreign or modified content is never overwritten.", + updateSkill: "Install update", refreshDiscovery: "Refresh discovery", - publishSkillCandidate: "Publish this managed Skill?", - publishConfirmation: "Publish revision {revision} to {target}. Existing PowerContext-managed content may be safely updated; foreign or modified content is never overwritten.", + publishSkillCandidate: "Install this managed Skill on this machine?", + publishConfirmation: "Install revision {revision} for {target}. Existing PowerContext-managed content may be safely updated; foreign or modified content is never overwritten.", cancel: "Cancel", - projectionUnpublished: "Not published", - projectionCurrent: "Current", + projectionUnpublished: "Not installed", + projectionCurrent: "Installed", projectionUpdateAvailable: "Update available", projectionConflict: "Target conflict", projectionDrifted: "Locally modified", @@ -128,13 +266,13 @@ const translations = { discoveryNotPublished: "Not yet available", publicationLoading: "Checking publication status...", publicationLoadFailed: "Publication status could not be loaded. HTTP {status}.", - publicationSucceeded: "Managed Skill revision {revision} is published and discoverable.", - publicationFailed: "The managed Skill could not be published. HTTP {status}.", + publicationSucceeded: "Managed Skill revision {revision} is installed and discoverable.", + publicationFailed: "The managed Skill could not be installed. HTTP {status}.", publicationConflict: "The publication target changed or contains content PowerContext will not overwrite.", - publishing: "Publishing Skill...", + publishing: "Installing Skill on this machine...", loading: "Loading Skills...", refreshing: "Refreshing local discovery...", - externalDiscoveryUnavailable: "Agent-local Skill discovery is not configured. Managed Skills are still shown.", + externalDiscoveryUnavailable: "Local Skill folders could not be refreshed. Managed Skills are still available.", externalSkillUnavailable: "This exact local package is no longer available at its registered fingerprint.", authRejected: "The Server rejected this token.", requestFailed: "The Skills request failed with HTTP {status}.", @@ -175,6 +313,15 @@ const translations = { searchSkills: "搜索", searchSkillsPlaceholder: "按名称、描述或标识搜索", authority: "权威来源", + origin: "出处", + originPowerContext: "自生成", + originExternalImport: "接管", + originExternalFork: "派生", + originExternal: "本地", + sourceMachine: "来源机器", + sourceAgent: "来源代理", + originalLocation: "原始位置", + externalIdentity: "外部技能标识符", allSkills: "全部技能", managedSkill: "受管技能", externalSkill: "外部技能", @@ -194,6 +341,9 @@ const translations = { description: "描述", status: "状态", approved: "已批准", + active: "活跃", + deprecated: "已废弃", + retired: "已退役", available: "可用", unavailable: "不可用", artifact: "制品", @@ -207,33 +357,159 @@ const translations = { entrypoint: "入口文件", instructions: "使用说明", validation: "验证要求", + packageContents: "技能包内容", + packageFiles: "技能包文件", + packageLoading: "正在加载已校验的技能包内容...", + packageBinary: "二进制文件不会在页面中预览。", + packageTruncated: "预览仅显示前 64 千字节。", + packageLoadFailed: "无法加载技能包内容(HTTP {status})。", + governanceGeneration: "治理代次", + replacement: "推荐替代技能", + governance: "治理", + governanceIntro: "无需修改不可变的技能包内容,即可废弃或退役这项逻辑技能。", + lifecycleState: "生命周期状态", + replacementSkill: "推荐替代技能(可选)", + noReplacementSkill: "不指定替代技能", + applyLifecycle: "应用生命周期", + lifecycleUpdating: "正在更新生命周期...", + lifecycleUpdated: "生命周期已更新为“{state}”。", + lifecycleFailed: "无法更新生命周期(HTTP {status})。", + lifecycleConflict: "技能治理状态已变化,请刷新后重试。", + retireSkillTitle: "退役这项技能?", + retireSkillWarning: "退役不可逆。已有发布需要单独安全下架。", + retireSkill: "退役技能", lineage: "沿袭关系", sourceReferences: "数据源引用", artifactReferences: "制品引用", noSourceReferences: "无数据源引用", noArtifactReferences: "无制品引用", delivery: "交付", - deliveryIntro: "检查发布状态,或将已批准修订发布到明确配置的代理技能目录。", - createSkillRevision: "创建新修订", - publishTarget: "发布目标", + deliveryIntro: "将已批准修订安装到本机,或分发到已连接的远端机器。", + deliveryLocation: "交付位置", + deliveryLocal: "本机", + deliveryRemote: "远端机器", + refreshRemoteStatus: "刷新状态", + createSkillRevision: "上传新修订包", + revisionUploading: "正在上传完整的后继技能包...", + revisionUploadFailed: "无法提交后继技能包(HTTP {status})。", + publishTarget: "安装到", agentCodex: "Codex", agentClaudeCode: "Claude Code", installationUser: "用户级", installationProject: "项目级", installationPlugin: "插件级", - noPublishTargets: "未配置可写的技能目标。", - noPublishTargetsHint: "请在一个明确的本地技能目录上启用受管发布。", - publishedRevision: "已发布修订", - destination: "目标位置", + currentProject: "当前项目", + noPublishTargets: "当前没有可用的本机技能目录。", + noPublishTargetsHint: "请设置服务工作目录,或在高级配置中指定本机代理目标。", + noRemoteTargets: "尚未连接远端机器。", + noRemoteTargetsHint: "添加一个 Codex 或 Claude Code 项目,然后在目标机器完成一次性注册。", + addRemoteMachine: "添加远端机器", + addRemoteMachineHint: "先给机器起一个容易识别的名称,再选择远端项目使用的代理类型。", + remoteMachineName: "机器名称", + remoteMachineNamePlaceholder: "例如:杭州构建机", + remoteMachineNameRequired: "请输入机器名称。", + renameRemoteMachine: "重命名", + saveRemoteMachineName: "保存名称", + remoteRenaming: "正在保存机器名称...", + remoteRenameFailed: "无法保存机器名称(HTTP {status})。", + searchRemoteMachines: "按名称、主机、工作区或技术标识搜索", + remoteSearchNoMatch: "没有匹配的远端机器。", + receiverConnection: "接收端连接", + receiverConnectionReady: "远端命令行将连接到 {url}。", + receiverConnectionInsecure: "远端命令行将通过明文 HTTP 连接到 {url}。注册凭据和技能包在传输过程中不会被加密。", + receiverConnectionNeedsSetup: "当前没有适合远端连接的服务地址。请先为远端命令行配置服务地址,再执行注册。", + insecureHttpEnabledTitle: "已启用明文 HTTP", + insecureHttpEnabledWarning: "接收端凭据和技能包在传输过程中不会被加密。仅限受保护的内部测试网络使用。", + createRemoteMachine: "创建连接", + connectRemoteMachine: "连接远端机器", + remoteAgent: "代理类型", + remoteTarget: "远端机器", + remoteTargetId: "目标标识", + remoteEnvironment: "上报环境", + remoteEnvironmentPending: "注册后自动显示", + remoteEnrollment: "连接状态", + remoteDeliveryState: "分发状态", + remoteObservedRevision: "已安装修订", + remoteLastSeen: "最后同步", + remoteNextStep: "在远端机器启用自动同步", + remoteTargetPending: "等待注册", + remoteTargetActive: "已连接", + remoteTargetRevoked: "已撤销", + remoteNoPublication: "尚未分发", + remoteStateUnpublished: "已移除", + remoteStatePending: "等待远端接收端", + remoteStateCurrent: "已是当前版本", + remoteStateUpdateAvailable: "有更新可分发", + remoteStateDeliveryFailed: "分发失败", + remoteStateConflict: "目标存在冲突", + remoteStateDrifted: "已被本地修改", + remoteStateIncompatible: "格式不兼容", + remoteRevisionNone: "尚未安装", + remoteNeverSeen: "从未同步", + remoteGuidancePending: "请使用一次性口令在远端项目完成注册。若关闭前未保存口令,请撤销这条连接后重新添加机器。", + remoteGuidanceReady: "自动同步会每隔几秒检查服务。准备好后直接点击“分发技能”。", + remoteGuidanceSync: "远端接收端会自动应用当前请求。", + remoteGuidanceCurrent: "当前修订已经安装,自动同步仍在运行。", + remoteGuidanceProblem: "请先检查远端项目再重试。PowerContext 不会覆盖本地改动。", + remoteGuidanceRemoved: "该目标上已不存在这项受管技能。", + publishRemoteSkill: "分发技能", + unpublishRemoteSkill: "请求移除", + revokeRemoteMachine: "撤销机器", + revokeRemoteMachineTitle: "撤销这台远端机器?", + revokeRemoteMachineWarning: "撤销后该机器的凭据会立即失效。请先移除已分发技能,并等待自动同步完成。", + remoteRevokeBlocked: "请先移除这台机器上的所有受管技能,并等待自动同步完成后再撤销。", + remoteTargetsLoading: "正在加载远端机器状态...", + remoteTargetsFailed: "无法加载远端机器状态(HTTP {status})。", + remoteCreating: "正在创建远端连接...", + remoteCreateFailed: "无法创建远端连接(HTTP {status})。", + remotePublishing: "正在创建远端期望状态...", + remotePublishRequested: "已请求分发,正在等待远端接收端自动安装第 {revision} 版。", + remotePublishFailed: "无法分发该技能(HTTP {status})。", + remoteUnpublishing: "正在请求远端移除...", + remoteUnpublishRequested: "已请求移除,正在等待远端接收端自动完成操作。", + remoteUnpublishFailed: "无法请求远端移除(HTTP {status})。", + remotePublishSkillTitle: "将这项受管技能分发到远端机器?", + remoteUnpublishSkillTitle: "从远端机器移除这项受管技能?", + remoteRevoking: "正在撤销远端机器...", + remoteRevoked: "{target} 的远端连接凭据已撤销。", + remoteRevokeFailed: "无法撤销远端机器(HTTP {status})。", + remotePublishConfirmation: "将第 {revision} 版分发到 {target}。远端接收端会自动安装。", + remotePublishDeprecatedConfirmation: "这项技能已废弃。仍要将第 {revision} 版分发到 {target} 吗?", + remoteUnpublishConfirmation: "请求从 {target} 移除。远端接收端会自动安全移除未被修改的受管包。", + enrollmentCodeWarning: "注册口令只显示一次且会很快过期。请在关闭前完成以下步骤。", + expiresAt: "过期时间", + installReceiver: "安装轻量接收端", + runEnrollment: "在远端项目中运行注册命令", + enterEnrollmentCode: "出现提示后,输入这段一次性口令", + copy: "复制", + copyCode: "复制口令", + copied: "已复制。", + copyFailed: "复制失败,请选中文本后手动复制。", + done: "已完成或妥善保存", + standardPackageRequired: "这项已批准技能创建于标准技能包支持之前。请先上传完整技能包并批准为下一修订,再进行发布。", + publishedRevision: "已安装修订", + destination: "安装路径", discovery: "发现状态", - publishSkill: "发布技能", - updateSkill: "发布更新", + compatibility: "兼容性", + compatible: "兼容", + incompatible: "不兼容", + unknown: "未知", + manual_review_required: "需要人工检查", + publishSkill: "安装到本机", + unpublishSkill: "从本机移除", + unpublishSkillTitle: "从本机移除这项受管技能?", + unpublishConfirmation: "从 {target} 安全移除完全一致且未被修改的技能包。已批准修订和历史包仍会保留。", + unpublishing: "正在从本机移除技能...", + unpublicationSucceeded: "已从本机安全移除受管技能包。", + unpublicationFailed: "无法安全移除该受管技能包(HTTP {status})。", + publishDeprecatedConfirmation: "这项技能已废弃。仍要为 {target} 安装第 {revision} 版吗?系统只会安全更新由 PowerContext 管理的内容,不会覆盖外部内容或本地改动。", + updateSkill: "安装更新", refreshDiscovery: "刷新发现状态", - publishSkillCandidate: "发布这项受管技能?", - publishConfirmation: "将第 {revision} 版发布到 {target}。系统只会安全更新由 PowerContext 管理的内容,不会覆盖外部内容或已被本地修改的内容。", + publishSkillCandidate: "将这项受管技能安装到本机?", + publishConfirmation: "为 {target} 安装第 {revision} 版。系统只会安全更新由 PowerContext 管理的内容,不会覆盖外部内容或已被本地修改的内容。", cancel: "取消", - projectionUnpublished: "尚未发布", - projectionCurrent: "已是当前版本", + projectionUnpublished: "尚未安装", + projectionCurrent: "已安装", projectionUpdateAvailable: "有更新可发布", projectionConflict: "目标存在冲突", projectionDrifted: "已被本地修改", @@ -246,13 +522,13 @@ const translations = { discoveryNotPublished: "尚不可用", publicationLoading: "正在检查发布状态...", publicationLoadFailed: "无法加载发布状态(HTTP {status})。", - publicationSucceeded: "受管技能第 {revision} 版已发布并可被发现。", - publicationFailed: "无法发布该受管技能(HTTP {status})。", + publicationSucceeded: "受管技能第 {revision} 版已安装并可被发现。", + publicationFailed: "无法安装该受管技能(HTTP {status})。", publicationConflict: "发布目标已经变化,或包含系统不会覆盖的内容。", - publishing: "正在发布技能...", + publishing: "正在将技能安装到本机...", loading: "正在加载技能...", refreshing: "正在刷新本地发现状态...", - externalDiscoveryUnavailable: "未配置代理本地技能发现,仍会显示受管技能。", + externalDiscoveryUnavailable: "暂时无法刷新本机技能目录,受管技能仍可正常使用。", externalSkillUnavailable: "该本地技能包已无法按登记的内容指纹精确解析。", authRejected: "服务器拒绝了该访问令牌。", requestFailed: "技能请求失败(HTTP {status})。", @@ -307,44 +583,127 @@ const facts = document.getElementById("skills-facts"); const managedContent = document.getElementById("skills-managed-content"); const instructions = document.getElementById("skills-instructions"); const validation = document.getElementById("skills-validation"); +const packageSection = document.getElementById("skills-package"); +const packageStatus = document.getElementById("skills-package-status"); +const packageFiles = document.getElementById("skills-package-files"); +const packagePath = document.getElementById("skills-package-path"); +const packagePreview = document.getElementById("skills-package-preview"); +const governanceSection = document.getElementById("skills-governance"); +const lifecycleState = document.getElementById("skills-lifecycle-state"); +const replacementId = document.getElementById("skills-replacement-id"); +const applyLifecycleButton = document.getElementById("skills-apply-lifecycle"); +const governanceStatus = document.getElementById("skills-governance-status"); const lineage = document.getElementById("skills-lineage"); const sourceRefs = document.getElementById("skills-source-refs"); const artifactRefs = document.getElementById("skills-artifact-refs"); const delivery = document.getElementById("skills-delivery"); +const deliveryMode = document.getElementById("skills-delivery-mode"); const projectionState = document.getElementById("skills-projection-state"); const deliveryStatus = document.getElementById("skills-delivery-status"); +const localDelivery = document.getElementById("skills-local-delivery"); const deliveryEmpty = document.getElementById("skills-delivery-empty"); const deliveryContent = document.getElementById("skills-delivery-content"); const deliveryTarget = document.getElementById("skills-delivery-target"); const publishedRevision = document.getElementById("skills-published-revision"); const discovery = document.getElementById("skills-discovery"); +const compatibility = document.getElementById("skills-compatibility"); +const compatibilityReasons = document.getElementById("skills-compatibility-reasons"); const destination = document.getElementById("skills-destination"); const createRevisionButton = document.getElementById("skills-create-revision"); +const revisionPackageInput = document.getElementById("skills-revision-package"); +const unpublishButton = document.getElementById("skills-unpublish"); const publishButton = document.getElementById("skills-publish"); +const remoteDelivery = document.getElementById("skills-remote-delivery"); +const insecureHttpWarning = document.getElementById("skills-insecure-http-warning"); +const remoteRefreshButton = document.getElementById("skills-remote-refresh"); +const remoteEmpty = document.getElementById("skills-remote-empty"); +const remoteContent = document.getElementById("skills-remote-content"); +const remoteTarget = document.getElementById("skills-remote-target"); +const remoteTargetSearch = document.getElementById("skills-remote-target-search"); +const remoteEnrollment = document.getElementById("skills-remote-enrollment"); +const remotePublicationState = document.getElementById("skills-remote-publication-state"); +const remoteObservedRevision = document.getElementById("skills-remote-observed-revision"); +const remoteLastSeen = document.getElementById("skills-remote-last-seen"); +const remoteEnvironment = document.getElementById("skills-remote-environment"); +const remoteTargetId = document.getElementById("skills-remote-target-id"); +const remoteGuidance = document.getElementById("skills-remote-guidance"); +const remoteAddButtons = [ + document.getElementById("skills-remote-add-empty"), + document.getElementById("skills-remote-add") +]; +const remotePublishButton = document.getElementById("skills-remote-publish"); +const remoteUnpublishButton = document.getElementById("skills-remote-unpublish"); +const remoteRevokeButton = document.getElementById("skills-remote-revoke"); +const remoteRenameButton = document.getElementById("skills-remote-rename"); const publishDialog = document.getElementById("skills-publish-dialog"); +const publishDialogTitle = publishDialog.querySelector("h2"); const publishConfirmation = document.getElementById("skills-publish-confirmation"); const confirmPublishButton = document.getElementById("skills-confirm-publish"); +const retireDialog = document.getElementById("skills-retire-dialog"); +const confirmRetireButton = document.getElementById("skills-confirm-retire"); +const remoteCreateDialog = document.getElementById("skills-remote-create-dialog"); +const remoteDisplayName = document.getElementById("skills-remote-display-name"); +const remoteAgentKind = document.getElementById("skills-remote-agent-kind"); +const remoteCreateError = document.getElementById("skills-remote-create-error"); +const confirmRemoteCreateButton = document.getElementById("skills-confirm-remote-create"); +const remoteRenameDialog = document.getElementById("skills-remote-rename-dialog"); +const remoteRenameName = document.getElementById("skills-remote-rename-name"); +const remoteRenameError = document.getElementById("skills-remote-rename-error"); +const confirmRemoteRenameButton = document.getElementById("skills-confirm-remote-rename"); +const remoteEnrollmentDialog = document.getElementById("skills-remote-enrollment-dialog"); +const enrollmentTargetId = document.getElementById("skills-enrollment-target-id"); +const enrollmentExpires = document.getElementById("skills-enrollment-expires"); +const enrollmentConnection = document.getElementById("skills-enrollment-connection"); +const enrollmentConnectionMessage = document.getElementById("skills-enrollment-connection-message"); +const receiverInstallCommand = document.getElementById("skills-receiver-install-command"); +const enrollmentCommand = document.getElementById("skills-enrollment-command"); +const enrollmentCode = document.getElementById("skills-enrollment-code"); +const copyStatus = document.getElementById("skills-copy-status"); +const finishEnrollmentButton = document.getElementById("skills-finish-enrollment"); +const remoteRevokeDialog = document.getElementById("skills-remote-revoke-dialog"); +const confirmRemoteRevokeButton = document.getElementById("skills-confirm-remote-revoke"); const authenticationRequired = document.documentElement.dataset.serverAuthRequired === "true"; +const allowInsecureHttp = library.dataset.allowInsecureHttp === "true"; const scopePreferenceKey = "powercontext.skills.scope"; +const deliveryModePreferenceKey = "powercontext.skills.delivery-mode"; const scopeOptionRenderLimit = 50; +const remoteFastRefreshMilliseconds = 2000; +const remoteIdleRefreshMilliseconds = 10000; let scopes = []; let records = []; let currentScopeId = ""; let selectedKey = ""; let projectionView = null; +let remoteTargets = []; +let selectedRemoteTargetId = ""; +let packageManifest = null; +let packageSelectedPath = ""; +let packageError = null; +let pendingPublicationAction = "publish"; +let pendingPublicationChannel = "local"; +let remoteFeedback = null; let currentAlert = null; let currentPageStatus = null; let currentAuthError = null; let libraryBusy = false; let projectionBusy = false; +let remoteBusy = false; +let remoteActionBusy = false; let actionBusy = false; +let packageBusy = false; +let lifecycleBusy = false; +let revisionBusy = false; let scopeActiveIndex = -1; +let remoteRefreshTimer = null; const scopeRequests = createRequestGate(); const libraryRequests = createRequestGate(); const projectionRequests = createRequestGate(); +const remoteRequests = createRequestGate(); +const packageRequests = createRequestGate(); +const packagePreviewRequests = createRequestGate(); const ui = createPageUi(translations, () => { renderAuthError(); renderPageStatus(); @@ -352,7 +711,8 @@ const ui = createPageUi(translations, () => { renderLibrary(); renderDetail(); }); -const {formatNumber, translate} = ui; +const {formatDateTime, formatNumber, translate} = ui; +deliveryMode.value = preferredDeliveryMode(); scopeSearchInput.addEventListener("focus", () => { if (scopeOptions.hidden) { @@ -391,21 +751,77 @@ refreshButton.addEventListener("click", () => { void loadLibrary({refreshDiscovery: true, preserveSelection: true}); }); +deliveryMode.addEventListener("change", () => { + rememberDeliveryMode(deliveryMode.value); + renderDelivery(); + scheduleRemoteRefresh(0); + if (deliveryMode.value === "remote" && !remoteBusy && remoteTargets.length === 0) { + void loadRemoteTargets(); + } +}); deliveryTarget.addEventListener("change", renderDelivery); +remoteTarget.addEventListener("change", () => { + selectedRemoteTargetId = remoteTarget.value; + remoteFeedback = null; + renderDelivery(); +}); +remoteTargetSearch.addEventListener("input", selectRemoteTargetFromSearch); +remoteRefreshButton.addEventListener("click", () => void loadRemoteTargets({preserveTarget: true})); +document.addEventListener("visibilitychange", () => { + if (document.hidden) { + stopRemoteRefresh(); + return; + } + scheduleRemoteRefresh(0); +}); +for (const button of remoteAddButtons) { + button.addEventListener("click", () => { + remoteCreateError.textContent = ""; + remoteDisplayName.value = ""; + remoteCreateDialog.showModal(); + remoteDisplayName.focus(); + }); +} +remoteRenameButton.addEventListener("click", () => { + const status = selectedRemoteTargetStatus(); + if (!status) { + return; + } + remoteRenameError.textContent = ""; + remoteRenameName.value = status.target.display_name; + remoteRenameDialog.showModal(); + remoteRenameName.select(); +}); +lifecycleState.addEventListener("change", renderGovernanceControls); createRevisionButton.addEventListener("click", () => { const record = selectedRecord(); - if (!record || record.authority !== "managed") { + if (!record || record.authority !== "managed" || record.governance.lifecycle_state === "retired") { return; } - const params = new URLSearchParams({ - scope: currentScopeId, - family: "skill", - status: "approved", - candidate: record.candidate.candidate_id, - action: "create-revision" - }); - window.location.assign(`/reviews?${params.toString()}`); + revisionPackageInput.click(); +}); + +revisionPackageInput.addEventListener("change", () => { + const [file] = revisionPackageInput.files; + revisionPackageInput.value = ""; + if (file) { + void uploadRevisionPackage(file); + } +}); + +applyLifecycleButton.addEventListener("click", () => { + if (lifecycleState.value === "retired") { + retireDialog.showModal(); + return; + } + void applyLifecycle(); +}); + +confirmRetireButton.addEventListener("click", (event) => { + event.preventDefault(); + retireDialog.close(); + void applyLifecycle(); }); publishButton.addEventListener("click", () => { @@ -414,19 +830,90 @@ publishButton.addEventListener("click", () => { if (!record || record.authority !== "managed" || !target || !canPublishProjection(target)) { return; } + const targetLabel = localTargetLabel(target, record.name); publishConfirmation.textContent = translate("publishConfirmation", { revision: record.candidate.result_artifact.revision, - target: `${agentLabel(target.agent_kind)} · ${target.target_id}` + target: targetLabel + }); + if (record.governance.lifecycle_state === "deprecated") { + publishConfirmation.textContent = translate("publishDeprecatedConfirmation", { + revision: record.candidate.result_artifact.revision, + target: targetLabel + }); + } + pendingPublicationAction = "publish"; + pendingPublicationChannel = "local"; + publishDialogTitle.textContent = translate("publishSkillCandidate"); + confirmPublishButton.textContent = translate("publishSkill"); + publishDialog.showModal(); +}); + +unpublishButton.addEventListener("click", () => { + const record = selectedRecord(); + const target = selectedProjectionTarget(); + if (!record || !target || !canUnpublishProjection(target)) { + return; + } + pendingPublicationAction = "unpublish"; + pendingPublicationChannel = "local"; + publishDialogTitle.textContent = translate("unpublishSkillTitle"); + publishConfirmation.textContent = translate("unpublishConfirmation", { + target: localTargetLabel(target, record.name) }); + confirmPublishButton.textContent = translate("unpublishSkill"); publishDialog.showModal(); }); +remotePublishButton.addEventListener("click", () => openRemotePublicationDialog("publish")); +remoteUnpublishButton.addEventListener("click", () => openRemotePublicationDialog("unpublish")); +remoteRevokeButton.addEventListener("click", () => { + const status = selectedRemoteTargetStatus(); + if (!status || !canRevokeRemoteTarget(status)) { + remoteFeedback = {key: "remoteRevokeBlocked", tone: "error"}; + renderDelivery(); + return; + } + remoteRevokeDialog.showModal(); +}); + confirmPublishButton.addEventListener("click", (event) => { event.preventDefault(); publishDialog.close(); - void publishSelectedSkill(); + if (pendingPublicationChannel === "remote") { + void (pendingPublicationAction === "unpublish" ? unpublishRemoteSkill() : publishRemoteSkill()); + return; + } + void (pendingPublicationAction === "unpublish" ? unpublishSelectedSkill() : publishSelectedSkill()); +}); + +confirmRemoteCreateButton.addEventListener("click", (event) => { + event.preventDefault(); + void createRemoteTarget(); }); +confirmRemoteRenameButton.addEventListener("click", (event) => { + event.preventDefault(); + void renameRemoteTarget(); +}); + +confirmRemoteRevokeButton.addEventListener("click", (event) => { + event.preventDefault(); + remoteRevokeDialog.close(); + void revokeRemoteTarget(); +}); + +document.getElementById("skills-copy-install-command").addEventListener("click", () => { + void copyEnrollmentValue(receiverInstallCommand.textContent); +}); +document.getElementById("skills-copy-enrollment-command").addEventListener("click", () => { + void copyEnrollmentValue(enrollmentCommand.textContent); +}); +document.getElementById("skills-copy-enrollment-code").addEventListener("click", () => { + void copyEnrollmentValue(enrollmentCode.textContent); +}); +finishEnrollmentButton.addEventListener("click", () => void loadRemoteTargets({preserveTarget: true})); +remoteEnrollmentDialog.addEventListener("close", clearEnrollmentSecrets); + pageStatusRetry.addEventListener("click", () => { void authenticate(readServerToken(), currentScopeId); }); @@ -518,16 +1005,23 @@ async function loadLibrary({refreshDiscovery = false, preserveSelection = false} if (handleAuthenticationError(externalResult.reason)) { return; } - currentAlert = {key: "externalDiscoveryUnavailable", tone: "warning"}; + if (!(externalResult.reason instanceof SkillsRequestError) + || externalResult.reason.code !== "external_skill_registry_unavailable") { + currentAlert = {key: "externalDiscoveryUnavailable", tone: "warning"}; + } } records = [...managedResult.value, ...externalRecords].sort(compareRecords); selectedKey = records.some((record) => record.key === previousSelection) ? previousSelection : (filteredRecords()[0]?.key || records[0]?.key || ""); projectionView = null; + packageManifest = null; + packageSelectedPath = ""; + packageError = null; + lifecycleState.dataset.recordKey = ""; renderLibrary(); renderDetail(); - await loadProjectionStatus(); + await Promise.all([loadProjectionStatus(), loadPackageManifest(), loadRemoteTargets({preserveTarget: true})]); } catch (error) { if (!request.isCurrent() || handleAuthenticationError(error)) { return; @@ -543,48 +1037,45 @@ async function loadLibrary({refreshDiscovery = false, preserveSelection = false} } async function loadApprovedManagedSkills() { - const candidates = []; - let cursor = null; - do { - const body = { - scope_id: currentScopeId, + const entries = await requestJson("/dashboard/skills/library", { + scope_id: currentScopeId, + include_deprecated: true, + limit: 200 + }); + return entries.map((entry) => { + const candidate = { + candidate_id: null, family: "skill", - status: "approved", - limit: 100 + proposal: entry.content, + result_artifact: entry.artifact, + source_refs: entry.sources.map((reference) => ({ + name: reference.source_type, + source_id: reference.source_id + })), + artifact_refs: entry.artifacts }; - if (cursor) { - body.cursor = cursor; - } - const page = await requestJson("/v1/artifact-candidates/list", body); - candidates.push(...page.candidates); - cursor = page.next_cursor; - } while (cursor); - - const latestByArtifact = new Map(); - for (const candidate of candidates) { - if (!candidate.result_artifact || candidate.family !== "skill") { - continue; - } - const artifactId = candidate.result_artifact.artifact_id; - const current = latestByArtifact.get(artifactId); - if (!current || candidate.result_artifact.revision > current.result_artifact.revision) { - latestByArtifact.set(artifactId, candidate); - } - } - return [...latestByArtifact.values()].map((candidate) => ({ - authority: "managed", - candidate, - key: `managed:${candidate.result_artifact.artifact_id}`, - name: candidate.proposal.name, - description: candidate.proposal.description, - identity: formatArtifactReference(candidate.result_artifact), - searchText: [ - candidate.proposal.name, - candidate.proposal.description, - candidate.candidate_id, - formatArtifactReference(candidate.result_artifact) - ].join("\n").toLocaleLowerCase() - })); + return { + authority: "managed", + origin: entry.origin, + candidate, + governance: entry.governance, + key: `managed:${candidate.result_artifact.artifact_id}`, + name: candidate.proposal.name, + description: candidate.proposal.description, + identity: formatArtifactReference(candidate.result_artifact), + searchText: [ + candidate.proposal.name, + candidate.proposal.description, + candidate.candidate_id, + formatArtifactReference(candidate.result_artifact), + entry.origin.kind, + entry.origin.registration?.host_id, + entry.origin.registration?.agent_kind, + entry.origin.registration?.locator, + entry.origin.registration?.external_skill_id + ].join("\n").toLocaleLowerCase() + }; + }); } async function loadExternalSkills(refreshDiscovery) { @@ -599,6 +1090,7 @@ async function loadExternalSkills(refreshDiscovery) { const registration = resolution.registration; return { authority: "external", + origin: {kind: "external", registration}, resolution, key: `external:${registration.external_skill_id}`, name: registration.name, @@ -608,6 +1100,8 @@ async function loadExternalSkills(refreshDiscovery) { registration.name, registration.description, registration.external_skill_id, + registration.host_id, + registration.agent_kind, registration.locator, registration.fingerprint ].join("\n").toLocaleLowerCase() @@ -653,6 +1147,179 @@ async function loadProjectionStatus() { } } +async function loadRemoteTargets({preserveTarget = false, silent = false} = {}) { + if (!currentScopeId) { + return; + } + if (remoteBusy) { + scheduleRemoteRefresh(1000); + return; + } + const request = remoteRequests.start(); + const previousTargetId = preserveTarget ? selectedRemoteTargetId : ""; + remoteBusy = true; + if (!silent) { + remoteFeedback = null; + renderDelivery(); + } + try { + const response = await requestJson("/v1/skill/remote/targets", { + scope_id: currentScopeId, + limit: 200 + }); + if (!request.isCurrent()) { + return; + } + remoteTargets = response.targets; + const availableTargets = remoteTargets.filter((status) => status.target.state !== "revoked"); + selectedRemoteTargetId = availableTargets.some((status) => status.target.target_id === previousTargetId) + ? previousTargetId + : (availableTargets[0]?.target.target_id || ""); + settleRemoteFeedback(); + } catch (error) { + if (!request.isCurrent()) { + return; + } + if (handleAuthenticationError(error)) { + remoteBusy = false; + return; + } + if (!silent) { + remoteFeedback = { + key: error instanceof SkillsRequestError ? "remoteTargetsFailed" : "serverUnavailable", + values: {status: error.status}, + tone: "error" + }; + } + } finally { + if (request.isCurrent()) { + remoteBusy = false; + renderDelivery(); + scheduleRemoteRefresh(); + } + } +} + +function settleRemoteFeedback() { + if (!["remotePublishRequested", "remoteUnpublishRequested"].includes(remoteFeedback?.key)) { + return; + } + const publication = selectedRemotePublication(); + if (publication && !["pending", "update_available"].includes(publication.state)) { + remoteFeedback = null; + } +} + +function scheduleRemoteRefresh(delay = remoteRefreshDelay()) { + stopRemoteRefresh(); + if (!shouldAutoRefreshRemoteTargets()) { + return; + } + remoteRefreshTimer = window.setTimeout(() => { + remoteRefreshTimer = null; + void loadRemoteTargets({preserveTarget: true, silent: true}); + }, delay); +} + +function stopRemoteRefresh() { + if (remoteRefreshTimer !== null) { + window.clearTimeout(remoteRefreshTimer); + remoteRefreshTimer = null; + } +} + +function shouldAutoRefreshRemoteTargets() { + return Boolean(currentScopeId && deliveryMode.value === "remote" && !document.hidden && !library.hidden); +} + +function remoteRefreshDelay() { + const pending = remoteTargets.some((status) => ( + status.target.state === "pending" + || status.publications.some((publication) => ["pending", "update_available"].includes(publication.state)) + )); + return pending ? remoteFastRefreshMilliseconds : remoteIdleRefreshMilliseconds; +} + +async function loadPackageManifest() { + packageRequests.cancel(); + packagePreviewRequests.cancel(); + packageManifest = null; + packageSelectedPath = ""; + packageError = null; + const record = selectedRecord(); + renderPackageBrowser(); + if (!record || record.authority !== "managed" || !record.candidate.proposal.package) { + return; + } + const request = packageRequests.start(); + packageBusy = true; + renderPackageBrowser(); + try { + const manifest = await requestJson("/dashboard/skill-packages/manifest", { + scope_id: currentScopeId, + package: record.candidate.proposal.package + }); + if (!request.isCurrent() || selectedKey !== record.key) { + return; + } + packageManifest = manifest; + packageSelectedPath = manifest.files.some((file) => file.path === "SKILL.md") + ? "SKILL.md" + : (manifest.files[0]?.path || ""); + renderPackageBrowser(); + if (packageSelectedPath) { + await loadPackagePreview(packageSelectedPath); + } + } catch (error) { + if (!request.isCurrent() || handleAuthenticationError(error)) { + return; + } + packageError = { + key: error instanceof SkillsRequestError ? "packageLoadFailed" : "serverUnavailable", + values: {status: error.status} + }; + } finally { + if (request.isCurrent()) { + packageBusy = false; + renderPackageBrowser(); + } + } +} + +async function loadPackagePreview(path) { + const record = selectedRecord(); + if (!record || record.authority !== "managed" || !record.candidate.proposal.package) { + return; + } + const request = packagePreviewRequests.start(); + packageSelectedPath = path; + packagePath.textContent = path; + packagePreview.textContent = translate("packageLoading"); + renderPackageFileSelection(); + try { + const preview = await requestJson("/dashboard/skill-packages/preview", { + scope_id: currentScopeId, + package: record.candidate.proposal.package, + path + }); + if (!request.isCurrent() || selectedKey !== record.key || packageSelectedPath !== path) { + return; + } + const body = preview.binary ? translate("packageBinary") : (preview.content || ""); + packagePreview.textContent = preview.truncated + ? `${body}\n\n${translate("packageTruncated")}` + : body; + } catch (error) { + if (!request.isCurrent() || handleAuthenticationError(error)) { + return; + } + packagePreview.textContent = translate( + error instanceof SkillsRequestError ? "packageLoadFailed" : "serverUnavailable", + {status: error.status} + ); + } +} + async function publishSelectedSkill() { const record = selectedRecord(); const target = selectedProjectionTarget(); @@ -669,7 +1336,8 @@ async function publishSelectedSkill() { scope_id: currentScopeId, candidate_id: record.candidate.candidate_id, artifact: record.candidate.result_artifact, - target_id: target.target_id + target_id: target.target_id, + allow_deprecated: record.governance.lifecycle_state === "deprecated" }); if (!request.isCurrent() || selectedKey !== record.key) { return; @@ -700,6 +1368,455 @@ async function publishSelectedSkill() { } } +async function unpublishSelectedSkill() { + const record = selectedRecord(); + const target = selectedProjectionTarget(); + if (!record || record.authority !== "managed" || !target || !canUnpublishProjection(target)) { + return; + } + const request = projectionRequests.start(); + actionBusy = true; + currentAlert = null; + liveStatus.textContent = translate("unpublishing"); + renderDelivery(); + try { + const view = await requestJson("/dashboard/skill-projections/unpublish", { + scope_id: currentScopeId, + candidate_id: record.candidate.candidate_id, + artifact: record.candidate.result_artifact, + target_id: target.target_id + }); + if (!request.isCurrent() || selectedKey !== record.key) { + return; + } + projectionView = view; + currentAlert = {key: "unpublicationSucceeded", tone: "success"}; + } catch (error) { + if (!request.isCurrent() || handleAuthenticationError(error)) { + return; + } + currentAlert = error instanceof SkillsRequestError && error.status === 409 + ? {key: "publicationConflict", tone: "error"} + : { + key: error instanceof SkillsRequestError ? "unpublicationFailed" : "serverUnavailable", + values: {status: error.status}, + tone: "error" + }; + } finally { + if (request.isCurrent()) { + actionBusy = false; + liveStatus.textContent = ""; + renderDetail(); + } + } +} + +function openRemotePublicationDialog(action) { + const record = selectedRecord(); + const status = selectedRemoteTargetStatus(); + const publication = selectedRemotePublication(status, record); + if (!record || record.authority !== "managed" || !status) { + return; + } + if (action === "publish" && !canPublishRemote(record, status, publication)) { + return; + } + if (action === "unpublish" && (!publication || publication.desired_state !== "published")) { + return; + } + const target = remoteTargetLabel(status.target); + pendingPublicationAction = action; + pendingPublicationChannel = "remote"; + if (action === "unpublish") { + publishDialogTitle.textContent = translate("remoteUnpublishSkillTitle"); + publishConfirmation.textContent = translate("remoteUnpublishConfirmation", {target}); + confirmPublishButton.textContent = translate("unpublishRemoteSkill"); + } else { + publishDialogTitle.textContent = translate("remotePublishSkillTitle"); + const confirmationKey = record.governance.lifecycle_state === "deprecated" + ? "remotePublishDeprecatedConfirmation" + : "remotePublishConfirmation"; + publishConfirmation.textContent = translate(confirmationKey, { + revision: record.candidate.result_artifact.revision, + target + }); + confirmPublishButton.textContent = translate("publishRemoteSkill"); + } + publishDialog.showModal(); +} + +async function createRemoteTarget() { + const displayName = remoteDisplayName.value.trim(); + if (!displayName) { + remoteCreateError.textContent = translate("remoteMachineNameRequired"); + remoteDisplayName.focus(); + return; + } + remoteActionBusy = true; + remoteCreateError.textContent = translate("remoteCreating"); + confirmRemoteCreateButton.disabled = true; + try { + const enrollment = await requestJson("/v1/skill/remote/target/create", { + scope_id: currentScopeId, + agent_kind: remoteAgentKind.value, + display_name: displayName + }); + remoteTargets.push({target: enrollment.target, publications: []}); + selectedRemoteTargetId = enrollment.target.target_id; + remoteCreateDialog.close(); + showRemoteEnrollment(enrollment); + remoteFeedback = null; + renderDelivery(); + } catch (error) { + if (handleAuthenticationError(error)) { + return; + } + remoteCreateError.textContent = translate( + error instanceof SkillsRequestError ? "remoteCreateFailed" : "serverUnavailable", + {status: error.status} + ); + } finally { + remoteActionBusy = false; + confirmRemoteCreateButton.disabled = false; + renderDelivery(); + } +} + +async function renameRemoteTarget() { + const status = selectedRemoteTargetStatus(); + const displayName = remoteRenameName.value.trim(); + if (!status) { + return; + } + if (!displayName) { + remoteRenameError.textContent = translate("remoteMachineNameRequired"); + remoteRenameName.focus(); + return; + } + remoteActionBusy = true; + remoteRenameError.textContent = translate("remoteRenaming"); + confirmRemoteRenameButton.disabled = true; + try { + status.target = await requestJson("/v1/skill/remote/target/rename", { + scope_id: currentScopeId, + target_id: status.target.target_id, + display_name: displayName, + expected_generation: status.target.generation + }); + remoteRenameDialog.close(); + remoteFeedback = null; + renderDelivery(); + } catch (error) { + if (handleAuthenticationError(error)) { + return; + } + remoteRenameError.textContent = translate( + error instanceof SkillsRequestError ? "remoteRenameFailed" : "serverUnavailable", + {status: error.status} + ); + } finally { + remoteActionBusy = false; + confirmRemoteRenameButton.disabled = false; + renderDelivery(); + } +} + +function showRemoteEnrollment(enrollment) { + const serverUrl = resolvedRemoteServerUrl(); + const insecureHttp = usesInsecureRemoteHttp(serverUrl); + enrollmentTargetId.textContent = enrollment.target.target_id; + enrollmentExpires.dateTime = enrollment.enrollment_expires_at; + enrollmentExpires.textContent = formatDateTime(enrollment.enrollment_expires_at); + enrollmentConnection.dataset.tone = serverUrl && !insecureHttp ? "ready" : "warning"; + enrollmentConnectionMessage.textContent = insecureHttp + ? translate("receiverConnectionInsecure", {url: serverUrl}) + : serverUrl + ? translate("receiverConnectionReady", {url: serverUrl}) + : translate("receiverConnectionNeedsSetup"); + enrollmentCommand.textContent = serverUrl + ? `powercontext --server-url ${shellQuote(serverUrl)} skill remote-enroll --workspace "$PWD" --install-service${insecureHttp ? " --allow-insecure-http" : ""}` + : `powercontext skill remote-enroll --workspace "$PWD" --install-service`; + enrollmentCode.textContent = enrollment.enrollment_code; + copyStatus.textContent = ""; + remoteEnrollmentDialog.showModal(); +} + +async function copyEnrollmentValue(value) { + try { + await navigator.clipboard.writeText(value || ""); + copyStatus.textContent = translate("copied"); + } catch (error) { + copyStatus.textContent = translate("copyFailed"); + } +} + +function clearEnrollmentSecrets() { + enrollmentCode.textContent = ""; + enrollmentCommand.textContent = ""; + enrollmentTargetId.textContent = ""; + enrollmentExpires.textContent = ""; + enrollmentExpires.removeAttribute("datetime"); + enrollmentConnection.dataset.tone = ""; + enrollmentConnectionMessage.textContent = ""; + copyStatus.textContent = ""; +} + +async function publishRemoteSkill() { + const record = selectedRecord(); + const status = selectedRemoteTargetStatus(); + const publication = selectedRemotePublication(status, record); + if (!record || record.authority !== "managed" || !status || !canPublishRemote(record, status, publication)) { + return; + } + remoteActionBusy = true; + remoteFeedback = {key: "remotePublishing"}; + renderDelivery(); + try { + const updated = await requestJson("/v1/skill/remote/publication/publish", { + scope_id: currentScopeId, + target_id: status.target.target_id, + artifact: record.candidate.result_artifact, + expected_generation: publication?.generation ?? null, + allow_deprecated: record.governance.lifecycle_state === "deprecated" + }); + replaceRemotePublication(status, updated); + remoteFeedback = { + key: "remotePublishRequested", + values: {revision: record.candidate.result_artifact.revision}, + tone: "success" + }; + scheduleRemoteRefresh(500); + } catch (error) { + if (handleAuthenticationError(error)) { + return; + } + remoteFeedback = { + key: error instanceof SkillsRequestError ? "remotePublishFailed" : "serverUnavailable", + values: {status: error.status}, + tone: "error" + }; + } finally { + remoteActionBusy = false; + renderDelivery(); + } +} + +async function unpublishRemoteSkill() { + const record = selectedRecord(); + const status = selectedRemoteTargetStatus(); + const publication = selectedRemotePublication(status, record); + if (!record || record.authority !== "managed" || !status || !publication) { + return; + } + remoteActionBusy = true; + remoteFeedback = {key: "remoteUnpublishing"}; + renderDelivery(); + try { + const updated = await requestJson("/v1/skill/remote/publication/unpublish", { + scope_id: currentScopeId, + target_id: status.target.target_id, + artifact_id: record.candidate.result_artifact.artifact_id, + expected_generation: publication.generation + }); + replaceRemotePublication(status, updated); + remoteFeedback = {key: "remoteUnpublishRequested", tone: "success"}; + scheduleRemoteRefresh(500); + } catch (error) { + if (handleAuthenticationError(error)) { + return; + } + remoteFeedback = { + key: error instanceof SkillsRequestError ? "remoteUnpublishFailed" : "serverUnavailable", + values: {status: error.status}, + tone: "error" + }; + } finally { + remoteActionBusy = false; + renderDelivery(); + } +} + +async function revokeRemoteTarget() { + const status = selectedRemoteTargetStatus(); + if (!status || !canRevokeRemoteTarget(status)) { + return; + } + const revokedTargetLabel = remoteTargetLabel(status.target); + remoteActionBusy = true; + remoteFeedback = {key: "remoteRevoking"}; + renderDelivery(); + try { + status.target = await requestJson("/v1/skill/remote/target/revoke", { + scope_id: currentScopeId, + target_id: status.target.target_id, + expected_generation: status.target.generation + }); + const next = remoteTargets.find((candidate) => candidate.target.state !== "revoked"); + selectedRemoteTargetId = next?.target.target_id || ""; + remoteFeedback = {key: "remoteRevoked", values: {target: revokedTargetLabel}, tone: "success"}; + } catch (error) { + if (handleAuthenticationError(error)) { + return; + } + remoteFeedback = { + key: error instanceof SkillsRequestError ? "remoteRevokeFailed" : "serverUnavailable", + values: {status: error.status}, + tone: "error" + }; + } finally { + remoteActionBusy = false; + renderDelivery(); + } +} + +function replaceRemotePublication(status, publication) { + const index = status.publications.findIndex((candidate) => candidate.artifact_id === publication.artifact_id); + if (index === -1) { + status.publications.push(publication); + } else { + status.publications[index] = publication; + } +} + +function normalizeRemoteServerUrl(value) { + try { + const url = new URL(value.trim()); + const loopback = isLoopbackHostname(url.hostname); + if (url.protocol !== "https:" && !(url.protocol === "http:" && (loopback || allowInsecureHttp))) { + return ""; + } + if (url.username || url.password || url.search || url.hash) { + return ""; + } + return url.toString().replace(/\/$/, ""); + } catch (error) { + return ""; + } +} + +function resolvedRemoteServerUrl() { + const configured = normalizeRemoteServerUrl(library.dataset.publicServerUrl || ""); + if (configured) { + return configured; + } + try { + return normalizeRemoteServerUrl(window.location.origin); + } catch (error) { + return ""; + } +} + +function usesInsecureRemoteHttp(value) { + if (!value) { + return false; + } + try { + const url = new URL(value); + return url.protocol === "http:" && !isLoopbackHostname(url.hostname); + } catch (error) { + return false; + } +} + +function isLoopbackHostname(hostname) { + return ["127.0.0.1", "::1", "[::1]", "localhost"].includes(hostname.toLocaleLowerCase()); +} + +function shellQuote(value) { + return `'${String(value).replaceAll("'", `'"'"'`)}'`; +} + +async function applyLifecycle() { + const record = selectedRecord(); + if (!record || record.authority !== "managed" || lifecycleBusy) { + return; + } + lifecycleBusy = true; + governanceStatus.textContent = translate("lifecycleUpdating"); + renderGovernanceControls(); + try { + const governance = await requestJson("/dashboard/skills/lifecycle", { + scope_id: currentScopeId, + artifact_id: record.candidate.result_artifact.artifact_id, + expected_generation: record.governance.governance_generation, + lifecycle_state: lifecycleState.value, + replacement_artifact_id: lifecycleState.value === "deprecated" + ? (replacementId.value.trim() || null) + : null + }); + if (selectedKey !== record.key) { + return; + } + record.governance = governance; + lifecycleState.dataset.recordKey = ""; + governanceStatus.textContent = translate("lifecycleUpdated", { + state: translate(governance.lifecycle_state) + }); + renderLibrary(); + renderDetail(); + } catch (error) { + if (handleAuthenticationError(error)) { + return; + } + governanceStatus.textContent = translate( + error instanceof SkillsRequestError && error.status === 409 + ? "lifecycleConflict" + : (error instanceof SkillsRequestError ? "lifecycleFailed" : "serverUnavailable"), + {status: error.status} + ); + } finally { + lifecycleBusy = false; + renderGovernanceControls(); + } +} + +async function uploadRevisionPackage(file) { + const record = selectedRecord(); + if (!record || record.authority !== "managed" || revisionBusy) { + return; + } + revisionBusy = true; + liveStatus.textContent = translate("revisionUploading"); + renderDelivery(); + try { + const archive = new Uint8Array(await file.arrayBuffer()); + const candidate = await requestJson("/v1/skill/package/propose", { + scope_id: currentScopeId, + archive_base64: bytesToBase64(archive), + reason: "Complete successor package uploaded from the Skills Library.", + target: record.candidate.result_artifact + }); + const params = new URLSearchParams({ + scope: currentScopeId, + family: "skill", + status: "pending", + candidate: candidate.candidate_id + }); + window.location.assign(`/reviews?${params.toString()}`); + } catch (error) { + if (!handleAuthenticationError(error)) { + currentAlert = { + key: error instanceof SkillsRequestError ? "revisionUploadFailed" : "serverUnavailable", + values: {status: error.status}, + tone: "error" + }; + renderDetail(); + } + } finally { + revisionBusy = false; + liveStatus.textContent = ""; + renderDelivery(); + } +} + +function bytesToBase64(bytes) { + const chunks = []; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + 0x8000))); + } + return btoa(chunks.join("")); +} + function renderLibrary() { const filtered = filteredRecords(); const managedTotal = records.filter((record) => record.authority === "managed").length; @@ -722,13 +1839,19 @@ function renderLibrary() { const heading = document.createElement("span"); heading.className = "skills-list-heading"; + const labels = document.createElement("span"); + labels.className = "skills-list-labels"; const authority = document.createElement("span"); authority.className = `skills-authority-label skills-authority-${record.authority}`; authority.textContent = translate(record.authority === "managed" ? "managedSkill" : "externalSkill"); + const origin = document.createElement("span"); + origin.className = `status-badge skills-origin-badge skills-origin-${record.origin.kind}`; + origin.textContent = originSummary(record, true); + labels.append(authority, origin); const state = document.createElement("span"); state.className = `status-badge skills-status-${recordStatus(record)}`; state.textContent = translate(recordStatus(record)); - heading.append(authority, state); + heading.append(labels, state); const name = document.createElement("strong"); name.textContent = record.name; @@ -763,6 +1886,8 @@ function renderDetail() { const isManaged = record.authority === "managed"; managedContent.hidden = !isManaged; + packageSection.hidden = !isManaged || !record.candidate.proposal.package; + governanceSection.hidden = !isManaged; lineage.hidden = !isManaged; delivery.hidden = !isManaged; if (isManaged) { @@ -770,6 +1895,8 @@ function renderDetail() { renderValidation(record.candidate.proposal.validation); renderReferences(sourceRefs, record.candidate.source_refs, formatSourceReference, "noSourceReferences"); renderReferences(artifactRefs, record.candidate.artifact_refs, formatArtifactReference, "noArtifactReferences"); + renderPackageBrowser(); + renderGovernanceControls(); renderDelivery(); } } @@ -782,28 +1909,148 @@ function clearDetail() { sourceRefs.replaceChildren(); artifactRefs.replaceChildren(); projectionRequests.cancel(); + packageRequests.cancel(); + packagePreviewRequests.cancel(); projectionView = null; + packageManifest = null; + packageSelectedPath = ""; + packageError = null; } function renderFacts(record) { facts.replaceChildren(); appendDefinition(facts, "authority", translate(record.authority === "managed" ? "managedSkill" : "externalSkill")); + appendDefinition(facts, "origin", originSummary(record, false)); appendDefinition(facts, "status", translate(recordStatus(record))); if (record.authority === "managed") { appendDefinition(facts, "artifact", formatArtifactReference(record.candidate.result_artifact), true); appendDefinition(facts, "revision", record.candidate.result_artifact.revision); - appendDefinition(facts, "candidate", record.candidate.candidate_id, true); + appendDefinition(facts, "governanceGeneration", record.governance.governance_generation); + if (record.governance.replacement_artifact_id) { + appendDefinition(facts, "replacement", record.governance.replacement_artifact_id, true); + } + appendOriginEvidence(facts, record.origin); return; } const registration = record.resolution.registration; appendDefinition(facts, "provider", registration.provider); appendDefinition(facts, "installationScope", translate(`installation${capitalize(registration.installation_scope)}`)); - appendDefinition(facts, "host", registration.host_id, true); + appendDefinition(facts, "sourceMachine", registration.host_id, true); + appendDefinition(facts, "sourceAgent", agentLabel(registration.agent_kind)); appendDefinition(facts, "fingerprint", registration.fingerprint, true); - appendDefinition(facts, "locator", registration.locator, true); + appendDefinition(facts, "originalLocation", registration.locator, true); appendDefinition(facts, "entrypoint", record.resolution.entrypoint || translate("unavailable"), true); } +function appendOriginEvidence(list, origin) { + const registration = origin.registration; + if (!registration) { + return; + } + appendDefinition(list, "sourceMachine", registration.host_id, true); + appendDefinition(list, "sourceAgent", agentLabel(registration.agent_kind)); + appendDefinition(list, "externalIdentity", registration.external_skill_id, true); + appendDefinition(list, "installationScope", translate(`installation${capitalize(registration.installation_scope)}`)); + appendDefinition(list, "originalLocation", registration.locator, true); +} + +function renderPackageBrowser() { + const record = selectedRecord(); + const visible = Boolean( + record && record.authority === "managed" && record.candidate.proposal.package + ); + packageSection.hidden = !visible; + if (!visible) { + packageStatus.textContent = ""; + packageFiles.replaceChildren(); + packagePath.textContent = ""; + packagePreview.textContent = ""; + return; + } + packageStatus.textContent = packageBusy + ? translate("packageLoading") + : (packageError ? translate(packageError.key, packageError.values) : ""); + packageFiles.replaceChildren(); + if (!packageManifest) { + packagePath.textContent = ""; + packagePreview.textContent = packageBusy ? translate("packageLoading") : ""; + return; + } + renderPackageFileSelection(); +} + +function renderPackageFileSelection() { + packageFiles.replaceChildren(); + if (!packageManifest) { + return; + } + for (const file of packageManifest.files) { + const item = document.createElement("li"); + const button = document.createElement("button"); + button.type = "button"; + button.textContent = `${file.path}${file.executable ? " · +x" : ""}`; + button.setAttribute("aria-current", String(file.path === packageSelectedPath)); + button.addEventListener("click", () => void loadPackagePreview(file.path)); + item.append(button); + packageFiles.append(item); + } +} + +function renderGovernanceControls() { + const record = selectedRecord(); + const visible = Boolean(record && record.authority === "managed"); + governanceSection.hidden = !visible; + if (!visible) { + return; + } + const recordChanged = lifecycleState.dataset.recordKey !== record.key; + if (recordChanged) { + lifecycleState.dataset.recordKey = record.key; + lifecycleState.value = record.governance.lifecycle_state; + governanceStatus.textContent = ""; + } + renderReplacementSkills( + record, + recordChanged ? (record.governance.replacement_artifact_id || "") : replacementId.value + ); + const retired = record.governance.lifecycle_state === "retired"; + lifecycleState.disabled = retired || lifecycleBusy || actionBusy; + replacementId.disabled = retired || lifecycleBusy || lifecycleState.value !== "deprecated"; + applyLifecycleButton.disabled = retired || lifecycleBusy || actionBusy; +} + +function renderReplacementSkills(record, selectedReplacement) { + const artifactId = record.candidate.result_artifact.artifact_id; + const candidates = records.filter((candidate) => ( + candidate.authority === "managed" + && candidate.candidate.result_artifact.artifact_id !== artifactId + && candidate.governance.lifecycle_state !== "retired" + )); + + replacementId.replaceChildren(); + const emptyOption = document.createElement("option"); + emptyOption.value = ""; + emptyOption.textContent = translate("noReplacementSkill"); + replacementId.append(emptyOption); + + for (const candidate of candidates) { + const option = document.createElement("option"); + option.value = candidate.candidate.result_artifact.artifact_id; + option.textContent = `${candidate.name} · ${option.value}`; + replacementId.append(option); + } + + if (selectedReplacement && !candidates.some( + (candidate) => candidate.candidate.result_artifact.artifact_id === selectedReplacement + )) { + const unavailableOption = document.createElement("option"); + unavailableOption.value = selectedReplacement; + unavailableOption.textContent = `${translate("unavailable")} · ${selectedReplacement}`; + replacementId.append(unavailableOption); + } + replacementId.value = selectedReplacement; +} + function renderValidation(items) { validation.replaceChildren(); for (const item of items) { @@ -852,15 +2099,36 @@ function renderDelivery() { return; } delivery.hidden = false; - createRevisionButton.disabled = libraryBusy || projectionBusy || actionBusy; + const retired = record.governance.lifecycle_state === "retired"; + createRevisionButton.disabled = retired || libraryBusy || projectionBusy || actionBusy || remoteActionBusy || revisionBusy; + const remoteMode = deliveryMode.value === "remote"; + localDelivery.hidden = remoteMode; + remoteDelivery.hidden = !remoteMode; + insecureHttpWarning.hidden = !allowInsecureHttp; + remoteRefreshButton.hidden = !remoteMode; + remoteRefreshButton.disabled = remoteBusy || remoteActionBusy; + if (remoteMode) { + renderRemoteDelivery(record, retired); + return; + } + renderLocalDelivery(record, retired); +} + +function renderLocalDelivery(record, retired) { publishButton.hidden = true; + unpublishButton.hidden = true; deliveryStatus.textContent = projectionBusy ? translate("publicationLoading") : ""; + deliveryStatus.dataset.tone = ""; projectionState.hidden = projectionBusy || !projectionView; deliveryEmpty.hidden = true; deliveryContent.hidden = true; if (projectionBusy || !projectionView) { return; } + if (projectionView.blocker === "standard_package_required") { + deliveryStatus.textContent = translate("standardPackageRequired"); + return; + } if (projectionView.targets.length === 0) { deliveryEmpty.hidden = false; return; @@ -872,7 +2140,7 @@ function renderDelivery() { for (const target of projectionView.targets) { const option = document.createElement("option"); option.value = target.target_id; - option.textContent = `${agentLabel(target.agent_kind)} · ${target.target_id} / ${translate(`installation${capitalize(target.installation_scope)}`)}`; + option.textContent = localTargetLabel(target, record.name); option.selected = target.target_id === selectedTargetId; deliveryTarget.append(option); } @@ -889,11 +2157,105 @@ function renderDelivery() { ? translate("unavailable") : String(target.published_revision); discovery.textContent = translate(discoveryStateKey(target.discovery)); + compatibility.textContent = translate(target.compatibility); + compatibilityReasons.replaceChildren(); + for (const reason of target.compatibility_reasons) { + const item = document.createElement("li"); + item.textContent = reason; + compatibilityReasons.append(item); + } destination.textContent = target.destination; publishButton.textContent = translate(publicationActionKey(target)); const canPublish = canPublishProjection(target); - publishButton.hidden = !canPublish; + publishButton.hidden = retired || !canPublish; publishButton.disabled = libraryBusy || projectionBusy || actionBusy || !canPublish; + const canUnpublish = canUnpublishProjection(target); + unpublishButton.hidden = !canUnpublish; + unpublishButton.disabled = libraryBusy || projectionBusy || actionBusy || !canUnpublish; +} + +function renderRemoteDelivery(record, retired) { + remoteEmpty.hidden = true; + remoteContent.hidden = true; + remotePublishButton.hidden = true; + remoteUnpublishButton.hidden = true; + projectionState.hidden = true; + deliveryStatus.textContent = remoteFeedback + ? translate(remoteFeedback.key, remoteFeedback.values || {}) + : (remoteBusy ? translate("remoteTargetsLoading") : ""); + deliveryStatus.dataset.tone = remoteFeedback?.tone || ""; + for (const button of remoteAddButtons) { + button.disabled = remoteBusy || remoteActionBusy; + } + remoteRenameButton.disabled = remoteBusy || remoteActionBusy; + + const availableTargets = remoteTargets.filter((status) => status.target.state !== "revoked"); + if (!availableTargets.length) { + remoteEmpty.hidden = remoteBusy; + return; + } + if (!availableTargets.some((status) => status.target.target_id === selectedRemoteTargetId)) { + selectedRemoteTargetId = availableTargets[0].target.target_id; + } + + remoteTarget.replaceChildren(); + for (const status of availableTargets) { + const target = status.target; + const option = document.createElement("option"); + option.value = target.target_id; + const environment = remoteTargetEnvironmentLabel(target); + option.textContent = [ + target.display_name, + agentLabel(target.agent_kind), + environment, + translate(remoteTargetStateKey(target.state)) + ].filter(Boolean).join(" · "); + option.selected = target.target_id === selectedRemoteTargetId; + remoteTarget.append(option); + } + + const status = selectedRemoteTargetStatus(); + if (!status) { + return; + } + const target = status.target; + const publication = selectedRemotePublication(status, record); + remoteContent.hidden = false; + remoteEnrollment.textContent = translate(remoteTargetStateKey(target.state)); + remotePublicationState.textContent = publication + ? translate(remotePublicationStateKey(publication.state)) + : translate("remoteNoPublication"); + remoteObservedRevision.textContent = publication?.observed_revision === null || !publication + ? translate("remoteRevisionNone") + : String(publication.observed_revision); + remoteLastSeen.textContent = target.last_seen_at ? formatDateTime(target.last_seen_at) : translate("remoteNeverSeen"); + remoteEnvironment.textContent = remoteTargetEnvironmentLabel(target) || translate("remoteEnvironmentPending"); + remoteTargetId.textContent = target.target_id; + const canRevoke = canRevokeRemoteTarget(status); + remoteGuidance.textContent = translate(remoteGuidanceKey(target, publication)); + if (!canRevoke) { + remoteGuidance.textContent = `${remoteGuidance.textContent} ${translate("remoteRevokeBlocked")}`; + } + + const state = publication?.state || "unpublished"; + projectionState.hidden = false; + projectionState.className = `status-badge skills-projection-${state}`; + projectionState.textContent = publication + ? translate(remotePublicationStateKey(publication.state)) + : translate("remoteNoPublication"); + + const packageBacked = Boolean(record.candidate.proposal.package); + if (!packageBacked && !remoteFeedback) { + deliveryStatus.textContent = translate("standardPackageRequired"); + } + const canPublish = canPublishRemote(record, status, publication); + remotePublishButton.hidden = retired || !canPublish; + remotePublishButton.disabled = remoteBusy || remoteActionBusy || !canPublish; + const canUnpublish = Boolean(publication && publication.desired_state === "published"); + remoteUnpublishButton.hidden = !canUnpublish; + remoteUnpublishButton.disabled = remoteBusy || remoteActionBusy || !canUnpublish; + remoteRevokeButton.disabled = remoteBusy || remoteActionBusy || !canRevoke; + remoteRevokeButton.title = canRevoke ? "" : translate("remoteRevokeBlocked"); } function selectRecord(key) { @@ -902,10 +2264,14 @@ function selectRecord(key) { } selectedKey = key; projectionView = null; + packageManifest = null; + packageSelectedPath = ""; + packageError = null; currentAlert = null; renderLibrary(); renderDetail(); void loadProjectionStatus(); + void loadPackageManifest(); } function ensureSelection() { @@ -913,10 +2279,14 @@ function ensureSelection() { if (!filtered.some((record) => record.key === selectedKey)) { selectedKey = filtered[0]?.key || ""; projectionView = null; + packageManifest = null; + packageSelectedPath = ""; + packageError = null; } renderLibrary(); renderDetail(); void loadProjectionStatus(); + void loadPackageManifest(); } function filteredRecords() { @@ -940,12 +2310,146 @@ function selectedProjectionTarget() { || null; } +function selectedRemoteTargetStatus() { + return remoteTargets.find((status) => status.target.target_id === selectedRemoteTargetId) + || remoteTargets.find((status) => status.target.state !== "revoked") + || null; +} + +function selectRemoteTargetFromSearch() { + const query = remoteTargetSearch.value.trim().toLocaleLowerCase(); + if (!query) { + deliveryStatus.textContent = remoteFeedback ? translate(remoteFeedback.key, remoteFeedback.values || {}) : ""; + deliveryStatus.dataset.tone = remoteFeedback?.tone || ""; + return; + } + const match = remoteTargets.find((status) => ( + status.target.state !== "revoked" && remoteTargetSearchText(status.target).includes(query) + )); + if (!match) { + deliveryStatus.textContent = translate("remoteSearchNoMatch"); + deliveryStatus.dataset.tone = ""; + return; + } + selectedRemoteTargetId = match.target.target_id; + remoteFeedback = null; + renderDelivery(); +} + +function remoteTargetSearchText(target) { + return [ + target.display_name, + target.machine_hostname, + target.workspace_name, + target.target_id, + target.installation_id, + agentLabel(target.agent_kind) + ].filter(Boolean).join(" ").toLocaleLowerCase(); +} + +function remoteTargetEnvironmentLabel(target) { + return [target.machine_hostname, target.workspace_name].filter(Boolean).join(" / "); +} + +function remoteTargetLabel(target) { + const environment = remoteTargetEnvironmentLabel(target); + return [target.display_name, environment, agentLabel(target.agent_kind)].filter(Boolean).join(" · "); +} + +function selectedRemotePublication(status = selectedRemoteTargetStatus(), record = selectedRecord()) { + if (!status || !record || record.authority !== "managed") { + return null; + } + const artifactId = record.candidate.result_artifact.artifact_id; + return status.publications.find((publication) => publication.artifact_id === artifactId) || null; +} + +function canPublishRemote(record, status, publication) { + if ( + status.target.state !== "active" + || record.governance.lifecycle_state === "retired" + || !record.candidate.proposal.package + ) { + return false; + } + return !publication + || publication.desired_state === "unpublished" + || publication.desired_revision !== record.candidate.result_artifact.revision; +} + +function canRevokeRemoteTarget(status) { + return status.publications.every((publication) => ( + publication.desired_state === "unpublished" && publication.state === "unpublished" + )); +} + +function remoteTargetStateKey(state) { + return { + pending: "remoteTargetPending", + active: "remoteTargetActive", + revoked: "remoteTargetRevoked" + }[state] || "unknown"; +} + +function remotePublicationStateKey(state) { + return { + unpublished: "remoteStateUnpublished", + pending: "remoteStatePending", + current: "remoteStateCurrent", + update_available: "remoteStateUpdateAvailable", + delivery_failed: "remoteStateDeliveryFailed", + conflict: "remoteStateConflict", + drifted: "remoteStateDrifted", + incompatible: "remoteStateIncompatible" + }[state] || "unknown"; +} + +function remoteGuidanceKey(target, publication) { + if (target.state === "pending") { + return "remoteGuidancePending"; + } + if (!publication) { + return "remoteGuidanceReady"; + } + if (["pending", "update_available"].includes(publication.state)) { + return "remoteGuidanceSync"; + } + if (publication.state === "current") { + return "remoteGuidanceCurrent"; + } + if (publication.state === "unpublished") { + return "remoteGuidanceRemoved"; + } + return "remoteGuidanceProblem"; +} + function agentLabel(agentKind) { return translate(agentKind === "claude_code" ? "agentClaudeCode" : "agentCodex"); } +function localTargetLabel(target, skillName) { + const standardRoot = target.agent_kind === "claude_code" ? ".claude/skills" : ".agents/skills"; + const normalizedDestination = target.destination.replaceAll("\\", "/"); + if (normalizedDestination.endsWith(`/${standardRoot}/${skillName}`)) { + return `${agentLabel(target.agent_kind)} · ${translate("currentProject")} · ${standardRoot}`; + } + return `${agentLabel(target.agent_kind)} · ${target.target_id} / ${translate(`installation${capitalize(target.installation_scope)}`)}`; +} + function recordStatus(record) { - return record.authority === "managed" ? "approved" : record.resolution.status; + return record.authority === "managed" ? record.governance.lifecycle_state : record.resolution.status; +} + +function originSummary(record, includeHost) { + const key = { + powercontext: "originPowerContext", + external_import: "originExternalImport", + external_fork: "originExternalFork", + external: "originExternal" + }[record.origin.kind] || "unknown"; + const label = translate(key); + const host = record.origin.registration?.host_id; + return includeHost && host ? `${label} · ${host}` : label; } function compareRecords(left, right) { @@ -983,8 +2487,14 @@ function appendDefinition(list, key, value, code = false) { } function canPublishProjection(target) { - return ["unpublished", "update_available"].includes(target.state) - || (target.state === "current" && target.discovery !== "available"); + return target.compatibility !== "incompatible" && ( + ["unpublished", "update_available"].includes(target.state) + || (target.state === "current" && target.discovery !== "available") + ); +} + +function canUnpublishProjection(target) { + return ["current", "update_available"].includes(target.state); } function publicationActionKey(target) { @@ -1077,6 +2587,11 @@ function showLogin(messageKey = "") { scopeRequests.cancel(); libraryRequests.cancel(); projectionRequests.cancel(); + remoteRequests.cancel(); + packageRequests.cancel(); + packagePreviewRequests.cancel(); + stopRemoteRefresh(); + remoteBusy = false; currentScopeId = ""; currentAuthError = messageKey ? {key: messageKey, values: {}} : null; renderAuthError(); @@ -1102,6 +2617,7 @@ function showLibrary() { pageStatus.hidden = true; library.hidden = false; signOut.hidden = !authenticationRequired; + scheduleRemoteRefresh(0); } function renderAuthError() { @@ -1259,6 +2775,11 @@ async function selectScope(scopeId) { records = []; selectedKey = ""; projectionView = null; + remoteRequests.cancel(); + remoteBusy = false; + remoteTargets = []; + selectedRemoteTargetId = ""; + remoteFeedback = null; renderLibrary(); renderDetail(); await loadLibrary(); @@ -1284,5 +2805,21 @@ function rememberScope(scopeId) { } } +function preferredDeliveryMode() { + try { + return sessionStorage.getItem(deliveryModePreferenceKey) === "remote" ? "remote" : "local"; + } catch (error) { + return "local"; + } +} + +function rememberDeliveryMode(mode) { + try { + sessionStorage.setItem(deliveryModePreferenceKey, mode === "remote" ? "remote" : "local"); + } catch (error) { + // The current page still retains the selected delivery mode. + } +} + ui.initialize(); void authenticate(readServerToken(), preferredScopeId()); diff --git a/src/powercontext/server/templates/base.html b/src/powercontext/server/templates/base.html index bc2065837..986a9bcfe 100644 --- a/src/powercontext/server/templates/base.html +++ b/src/powercontext/server/templates/base.html @@ -44,7 +44,7 @@ // Theme persistence is optional when browser storage is unavailable. } - + {% block head %}{% endblock %} diff --git a/src/powercontext/server/templates/pages/review.html b/src/powercontext/server/templates/pages/review.html index 7a33cfaff..fc74d640b 100644 --- a/src/powercontext/server/templates/pages/review.html +++ b/src/powercontext/server/templates/pages/review.html @@ -121,6 +121,18 @@

Proposal

+ +

Evidence

@@ -243,5 +255,5 @@

Reject Candidate?

{% endblock %} {% block scripts %} - + {% endblock %} diff --git a/src/powercontext/server/templates/pages/skills.html b/src/powercontext/server/templates/pages/skills.html index 9f8a43b51..1cd7f583d 100644 --- a/src/powercontext/server/templates/pages/skills.html +++ b/src/powercontext/server/templates/pages/skills.html @@ -25,7 +25,12 @@ {% set status_title = "Skills" %} {% include "components/status.html" %} -
+

Skills

@@ -139,6 +144,43 @@

Validation

    + + + +
    @@ -205,9 +328,111 @@

    Publish this managed Skill?

    + + +
    +

    Retire this Skill?

    +

    Retirement is irreversible. Existing publication must be removed separately.

    +
    + + +
    +
    +
    + + +
    +

    Add remote machine

    +

    Choose the Agent used by this project. PowerContext will create a one-time enrollment code.

    + + + +
    + + +
    +
    +
    + + +
    +

    Rename remote machine

    + + +
    + + +
    +
    +
    + + +
    +

    Connect the remote machine

    +

    This enrollment code is shown once and expires soon. Complete these steps before closing.

    +
    + Target ID + + Expires + +
    +
    + Receiver connection +

    +
    +
    + Install the lightweight Receiver +
    + python -m pip install "powercontext[cli]" + +
    +
    +
    + Run enrollment in the remote project +
    + + +
    +
    +
    + Enter this one-time code when prompted +
    + + +
    +
    +

    +
    + +
    +
    +
    + + +
    +

    Revoke this remote machine?

    +

    Its credential will stop working. Remove distributed Skills and wait for automatic synchronization before revoking.

    +
    + + +
    +
    +
    {% endblock %} {% block scripts %} - + {% endblock %} diff --git a/src/powercontext/server/web.py b/src/powercontext/server/web.py index b3f8f023a..0f0f6be50 100644 --- a/src/powercontext/server/web.py +++ b/src/powercontext/server/web.py @@ -16,7 +16,6 @@ from __future__ import annotations -import asyncio import logging from collections.abc import Mapping from functools import cache @@ -31,17 +30,38 @@ from powercontext._logging import log_safely from powercontext.artifacts import ArtifactRef -from powercontext.builtin.artifacts.skill import AgentKind, AgentSkillTarget, ExternalSkillResolutionStatus +from powercontext.builtin.artifacts.skill import ( + AgentKind, + AgentSkillTarget, + ExternalSkillResolutionStatus, + Skill, + SkillCompatibilityState, + SkillContent, + SkillOrigin, + SkillPackageRef, + SkillPackageSnapshot, + assess_skill_compatibility, + package_file, +) from powercontext.builtin.artifacts.skill.projection import ( AgentSkillProjectionConflictError, AgentSkillProjectionState, - inspect_skill_projection, - publish_skill_projection, ) -from powercontext.builtin.review import CandidateStatus -from powercontext.builtin.runtime import GetArtifactCandidateRequest, GetSkillRequest, ListExternalSkillsRequest -from powercontext.http import ErrorDetail, ErrorResponse +from powercontext.builtin.persistence.artifact_governance import ( + ArtifactGovernance, + ArtifactLifecycleState, +) +from powercontext.builtin.runtime import GetSkillRequest, ListExternalSkillsRequest +from powercontext.errors import ArtifactNotFoundError +from powercontext.http import ( + ErrorDetail, + ErrorResponse, + SkillPackageFile, + SkillPackageManifest, + SkillPackageReference, +) from powercontext.limits import MAX_ARTIFACT_ID_LENGTH +from powercontext.sources import SourceRef logger = logging.getLogger(__name__) @@ -69,7 +89,7 @@ class DashboardSkillProjectionRequest(BaseModel): model_config = ConfigDict(extra="forbid") scope_id: str = Field(min_length=1, max_length=256) - candidate_id: str = Field(min_length=1, max_length=MAX_ARTIFACT_ID_LENGTH) + candidate_id: str | None = Field(default=None, min_length=1, max_length=MAX_ARTIFACT_ID_LENGTH) artifact: ArtifactRef @model_validator(mode="after") @@ -83,6 +103,11 @@ class DashboardSkillPublishRequest(DashboardSkillProjectionRequest): """Explicitly publish one exact approved managed Skill Revision.""" target_id: str = Field(min_length=1, max_length=64) + allow_deprecated: bool = False + + +class DashboardSkillUnpublishRequest(DashboardSkillPublishRequest): + """Explicitly remove an exact unmodified managed publication.""" class DashboardSkillProjectionTarget(BaseModel): @@ -99,6 +124,11 @@ class DashboardSkillProjectionTarget(BaseModel): reason: str | None = None discovery: Literal["available", "unavailable", "not_published"] external_skill_id: str | None = None + generation: int | None = None + tree_digest: str | None = None + compatibility: SkillCompatibilityState + compatibility_reasons: tuple[str, ...] + environment_fingerprint: str = Field(pattern=r"^[0-9a-f]{64}$") class DashboardSkillProjection(BaseModel): @@ -108,9 +138,73 @@ class DashboardSkillProjection(BaseModel): artifact: ArtifactRef name: str + blocker: Literal["standard_package_required"] | None = None targets: list[DashboardSkillProjectionTarget] +class DashboardSkillLibraryRequest(BaseModel): + """Search current managed Skill heads without reconstructing them from Review history.""" + + model_config = ConfigDict(extra="forbid") + + scope_id: str = Field(min_length=1, max_length=256) + query: str | None = Field(default=None, max_length=2_000) + include_deprecated: bool = False + limit: int = Field(default=100, ge=1, le=200) + + +class DashboardManagedSkill(BaseModel): + """One current managed Skill Library row with governance and exact lineage.""" + + model_config = ConfigDict(extra="forbid") + + artifact: ArtifactRef + content: SkillContent + sources: tuple[SourceRef, ...] + artifacts: tuple[ArtifactRef, ...] + governance: ArtifactGovernance + origin: SkillOrigin + + +class DashboardSkillLifecycleRequest(BaseModel): + """CAS lifecycle transition for one logical managed Skill.""" + + model_config = ConfigDict(extra="forbid") + + scope_id: str = Field(min_length=1, max_length=256) + artifact_id: str = Field(min_length=1, max_length=MAX_ARTIFACT_ID_LENGTH) + expected_generation: int = Field(ge=0) + lifecycle_state: ArtifactLifecycleState + replacement_artifact_id: str | None = Field(default=None, min_length=1, max_length=MAX_ARTIFACT_ID_LENGTH) + + +class DashboardSkillPackageRequest(BaseModel): + """Resolve a package reference already visible in a scoped Candidate or Artifact.""" + + model_config = ConfigDict(extra="forbid") + + scope_id: str = Field(min_length=1, max_length=256) + package: SkillPackageRef + + +class DashboardSkillPackageFileRequest(DashboardSkillPackageRequest): + """Select one exact package path for inert bounded preview.""" + + path: str = Field(min_length=1, max_length=512) + + +class DashboardSkillPackageFilePreview(BaseModel): + """Inert text preview; binary files expose metadata but never content.""" + + model_config = ConfigDict(extra="forbid") + + path: str + media_type: str + content: str | None = None + binary: bool + truncated: bool + + class _DashboardSkillProjectionRoutes: def __init__(self, scope_ids: frozenset[str], targets: tuple[AgentSkillTarget, ...]) -> None: self._scope_ids = scope_ids @@ -141,14 +235,9 @@ async def publish( return _web_error( 404, "skill_publish_target_not_found", "The Agent Skill publication target was not found." ) - expected = await asyncio.to_thread(inspect_skill_projection, skill.as_ref(), skill.content, target) try: - await asyncio.to_thread( - publish_skill_projection, - skill.as_ref(), - skill.content, - target, - expected=expected, + await application.skill.for_scope(request.scope_id).publish( + skill.as_ref(), target, allow_deprecated=request.allow_deprecated ) except AgentSkillProjectionConflictError as error: return _web_error( @@ -174,8 +263,48 @@ async def publish( ) return await _skill_projection_response(application, request.scope_id, skill, self._targets) + async def unpublish( + self, + request: DashboardSkillUnpublishRequest, + http_request: Request, + ) -> DashboardSkillProjection | JSONResponse: + resolved = await _dashboard_managed_skill(http_request, request, self._scope_ids) + if isinstance(resolved, JSONResponse): + return resolved + application, skill = resolved + target = next((item for item in self._targets if item.target_id == request.target_id), None) + if target is None: + return _web_error( + 404, "skill_publish_target_not_found", "The Agent Skill publication target was not found." + ) + try: + await application.skill.for_scope(request.scope_id).unpublish(skill.as_ref(), target) + except AgentSkillProjectionConflictError as error: + return _web_error( + 409, + "skill_projection_conflict", + "The Agent Skill publication changed or cannot be removed safely.", + details={"state": error.status.state.value, "reason": error.status.reason}, + ) + except (OSError, UnicodeError, ValueError) as error: + return _web_error( + 422, + "skill_projection_failed", + "The approved managed Skill could not be unpublished from the configured Agent target.", + details={"reason": str(error)}, + ) + # The publication removal already succeeded; keep registry bookkeeping best-effort for + # the same reason as the publish path above. + try: + await application.external_skills.for_scope(request.scope_id).scan() + except Exception as error: + log_safely( + logger, logging.WARNING, "PowerContext external Skill scan failed after unpublication", exc_info=error + ) + return await _skill_projection_response(application, request.scope_id, skill, self._targets) + -def mount_web_ui( +def mount_web_ui( # noqa: C901 app: FastAPI, *, scopes: Mapping[str, str], @@ -183,6 +312,8 @@ def mount_web_ui( handoff_report_enabled: bool = False, authentication_required: bool = False, agent_skill_targets: tuple[AgentSkillTarget, ...] = (), + public_server_url: str | None = None, + allow_insecure_http: bool = False, ) -> None: """Mount Server-owned pages, static assets, and UI support endpoints.""" @@ -229,6 +360,8 @@ async def skills_page(request: Request) -> Response: "handoff_report_enabled": handoff_report_enabled, "home_route": "dashboard_home", "authentication_required": authentication_required, + "public_server_url": public_server_url, + "allow_insecure_http": allow_insecure_http, }, headers=_PAGE_HEADERS, ) @@ -269,6 +402,104 @@ async def list_dashboard_scopes(response: Response) -> tuple[DashboardScope, ... response.headers["Cache-Control"] = "no-store" return dashboard_scopes + async def list_managed_skills( + request: DashboardSkillLibraryRequest, + http_request: Request, + ) -> list[DashboardManagedSkill] | JSONResponse: + if request.scope_id not in dashboard_scope_ids: + return _web_error(404, "dashboard_scope_not_found", "The Dashboard scope was not found.") + application = http_request.app.state.application + if application is None: + return _web_error(503, "runtime_not_ready", "The Runtime is not ready.") + scoped = application.skill.for_scope(request.scope_id) + values: list[tuple[Skill, ArtifactGovernance]] = [] + query = "" if request.query is None else request.query.strip() + if query: + for hit in await scoped.search(query, request.limit): + skill = await scoped.get(GetSkillRequest(artifact=hit.artifact_ref)) + values.append((skill, await scoped.governance(skill.artifact_id))) + else: + values.extend(await scoped.list(include_deprecated=request.include_deprecated, limit=request.limit)) + if query and request.include_deprecated: + seen = {skill.artifact_id for skill, _governance in values} + for skill, governance in await scoped.list(include_deprecated=True, limit=request.limit): + if ( + governance.lifecycle_state is ArtifactLifecycleState.DEPRECATED + and skill.artifact_id not in seen + and query.casefold() in _skill_library_search_text(skill.content).casefold() + ): + values.append((skill, governance)) + selected = values[: request.limit] + origins = await scoped.origins(tuple(skill for skill, _governance in selected)) + return [ + DashboardManagedSkill( + artifact=skill.as_ref(), + content=skill.content, + sources=skill.lineage.sources, + artifacts=skill.lineage.artifacts, + governance=governance, + origin=origin, + ) + for (skill, governance), origin in zip(selected, origins, strict=True) + ] + + async def update_skill_lifecycle( + request: DashboardSkillLifecycleRequest, + http_request: Request, + ) -> ArtifactGovernance | JSONResponse: + if request.scope_id not in dashboard_scope_ids: + return _web_error(404, "dashboard_scope_not_found", "The Dashboard scope was not found.") + application = http_request.app.state.application + if application is None: + return _web_error(503, "runtime_not_ready", "The Runtime is not ready.") + try: + return await application.skill.for_scope(request.scope_id).update_lifecycle( + request.artifact_id, + request.expected_generation, + request.lifecycle_state, + request.replacement_artifact_id, + ) + except ValueError as error: + return _web_error( + 422, + "skill_lifecycle_invalid", + "The requested Skill lifecycle transition is not allowed.", + details={"reason": str(error)}, + ) + + async def get_package_manifest( + request: DashboardSkillPackageRequest, + http_request: Request, + ) -> SkillPackageManifest | JSONResponse: + resolved = await _dashboard_package(http_request, request, dashboard_scope_ids) + if isinstance(resolved, JSONResponse): + return resolved + return _dashboard_package_manifest(resolved) + + async def preview_package_file( + request: DashboardSkillPackageFileRequest, + http_request: Request, + ) -> DashboardSkillPackageFilePreview | JSONResponse: + resolved = await _dashboard_package(http_request, request, dashboard_scope_ids) + if isinstance(resolved, JSONResponse): + return resolved + entry = next((entry for entry in resolved.entries if entry.path == request.path), None) + if entry is None: + return _web_error(404, "skill_package_file_not_found", "The package file was not found.") + content = package_file(resolved, request.path) + bounded = content[: 64 * 1024] + try: + preview = bounded.decode("utf-8") + except UnicodeDecodeError: + preview = None + return DashboardSkillPackageFilePreview( + path=entry.path, + media_type=entry.media_type, + content=preview, + binary=preview is None, + truncated=len(content) > len(bounded), + ) + if dashboard_enabled: router.add_api_route( "/", @@ -291,6 +522,34 @@ async def list_dashboard_scopes(response: Response) -> tuple[DashboardScope, ... response_class=HTMLResponse, name="skills_library", ) + router.add_api_route( + "/dashboard/skills/library", + list_managed_skills, + methods=["POST"], + response_model=list[DashboardManagedSkill], + name="dashboard_skills_library_data", + ) + router.add_api_route( + "/dashboard/skills/lifecycle", + update_skill_lifecycle, + methods=["POST"], + response_model=ArtifactGovernance, + name="dashboard_skill_lifecycle", + ) + router.add_api_route( + "/dashboard/skill-packages/manifest", + get_package_manifest, + methods=["POST"], + response_model=SkillPackageManifest, + name="dashboard_skill_package_manifest", + ) + router.add_api_route( + "/dashboard/skill-packages/preview", + preview_package_file, + methods=["POST"], + response_model=DashboardSkillPackageFilePreview, + name="dashboard_skill_package_preview", + ) router.add_api_route( "/reviews", review_page, @@ -312,6 +571,13 @@ async def list_dashboard_scopes(response: Response) -> tuple[DashboardScope, ... response_model=DashboardSkillProjection, name="dashboard_skill_projection_publish", ) + router.add_api_route( + "/dashboard/skill-projections/unpublish", + skill_projection_routes.unpublish, + methods=["POST"], + response_model=DashboardSkillProjection, + name="dashboard_skill_projection_unpublish", + ) if handoff_report_enabled: router.add_api_route( "/handoff-reports", @@ -348,20 +614,14 @@ async def _dashboard_managed_skill( application = request.app.state.application if application is None: return _web_error(503, "runtime_not_ready", "The Runtime is not ready.") - candidate = await application.review.for_scope(selection.scope_id).get( - GetArtifactCandidateRequest(candidate_id=selection.candidate_id) - ) - if ( - candidate.family != "skill" - or candidate.status is not CandidateStatus.APPROVED - or candidate.result_artifact != selection.artifact - ): + try: + skill = await application.skill.for_scope(selection.scope_id).get(GetSkillRequest(artifact=selection.artifact)) + except ArtifactNotFoundError: return _web_error( 409, "skill_projection_not_approved", - "The selected Artifact is not the exact approved result of this Skill Candidate.", + "The selected Artifact is not an exact approved managed Skill Revision.", ) - skill = await application.skill.for_scope(selection.scope_id).get(GetSkillRequest(artifact=selection.artifact)) return application, skill @@ -371,22 +631,30 @@ async def _skill_projection_response( skill, targets_config: tuple[AgentSkillTarget, ...], ) -> DashboardSkillProjection: + if not skill.content.package_backed: + return DashboardSkillProjection( + artifact=skill.as_ref(), + name=skill.content.name, + blocker="standard_package_required", + targets=[], + ) if not targets_config: + return DashboardSkillProjection(artifact=skill.as_ref(), name=skill.content.name, targets=[]) + # Registry discovery is best-effort bookkeeping; when it cannot be read (for example an + # unavailable registry database), report on-disk state with stale discovery instead of + # failing the whole response after the projection was already changed. + try: + registrations = await application.external_skills.for_scope(scope_id).list( + ListExternalSkillsRequest(include_unavailable=True) + ) + except Exception as error: + log_safely(logger, logging.WARNING, "PowerContext external Skill registry discovery failed", exc_info=error) registrations = () - # Registry discovery is best-effort bookkeeping; when it cannot be read (for example an - # unavailable registry database), report on-disk state with stale discovery instead of - # failing the whole response after the projection was already published. - else: - try: - registrations = await application.external_skills.for_scope(scope_id).list( - ListExternalSkillsRequest(include_unavailable=True) - ) - except Exception as error: - log_safely(logger, logging.WARNING, "PowerContext external Skill registry discovery failed", exc_info=error) - registrations = () targets = [] + package = await application.skill.for_scope(scope_id).package(skill.as_ref()) for target in targets_config: - status = await asyncio.to_thread(inspect_skill_projection, skill.as_ref(), skill.content, target) + status = await application.skill.for_scope(scope_id).inspect_publication(skill.as_ref(), target) + compatibility = assess_skill_compatibility(skill.content, package, target) registration = next( ( item @@ -412,6 +680,11 @@ async def _skill_projection_response( reason=status.reason, discovery=discovery, external_skill_id=(None if registration is None else registration.registration.external_skill_id), + generation=status.generation, + tree_digest=status.published_tree_digest, + compatibility=compatibility.state, + compatibility_reasons=compatibility.reasons, + environment_fingerprint=compatibility.environment_fingerprint, ) ) return DashboardSkillProjection(artifact=skill.as_ref(), name=skill.content.name, targets=targets) @@ -428,11 +701,54 @@ def _web_error( return JSONResponse(status_code=response_status, content=error.model_dump(mode="json")) +def _skill_library_search_text(content: SkillContent) -> str: + return "\n".join((content.name, content.description, content.instructions, *content.metadata.values())) + + +async def _dashboard_package( + request: Request, + selection: DashboardSkillPackageRequest, + dashboard_scope_ids: frozenset[str], +) -> SkillPackageSnapshot | JSONResponse: + if selection.scope_id not in dashboard_scope_ids: + return _web_error(404, "dashboard_scope_not_found", "The Dashboard scope was not found.") + application = request.app.state.application + if application is None: + return _web_error(503, "runtime_not_ready", "The Runtime is not ready.") + return await application.skill.for_scope(selection.scope_id).package_snapshot(selection.package) + + +def _dashboard_package_manifest(package: SkillPackageSnapshot) -> SkillPackageManifest: + return SkillPackageManifest( + package=SkillPackageReference.model_validate(package.reference.model_dump()), + name=package.metadata.name, + description=package.metadata.description, + license=package.metadata.license, + compatibility=package.metadata.compatibility, + metadata=package.metadata.metadata, + allowed_tools=package.metadata.allowed_tools, + files=[ + SkillPackageFile( + path=entry.path, + digest=entry.digest, + size=entry.size, + media_type=entry.media_type, + executable=bool(entry.mode & 0o111), + ) + for entry in package.entries + ], + ) + + __all__ = [ "DashboardScope", + "DashboardSkillPackageFilePreview", + "DashboardSkillPackageFileRequest", + "DashboardSkillPackageRequest", "DashboardSkillProjection", "DashboardSkillProjectionRequest", "DashboardSkillProjectionTarget", "DashboardSkillPublishRequest", + "DashboardSkillUnpublishRequest", "mount_web_ui", ] diff --git a/tests/builtin/artifacts/skill/test_compatibility.py b/tests/builtin/artifacts/skill/test_compatibility.py new file mode 100644 index 000000000..088d1db66 --- /dev/null +++ b/tests/builtin/artifacts/skill/test_compatibility.py @@ -0,0 +1,175 @@ +# 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. + +from __future__ import annotations + +import io +import zipfile +from pathlib import Path + +from powercontext.builtin.artifacts.skill import ( + AgentEnvironmentProfile, + AgentSkillTarget, + SkillCompatibilityState, + assess_skill_compatibility, + capture_skill_archive, +) + + +def test_compatibility_is_reasoned_per_agent_format_and_observed_environment(tmp_path: Path) -> None: + package = capture_skill_archive(_archive(description="Use for release checks.")) + codex = AgentSkillTarget( + target_id="codex-project", + agent_kind="codex", + installation_scope="project", + path=tmp_path / ".agents" / "skills", + environment=AgentEnvironmentProfile( + operating_system="linux", + architecture="x86_64", + commands={"bash": "5.2"}, + network_policy="disabled", + dependency_install_policy="denied", + ), + ) + claude = AgentSkillTarget( + target_id="claude-project", + agent_kind="claude_code", + installation_scope="project", + path=tmp_path / ".claude" / "skills", + environment=codex.environment, + ) + + codex_assessment = assess_skill_compatibility(package.as_skill_content(), package, codex) + claude_assessment = assess_skill_compatibility(package.as_skill_content(), package, claude) + + assert codex_assessment.state is SkillCompatibilityState.INCOMPATIBLE + assert claude_assessment.state is SkillCompatibilityState.COMPATIBLE + assert codex_assessment.environment_fingerprint != claude_assessment.environment_fingerprint + + +def test_script_compatibility_preserves_unknown_and_manual_review_states(tmp_path: Path) -> None: + package = capture_skill_archive(_archive(description="Verify releases.")) + unobserved = AgentSkillTarget( + target_id="codex-project", + agent_kind="codex", + installation_scope="project", + path=tmp_path, + ) + missing_bash = unobserved.model_copy( + update={ + "environment": AgentEnvironmentProfile( + operating_system="linux", + architecture="arm64", + commands={}, + ) + } + ) + + assert ( + assess_skill_compatibility(package.as_skill_content(), package, unobserved).state + is SkillCompatibilityState.UNKNOWN + ) + assert ( + assess_skill_compatibility(package.as_skill_content(), package, missing_bash).state + is SkillCompatibilityState.MANUAL_REVIEW_REQUIRED + ) + + +def test_declared_runtime_variant_matches_versions_and_host_requirements(tmp_path: Path) -> None: + package = capture_skill_archive(_runtime_archive()) + target = AgentSkillTarget( + target_id="codex-project", + agent_kind="codex", + installation_scope="project", + path=tmp_path, + environment=AgentEnvironmentProfile( + operating_system="linux", + architecture="x86_64", + commands={"python": "3.13.2"}, + network_policy="disabled", + writable_roots=("workspace",), + ), + ) + assert target.environment is not None + environment = target.environment + + compatible = assess_skill_compatibility(package.as_skill_content(), package, target) + wrong_os = assess_skill_compatibility( + package.as_skill_content(), + package, + target.model_copy(update={"environment": environment.model_copy(update={"operating_system": "windows"})}), + ) + old_python = assess_skill_compatibility( + package.as_skill_content(), + package, + target.model_copy(update={"environment": environment.model_copy(update={"commands": {"python": "3.10.14"}})}), + ) + + assert compatible.state is SkillCompatibilityState.COMPATIBLE + assert compatible.selected_runtime_variant == "python" + assert wrong_os.state is SkillCompatibilityState.INCOMPATIBLE + assert old_python.state is SkillCompatibilityState.INCOMPATIBLE + + +def test_invalid_runtime_declaration_is_reported_without_execution(tmp_path: Path) -> None: + package = capture_skill_archive(_runtime_archive(entrypoint="../outside.py")) + target = AgentSkillTarget( + target_id="codex-project", + agent_kind="codex", + installation_scope="project", + path=tmp_path, + ) + + assessment = assess_skill_compatibility(package.as_skill_content(), package, target) + + assert assessment.state is SkillCompatibilityState.INCOMPATIBLE + assert "runtime declaration is invalid" in assessment.reasons[0] + + +def _archive(*, description: str) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr( + "SKILL.md", + f"---\nname: release-check\ndescription: {description}\n---\n\nRun the check.\n", + ) + script = zipfile.ZipInfo("scripts/check.sh") + script.external_attr = 0o100755 << 16 + archive.writestr(script, "#!/bin/sh\nexit 0\n") + return buffer.getvalue() + + +def _runtime_archive(*, entrypoint: str = "scripts/check.py") -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr( + "SKILL.md", + "---\nname: release-check\ndescription: Verify releases.\n---\n\nRun the check.\n", + ) + archive.writestr("scripts/check.py", "raise SystemExit('must not execute')\n") + archive.writestr( + "powercontext.runtime.yaml", + "schema: powercontext.skill-runtime.v1\n" + "variants:\n" + " - id: python\n" + f" entrypoint: {entrypoint}\n" + " interpreter: python\n" + " requirements:\n" + " operating_systems: [linux, darwin]\n" + " commands:\n" + " python: '>=3.11'\n" + " network: none\n" + " writable_roots: [workspace]\n", + ) + return buffer.getvalue() diff --git a/tests/builtin/artifacts/skill/test_distribution.py b/tests/builtin/artifacts/skill/test_distribution.py new file mode 100644 index 000000000..2f465bab1 --- /dev/null +++ b/tests/builtin/artifacts/skill/test_distribution.py @@ -0,0 +1,380 @@ +# 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. + +import asyncio + +import pytest + +from powercontext.artifacts import ArtifactRef +from powercontext.builtin.artifacts.skill import SkillContent, build_instruction_skill_package +from powercontext.builtin.artifacts.skill.distribution import ( + RemoteSkillLifecycleError, + RemoteSkillObservation, + RemoteSkillOperation, + RemoteSkillReceipt, + RemoteSkillReceiptOutcome, + RemoteTargetAuthenticationError, + RemoteTargetEnrollmentError, + RemoteTargetStateError, +) +from powercontext.builtin.artifacts.skill.projection import AgentSkillProjectionState +from powercontext.builtin.persistence.agent_skill_targets import RemoteAgentSkillTargetState +from powercontext.builtin.persistence.artifact_governance import ArtifactLifecycleState +from powercontext.builtin.persistence.skill_publications import SkillPublicationDesiredState +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.runtime import BuiltinConfig, open_builtin_contexts + + +def _package(instruction: str = "Run the release verification."): + return build_instruction_skill_package( + SkillContent( + name="release-check", + description="Verify a release before publishing it.", + instructions=instruction, + validation=("The report passes.",), + ) + ) + + +async def _approved_package(contexts, scope_id: str, *, target: ArtifactRef | None = None): + package = _package( + "Run the updated release verification." if target is not None else "Run the release verification." + ) + candidate = await contexts.upload_skill_package(scope_id, package.archive_bytes, None, target) + approved = await contexts.review(scope_id).approve(candidate.candidate_id, candidate.version) + assert approved.result_artifact is not None + return approved.result_artifact, package + + +async def _active_target(service, scope_id: str, agent_kind: str = "codex"): + enrollment = await service.create_target(scope_id, agent_kind, f"{agent_kind} test machine") + credential = await service.enroll( + enrollment.enrollment_code.get_secret_value(), + f"workspace-{agent_kind}", + "0.1.0", + "e" * 64, + "test-host", + "powercontext", + ) + return enrollment, credential + + +def test_remote_target_has_readable_environment_identity_and_can_be_renamed() -> None: + async def exercise() -> None: + async with open_builtin_contexts(BuiltinConfig(database=SQLiteConfig())) as contexts: + service = contexts.remote_skill_distribution() + _enrollment, credential = await _active_target(service, "project:one") + status = (await service.list_targets("project:one", target_id=credential.target_id))[0] + + assert status.target.display_name == "codex test machine" + assert status.target.machine_hostname == "test-host" + assert status.target.workspace_name == "powercontext" + renamed = await service.rename_target( + "project:one", + credential.target_id, + status.target.generation, + " Hangzhou build machine ", + ) + assert renamed.display_name == "Hangzhou build machine" + assert renamed.target_id == credential.target_id + assert renamed.credential_verifier == status.target.credential_verifier + + with pytest.raises(RemoteTargetStateError): + await service.rename_target( + "project:one", + credential.target_id, + status.target.generation, + "Stale rename", + ) + + asyncio.run(exercise()) + + +def test_remote_enrollment_is_one_time_and_revocation_invalidates_the_credential() -> None: + async def exercise() -> None: + async with open_builtin_contexts(BuiltinConfig(database=SQLiteConfig())) as contexts: + service = contexts.remote_skill_distribution() + enrollment, credential = await _active_target(service, "project:one") + + with pytest.raises(RemoteTargetEnrollmentError): + await service.enroll( + enrollment.enrollment_code.get_secret_value(), + "workspace-replay", + "0.1.0", + None, + ) + + revoked = await service.revoke_target( + "project:one", + credential.target_id, + enrollment.target.generation + 1, + ) + assert revoked.state is RemoteAgentSkillTargetState.REVOKED + assert revoked.credential_verifier is None + with pytest.raises(RemoteTargetAuthenticationError): + await service.reconcile( + credential.credential.get_secret_value(), + (), + "0.1.0", + None, + ) + + asyncio.run(exercise()) + + +def test_remote_publish_reconcile_download_receipt_and_safe_unpublish_converge() -> None: + async def exercise() -> None: + async with open_builtin_contexts(BuiltinConfig(database=SQLiteConfig())) as contexts: + scope_id = "project:one" + artifact, package = await _approved_package(contexts, scope_id) + service = contexts.remote_skill_distribution() + _enrollment, target = await _active_target(service, scope_id) + credential = target.credential.get_secret_value() + + publication = await service.publish(scope_id, target.target_id, artifact, None) + assert publication.generation == 0 + assert publication.state is AgentSkillProjectionState.PENDING + statuses = await service.list_targets(scope_id, target_id=target.target_id) + assert len(statuses) == 1 + assert statuses[0].target.target_id == target.target_id + assert len(statuses[0].publications) == 1 + assert statuses[0].publications[0].artifact_id == publication.artifact_id + assert statuses[0].publications[0].generation == publication.generation + assert await service.list_targets(scope_id, target_id="codex-missing") == () + + reconcile = await service.reconcile(credential, (), "0.1.0", "e" * 64) + assert len(reconcile.actions) == 1 + install = reconcile.actions[0] + assert install.operation is RemoteSkillOperation.INSTALL + assert install.package == package.reference + assert (await service.download(credential, install.generation, artifact, package.reference)).reference == ( + package.reference + ) + + installed = await service.receipt( + credential, + RemoteSkillReceipt( + operation=RemoteSkillOperation.INSTALL, + generation=install.generation, + artifact=artifact, + expected_tree_digest=package.reference.tree_digest, + observed_tree_digest=package.reference.tree_digest, + outcome=RemoteSkillReceiptOutcome.SUCCEEDED, + receiver_version="0.1.0", + environment_fingerprint="e" * 64, + ), + ) + assert installed.publication.generation == 0 + assert installed.publication.observed_generation == 0 + assert installed.publication.state is AgentSkillProjectionState.CURRENT + + observation = RemoteSkillObservation( + artifact=artifact, + tree_digest=package.reference.tree_digest, + actual_tree_digest=package.reference.tree_digest, + skill_name="release-check", + applied_generation=0, + ) + assert (await service.reconcile(credential, (observation,), "0.1.0", "e" * 64)).actions == () + + unpublished = await service.unpublish(scope_id, target.target_id, artifact.artifact_id, 0) + assert unpublished.generation == 1 + assert unpublished.desired_state is SkillPublicationDesiredState.UNPUBLISHED + remove = (await service.reconcile(credential, (observation,), "0.1.0", "e" * 64)).actions[0] + assert remove.operation is RemoteSkillOperation.UNPUBLISH + assert remove.expected_local == observation + + removed = await service.receipt( + credential, + RemoteSkillReceipt( + operation=RemoteSkillOperation.UNPUBLISH, + generation=remove.generation, + artifact=artifact, + expected_tree_digest=package.reference.tree_digest, + outcome=RemoteSkillReceiptOutcome.SUCCEEDED, + receiver_version="0.1.0", + environment_fingerprint="e" * 64, + ), + ) + assert removed.publication.state is AgentSkillProjectionState.UNPUBLISHED + assert removed.publication.observed_revision is None + assert (await service.reconcile(credential, (), "0.1.0", "e" * 64)).actions == () + with pytest.raises(RemoteTargetAuthenticationError): + await service.download(credential, remove.generation, artifact, package.reference) + + asyncio.run(exercise()) + + +def test_remote_publish_distinguishes_skill_lifecycle_from_target_state() -> None: + async def exercise() -> None: + async with open_builtin_contexts(BuiltinConfig(database=SQLiteConfig())) as contexts: + scope_id = "project:one" + artifact, _package_snapshot = await _approved_package(contexts, scope_id) + service = contexts.remote_skill_distribution() + _enrollment, target = await _active_target(service, scope_id) + await contexts.update_skill_lifecycle( + scope_id, + artifact.artifact_id, + 0, + ArtifactLifecycleState.DEPRECATED, + None, + ) + + with pytest.raises(RemoteSkillLifecycleError): + await service.publish(scope_id, target.target_id, artifact, None) + + publication = await service.publish( + scope_id, + target.target_id, + artifact, + None, + allow_deprecated=True, + ) + await contexts.update_skill_lifecycle( + scope_id, + artifact.artifact_id, + 1, + ArtifactLifecycleState.RETIRED, + None, + ) + + with pytest.raises(RemoteSkillLifecycleError): + await service.publish( + scope_id, + target.target_id, + artifact, + publication.generation, + allow_deprecated=True, + ) + + asyncio.run(exercise()) + + +def test_remote_receipt_loss_and_failure_retry_do_not_advance_or_regress_desired_generation() -> None: + async def exercise() -> None: + async with open_builtin_contexts(BuiltinConfig(database=SQLiteConfig())) as contexts: + scope_id = "project:one" + artifact, package = await _approved_package(contexts, scope_id) + service = contexts.remote_skill_distribution() + _enrollment, target = await _active_target(service, scope_id) + credential = target.credential.get_secret_value() + publication = await service.publish(scope_id, target.target_id, artifact, None) + + observation = RemoteSkillObservation( + artifact=artifact, + tree_digest=package.reference.tree_digest, + actual_tree_digest=package.reference.tree_digest, + skill_name="release-check", + applied_generation=publication.generation, + ) + lost_receipt_retry = await service.reconcile(credential, (observation,), "0.1.0", None) + assert lost_receipt_retry.actions[0].operation is RemoteSkillOperation.INSTALL + + failed_receipt = RemoteSkillReceipt( + operation=RemoteSkillOperation.INSTALL, + generation=publication.generation, + artifact=artifact, + expected_tree_digest=package.reference.tree_digest, + outcome=RemoteSkillReceiptOutcome.FAILED, + failure_state=AgentSkillProjectionState.DELIVERY_FAILED, + error_code="network_interrupted", + receiver_version="0.1.0", + ) + failed = await service.receipt(credential, failed_receipt) + assert failed.publication.generation == publication.generation + assert failed.publication.state is AgentSkillProjectionState.DELIVERY_FAILED + + success_receipt = failed_receipt.model_copy( + update={ + "outcome": RemoteSkillReceiptOutcome.SUCCEEDED, + "failure_state": None, + "error_code": None, + "observed_tree_digest": package.reference.tree_digest, + } + ) + succeeded = await service.receipt(credential, success_receipt) + assert succeeded.publication.state is AgentSkillProjectionState.CURRENT + assert succeeded.publication.generation == publication.generation + late_failure = await service.receipt(credential, failed_receipt) + assert late_failure.publication.state is AgentSkillProjectionState.CURRENT + + revised_artifact, revised_package = await _approved_package(contexts, scope_id, target=artifact) + revised = await service.publish( + scope_id, + target.target_id, + revised_artifact, + publication.generation, + ) + assert revised.generation == publication.generation + 1 + stale = await service.receipt(credential, success_receipt) + assert stale.stale is True + assert stale.accepted is False + assert stale.publication.desired_tree_digest == revised_package.reference.tree_digest + + asyncio.run(exercise()) + + +def test_reconcile_persists_authenticated_drift_after_a_successful_receipt() -> None: + async def exercise() -> None: + async with open_builtin_contexts(BuiltinConfig(database=SQLiteConfig())) as contexts: + scope_id = "project:one" + artifact, package = await _approved_package(contexts, scope_id) + service = contexts.remote_skill_distribution() + _enrollment, target = await _active_target(service, scope_id) + credential = target.credential.get_secret_value() + publication = await service.publish(scope_id, target.target_id, artifact, None) + succeeded = await service.receipt( + credential, + RemoteSkillReceipt( + operation=RemoteSkillOperation.INSTALL, + generation=publication.generation, + artifact=artifact, + expected_tree_digest=package.reference.tree_digest, + observed_tree_digest=package.reference.tree_digest, + outcome=RemoteSkillReceiptOutcome.SUCCEEDED, + receiver_version="0.1.0", + ), + ) + assert succeeded.publication.state is AgentSkillProjectionState.CURRENT + drifted_observation = RemoteSkillObservation( + artifact=artifact, + tree_digest=package.reference.tree_digest, + actual_tree_digest="f" * 64, + skill_name="release-check", + applied_generation=publication.generation, + ) + + reconcile = await service.reconcile(credential, (drifted_observation,), "0.1.0", None) + + assert reconcile.actions[0].blocked_error_code == "drifted" + statuses = await service.list_targets(scope_id, target_id=target.target_id) + observed = statuses[0].publications[0] + assert observed.state is AgentSkillProjectionState.DRIFTED + assert observed.last_error_code == "drifted" + receipt = await service.receipt( + credential, + RemoteSkillReceipt( + operation=RemoteSkillOperation.INSTALL, + generation=publication.generation, + artifact=artifact, + expected_tree_digest=package.reference.tree_digest, + outcome=RemoteSkillReceiptOutcome.FAILED, + failure_state=AgentSkillProjectionState.DRIFTED, + error_code="drifted", + receiver_version="0.1.0", + ), + ) + assert receipt.publication.state is AgentSkillProjectionState.DRIFTED + + asyncio.run(exercise()) diff --git a/tests/builtin/artifacts/skill/test_models.py b/tests/builtin/artifacts/skill/test_models.py index 6307a94d3..23a628968 100644 --- a/tests/builtin/artifacts/skill/test_models.py +++ b/tests/builtin/artifacts/skill/test_models.py @@ -15,7 +15,7 @@ import pytest from pydantic import ValidationError -from powercontext.builtin.artifacts.skill import SkillContent +from powercontext.builtin.artifacts.skill import SkillContent, SkillGenerationOutput def test_skill_content_requires_complete_portable_instructions() -> None: @@ -49,3 +49,22 @@ def test_skill_content_rejects_incomplete_or_ambiguous_text(field: str, value: o with pytest.raises(ValidationError): SkillContent.model_validate(payload) + + +def test_model_generated_skill_cannot_claim_an_existing_package_snapshot() -> None: + with pytest.raises(ValidationError): + SkillGenerationOutput.model_validate({ + "proposal": { + "name": "forked-skill", + "description": "A semantic fork.", + "instructions": "Use the revised procedure.", + "validation": ["The revised check passes."], + "package": { + "tree_digest": "a" * 64, + "archive_digest": "b" * 64, + "file_count": 1, + "uncompressed_size": 1, + "archive_size": 1, + }, + } + }) diff --git a/tests/builtin/artifacts/skill/test_package.py b/tests/builtin/artifacts/skill/test_package.py new file mode 100644 index 000000000..4b784bc7b --- /dev/null +++ b/tests/builtin/artifacts/skill/test_package.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. + +import io +import stat +import zipfile +from pathlib import Path + +import pytest + +from powercontext.builtin.artifacts.skill import ( + MAX_SKILL_PACKAGE_BYTES, + MAX_SKILL_PACKAGE_FILES, + SkillContent, + SkillPackageError, + build_instruction_skill_package, + capture_skill_archive, + capture_skill_directory, + materialize_skill_package, + package_file, +) + + +def _write_package(root: Path) -> Path: + package = root / "release-check" + (package / "scripts").mkdir(parents=True) + (package / "references").mkdir() + (package / "assets").mkdir() + (package / "SKILL.md").write_text( + "---\n" + "name: release-check\n" + "description: Verify a release before publishing it.\n" + "license: Apache-2.0\n" + "compatibility: Requires Python 3.11 or newer.\n" + "metadata:\n" + " owner: release-team\n" + "allowed-tools: Bash(git:*) Read\n" + "---\n\n" + "Run the verification script and inspect its report.\n", + encoding="utf-8", + ) + script = package / "scripts" / "verify.py" + script.write_text("print('verified')\n", encoding="utf-8") + script.chmod(0o755) + (package / "references" / "policy.md").write_text("# Release policy\n", encoding="utf-8") + (package / "assets" / "report.json").write_bytes(b'{"status":"pending"}\n') + (package / ".hidden-note").write_text("Preserved.\n", encoding="utf-8") + return package + + +def test_directory_package_round_trips_exact_files_and_executable_mode(tmp_path: Path) -> None: + package = _write_package(tmp_path) + + snapshot = capture_skill_directory(package) + restored = tmp_path / "restored" + materialize_skill_package(snapshot, restored) + + assert snapshot.metadata.name == "release-check" + assert snapshot.metadata.metadata == {"owner": "release-team"} + assert snapshot.reference.file_count == 5 + assert package_file(snapshot, "references/policy.md") == b"# Release policy\n" + assert (restored / ".hidden-note").read_text(encoding="utf-8") == "Preserved.\n" + assert (restored / "scripts" / "verify.py").stat().st_mode & stat.S_IXUSR + for entry in snapshot.entries: + assert (restored / entry.path).read_bytes() == (package / entry.path).read_bytes() + + +def test_different_zip_order_converges_on_the_same_canonical_package(tmp_path: Path) -> None: + package = _write_package(tmp_path) + expected = capture_skill_directory(package) + paths = [entry.path for entry in expected.entries] + + first = _zip_files(package, paths) + second = _zip_files(package, reversed(paths)) + + first_snapshot = capture_skill_archive(first) + second_snapshot = capture_skill_archive(second) + assert first_snapshot.reference == expected.reference + assert second_snapshot.reference == expected.reference + assert first_snapshot.archive_bytes == second_snapshot.archive_bytes == expected.archive_bytes + + +@pytest.mark.parametrize( + ("name", "content"), + [ + ("../outside", b"bad"), + (".env", b"SECRET=value"), + ("nested\\windows", b"bad"), + ], +) +def test_archive_rejects_unsafe_paths(name: str, content: bytes) -> None: + archive = _zip_entries(( + ("SKILL.md", b"---\nname: safe-skill\ndescription: Safe.\n---\n"), + (name, content), + )) + + with pytest.raises(SkillPackageError): + capture_skill_archive(archive) + + +def test_archive_rejects_duplicate_and_symlink_entries() -> None: + duplicate = io.BytesIO() + with pytest.warns(UserWarning, match="Duplicate name"), zipfile.ZipFile(duplicate, "w") as archive: + archive.writestr("SKILL.md", "---\nname: safe-skill\ndescription: Safe.\n---\n") + archive.writestr("SKILL.md", "different") + with pytest.raises(SkillPackageError, match="duplicate"): + capture_skill_archive(duplicate.getvalue()) + + linked = io.BytesIO() + with zipfile.ZipFile(linked, "w") as archive: + archive.writestr("SKILL.md", "---\nname: safe-skill\ndescription: Safe.\n---\n") + info = zipfile.ZipInfo("scripts/run") + info.create_system = 3 + info.external_attr = (stat.S_IFLNK | 0o777) << 16 + archive.writestr(info, "../outside") + with pytest.raises(SkillPackageError, match="non-regular"): + capture_skill_archive(linked.getvalue()) + + +def test_archive_rejects_case_collisions_special_files_and_invalid_frontmatter() -> None: + collision = _zip_entries(( + ("SKILL.md", b"---\nname: safe-skill\ndescription: Safe.\n---\n"), + ("References/Policy.md", b"first"), + ("references/policy.md", b"second"), + )) + with pytest.raises(SkillPackageError, match="colliding"): + capture_skill_archive(collision) + + special = io.BytesIO() + with zipfile.ZipFile(special, "w") as archive: + archive.writestr("SKILL.md", "---\nname: safe-skill\ndescription: Safe.\n---\n") + info = zipfile.ZipInfo("scripts/pipe") + info.create_system = 3 + info.external_attr = (stat.S_IFIFO | 0o644) << 16 + archive.writestr(info, "") + with pytest.raises(SkillPackageError, match="non-regular"): + capture_skill_archive(special.getvalue()) + + malformed = _zip_entries((("SKILL.md", b"---\nname: [unterminated\n---\n"),)) + with pytest.raises(SkillPackageError, match="invalid YAML"): + capture_skill_archive(malformed) + + +def test_archive_rejects_decompression_and_file_count_bounds() -> None: + oversized = _zip_entries(( + ("SKILL.md", b"---\nname: safe-skill\ndescription: Safe.\n---\n"), + ("assets/large.bin", b"x" * (MAX_SKILL_PACKAGE_BYTES + 1)), + )) + with pytest.raises(SkillPackageError, match="entry exceeds"): + capture_skill_archive(oversized) + + entries = [("SKILL.md", b"---\nname: safe-skill\ndescription: Safe.\n---\n")] + entries.extend((f"references/{index}.txt", b"x") for index in range(MAX_SKILL_PACKAGE_FILES)) + with pytest.raises(SkillPackageError, match="file count"): + capture_skill_archive(_zip_entries(entries)) + + +def test_directory_requires_standard_name_to_match_package_root(tmp_path: Path) -> None: + package = _write_package(tmp_path) + (package / "SKILL.md").write_text( + "---\nname: another-name\ndescription: Does not match.\n---\n", + encoding="utf-8", + ) + + with pytest.raises(SkillPackageError, match="match its package directory"): + capture_skill_directory(package) + + +def test_legacy_instruction_content_builds_a_standard_one_file_package() -> None: + content = SkillContent( + name="release-check", + description="Verify a release before publishing it.", + instructions="Run the release verification.", + validation=("The release report passes.",), + ) + + snapshot = build_instruction_skill_package(content) + packaged = snapshot.as_skill_content() + + assert [entry.path for entry in snapshot.entries] == ["SKILL.md"] + assert packaged.package == snapshot.reference + assert packaged.instructions == "Run the release verification.\n\n## Validation\n\n- The release report passes." + assert packaged.validation == () + + +def _zip_files(package: Path, paths) -> bytes: + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as archive: + for path in paths: + source = package / path + info = zipfile.ZipInfo(path) + info.create_system = 3 + info.external_attr = source.stat().st_mode << 16 + archive.writestr(info, source.read_bytes()) + return output.getvalue() + + +def _zip_entries(entries) -> bytes: + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as archive: + for name, content in entries: + archive.writestr(name, content) + return output.getvalue() diff --git a/tests/builtin/inference/test_pydantic_ai.py b/tests/builtin/inference/test_pydantic_ai.py index cd76fd8b5..ce6ffc606 100644 --- a/tests/builtin/inference/test_pydantic_ai.py +++ b/tests/builtin/inference/test_pydantic_ai.py @@ -277,7 +277,7 @@ async def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelRespons assert observed_settings == [ { - "max_tokens": 1, + "max_tokens": 16, "extra_body": {"chat_template_kwargs": {"enable_thinking": False}}, } ] diff --git a/tests/builtin/persistence/test_agent_skill_targets.py b/tests/builtin/persistence/test_agent_skill_targets.py new file mode 100644 index 000000000..d0c21a36e --- /dev/null +++ b/tests/builtin/persistence/test_agent_skill_targets.py @@ -0,0 +1,147 @@ +# 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. + +import asyncio +from datetime import UTC, datetime, timedelta + +import pytest + +from powercontext.builtin.persistence import ( + RemoteAgentSkillTarget, + RemoteAgentSkillTargetRepository, + RemoteAgentSkillTargetState, + StoredPayloadConflictError, +) +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.builtin.persistence.tables import BUILTIN_TABLES + + +def _pending_target( + *, + scope_id: str = "project:one", + target_id: str = "codex-a", + token_digest: str = "a" * 64, +) -> RemoteAgentSkillTarget: + now = datetime.now(UTC) + return RemoteAgentSkillTarget( + scope_id=scope_id, + target_id=target_id, + display_name="Test machine", + agent_kind="codex", + state=RemoteAgentSkillTargetState.PENDING, + enrollment_token_digest=token_digest, + enrollment_expires_at=now + timedelta(minutes=10), + generation=0, + created_at=now, + updated_at=now, + ) + + +def test_remote_agent_skill_target_enrollment_is_credential_addressable_and_cas_guarded() -> None: + async def exercise() -> None: + repository = RemoteAgentSkillTargetRepository() + pending = _pending_target() + async with ( + SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile, + profile.database.transaction() as connection, + ): + await repository.create(connection, pending) + restored_pending = await repository.find_by_enrollment_token(connection, "a" * 64) + assert restored_pending is not None + assert restored_pending.target_id == pending.target_id + assert restored_pending.state is RemoteAgentSkillTargetState.PENDING + + active_payload = pending.model_copy( + update={ + "state": RemoteAgentSkillTargetState.ACTIVE, + "installation_id": "workspace-8d3a", + "enrollment_token_digest": None, + "enrollment_expires_at": None, + "credential_subject": "installation-01", + "credential_verifier": "b" * 64, + "receiver_version": "0.1.0", + } + ) + active = await repository.replace(connection, active_payload, 0) + + assert active.generation == 1 + assert active.updated_at >= pending.updated_at + assert await repository.find_by_enrollment_token(connection, "a" * 64) is None + restored_active = await repository.find_by_credential(connection, "b" * 64) + assert restored_active is not None + assert restored_active.target_id == active.target_id + assert restored_active.installation_id == "workspace-8d3a" + + with pytest.raises(StoredPayloadConflictError): + await repository.replace(connection, active, 0) + + asyncio.run(exercise()) + + +def test_remote_agent_skill_target_rejects_duplicate_installation_identity() -> None: + async def exercise() -> None: + repository = RemoteAgentSkillTargetRepository() + async with ( + SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile, + profile.database.transaction() as connection, + ): + first = _pending_target() + second = _pending_target(target_id="codex-b", token_digest="c" * 64) + await repository.create(connection, first) + await repository.create(connection, second) + + for pending, subject, verifier in ( + (first, "installation-01", "b" * 64), + (second, "installation-02", "d" * 64), + ): + active = pending.model_copy( + update={ + "state": RemoteAgentSkillTargetState.ACTIVE, + "installation_id": "workspace-8d3a", + "enrollment_token_digest": None, + "enrollment_expires_at": None, + "credential_subject": subject, + "credential_verifier": verifier, + } + ) + if pending is first: + await repository.replace(connection, active, 0) + else: + with pytest.raises(StoredPayloadConflictError): + await repository.replace(connection, active, 0) + + asyncio.run(exercise()) + + +def test_remote_agent_skill_targets_are_listed_by_scope_with_a_bound() -> None: + async def exercise() -> None: + repository = RemoteAgentSkillTargetRepository() + async with ( + SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile, + profile.database.transaction() as connection, + ): + await repository.create(connection, _pending_target(target_id="codex-a", token_digest="a" * 64)) + await repository.create(connection, _pending_target(target_id="codex-b", token_digest="b" * 64)) + await repository.create( + connection, + _pending_target(scope_id="project:other", target_id="codex-c", token_digest="c" * 64), + ) + + first = await repository.list_for_scope(connection, "project:one", limit=1) + all_in_scope = await repository.list_for_scope(connection, "project:one", limit=10) + + assert [target.target_id for target in first] == ["codex-a"] + assert [target.target_id for target in all_in_scope] == ["codex-a", "codex-b"] + + asyncio.run(exercise()) diff --git a/tests/builtin/persistence/test_experience_index.py b/tests/builtin/persistence/test_experience_index.py index 7aa22de21..85125fbfd 100644 --- a/tests/builtin/persistence/test_experience_index.py +++ b/tests/builtin/persistence/test_experience_index.py @@ -27,6 +27,7 @@ from powercontext.builtin.artifacts.experience import Experience, ExperienceContent from powercontext.builtin.artifacts.skill import Skill, SkillContent +from powercontext.builtin.persistence.artifact_governance import ArtifactLifecycleState from powercontext.builtin.persistence.experience_index import ensure_artifact_head_searchable_text from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.persistence.tables import ARTIFACT_HEADS_TABLE, BUILTIN_TABLES @@ -97,9 +98,12 @@ def test_oceanbase_startup_upgrades_legacy_artifact_heads_with_mediumtext() -> N asyncio.run(ensure_artifact_head_searchable_text(cast(AsyncConnection, connection))) - connection.exec_driver_sql.assert_awaited_once_with( - "ALTER TABLE pc_artifact_heads ADD COLUMN searchable_text MEDIUMTEXT NULL" - ) + assert [call.args[0] for call in connection.exec_driver_sql.await_args_list] == [ + "ALTER TABLE pc_artifact_heads ADD COLUMN searchable_text MEDIUMTEXT NULL", + "ALTER TABLE pc_artifact_heads ADD COLUMN lifecycle_state VARCHAR(16) NOT NULL DEFAULT 'active'", + "ALTER TABLE pc_artifact_heads ADD COLUMN replacement_artifact_id VARCHAR(128) NULL", + "ALTER TABLE pc_artifact_heads ADD COLUMN governance_generation BIGINT NOT NULL DEFAULT 0", + ] def test_sqlite_experience_fts_tracks_only_approved_current_heads_and_rebuilds() -> None: @@ -112,12 +116,13 @@ async def scenario() -> None: await connection.execute( text( "SELECT name FROM sqlite_master " - "WHERE name IN ('pc_experience_heads', 'pc_experience_fts_index', 'pc_experience_fts')" + "WHERE name IN ('pc_experience_heads', 'pc_experience_fts_index', " + "'pc_experience_fts', 'pc_artifact_fts')" ) ) ).scalars() ) - assert experience_tables == {"pc_experience_fts"} + assert experience_tables == {"pc_artifact_fts"} first_source, _ = await context.sources.capture( ContentCapture(source_id="task-1", content="The first client repair passed.") @@ -167,6 +172,30 @@ async def scenario() -> None: ) skill_approval = await review.approve(skill_candidate.candidate_id, skill_candidate.version) assert skill_approval.result_artifact is not None + skill_hits = await contexts.search_skills("project", "regenerate client", 8) + assert tuple(hit.artifact_ref for hit in skill_hits) == (skill_approval.result_artifact,) + governance = await contexts.update_skill_lifecycle( + "project", + skill_approval.result_artifact.artifact_id, + 0, + ArtifactLifecycleState.DEPRECATED, + None, + ) + assert governance.governance_generation == 1 + assert await contexts.search_skills("project", "regenerate client", 8) == () + deprecated = await contexts.list_skills("project", True, 8) + assert deprecated[0][1].lifecycle_state is ArtifactLifecycleState.DEPRECATED + reactivated = await contexts.update_skill_lifecycle( + "project", + skill_approval.result_artifact.artifact_id, + 1, + ArtifactLifecycleState.ACTIVE, + None, + ) + assert reactivated.governance_generation == 2 + assert tuple(hit.artifact_ref for hit in await contexts.search_skills("project", "regenerate", 8)) == ( + skill_approval.result_artifact, + ) async with contexts.database.transaction() as connection: experience_searchable_text = await connection.scalar( @@ -183,7 +212,8 @@ async def scenario() -> None: ) assert experience_searchable_text is not None assert "falconcurrent" in experience_searchable_text - assert skill_searchable_text is None + assert skill_searchable_text is not None + assert "regenerate" in skill_searchable_text await connection.execute( ARTIFACT_HEADS_TABLE @@ -191,7 +221,7 @@ async def scenario() -> None: .where(ARTIFACT_HEADS_TABLE.c.family == Experience.family) .values(searchable_text=None) ) - await connection.exec_driver_sql("DELETE FROM pc_experience_fts") + await connection.exec_driver_sql("DELETE FROM pc_artifact_fts") assert await contexts.search_experience("project", "falconcurrent", 8) == () async with contexts.database.transaction() as connection: diff --git a/tests/builtin/persistence/test_mysql_schema.py b/tests/builtin/persistence/test_mysql_schema.py index 706ba3df0..07f172e0c 100644 --- a/tests/builtin/persistence/test_mysql_schema.py +++ b/tests/builtin/persistence/test_mysql_schema.py @@ -17,8 +17,10 @@ from sqlalchemy.schema import CreateTable, ForeignKeyConstraint, PrimaryKeyConstraint, UniqueConstraint from powercontext.builtin.persistence.tables import ( + AGENT_SKILL_TARGETS_TABLE, ARTIFACTS_TABLE, SHARED_METADATA, + SKILL_PACKAGES_TABLE, SOURCE_CURSORS_TABLE, SOURCES_TABLE, ) @@ -56,14 +58,26 @@ def test_mysql_ddl_uses_utf8mb4_bin_for_identity_keys() -> None: def test_mysql_ddl_uses_mediumblob_for_every_canonical_payload() -> None: dialect = mysql.dialect() expected = { - SOURCES_TABLE: "payload", - ARTIFACTS_TABLE: "content", - SOURCE_CURSORS_TABLE: "`cursor`", + SOURCES_TABLE: ("payload",), + ARTIFACTS_TABLE: ("content",), + SOURCE_CURSORS_TABLE: ("`cursor`",), + SKILL_PACKAGES_TABLE: ("archive_bytes", "manifest"), } - for table, column_name in expected.items(): + for table, column_names in expected.items(): ddl = str(CreateTable(table).compile(dialect=dialect)) - assert f"{column_name} MEDIUMBLOB NOT NULL" in ddl + for column_name in column_names: + assert f"{column_name} MEDIUMBLOB NOT NULL" in ddl + + +def test_mysql_remote_target_credentials_use_binary_identity_columns() -> None: + ddl = str(CreateTable(AGENT_SKILL_TARGETS_TABLE).compile(dialect=mysql.dialect())) + + assert "credential_verifier VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin" in ddl + assert "UNIQUE (credential_verifier)" in ddl + assert "ck_pc_agent_skill_targets_state_payload" in ddl + assert "state = 'active'" in ddl + assert "credential_verifier IS NOT NULL" in ddl def test_every_mysql_utf8mb4_key_stays_below_the_innodb_limit() -> None: diff --git a/tests/builtin/persistence/test_skill_distribution_schema.py b/tests/builtin/persistence/test_skill_distribution_schema.py new file mode 100644 index 000000000..f1b7cb20c --- /dev/null +++ b/tests/builtin/persistence/test_skill_distribution_schema.py @@ -0,0 +1,176 @@ +# 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. + +import asyncio +import sqlite3 +from types import SimpleNamespace +from typing import cast +from unittest.mock import AsyncMock + +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.builtin.persistence.skill_distribution_schema import ensure_skill_distribution_schema +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.builtin.persistence.tables import BUILTIN_TABLES + + +def test_sqlite_startup_upgrades_legacy_skill_publications_idempotently(tmp_path) -> None: + database = tmp_path / "legacy-skill-publications.db" + with sqlite3.connect(database) as connection: + connection.execute( + """ + CREATE TABLE pc_agent_skill_targets ( + scope_id VARCHAR(256) NOT NULL, + target_id VARCHAR(64) NOT NULL, + agent_kind VARCHAR(32) NOT NULL, + installation_scope VARCHAR(16) NOT NULL, + delivery_mode VARCHAR(16) NOT NULL, + installation_id VARCHAR(128), + state VARCHAR(16) NOT NULL, + enrollment_token_digest VARCHAR(64), + enrollment_expires_at DATETIME, + credential_subject VARCHAR(128), + credential_verifier VARCHAR(64), + receiver_version VARCHAR(64), + environment_fingerprint VARCHAR(64), + last_seen_at DATETIME, + generation BIGINT NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (scope_id, target_id) + ) + """ + ) + connection.execute( + """ + INSERT INTO pc_agent_skill_targets VALUES ( + 'project:one', 'codex-legacy', 'codex', 'project', 'agent_pull', NULL, + 'pending', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + '2026-08-24 10:10:00', NULL, NULL, NULL, NULL, NULL, 0, + '2026-08-24 10:00:00', '2026-08-24 10:00:00' + ) + """ + ) + connection.execute( + """ + CREATE TABLE pc_skill_publications ( + scope_id VARCHAR(256) NOT NULL, + target_id VARCHAR(64) NOT NULL, + artifact_id VARCHAR(128) NOT NULL, + desired_revision INTEGER NOT NULL, + desired_tree_digest VARCHAR(64) NOT NULL, + observed_revision INTEGER, + observed_tree_digest VARCHAR(64), + destination TEXT NOT NULL, + state VARCHAR(32) NOT NULL, + selected_runtime_variant VARCHAR(128), + environment_fingerprint VARCHAR(64), + generation BIGINT NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (scope_id, target_id, artifact_id) + ) + """ + ) + connection.execute( + """ + INSERT INTO pc_skill_publications VALUES ( + 'project:one', 'codex-local', 'release-check', 3, + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 3, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + '/workspace/.agents/skills/release-check', 'current', NULL, NULL, 7, + '2026-08-24 10:00:00' + ) + """ + ) + + async def exercise() -> None: + config = SQLiteConfig(url=f"sqlite+aiosqlite:///{database}") + for _ in range(2): + async with ( + SQLiteProfile.open(config, tables=BUILTIN_TABLES) as profile, + profile.database.transaction() as connection, + ): + await ensure_skill_distribution_schema(connection) + columns = tuple( + (await connection.exec_driver_sql("PRAGMA table_info('pc_skill_publications')")).mappings() + ) + assert {column["name"] for column in columns} >= { + "desired_state", + "observed_generation", + "last_error_code", + "observed_at", + } + destination = next(column for column in columns if column["name"] == "destination") + assert destination["notnull"] == 0 + + row = ( + ( + await connection.exec_driver_sql( + "SELECT desired_state, observed_generation, observed_at FROM pc_skill_publications" + ) + ) + .mappings() + .one() + ) + assert row["desired_state"] == "published" + assert row["observed_generation"] == 7 + assert row["observed_at"] is not None + + target_columns = { + column["name"] + for column in ( + await connection.exec_driver_sql("PRAGMA table_info('pc_agent_skill_targets')") + ).mappings() + } + assert target_columns >= {"display_name", "machine_hostname", "workspace_name"} + target = ( + ( + await connection.exec_driver_sql( + "SELECT target_id, display_name, machine_hostname, workspace_name " + "FROM pc_agent_skill_targets" + ) + ) + .mappings() + .one() + ) + assert target == { + "target_id": "codex-legacy", + "display_name": "codex-legacy", + "machine_hostname": None, + "workspace_name": None, + } + + asyncio.run(exercise()) + + +def test_oceanbase_migration_adds_remote_columns_and_replaces_checks() -> None: + query_result = SimpleNamespace(mappings=lambda: []) + connection = SimpleNamespace( + dialect=SimpleNamespace(name="mysql"), + execute=AsyncMock(return_value=query_result), + scalar=AsyncMock(return_value=None), + exec_driver_sql=AsyncMock(), + ) + + asyncio.run(ensure_skill_distribution_schema(cast(AsyncConnection, connection))) + + statements = [call.args[0] for call in connection.exec_driver_sql.await_args_list] + assert "ALTER TABLE pc_skill_publications ADD COLUMN desired_state VARCHAR(16) " in statements[0] + assert any("MODIFY COLUMN destination MEDIUMTEXT NULL" in statement for statement in statements) + assert any("delivery_failed" in statement for statement in statements) + assert any("observed_generation IS NULL OR observed_generation >= 0" in statement for statement in statements) + assert any("ADD COLUMN display_name" in statement for statement in statements) + assert any("SET display_name = target_id" in statement for statement in statements) + assert any("ADD COLUMN machine_hostname" in statement for statement in statements) + assert any("ADD COLUMN workspace_name" in statement for statement in statements) diff --git a/tests/builtin/persistence/test_skill_packages.py b/tests/builtin/persistence/test_skill_packages.py new file mode 100644 index 000000000..16364c906 --- /dev/null +++ b/tests/builtin/persistence/test_skill_packages.py @@ -0,0 +1,72 @@ +# 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. + +import asyncio + +import pytest +from sqlalchemy import update + +from powercontext.builtin.artifacts.skill import SkillContent, build_instruction_skill_package +from powercontext.builtin.persistence import InvalidStoredPayloadError, RepositoryNotFoundError, SkillPackageRepository +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.builtin.persistence.tables import BUILTIN_TABLES, SKILL_PACKAGES_TABLE + + +def _snapshot(): + return build_instruction_skill_package( + SkillContent( + name="release-check", + description="Verify a release before publishing it.", + instructions="Run the release verification.", + validation=("The report passes.",), + ) + ) + + +def test_sqlite_skill_package_round_trip_is_idempotent_and_scope_isolated() -> None: + async def exercise() -> None: + repository = SkillPackageRepository() + snapshot = _snapshot() + async with ( + SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile, + profile.database.transaction() as connection, + ): + assert await repository.add(connection, "project:one", snapshot) == snapshot.reference + assert await repository.add(connection, "project:one", snapshot) == snapshot.reference + restored = await repository.get(connection, "project:one", snapshot.reference) + assert restored == snapshot + with pytest.raises(RepositoryNotFoundError): + await repository.get(connection, "project:two", snapshot.reference) + + asyncio.run(exercise()) + + +def test_skill_package_read_detects_stored_archive_corruption() -> None: + async def exercise() -> None: + repository = SkillPackageRepository() + snapshot = _snapshot() + async with ( + SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile, + profile.database.transaction() as connection, + ): + await repository.add(connection, "project:one", snapshot) + await connection.execute( + update(SKILL_PACKAGES_TABLE) + .where(SKILL_PACKAGES_TABLE.c.scope_id == "project:one") + .values(archive_bytes=b"not-a-zip") + ) + with pytest.raises(InvalidStoredPayloadError, match="canonical archive is invalid"): + await repository.get(connection, "project:one", snapshot.reference) + + asyncio.run(exercise()) diff --git a/tests/builtin/review/test_service.py b/tests/builtin/review/test_service.py index a2f440679..37353a5ad 100644 --- a/tests/builtin/review/test_service.py +++ b/tests/builtin/review/test_service.py @@ -23,7 +23,7 @@ from powercontext.artifacts import ArtifactRef from powercontext.builtin.artifacts.experience import Experience, ExperienceContent, ExperienceSearchHit from powercontext.builtin.artifacts.memory import MemoryEntryInput -from powercontext.builtin.artifacts.skill import SkillContent +from powercontext.builtin.artifacts.skill import Skill, SkillContent, SkillPackageSnapshot, SkillSearchHit from powercontext.builtin.persistence.errors import RepositoryNotFoundError from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.builtin.persistence.tables import ARTIFACTS_TABLE, BUILTIN_TABLES @@ -85,6 +85,26 @@ async def search( ) -> tuple[ExperienceSearchHit, ...]: return () + async def replace_skill( + self, + _connection: AsyncConnection, + _scope_id: str, + _skill: Skill, + _package: SkillPackageSnapshot, + /, + ) -> None: + pass + + async def search_skills( + self, + _connection: AsyncConnection, + _scope_id: str, + _query: str, + _limit: int, + /, + ) -> tuple[SkillSearchHit, ...]: + return () + def _proposal(lesson: str = "Regenerate the Client before contract tests.") -> ExperienceContent: return ExperienceContent( diff --git a/tests/builtin/runtime/test_external_skills.py b/tests/builtin/runtime/test_external_skills.py index 75b20592d..bed4b8eff 100644 --- a/tests/builtin/runtime/test_external_skills.py +++ b/tests/builtin/runtime/test_external_skills.py @@ -13,6 +13,7 @@ # limitations under the License. import asyncio +import stat from pathlib import Path import pytest @@ -117,7 +118,13 @@ async def generate(self, value: ArtifactGenerationInput, /) -> SkillContent: def test_explicit_external_skill_import_captures_exact_snapshot_and_enters_review(tmp_path: Path) -> None: async def exercise() -> None: root = tmp_path / ".agents" / "skills" - _write_skill(root) + package = _write_skill(root) + (package / "scripts").mkdir() + script = package / "scripts" / "check.py" + script.write_text("print('exact external snapshot')\n", encoding="utf-8") + script.chmod(0o755) + (package / "references").mkdir() + (package / "references" / "guide.md").write_text("# Exact guide\n", encoding="utf-8") config = BuiltinConfig( database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'powercontext.db'}"), external_skills=ExternalSkillsConfig( @@ -125,8 +132,7 @@ async def exercise() -> None: codex_roots=(CodexSkillRoot(root_id="repository", installation_scope="project", path=root),), ), ) - generator = _SkillGenerator() - async with open_builtin_runtime(config, skill_generator=generator) as runtime: + async with open_builtin_runtime(config) as runtime: scoped = runtime.external_skills.for_scope("project:example") registration = (await scoped.scan()).registrations[0] @@ -143,14 +149,27 @@ async def exercise() -> None: assert result.candidate.family == "skill" assert result.candidate.sources[0].source_type == "external-skill-snapshot" assert result.candidate.artifacts == () - assert registration.fingerprint in generator.inputs[0].evidence[0].content - assert "Keep boundaries explicit." in generator.inputs[0].evidence[0].content + assert isinstance(result.candidate.proposal, SkillContent) + assert result.candidate.proposal.name == "friendly-python" + assert result.candidate.proposal.instructions == "Keep boundaries explicit." + assert result.candidate.proposal.package is not None + snapshot = await runtime.skill.for_scope("project:example").package_snapshot( + result.candidate.proposal.package + ) + assert [entry.path for entry in snapshot.entries] == [ + "SKILL.md", + "references/guide.md", + "scripts/check.py", + ] + assert snapshot.archive_bytes + assert next(entry for entry in snapshot.entries if entry.path == "scripts/check.py").mode == 0o755 + assert stat.S_IXUSR & script.stat().st_mode assert (await scoped.list(ListExternalSkillsRequest()))[0].registration == registration asyncio.run(exercise()) -def test_external_skill_import_requires_generation_model_before_snapshot(tmp_path: Path) -> None: +def test_external_skill_fork_requires_generation_model_before_snapshot(tmp_path: Path) -> None: async def exercise() -> None: root = tmp_path / ".agents" / "skills" _write_skill(root) diff --git a/tests/client/test_receiver_service.py b/tests/client/test_receiver_service.py new file mode 100644 index 000000000..5886a97e9 --- /dev/null +++ b/tests/client/test_receiver_service.py @@ -0,0 +1,118 @@ +# 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. + +from pathlib import Path + +import pytest +from pydantic import SecretStr + +import powercontext.client.receiver_service as service_module +from powercontext.client.receiver_service import ( + ReceiverServiceError, + install_systemd_user_service, + uninstall_systemd_user_service, +) +from powercontext.client.skill_receiver import RemoteSkillReceiverConfig + + +def _config(tmp_path: Path) -> RemoteSkillReceiverConfig: + return RemoteSkillReceiverConfig( + server_url="https://powercontext.example.com", + target_id="codex-a", + credential=SecretStr("pct_target.super-secret-value"), + agent_kind="codex", + workspace=tmp_path / "project with space", + ) + + +def test_systemd_user_service_is_secret_free_target_scoped_and_reversible( + tmp_path: Path, + monkeypatch, +) -> None: + config = _config(tmp_path) + config_file = tmp_path / "project with space/.powercontext/remote-skill-target.json" + config_file.parent.mkdir(parents=True) + config_file.write_text("credential stays here", encoding="utf-8") + config_file.chmod(0o600) + commands: list[tuple[str, ...]] = [] + executables = { + "powercontext": Path("/opt/power context/bin/powercontext"), + "systemctl": Path("/usr/bin/systemctl"), + } + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + monkeypatch.setattr(service_module, "_required_executable", executables.__getitem__) + monkeypatch.setattr( + service_module, + "_run_systemctl", + lambda _systemctl, *arguments: commands.append(arguments), + ) + + installation = install_systemd_user_service(config_file, config, interval_seconds=3) + contents = installation.unit_path.read_text(encoding="utf-8") + + assert installation.unit_name == "powercontext-skill-receiver-codex-a.service" + assert "remote-watch" in contents + assert '"--interval" "3"' in contents + assert str(config_file) in contents + assert "WorkingDirectory=" not in contents + assert config.credential.get_secret_value() not in contents + assert commands == [("daemon-reload",), ("enable", "--now", installation.unit_name)] + + removed = uninstall_systemd_user_service(config.target_id) + + assert removed == installation + assert not installation.unit_path.exists() + assert commands[-2:] == [("disable", "--now", installation.unit_name), ("daemon-reload",)] + + +def test_systemd_user_service_uninstall_does_not_claim_an_absent_unit_was_stopped( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + monkeypatch.setattr(service_module, "_required_executable", lambda _name: Path("/usr/bin/systemctl")) + + with pytest.raises(ReceiverServiceError, match="does not exist"): + uninstall_systemd_user_service("codex-a") + + +def test_systemd_user_service_uses_the_invoked_venv_entrypoint_when_it_is_not_on_path( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config = _config(tmp_path) + config_file = tmp_path / "project with space/.powercontext/remote-skill-target.json" + config_file.parent.mkdir(parents=True) + config_file.write_text("credential stays here", encoding="utf-8") + entrypoint = tmp_path / "venv/bin/powercontext" + entrypoint.parent.mkdir(parents=True) + entrypoint.write_text("#!/bin/sh\n", encoding="utf-8") + entrypoint.chmod(0o755) + monkeypatch.setattr(service_module.sys, "argv", [str(entrypoint), "skill", "remote-service-install"]) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + monkeypatch.setattr( + service_module, + "_required_executable", + lambda name: ( + Path("/usr/bin/systemctl") + if name == "systemctl" + else pytest.fail("the invoked venv entrypoint should not require a PATH lookup") + ), + ) + monkeypatch.setattr(service_module, "_run_systemctl", lambda *_args: None) + + installation = install_systemd_user_service(config_file, config, interval_seconds=3) + + contents = installation.unit_path.read_text(encoding="utf-8") + assert str(entrypoint) in contents diff --git a/tests/client/test_skill_receiver.py b/tests/client/test_skill_receiver.py new file mode 100644 index 000000000..289341cd6 --- /dev/null +++ b/tests/client/test_skill_receiver.py @@ -0,0 +1,414 @@ +# 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. + +# Receiver tests intentionally inspect synchronous target-local filesystem effects inside compact async scenarios. +# ruff: noqa: ASYNC240 + +import asyncio +import base64 +from pathlib import Path +from typing import Literal + +import pytest +from pydantic import SecretStr + +import powercontext.client.skill_receiver as receiver_module +from powercontext.builtin.artifacts.skill import SkillContent, build_instruction_skill_package +from powercontext.client.errors import ServerResponseError, TransportError +from powercontext.client.skill_receiver import ReceiverSyncResult, RemoteSkillReceiver, RemoteSkillReceiverConfig +from powercontext.http import ( + ArtifactReference, + ReconcileRemoteSkillsResponse, + RemoteSkillAction, + RemoteSkillFailureState, + RemoteSkillObservation, + RemoteSkillOperation, + SkillPackageDownload, +) + + +class _FakeRemoteClient: + def __init__(self) -> None: + self.actions: list[list[RemoteSkillAction]] = [] + self.packages: dict[str, SkillPackageDownload] = {} + self.receipts = [] + self.fail_receipts = 0 + + async def reconcile_remote_skills(self, request): + actions = self.actions.pop(0) + return ReconcileRemoteSkillsResponse(scope_id="project:one", target_id="codex-a", actions=actions) + + async def download_remote_skill_package(self, request): + return self.packages[request.package.tree_digest] + + async def record_remote_skill_receipt(self, request): + if self.fail_receipts: + self.fail_receipts -= 1 + raise RuntimeError("simulated Receipt transport loss") # noqa: TRY003 + self.receipts.append(request) + + async def aclose(self) -> None: + return None + + +def _package(instructions: str): + return build_instruction_skill_package( + SkillContent( + name="release-check", + description="Verify the release.", + instructions=instructions, + validation=("The report passes.",), + ) + ) + + +def _install_action(package, *, revision: int = 1, generation: int = 0, expected=None): + return RemoteSkillAction( + operation=RemoteSkillOperation.INSTALL, + generation=generation, + artifact=ArtifactReference(family="skill", artifact_id="release-check-artifact", revision=revision), + tree_digest=package.reference.tree_digest, + skill_name="release-check", + package=package.reference.model_dump(mode="json"), + expected_local=expected, + blocked_error_code=None, + ) + + +def _receiver( + tmp_path: Path, + client: _FakeRemoteClient, + *, + agent_kind: Literal["codex", "claude_code"] = "codex", +) -> RemoteSkillReceiver: + return RemoteSkillReceiver( + RemoteSkillReceiverConfig( + server_url="http://127.0.0.1:8765", + target_id="codex-a", + credential=SecretStr("pct_installation-a.super-secret-target-value"), + agent_kind=agent_kind, + workspace=tmp_path, + ), + client=client, + ) + + +def _download(package) -> SkillPackageDownload: + return SkillPackageDownload( + package=package.reference.model_dump(mode="json"), + archive_base64=base64.b64encode(package.archive_bytes).decode("ascii"), + ) + + +def test_receiver_rejects_remote_cleartext_http_without_explicit_permission(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="requires HTTPS"): + RemoteSkillReceiver( + RemoteSkillReceiverConfig( + server_url="http://11.162.218.22:8765", + target_id="codex-a", + credential=SecretStr("pct_installation-a.super-secret-target-value"), + agent_kind="codex", + workspace=tmp_path, + ), + client=_FakeRemoteClient(), + ) + + +def test_receiver_allows_remote_cleartext_http_with_explicit_permission(tmp_path: Path) -> None: + receiver = RemoteSkillReceiver( + RemoteSkillReceiverConfig( + server_url="http://11.162.218.22:8765", + target_id="codex-a", + credential=SecretStr("pct_installation-a.super-secret-target-value"), + agent_kind="codex", + workspace=tmp_path, + allow_insecure_http=True, + ), + client=_FakeRemoteClient(), + ) + + assert receiver.config.allow_insecure_http is True + + +def test_receiver_forwards_cleartext_permission_to_its_owned_client( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + client_options: list[dict[str, object]] = [] + + def owned_client(*_args: object, **kwargs: object) -> _FakeRemoteClient: + client_options.append(kwargs) + return _FakeRemoteClient() + + monkeypatch.setattr(receiver_module, "PowerContextClient", owned_client) + + RemoteSkillReceiver( + RemoteSkillReceiverConfig( + server_url="http://11.162.218.22:8765", + target_id="codex-a", + credential=SecretStr("pct_installation-a.super-secret-target-value"), + agent_kind="codex", + workspace=tmp_path, + allow_insecure_http=True, + ) + ) + + assert client_options == [ + { + "token": "pct_installation-a.super-secret-target-value", + "allow_insecure_http": True, + } + ] + + +@pytest.mark.parametrize( + ("agent_kind", "relative_root"), + (("codex", ".agents/skills"), ("claude_code", ".claude/skills")), +) +def test_receiver_installs_to_agent_owned_project_root_and_recovers_a_lost_receipt( + tmp_path: Path, + agent_kind: Literal["codex", "claude_code"], + relative_root: str, +) -> None: + async def exercise() -> None: + package = _package("Run the release checks.") + client = _FakeRemoteClient() + action = _install_action(package) + client.actions = [[action], [action]] + client.packages[package.reference.tree_digest] = _download(package) + client.fail_receipts = 1 + receiver = _receiver(tmp_path, client, agent_kind=agent_kind) + + first = await receiver.sync() + destination = tmp_path / relative_root / "release-check" + assert first.receipt_pending == 1 + assert destination.is_dir() + inode = destination.stat().st_ino + + second = await receiver.sync() + assert second.succeeded == 1 + assert destination.stat().st_ino == inode + assert client.receipts[-1].observed_tree_digest == package.reference.tree_digest + assert not list((tmp_path / ".powercontext/skill-receiver/codex-a/journals").glob("*.json")) + + asyncio.run(exercise()) + + +def test_receiver_refuses_foreign_content_and_reports_conflict_without_replacing_it(tmp_path: Path) -> None: + async def exercise() -> None: + package = _package("Run the release checks.") + client = _FakeRemoteClient() + action = _install_action(package) + client.actions = [[action]] + client.packages[package.reference.tree_digest] = _download(package) + foreign = tmp_path / ".agents/skills/release-check" + foreign.mkdir(parents=True) + (foreign / "SKILL.md").write_text("foreign content", encoding="utf-8") + + result = await _receiver(tmp_path, client).sync() + + assert result.failed == 1 + assert (foreign / "SKILL.md").read_text(encoding="utf-8") == "foreign content" + assert client.receipts[-1].failure_state is RemoteSkillFailureState.CONFLICT + + asyncio.run(exercise()) + + +def test_receiver_keeps_unpublish_quarantine_until_receipt_and_finishes_after_retry(tmp_path: Path) -> None: + async def exercise() -> None: + package = _package("Run the release checks.") + client = _FakeRemoteClient() + install = _install_action(package) + client.packages[package.reference.tree_digest] = _download(package) + client.actions = [[install]] + receiver = _receiver(tmp_path, client) + await receiver.sync() + + observation = RemoteSkillObservation( + artifact=install.artifact, + tree_digest=install.tree_digest, + actual_tree_digest=install.tree_digest, + skill_name=install.skill_name, + applied_generation=install.generation, + ) + remove = RemoteSkillAction( + operation=RemoteSkillOperation.UNPUBLISH, + generation=1, + artifact=install.artifact, + tree_digest=install.tree_digest, + skill_name=install.skill_name, + package=None, + expected_local=observation, + blocked_error_code=None, + ) + retry = remove.model_copy(update={"expected_local": None}) + client.actions = [[remove], [retry]] + client.fail_receipts = 1 + + first = await receiver.sync() + assert first.receipt_pending == 1 + assert not (tmp_path / ".agents/skills/release-check").exists() + assert list(tmp_path.glob(".agents/.powercontext-quarantine-*")) + + second = await receiver.sync() + assert second.succeeded == 1 + assert not list(tmp_path.glob(".agents/.powercontext-quarantine-*")) + assert not list((tmp_path / ".powercontext/skill-receiver/codex-a/journals").glob("*.json")) + + asyncio.run(exercise()) + + +def test_receiver_reports_server_blocked_drift_without_touching_the_package(tmp_path: Path) -> None: + async def exercise() -> None: + package = _package("Run the release checks.") + client = _FakeRemoteClient() + install = _install_action(package) + client.packages[package.reference.tree_digest] = _download(package) + client.actions = [[install]] + receiver = _receiver(tmp_path, client) + await receiver.sync() + skill_markdown = tmp_path / ".agents/skills/release-check/SKILL.md" + skill_markdown.write_text("user modified", encoding="utf-8") + + blocked = install.model_copy(update={"blocked_error_code": "drifted"}) + client.actions = [[blocked]] + result = await receiver.sync() + + assert result.failed == 1 + assert skill_markdown.read_text(encoding="utf-8") == "user modified" + assert client.receipts[-1].failure_state is RemoteSkillFailureState.DRIFTED + + asyncio.run(exercise()) + + +def test_receiver_recovers_an_interrupted_atomic_update_from_its_signed_journal( + tmp_path: Path, + monkeypatch, +) -> None: + async def exercise() -> None: + first_package = _package("Run the release checks.") + second_package = _package("Run the stricter release checks.") + client = _FakeRemoteClient() + first = _install_action(first_package) + client.packages[first_package.reference.tree_digest] = _download(first_package) + client.packages[second_package.reference.tree_digest] = _download(second_package) + client.actions = [[first]] + receiver = _receiver(tmp_path, client) + await receiver.sync() + + observed = RemoteSkillObservation( + artifact=first.artifact, + tree_digest=first.tree_digest, + actual_tree_digest=first.tree_digest, + skill_name=first.skill_name, + applied_generation=first.generation, + ) + update = _install_action(second_package, revision=2, generation=1, expected=observed) + client.actions = [[update], [update]] + original_replace = receiver_module.os.replace + + def interrupt_new_package(source, destination) -> None: + source_path = Path(source) + destination_path = Path(destination) + if ( + source_path.name == "release-check" + and source_path.parent.name.startswith(".powercontext-stage-") + and destination_path == tmp_path / ".agents/skills/release-check" + ): + raise OSError("simulated interruption after quarantining the old package") # noqa: TRY003 + original_replace(source, destination) + + monkeypatch.setattr(receiver_module.os, "replace", interrupt_new_package) + interrupted = await receiver.sync() + assert interrupted.failed == 1 + assert not (tmp_path / ".agents/skills/release-check").exists() + assert list(tmp_path.glob(".agents/.powercontext-stage-*")) + assert list(tmp_path.glob(".agents/.powercontext-quarantine-*")) + + monkeypatch.setattr(receiver_module.os, "replace", original_replace) + recovered = await receiver.sync() + assert recovered.succeeded == 1 + assert "stricter" in (tmp_path / ".agents/skills/release-check/SKILL.md").read_text(encoding="utf-8") + assert not list(tmp_path.glob(".agents/.powercontext-stage-*")) + assert not list(tmp_path.glob(".agents/.powercontext-quarantine-*")) + + asyncio.run(exercise()) + + +def test_receiver_watch_retries_incomplete_and_transport_failures_with_bounded_backoff( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def exercise() -> None: + client = _FakeRemoteClient() + receiver = _receiver(tmp_path, client) + outcomes: list[ReceiverSyncResult | Exception] = [ + ReceiverSyncResult(requested=1, succeeded=0, failed=1, receipt_pending=0), + TransportError("/v1/skill/remote/reconcile"), + ReceiverSyncResult(requested=0, succeeded=0, failed=0, receipt_pending=0), + ] + results: list[ReceiverSyncResult] = [] + errors: list[tuple[Exception, float]] = [] + delays: list[float] = [] + + async def sync() -> ReceiverSyncResult: + outcome = outcomes.pop(0) + if isinstance(outcome, Exception): + raise outcome + return outcome + + async def sleep(delay: float) -> None: + delays.append(delay) + if len(delays) == 3: + raise asyncio.CancelledError + + monkeypatch.setattr(receiver, "sync", sync) + monkeypatch.setattr(receiver_module.asyncio, "sleep", sleep) + + with pytest.raises(asyncio.CancelledError): + await receiver.watch( + interval_seconds=2, + max_backoff_seconds=8, + on_result=results.append, + on_error=lambda error, delay: errors.append((error, delay)), + ) + + assert delays == [2, 4, 2] + assert [result.requested for result in results] == [1, 0] + assert len(errors) == 1 + assert isinstance(errors[0][0], TransportError) + assert errors[0][1] == 4 + + asyncio.run(exercise()) + + +def test_receiver_watch_stops_when_target_credential_is_rejected( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + async def exercise() -> None: + receiver = _receiver(tmp_path, _FakeRemoteClient()) + + async def rejected() -> ReceiverSyncResult: + raise ServerResponseError(status_code=401, request_id=None) + + async def unexpected_sleep(_delay: float) -> None: + raise AssertionError + + monkeypatch.setattr(receiver, "sync", rejected) + monkeypatch.setattr(receiver_module.asyncio, "sleep", unexpected_sleep) + + with pytest.raises(ServerResponseError, match="HTTP 401"): + await receiver.watch() + + asyncio.run(exercise()) diff --git a/tests/e2e/real_experience_skill/harness.py b/tests/e2e/real_experience_skill/harness.py index 4a061152e..3e5c7e17e 100644 --- a/tests/e2e/real_experience_skill/harness.py +++ b/tests/e2e/real_experience_skill/harness.py @@ -103,12 +103,14 @@ "pc_memory_vector_entries", "pc_memory_entry_heads", "pc_memory_entry_versions", + "pc_skill_publications", "pc_artifact_candidate_heads", "pc_artifact_candidate_versions", "pc_artifact_heads", "pc_artifact_lineage_artifacts", "pc_artifact_lineage_sources", "pc_artifacts", + "pc_skill_packages", "pc_source_cursors", "pc_external_skill_registrations", "pc_sources", @@ -394,6 +396,7 @@ def main(argv: Sequence[str] | None = None) -> int: # noqa: C901 - one exceptio server_url=server.base_url, timeout=arguments.codex_timeout, generation_timeout=configured_settings.inference.generation_timeout_seconds, + api_token=_server_api_token(configured_server_settings), ) ) server.stop() @@ -409,6 +412,7 @@ def main(argv: Sequence[str] | None = None) -> int: # noqa: C901 - one exceptio state=journey, server_url=server.base_url, generation_timeout=configured_settings.inference.generation_timeout_seconds, + api_token=_server_api_token(configured_server_settings), ) ) finally: @@ -624,21 +628,18 @@ async def _run_journey( with recorder.scenario( "exact managed Skill projects into an isolated Codex repository", "projection/SKILL.md", - "projection/powercontext.json", + "projection/files.json", ): projection = repositories["consumer"] / ".agents" / "skills" / skill_v1.content.name _project_via_cli( server_url=server_url, + api_token=None, scope_id=scope_id, skill=skill_v1, destination=projection, recorder=recorder, ) - recorder.write_text("projection/SKILL.md", (projection / "SKILL.md").read_text(encoding="utf-8")) - recorder.write_text( - "projection/powercontext.json", - (projection / "powercontext.json").read_text(encoding="utf-8"), - ) + _record_standard_projection(recorder, projection) with recorder.scenario( "next real Codex task explicitly uses the projected managed Skill", @@ -755,6 +756,7 @@ async def _run_configured_journey( server_url: str, timeout: int, # noqa: ASYNC109 - external Codex process budget, not an asyncio timeout scope generation_timeout: float, + api_token: str | None, ) -> ConfiguredJourneyState: memory_scope = scopes.memory artifact_scope = scopes.artifacts @@ -767,7 +769,7 @@ async def _run_configured_journey( generation_timeout + CONFIGURED_EXPERIENCE_SCHEDULE_SECONDS + 30.0, ) - async with PowerContextClient(server_url, timeout=max(30.0, generation_wait)) as client: + async with PowerContextClient(server_url, token=api_token, timeout=max(30.0, generation_wait)) as client: with recorder.scenario( "configured embedding model and database support vector and hybrid Memory retrieval", "api/capabilities.json", @@ -1083,21 +1085,18 @@ async def _run_configured_journey( with recorder.scenario( "exact managed Skill projects into an isolated Codex repository", "projection/SKILL.md", - "projection/powercontext.json", + "projection/files.json", ): projection = repositories["consumer"] / ".agents" / "skills" / skill_v1.content.name _project_via_cli( server_url=server_url, + api_token=api_token, scope_id=artifact_scope, skill=skill_v1, destination=projection, recorder=recorder, ) - recorder.write_text("projection/SKILL.md", (projection / "SKILL.md").read_text(encoding="utf-8")) - recorder.write_text( - "projection/powercontext.json", - (projection / "powercontext.json").read_text(encoding="utf-8"), - ) + _record_standard_projection(recorder, projection) with recorder.scenario( "a second real Codex task explicitly reuses the projected managed Skill", @@ -1551,13 +1550,14 @@ async def _verify_configured_restart( state: ConfiguredJourneyState, server_url: str, generation_timeout: float, + api_token: str | None, ) -> None: with recorder.scenario( "configured state remains exact and searchable after a clean Server restart", "api/restart-persistence.json", ): timeout = max(30.0, generation_timeout + 30.0) - async with PowerContextClient(server_url, timeout=timeout) as client: + async with PowerContextClient(server_url, token=api_token, timeout=timeout) as client: persisted_experience_values: list[ExperienceArtifact] = [] for expected in state.experience_revisions: persisted_experience_values.append( @@ -1783,12 +1783,16 @@ async def _replace_skill(client, scope_id, current, source): def _project_via_cli( *, server_url: str, + api_token: str | None, scope_id: str, skill: SkillArtifact, destination: Path, recorder: Recorder, ) -> None: uv = _required_executable("uv") + environment = dict(os.environ) + if api_token is not None: + environment["POWERCONTEXT_CLIENT_API_TOKEN"] = api_token completed = _run( [ uv, @@ -1809,10 +1813,19 @@ def _project_via_cli( skill.artifact.artifact_id, ], cwd=PROJECT_ROOT, + env=environment, ) recorder.write_text("projection/cli.stdout", completed.stdout) +def _record_standard_projection(recorder: Recorder, projection: Path) -> None: + files = sorted(path.relative_to(projection).as_posix() for path in projection.rglob("*") if path.is_file()) + _require("SKILL.md" in files, "projected managed Skill omitted the standard entrypoint") + _require("powercontext.json" not in files, "projected standard Skill package contains a private ownership sidecar") + recorder.write_text("projection/SKILL.md", (projection / "SKILL.md").read_text(encoding="utf-8")) + recorder.write_json("projection/files.json", {"files": files}) + + def _run_codex( *, recorder: Recorder, @@ -2035,6 +2048,14 @@ def _without_scheduled_processing(settings: ServerSettings) -> ServerSettings: return settings.model_copy(update={"runtime": runtime}) +def _server_api_token(settings: ServerSettings) -> str | None: + if not settings.auth.enabled: + return None + if settings.auth.token is None: + _fail("configured authenticated Server has no bearer token") + return settings.auth.token.get_secret_value() + + def _remove_existing_harness_outputs(output_root: Path) -> int: if not output_root.is_dir(): return 0 diff --git a/tests/e2e/test_candidate_review.py b/tests/e2e/test_candidate_review.py index 49ab5eac7..89e838bf9 100644 --- a/tests/e2e/test_candidate_review.py +++ b/tests/e2e/test_candidate_review.py @@ -245,7 +245,7 @@ async def scenario() -> None: asyncio.run(scenario()) -def test_candidate_cli_lists_shows_revises_approves_and_rejects( +def test_candidate_cli_lists_shows_revises_approves_and_rejects( # noqa: C901 tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -294,6 +294,10 @@ async def get_skill(self, request): assert self._client is not None return await self._client.get_skill(request) + async def download_skill_package(self, request): + assert self._client is not None + return await self._client.download_skill_package(request) + monkeypatch.setattr(client_cli, "PowerContextClient", InProcessClient) cli = create_cli([]) runner = CliRunner() diff --git a/tests/e2e/test_remote_skill_distribution.py b/tests/e2e/test_remote_skill_distribution.py new file mode 100644 index 000000000..daa346768 --- /dev/null +++ b/tests/e2e/test_remote_skill_distribution.py @@ -0,0 +1,317 @@ +# 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. + +import asyncio +import base64 +import logging +from pathlib import Path +from typing import cast + +import httpx +import pytest +from pydantic import SecretStr + +from powercontext.builtin.artifacts.skill import SkillContent, build_instruction_skill_package +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.runtime import BuiltinConfig, open_builtin_runtime +from powercontext.client import PowerContextClient, RemoteSkillReceiver, RemoteSkillReceiverConfig, ServerResponseError +from powercontext.http import ( + ApproveArtifactCandidateRequest, + CreateRemoteSkillTargetRequest, + DownloadRemoteSkillPackageRequest, + EnrollRemoteSkillTargetRequest, + ListRemoteSkillTargetsRequest, + ProposeSkillPackageRequest, + PublishRemoteSkillRequest, + ReconcileRemoteSkillsRequest, + RemoteAgentKind, + RenameRemoteSkillTargetRequest, + RevokeRemoteSkillTargetRequest, + SkillLifecycleState, + UnpublishRemoteSkillRequest, + UpdateSkillLifecycleRequest, +) +from powercontext.server.app import ServerApplication, create_app + + +def test_https_remote_receiver_http_vertical_slice_is_exact_isolated_and_reversible( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + async def scenario() -> None: + package = build_instruction_skill_package( + SkillContent( + name="release-check", + description="Verify a release before publishing it.", + instructions="Run the release verification.", + validation=("The report passes.",), + ) + ) + async with open_builtin_runtime(BuiltinConfig(database=SQLiteConfig())) as runtime: + app = create_app(application=cast(ServerApplication, runtime)) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="https://testserver", + ) as transport: + admin = PowerContextClient("https://testserver", http_client=transport) + candidate = await admin.propose_skill_package( + ProposeSkillPackageRequest( + scope_id="project:one", + archive_base64=base64.b64encode(package.archive_bytes).decode("ascii"), + ) + ) + approved = await admin.approve_artifact_candidate( + ApproveArtifactCandidateRequest( + scope_id="project:one", + candidate_id=candidate.candidate_id, + expected_version=candidate.version, + ) + ) + assert approved.result_artifact is not None + + await admin.update_skill_lifecycle( + UpdateSkillLifecycleRequest( + scope_id="project:one", + artifact_id=approved.result_artifact.artifact_id, + expected_generation=0, + lifecycle_state=SkillLifecycleState.DEPRECATED, + ) + ) + + enrollment = await admin.create_remote_skill_target( + CreateRemoteSkillTargetRequest( + scope_id="project:one", + agent_kind=RemoteAgentKind.CODEX, + display_name="Primary build machine", + ) + ) + activated = await admin.enroll_remote_skill_target( + EnrollRemoteSkillTargetRequest( + enrollment_code=enrollment.enrollment_code, + installation_id="workspace-primary", + receiver_version="0.1.0", + machine_hostname="build-host-01", + workspace_name="powercontext", + ) + ) + activated_status = await admin.list_remote_skill_targets( + ListRemoteSkillTargetsRequest(scope_id="project:one", target_id=activated.target_id) + ) + renamed = await admin.rename_remote_skill_target( + RenameRemoteSkillTargetRequest( + scope_id="project:one", + target_id=activated.target_id, + display_name="Hangzhou build machine", + expected_generation=activated_status.targets[0].target.generation, + ) + ) + assert renamed.display_name == "Hangzhou build machine" + assert renamed.machine_hostname == "build-host-01" + assert renamed.workspace_name == "powercontext" + assert renamed.target_id == activated.target_id + with pytest.raises(ServerResponseError) as lifecycle_rejected: + await admin.publish_remote_skill( + PublishRemoteSkillRequest( + scope_id="project:one", + target_id=activated.target_id, + artifact=approved.result_artifact, + expected_generation=None, + ) + ) + assert lifecycle_rejected.value.status_code == 422 + assert lifecycle_rejected.value.code == "invalid_skill_lifecycle" + publication = await admin.publish_remote_skill( + PublishRemoteSkillRequest( + scope_id="project:one", + target_id=activated.target_id, + artifact=approved.result_artifact, + expected_generation=None, + allow_deprecated=True, + ) + ) + assert publication.generation == 0 + status = await admin.list_remote_skill_targets( + ListRemoteSkillTargetsRequest(scope_id="project:one", target_id=activated.target_id) + ) + assert len(status.targets) == 1 + assert status.targets[0].target.target_id == activated.target_id + assert status.targets[0].target.display_name == "Hangzhou build machine" + assert status.targets[0].publications == [publication] + + other_enrollment = await admin.create_remote_skill_target( + CreateRemoteSkillTargetRequest( + scope_id="project:one", + agent_kind=RemoteAgentKind.CLAUDE_CODE, + display_name="Other test machine", + ) + ) + other = await admin.enroll_remote_skill_target( + EnrollRemoteSkillTargetRequest( + enrollment_code=other_enrollment.enrollment_code, + installation_id="workspace-other", + receiver_version="0.1.0", + ) + ) + target_client = PowerContextClient( + "https://testserver", + token=activated.credential, + http_client=transport, + ) + other_client = PowerContextClient( + "https://testserver", + token=other.credential, + http_client=transport, + ) + pending = await target_client.reconcile_remote_skills( + ReconcileRemoteSkillsRequest(observations=[], receiver_version="0.1.0") + ) + install = pending.actions[0] + assert install.package is not None + with pytest.raises(ServerResponseError) as denied: + await other_client.download_remote_skill_package( + DownloadRemoteSkillPackageRequest( + generation=install.generation, + artifact=install.artifact, + package=install.package, + ) + ) + assert denied.value.status_code == 401 + + receiver = RemoteSkillReceiver( + RemoteSkillReceiverConfig( + server_url="https://testserver", + target_id=activated.target_id, + credential=SecretStr(activated.credential), + agent_kind="codex", + workspace=tmp_path, + ), + client=target_client, + ) + installed = await receiver.sync() + assert installed.succeeded == 1 + assert (tmp_path / ".agents/skills/release-check/SKILL.md").is_file() + assert (await receiver.sync()).requested == 0 + + desired_absence = await admin.unpublish_remote_skill( + UnpublishRemoteSkillRequest( + scope_id="project:one", + target_id=activated.target_id, + artifact_id=approved.result_artifact.artifact_id, + expected_generation=publication.generation, + ) + ) + assert desired_absence.generation == 1 + removed = await receiver.sync() + assert removed.succeeded == 1 + assert not (tmp_path / ".agents/skills/release-check").exists() + + current = await admin.list_remote_skill_targets( + ListRemoteSkillTargetsRequest(scope_id="project:one", target_id=activated.target_id) + ) + await admin.revoke_remote_skill_target( + RevokeRemoteSkillTargetRequest( + scope_id="project:one", + target_id=activated.target_id, + expected_generation=current.targets[0].target.generation, + ) + ) + caplog.clear() + with ( + caplog.at_level(logging.WARNING, logger="powercontext.server.app"), + pytest.raises(ServerResponseError) as rejected, + ): + await target_client.reconcile_remote_skills( + ReconcileRemoteSkillsRequest(observations=[], receiver_version="0.1.0") + ) + + record = next( + record + for record in caplog.records + if getattr(record, "operation", None) == "reconcile_remote_skills" + ) + assert rejected.value.status_code == 401 + assert getattr(record, "error_code", None) == "invalid_target_credential" + assert record.exc_info is None + assert "Traceback" not in caplog.text + + asyncio.run(scenario()) + + +def test_remote_enrollment_rejects_non_loopback_cleartext_http() -> None: + async def scenario() -> None: + async with open_builtin_runtime(BuiltinConfig(database=SQLiteConfig())) as runtime: + app = create_app(application=cast(ServerApplication, runtime)) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + ) as transport: + client = PowerContextClient( + "http://testserver", + http_client=transport, + trust_transport_security=True, + ) + enrollment = await client.create_remote_skill_target( + CreateRemoteSkillTargetRequest( + scope_id="project:one", + agent_kind=RemoteAgentKind.CODEX, + display_name="Rejected HTTP machine", + ) + ) + with pytest.raises(ServerResponseError) as denied: + await client.enroll_remote_skill_target( + EnrollRemoteSkillTargetRequest( + enrollment_code=enrollment.enrollment_code, + installation_id="workspace-primary", + receiver_version="0.1.0", + ) + ) + assert denied.value.status_code == 422 + + asyncio.run(scenario()) + + +def test_remote_enrollment_allows_non_loopback_cleartext_http_only_after_server_opt_in() -> None: + async def scenario() -> None: + async with open_builtin_runtime(BuiltinConfig(database=SQLiteConfig())) as runtime: + app = create_app( + application=cast(ServerApplication, runtime), + allow_insecure_remote_http=True, + ) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + ) as transport: + client = PowerContextClient( + "http://testserver", + http_client=transport, + trust_transport_security=True, + ) + enrollment = await client.create_remote_skill_target( + CreateRemoteSkillTargetRequest( + scope_id="project:one", + agent_kind=RemoteAgentKind.CODEX, + display_name="Private HTTP machine", + ) + ) + + enrolled = await client.enroll_remote_skill_target( + EnrollRemoteSkillTargetRequest( + enrollment_code=enrollment.enrollment_code, + installation_id="workspace-primary", + receiver_version="0.1.0", + ) + ) + + assert enrolled.target_id == enrollment.target.target_id + + asyncio.run(scenario()) diff --git a/tests/e2e/test_standard_skill_lifecycle.py b/tests/e2e/test_standard_skill_lifecycle.py new file mode 100644 index 000000000..be59e9d54 --- /dev/null +++ b/tests/e2e/test_standard_skill_lifecycle.py @@ -0,0 +1,317 @@ +# 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. + +from __future__ import annotations + +import base64 +import io +import zipfile +from pathlib import Path +from typing import Any + +from fastapi.testclient import TestClient + +from powercontext.builtin.artifacts.skill import AgentSkillTarget, capture_skill_archive +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.runtime.config import ExternalSkillsConfig +from powercontext.server.factory import create_server_app +from powercontext.server.settings import DashboardConfig, DashboardScopeConfig, McpConfig, ServerSettings + +_SCOPE = "project:standard-skill" + + +def test_standard_skill_package_review_revision_usage_governance_and_publication(tmp_path: Path) -> None: + codex_root = tmp_path / "repo" / ".agents" / "skills" + claude_root = tmp_path / "repo" / ".claude" / "skills" + app = create_server_app( + settings=ServerSettings( + dashboard=DashboardConfig( + enabled=True, + scopes=[DashboardScopeConfig(scope_id=_SCOPE, display_name="Standard Skill")], + ), + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'standard-skill.db'}"), + external_skills=ExternalSkillsConfig( + host_id="standard-skill-test", + targets=( + AgentSkillTarget( + target_id="codex-project", + agent_kind="codex", + installation_scope="project", + path=codex_root, + allow_managed_publish=True, + ), + AgentSkillTarget( + target_id="claude-project", + agent_kind="claude_code", + installation_scope="project", + path=claude_root, + allow_managed_publish=True, + ), + ), + ), + mcp=McpConfig(enabled=False), + ) + ) + first_archive = _skill_archive("Run the exact checks.", "reference-search-needle") + second_archive = _skill_archive("Run the exact checks, then inspect the report.", "successor-search-needle") + + with TestClient(app) as client: + first_candidate = _propose_package(client, first_archive) + first_approved = _approve(client, first_candidate) + first_ref = first_approved["result_artifact"] + + manifest = client.post( + "/v1/skill/package/manifest", + json={"scope_id": _SCOPE, "artifact": first_ref}, + ) + download = client.post( + "/v1/skill/package/download", + json={"scope_id": _SCOPE, "artifact": first_ref}, + ) + searched_reference = client.post( + "/v1/skill/library", + json={"scope_id": _SCOPE, "query": "reference-search-needle", "limit": 20}, + ) + searched_script_body = client.post( + "/v1/skill/library", + json={"scope_id": _SCOPE, "query": "script-body-must-not-be-indexed", "limit": 20}, + ) + + second_candidate = _propose_package(client, second_archive, target=first_ref) + second_approved = _approve(client, second_candidate) + second_ref = second_approved["result_artifact"] + second_package = second_candidate["proposal"]["package"] + + task_source = client.post( + "/v1/sources/content", + json={ + "scope_id": _SCOPE, + "source_id": "task-outcome-1", + "content": "The release verification completed successfully.", + }, + ).json()["source"] + usage_payload = { + "scope_id": _SCOPE, + "observation_id": "usage-1", + "skill_ref": second_ref, + "package_digest": f"sha256:{second_package['tree_digest']}", + "target_id": "codex-project", + "selected": True, + "invoked": "true", + "validation": "passed", + "outcome": "success", + "task_source": task_source, + "environment_fingerprint": f"sha256:{'a' * 64}", + } + usage = client.post("/v1/skill/usage", json=usage_payload) + usage_retry = client.post("/v1/skill/usage", json=usage_payload) + usage_conflict = client.post( + "/v1/skill/usage", + json={**usage_payload, "outcome": "failure"}, + ) + usage_wrong_digest = client.post( + "/v1/skill/usage", + json={**usage_payload, "observation_id": "usage-wrong", "package_digest": f"sha256:{'b' * 64}"}, + ) + + selection = {"scope_id": _SCOPE, "candidate_id": None, "artifact": second_ref} + codex_published = client.post( + "/dashboard/skill-projections/publish", + json={**selection, "target_id": "codex-project"}, + ) + claude_published = client.post( + "/dashboard/skill-projections/publish", + json={**selection, "target_id": "claude-project"}, + ) + + deprecated = client.post( + "/v1/skill/lifecycle", + json={ + "scope_id": _SCOPE, + "artifact_id": second_ref["artifact_id"], + "expected_generation": 0, + "lifecycle_state": "deprecated", + "replacement_artifact_id": None, + }, + ) + stale_lifecycle = client.post( + "/v1/skill/lifecycle", + json={ + "scope_id": _SCOPE, + "artifact_id": second_ref["artifact_id"], + "expected_generation": 0, + "lifecycle_state": "active", + "replacement_artifact_id": None, + }, + ) + active_library = client.post( + "/v1/skill/library", + json={"scope_id": _SCOPE, "include_deprecated": False, "limit": 20}, + ) + governed_library = client.post( + "/v1/skill/library", + json={"scope_id": _SCOPE, "include_deprecated": True, "limit": 20}, + ) + + codex_unpublished = client.post( + "/dashboard/skill-projections/unpublish", + json={**selection, "target_id": "codex-project"}, + ) + deprecated_without_override = client.post( + "/dashboard/skill-projections/publish", + json={**selection, "target_id": "codex-project"}, + ) + deprecated_with_override = client.post( + "/dashboard/skill-projections/publish", + json={**selection, "target_id": "codex-project", "allow_deprecated": True}, + ) + + claude_entrypoint = claude_root / "release-verification" / "SKILL.md" + original_entrypoint = claude_entrypoint.read_bytes() + claude_entrypoint.write_bytes(original_entrypoint + b"\nlocal drift\n") + drifted = client.post("/dashboard/skill-projections/status", json=selection) + drifted_unpublish = client.post( + "/dashboard/skill-projections/unpublish", + json={**selection, "target_id": "claude-project"}, + ) + + retired = client.post( + "/v1/skill/lifecycle", + json={ + "scope_id": _SCOPE, + "artifact_id": second_ref["artifact_id"], + "expected_generation": 1, + "lifecycle_state": "retired", + "replacement_artifact_id": None, + }, + ) + reverse_retirement = client.post( + "/v1/skill/lifecycle", + json={ + "scope_id": _SCOPE, + "artifact_id": second_ref["artifact_id"], + "expected_generation": 2, + "lifecycle_state": "active", + "replacement_artifact_id": None, + }, + ) + + assert first_candidate["proposal"]["package"]["file_count"] == 3 + assert manifest.status_code == 200 + assert [(item["path"], item["executable"]) for item in manifest.json()["files"]] == [ + ("SKILL.md", False), + ("references/runbook.md", False), + ("scripts/check.sh", True), + ] + assert download.status_code == 200 + downloaded = capture_skill_archive(base64.b64decode(download.json()["archive_base64"], validate=True)) + assert downloaded.reference.model_dump(mode="json") == first_candidate["proposal"]["package"] + assert len(searched_reference.json()["skills"]) == 1 + assert searched_script_body.json()["skills"] == [] + + assert second_ref == {**first_ref, "revision": first_ref["revision"] + 1} + assert second_candidate["target"] == first_ref + assert second_candidate["artifact_refs"] == [first_ref] + assert second_package["tree_digest"] != first_candidate["proposal"]["package"]["tree_digest"] + + assert usage.status_code == 201 + assert usage.json()["source"] == {"name": "skill-usage", "source_id": "usage-1"} + assert usage_retry.status_code == 201 + assert usage_retry.json()["position"] == usage.json()["position"] + assert usage_conflict.status_code == 409 + assert usage_wrong_digest.status_code == 422 + + assert codex_published.status_code == 200 + assert claude_published.status_code == 200 + assert (codex_root / "release-verification" / "scripts" / "check.sh").read_bytes() == ( + b"#!/bin/sh\n# script-body-must-not-be-indexed\nexit 0\n" + ) + assert (codex_root / "release-verification" / "scripts" / "check.sh").stat().st_mode & 0o111 + assert not (codex_root / "release-verification" / "powercontext.json").exists() + + assert deprecated.status_code == 200 + assert deprecated.json()["governance_generation"] == 1 + assert stale_lifecycle.status_code == 409 + assert active_library.json()["skills"] == [] + assert governed_library.json()["skills"][0]["governance"]["lifecycle_state"] == "deprecated" + assert codex_unpublished.status_code == 200 + assert deprecated_without_override.status_code == 422 + assert deprecated_with_override.status_code == 200 + + assert drifted.status_code == 200 + assert drifted.json()["targets"][1]["state"] == "drifted" + assert drifted_unpublish.status_code == 409 + assert claude_entrypoint.read_bytes().endswith(b"local drift\n") + assert retired.status_code == 200 + assert retired.json()["lifecycle_state"] == "retired" + assert reverse_retirement.status_code == 422 + + +def _propose_package( + client: TestClient, + archive: bytes, + *, + target: dict[str, Any] | None = None, +) -> dict[str, Any]: + response = client.post( + "/v1/skill/package/propose", + json={ + "scope_id": _SCOPE, + "archive_base64": base64.b64encode(archive).decode("ascii"), + "reason": "Review the complete standard package.", + "target": target, + }, + ) + assert response.status_code == 201, response.text + return response.json() + + +def _approve(client: TestClient, candidate: dict[str, Any]) -> dict[str, Any]: + response = client.post( + "/v1/artifact-candidates/approve", + json={ + "scope_id": _SCOPE, + "candidate_id": candidate["candidate_id"], + "expected_version": candidate["version"], + }, + ) + assert response.status_code == 200, response.text + return response.json() + + +def _skill_archive(instructions: str, reference_marker: str) -> bytes: + entrypoint = ( + "---\n" + "name: release-verification\n" + "description: Verify a release from exact reviewed evidence.\n" + "license: Apache-2.0\n" + "compatibility: Works with Codex and Claude Code.\n" + "metadata:\n" + " owner: release-engineering\n" + "allowed-tools: Bash Read\n" + "---\n\n" + f"{instructions}\n" + ).encode() + files = { + "SKILL.md": (entrypoint, 0o100644), + "references/runbook.md": (f"Release runbook: {reference_marker}\n".encode(), 0o100644), + "scripts/check.sh": (b"#!/bin/sh\n# script-body-must-not-be-indexed\nexit 0\n", 0o100755), + } + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for path, (content, mode) in reversed(tuple(files.items())): + entry = zipfile.ZipInfo(path) + entry.external_attr = mode << 16 + archive.writestr(entry, content) + return buffer.getvalue() diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index 537e827ef..71fcec10f 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -117,7 +117,7 @@ def test_contract_uses_the_namespaced_request_id_header() -> None: assert "X-Request-ID" not in contract -def test_contract_declares_optional_bearer_authentication() -> None: +def test_contract_declares_server_and_remote_target_bearer_boundaries() -> None: contract = yaml.safe_load(CONTRACT_PATH.read_text()) assert contract["security"] == [{"BearerAuth": []}, {}] @@ -126,12 +126,25 @@ def test_contract_declares_optional_bearer_authentication() -> None: "scheme": "bearer", "description": "Static bearer token used when local Server authentication is enabled.", } + assert contract["components"]["securitySchemes"]["TargetBearerAuth"] == { + "type": "http", + "scheme": "bearer", + "description": "Per-target credential issued once during remote Receiver enrollment.", + } + public_paths = {"/health/live", "/health/ready", "/v1/skill/remote/target/enroll"} + target_paths = { + "/v1/skill/remote/reconcile", + "/v1/skill/remote/package/download", + "/v1/skill/remote/receipt", + } for path, path_item in contract["paths"].items(): operation = next(iter(path_item.values())) - if path.startswith("/health/"): + if path in public_paths: assert operation["security"] == [] else: assert operation["responses"]["401"] == {"$ref": "#/components/responses/Unauthorized"} + if path in target_paths: + assert operation["security"] == [{"TargetBearerAuth": []}] def test_capabilities_report_semantics_without_runtime_tuning_values() -> None: @@ -263,6 +276,11 @@ def test_experience_skill_and_review_operations_are_typed_and_family_routed() -> "description", "instructions", "validation", + "package", + "license", + "compatibility", + "metadata", + "allowed_tools", } assert schemas["ListArtifactCandidatesRequest"]["properties"]["limit"] == { "type": "integer", diff --git a/tests/test_cli.py b/tests/test_cli.py index aaa65eabb..c87fe69db 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json import os from importlib.metadata import version from pathlib import Path @@ -27,8 +28,12 @@ import powercontext.client.cli as client_cli from powercontext.cli.app import create_cli from powercontext.client import ServerResponseError +from powercontext.client.receiver_service import ReceiverServiceInstallation from powercontext.client.settings import ClientSettings +from powercontext.client.skill_receiver import ReceiverSyncResult, RemoteSkillReceiverConfig from powercontext.http import ( + ArtifactReference, + EnrollRemoteSkillTargetRequest, ExperienceProposal, ExternalSkillImportMode, GeneratedCandidateResponse, @@ -39,13 +44,22 @@ GetStatsRequest, HealthResponse, ImportExternalSkillRequest, + ListRemoteSkillTargetsRequest, + ListRemoteSkillTargetsResponse, + PublishRemoteSkillRequest, ReadinessResponse, + RemoteAgentKind, + RemoteSkillPublication, + RemoteSkillTarget, + RemoteSkillTargetCredential, ReviseArtifactCandidateRequest, + RevokeRemoteSkillTargetRequest, ScopedStats, SkillArtifact, SkillGenerationOrigin, SkillProposal, SkillValidationItem, + UnpublishRemoteSkillRequest, ) from powercontext.server.cli import app as server_app @@ -149,6 +163,379 @@ def test_skill_cli_exposes_the_target_based_export_command() -> None: assert "codex" in export_help_text +def test_skill_cli_exposes_complete_remote_distribution_commands() -> None: + runner = CliRunner() + result = runner.invoke(create_cli([]), ["skill", "--help"]) + enrollment_help = runner.invoke(create_cli([]), ["skill", "remote-enroll", "--help"]) + + assert result.exit_code == 0 + assert enrollment_help.exit_code == 0 + help_text = unstyle(result.output) + assert all( + command in help_text + for command in ( + "remote-status", + "remote-target-create", + "remote-target-rename", + "remote-target-revoke", + "remote-enroll", + "remote-publish", + "remote-service-install", + "remote-service-uninstall", + "remote-unpublish", + "remote-sync", + "remote-watch", + ) + ) + assert "--install-service" in unstyle(enrollment_help.output) + assert "--allow-insecure-http" in unstyle(enrollment_help.output) + + +def test_remote_service_install_uses_enrolled_receiver_config( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_file = tmp_path / "remote-skill-target.json" + config = Mock(target_id="codex-a") + unit = ReceiverServiceInstallation( + unit_name="powercontext-skill-receiver-codex-a.service", + unit_path=tmp_path / "powercontext-skill-receiver-codex-a.service", + ) + received: list[tuple[Path, object, float]] = [] + monkeypatch.setattr(client_cli, "_read_receiver_config", lambda path: config if path == config_file else None) + monkeypatch.setattr( + client_cli, + "install_systemd_user_service", + lambda path, value, *, interval_seconds: received.append((path, value, interval_seconds)) or unit, + ) + + result = CliRunner().invoke( + create_cli([]), + ["skill", "remote-service-install", "--config-file", str(config_file), "--interval", "3"], + ) + + assert result.exit_code == 0 + assert received == [(config_file, config, 3)] + assert unit.unit_name in result.output + + +def test_remote_enroll_can_install_automatic_service_in_one_command( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + enrolled = RemoteSkillTargetCredential( + scope_id="project", + target_id="codex-a", + agent_kind=RemoteAgentKind.CODEX, + credential="pct_target.super-secret-target-value", + ) + unit = ReceiverServiceInstallation( + unit_name="powercontext-skill-receiver-codex-a.service", + unit_path=tmp_path / "powercontext-skill-receiver-codex-a.service", + ) + installed: list[tuple[Path, RemoteSkillReceiverConfig, float]] = [] + enrollment_requests: list[EnrollRemoteSkillTargetRequest] = [] + + class EnrollmentClient: + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + async def enroll_remote_skill_target( + self, + request: EnrollRemoteSkillTargetRequest, + ) -> RemoteSkillTargetCredential: + enrollment_requests.append(request) + return enrolled + + monkeypatch.setattr(client_cli, "PowerContextClient", lambda *_args, **_kwargs: EnrollmentClient()) + monkeypatch.setattr(client_cli.socket, "gethostname", lambda: "build-host-01") + monkeypatch.setattr( + client_cli, + "install_systemd_user_service", + lambda path, config, *, interval_seconds: installed.append((path, config, interval_seconds)) or unit, + ) + workspace = tmp_path / "project" + + result = CliRunner().invoke( + create_cli([]), + [ + "--server-url", + "http://127.0.0.1:8765", + "skill", + "remote-enroll", + "--workspace", + str(workspace), + "--enrollment-code", + "e" * 32, + "--install-service", + "--watch-interval", + "3", + ], + ) + + config_file = workspace / ".powercontext/remote-skill-target.json" + assert result.exit_code == 0 + assert config_file.stat().st_mode & 0o777 == 0o600 + assert len(installed) == 1 + assert installed[0][0] == config_file + assert installed[0][1].target_id == enrolled.target_id + assert installed[0][2] == 3 + assert len(enrollment_requests) == 1 + assert enrollment_requests[0].machine_hostname == "build-host-01" + assert enrollment_requests[0].workspace_name == "project" + assert unit.unit_name in result.output + + +def test_remote_enroll_requires_and_persists_explicit_cleartext_http_permission( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + enrolled = RemoteSkillTargetCredential( + scope_id="project", + target_id="codex-a", + agent_kind=RemoteAgentKind.CODEX, + credential="pct_target.super-secret-target-value", + ) + enrollment_calls = 0 + client_options: list[dict[str, object]] = [] + + class EnrollmentClient: + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + async def enroll_remote_skill_target(self, _request: object) -> RemoteSkillTargetCredential: + nonlocal enrollment_calls + enrollment_calls += 1 + return enrolled + + def enrollment_client(*_args: object, **kwargs: object) -> EnrollmentClient: + client_options.append(kwargs) + return EnrollmentClient() + + monkeypatch.setattr(client_cli, "PowerContextClient", enrollment_client) + runner = CliRunner() + workspace = tmp_path / "project" + arguments = [ + "--server-url", + "http://11.162.218.22:8765", + "skill", + "remote-enroll", + "--workspace", + str(workspace), + "--enrollment-code", + "e" * 32, + ] + + rejected = runner.invoke(create_cli([]), arguments) + + assert rejected.exit_code == 2 + assert "requires HTTPS" in rejected.output + assert enrollment_calls == 0 + + accepted = runner.invoke(create_cli([]), [*arguments, "--allow-insecure-http"]) + + config = json.loads((workspace / ".powercontext/remote-skill-target.json").read_text(encoding="utf-8")) + assert accepted.exit_code == 0 + assert enrollment_calls == 1 + assert client_options == [{"timeout": 10.0, "allow_insecure_http": True}] + assert config["allow_insecure_http"] is True + assert "WARNING" in accepted.output + + +@pytest.mark.parametrize( + ("arguments", "sync_result", "expected_output"), + ( + ( + ["skill", "remote-sync"], + ReceiverSyncResult(requested=1, succeeded=0, failed=1, receipt_pending=0), + "0 succeeded, 1 failed", + ), + ( + ["--json", "skill", "remote-sync"], + ReceiverSyncResult(requested=1, succeeded=1, failed=0, receipt_pending=1), + '"receipt_pending": 1', + ), + ), +) +def test_remote_sync_exits_nonzero_when_convergence_is_incomplete( + monkeypatch: pytest.MonkeyPatch, + arguments: list[str], + sync_result: ReceiverSyncResult, + expected_output: str, +) -> None: + class IncompleteReceiver: + def __init__(self, _config: object) -> None: + pass + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + async def sync(self) -> ReceiverSyncResult: + return sync_result + + monkeypatch.setattr(client_cli, "_read_receiver_config", lambda _path: Mock()) + monkeypatch.setattr(client_cli, "RemoteSkillReceiver", IncompleteReceiver) + + result = CliRunner().invoke(create_cli([]), arguments) + + assert result.exit_code == 1 + assert expected_output in result.output + + +def test_remote_distribution_cli_resolves_cas_generations_and_prints_status( + monkeypatch: pytest.MonkeyPatch, +) -> None: + target = RemoteSkillTarget.model_validate({ + "scope_id": "project", + "target_id": "codex-a", + "display_name": "Hangzhou build machine", + "agent_kind": "codex", + "installation_scope": "project", + "delivery_mode": "agent_pull", + "installation_id": "workspace-a", + "state": "active", + "receiver_version": "0.1.0", + "environment_fingerprint": None, + "machine_hostname": "build-host-01", + "workspace_name": "powercontext", + "last_seen_at": "2026-08-24T12:00:00Z", + "generation": 3, + }) + publication = RemoteSkillPublication.model_validate({ + "scope_id": "project", + "target_id": "codex-a", + "artifact_id": "release-check", + "desired_state": "published", + "desired_revision": 1, + "desired_tree_digest": "a" * 64, + "observed_revision": 1, + "observed_tree_digest": "a" * 64, + "observed_generation": 7, + "state": "current", + "last_error_code": None, + "observed_at": "2026-08-24T12:00:00Z", + "generation": 7, + }) + status = ListRemoteSkillTargetsResponse.model_validate({ + "targets": [ + { + "target": target, + "publications": [ + publication, + { + **publication.model_dump(mode="json"), + "artifact_id": "pending-check", + "observed_revision": None, + "observed_tree_digest": None, + "observed_generation": None, + "state": "pending", + "observed_at": None, + }, + ], + } + ] + }) + received: list[object] = [] + + class RemoteAdminClient: + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *_args) -> None: + return None + + async def list_remote_skill_targets( + self, + request: ListRemoteSkillTargetsRequest, + ) -> ListRemoteSkillTargetsResponse: + received.append(request) + return status + + async def publish_remote_skill(self, request: PublishRemoteSkillRequest) -> RemoteSkillPublication: + received.append(request) + return RemoteSkillPublication.model_validate({ + **publication.model_dump(mode="json"), + "desired_revision": 2, + "state": "pending", + "generation": 8, + }) + + async def unpublish_remote_skill(self, request: UnpublishRemoteSkillRequest) -> RemoteSkillPublication: + received.append(request) + return RemoteSkillPublication.model_validate({ + **publication.model_dump(mode="json"), + "desired_state": "unpublished", + "state": "pending", + "generation": 8, + }) + + async def revoke_remote_skill_target(self, request: RevokeRemoteSkillTargetRequest) -> RemoteSkillTarget: + received.append(request) + return RemoteSkillTarget.model_validate({ + **target.model_dump(mode="json"), + "state": "revoked", + "generation": 4, + }) + + monkeypatch.setattr(client_cli, "PowerContextClient", lambda *_args, **_kwargs: RemoteAdminClient()) + cli = create_cli([]) + runner = CliRunner() + + shown = runner.invoke(cli, ["skill", "remote-status", "--scope-id", "project"]) + published = runner.invoke( + cli, + [ + "skill", + "remote-publish", + "--scope-id", + "project", + "--target-id", + "codex-a", + "--revision", + "2", + "release-check", + ], + ) + unpublished = runner.invoke( + cli, + [ + "skill", + "remote-unpublish", + "--scope-id", + "project", + "--target-id", + "codex-a", + "release-check", + ], + ) + revoked = runner.invoke( + cli, + ["skill", "remote-target-revoke", "--scope-id", "project", "codex-a"], + ) + + assert all(result.exit_code == 0 for result in (shown, published, unpublished, revoked)) + assert "release-check: desired=published revision 1, observed=revision 1, state=current" in shown.output + assert "pending-check: desired=published revision 1, observed=not reported, state=pending" in shown.output + publish_request = next(item for item in received if isinstance(item, PublishRemoteSkillRequest)) + assert publish_request.artifact == ArtifactReference(family="skill", artifact_id="release-check", revision=2) + assert publish_request.expected_generation == 7 + unpublish_request = next(item for item in received if isinstance(item, UnpublishRemoteSkillRequest)) + assert unpublish_request.expected_generation == 7 + revoke_request = next(item for item in received if isinstance(item, RevokeRemoteSkillTargetRequest)) + assert revoke_request.expected_generation == 3 + assert "next remote-sync" in published.output + assert "state=revoked" in revoked.output + + def test_cli_version_reports_the_installed_distribution() -> None: installed_version = CliRunner().invoke(create_cli([]), ["--version"]) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 41c3efd1d..16eebd6f0 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -14,13 +14,14 @@ from __future__ import annotations +import asyncio import logging from pathlib import Path from fastapi.testclient import TestClient from pydantic import SecretStr -from powercontext.builtin.artifacts.skill import AgentSkillTarget +from powercontext.builtin.artifacts.skill import AgentSkillTarget, CodexSkillRoot, Skill, SkillContent from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.runtime.config import ExternalSkillsConfig, HandoffReportConfig from powercontext.server.factory import create_server_app @@ -31,6 +32,7 @@ McpConfig, ServerSettings, ) +from powercontext.server.web import _skill_projection_response _AUTH_HEADERS = {"Authorization": "Bearer dashboard-secret"} @@ -41,6 +43,8 @@ def test_dashboard_is_enabled_by_default_without_authentication_or_scopes(tmp_pa "POWERCONTEXT_SERVER_AUTH_TOKEN", "POWERCONTEXT_SERVER_DASHBOARD_ENABLED", "POWERCONTEXT_SERVER_DASHBOARD_SCOPES", + "POWERCONTEXT_SERVER_PUBLIC_URL", + "POWERCONTEXT_SERVER_ALLOW_INSECURE_HTTP", ): monkeypatch.delenv(name, raising=False) settings = ServerSettings( @@ -60,7 +64,9 @@ def test_dashboard_is_enabled_by_default_without_authentication_or_scopes(tmp_pa assert home.status_code == 200 assert skills.status_code == 200 assert review.status_code == 200 - assert 'class="server-content" id="skills-library"' in skills.text + assert 'id="skills-library"' in skills.text + assert 'data-public-server-url=""' in skills.text + assert 'data-allow-insecure-http="false"' in skills.text assert 'class="server-content" id="review-inbox"' in review.text assert 'data-server-session="active"' in home.text assert 'data-server-auth-required="false"' in home.text @@ -68,6 +74,25 @@ def test_dashboard_is_enabled_by_default_without_authentication_or_scopes(tmp_pa assert scopes.json() == [] +def test_dashboard_exposes_explicit_insecure_http_enrollment_guidance(tmp_path) -> None: + app = create_server_app( + settings=ServerSettings( + public_url="http://11.162.218.22:8765", + allow_insecure_http=True, + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'dashboard-http.db'}"), + mcp=McpConfig(enabled=False), + ) + ) + + with TestClient(app) as client: + skills = client.get("/skills") + + assert skills.status_code == 200 + assert 'data-public-server-url="http://11.162.218.22:8765"' in skills.text + assert 'data-allow-insecure-http="true"' in skills.text + assert 'id="skills-insecure-http-warning"' in skills.text + + def test_dashboard_can_be_disabled_explicitly(tmp_path) -> None: app = create_server_app( settings=ServerSettings( @@ -114,6 +139,7 @@ def fail_to_mount(*_args, **_kwargs) -> None: def test_dashboard_is_the_authenticated_server_ui_entry(tmp_path) -> None: app = create_server_app( settings=ServerSettings( + public_url="https://powercontext.example.com/base/", auth=BearerAuthConfig( enabled=True, token=SecretStr("dashboard-secret"), @@ -167,9 +193,35 @@ def test_dashboard_is_the_authenticated_server_ui_entry(tmp_path) -> None: assert 'id="skills-list" role="listbox"' in skills.text assert 'id="skills-managed-content"' in skills.text assert 'id="skills-delivery"' in skills.text + assert 'id="skills-delivery-mode"' in skills.text + assert '