diff --git a/docs/en/docs/reference/configuration.md b/docs/en/docs/reference/configuration.md index 88034095e..a574dc68a 100644 --- a/docs/en/docs/reference/configuration.md +++ b/docs/en/docs/reference/configuration.md @@ -1,6 +1,6 @@ --- title: Configuration -description: PowerContext paths, Server, Client, inference, and Codex environment variables. +description: PowerContext paths, Server, Client, inference, and Agent integration environment variables. --- # Configuration @@ -52,7 +52,7 @@ Server settings use the `POWERCONTEXT_SERVER_` prefix. | `POWERCONTEXT_SERVER_INFERENCE_GENERATION_TIMEOUT_SECONDS` | `30` | Generation timeout | | `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 Codex Skill roots | +| `POWERCONTEXT_SERVER_EXTERNAL_SKILLS` | unset | JSON object 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 should remain on a @@ -105,35 +105,54 @@ The same configured generation model gates explicit Experience generation, manag 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. -Experience incubation is a separate APScheduler job with its own persisted Source cursor. It requires both -`POWERCONTEXT_SERVER_RUNTIME_EXPERIENCE_SCHEDULE_SECONDS` and -`POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL`. Each activation inspects at most 32 Sources and exposes only Content -Sources whose metadata contains `"kind": "task-outcome"`. The Memory and Experience jobs share the scheduler sidecar -under `POWERCONTEXT_HOME`, but keep independent job identities and business cursors. Unsetting one interval removes -only that job. See [Create and review an Experience](../how-to/create-and-review-experience.md) for setup and -verification steps. +Experience incubation is a separate APScheduler job with its own persisted Source cursor. Enable it with: -### External Codex Skills +```bash +export POWERCONTEXT_SERVER_RUNTIME_EXPERIENCE_SCHEDULE_SECONDS=30 +export POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL=provider:model-name +powercontext server run +``` -Configure host-local roots as one JSON value: +Each activation inspects a fixed window of at most 32 Sources and exposes only Content Sources whose metadata contains +`"kind": "task-outcome"` to the model. It creates pending Experience Candidates in the Review Inbox; it does not +approve them, place them in PreparedContext, create a managed Skill, export it to an Agent target, or execute anything. +The Memory and Experience jobs share the APScheduler sidecar under `POWERCONTEXT_HOME`, but keep independent job +identities and business cursors. Unsetting one interval removes only that job. +See [Create and review an Experience](../how-to/create-and-review-experience.md) for setup and verification steps. + +### Agent Skill targets + +Configure Codex and Claude Code host-local targets as one JSON value: ```bash export POWERCONTEXT_SERVER_EXTERNAL_SKILLS='{ "host_id": "workstation-1", - "codex_roots": [ + "targets": [ + { + "target_id": "codex-project", + "agent_kind": "codex", + "installation_scope": "project", + "path": "/srv/project/.agents/skills", + "allow_managed_publish": true + }, { - "root_id": "repository", + "target_id": "claude-project", + "agent_kind": "claude_code", "installation_scope": "project", - "path": "/srv/project/.agents/skills" + "path": "/srv/project/.claude/skills", + "allow_managed_publish": true } ] }' ``` -Root IDs must be unique. Supported installation scopes are `user`, `project`, and `plugin`. PowerContext scans only -the immediate Skill package directories under these explicit roots; it does not infer a home directory, install -packages, or grant execution authority. The `host_id`, locator, and registration are local-environment state, not a -cross-host or cross-Agent contract. +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 +`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 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 diff --git a/docs/en/docs/reference/interfaces.md b/docs/en/docs/reference/interfaces.md index 4fa439c03..b88b4faf5 100644 --- a/docs/en/docs/reference/interfaces.md +++ b/docs/en/docs/reference/interfaces.md @@ -1,6 +1,6 @@ --- title: Interfaces -description: Choose between the Codex plugin, DeepSeek Harness plugin, Pi package, CLI, Python SDKs, HTTP, and MCP. +description: Choose between the Codex and Claude Code plugins, DeepSeek Harness plugin, Pi package, CLI, Python SDKs, HTTP, and MCP. --- # Interfaces @@ -202,21 +202,42 @@ For the relationship between evidence, Candidate versions, approved Revisions, r ## Scheduled Experience incubation -The scheduler accepts only Content Sources with metadata `"kind": "task-outcome"`, uses an independent persisted -cursor, and creates pending Experience Candidates. Candidate writes and cursor advancement commit together. The job -does not approve content or include pending content in `PreparedContext`. Setup and verification steps are in +An integration can capture a completed task as a Content Source with metadata `"kind": "task-outcome"`. When the +Experience schedule is configured, APScheduler scans bounded Source windows and asks the configured schema-bound +pipeline for reusable situation, action, outcome, and lesson proposals. Each proposal cites exact Sources and enters +the Review Inbox as a pending Experience Candidate. + +Experience incubation has its own persisted Source cursor, independent from Memory extraction. Candidate writes and +cursor advancement commit together; a generation or write failure leaves the window available for retry. Ordinary +prompt Sources are not Task Outcomes and are ignored by this job. + +Scheduling stops at the review boundary. It never approves an Experience, includes pending content in +PreparedContext, derives a managed Skill, exports a Skill for an Agent target, or executes instructions. Skill authoring and +export remain explicit steps after the supporting Experience is approved. +Setup and verification steps are in [Create and review an Experience](../how-to/create-and-review-experience.md). -## Managed Skill export to Codex +## Managed Skill export to Agent targets + +A configured generator can produce complete managed Skill content through `generate_skill`; a human or integration +can submit already-complete typed content through `propose_skill`. The proposal contains a name, discovery +description, instructions, validation checks, and exact Source or Artifact lineage. It remains a Candidate until a +reviewer approves the exact Candidate version. + +Approval creates an immutable Skill Revision. It does not install the Skill or grant execution authority. To make one +approved Revision available to Codex or Claude Code, export it explicitly into a configured repository, user, or plugin +Skill target. The projection writes `SKILL.md` and `powercontext.json`; the manifest records the Agent kind, exact +Artifact reference and rendered-content hash. It refuses to replace an existing destination, so updates require an +intentional new export rather than a silent overwrite. -Approval creates an immutable Skill Revision but does not install it or grant execution authority. The Codex exporter -writes `SKILL.md` and `powercontext.json` into a new destination and refuses to replace an existing directory. The -manifest binds the projection to one exact Artifact Revision and rendered-content hash. See -[Create and export a managed Skill](../how-to/create-and-export-skill.md) for the procedure. +Codex can discover a repository-local export under `.agents/skills//SKILL.md`. The Artifact Revision remains +the content authority; Claude Code uses `.claude/skills//SKILL.md` for the equivalent project target. Both +directories are host-local projections that can be rebuilt from the same exact Revision. +See [Create and export a managed Skill](../how-to/create-and-export-skill.md) for the procedure. ## External Agent-native Skills -External Skills remain authoritative in their original local packages. With explicitly configured Codex roots, the +External Skills remain authoritative in their original local packages. With explicitly configured Agent targets, the Server can scan a scope-local, rebuildable Registry and report name, description, provider, Agent kind, host, installation scope, locator, and whole-package fingerprint. Exact resolve succeeds only when the same package remains readable on the configured host and its fingerprint still matches. It never installs a package or falls back to a @@ -234,8 +255,8 @@ managed Artifact. | --- | --- | --- | --- | --- | | External Agent-native Skill | Original package | No for scan/list/resolve; yes for import/fork | No for discovery; yes after import/fork | Host-local Registry and exact resolve | | Experience | Exact approved Artifact Revision | Yes for generate/evolve; no for typed `propose` | Yes | Exact read and approved-head FTS recall in PreparedContext | -| Managed Skill | Exact approved Artifact Revision | Yes for generate/evolve/import/fork; no for typed `propose` | Yes | Exact read and explicit Codex projection | -| Codex projection | Its source managed Skill Revision | No | No additional review | Rebuildable host-local copy | +| Managed Skill | Exact approved Artifact Revision | Yes for generate/evolve/import/fork; no for typed `propose` | Yes | Exact read and explicit Agent projection | +| Agent projection | Its source managed Skill Revision | No | No additional review | Rebuildable Codex or Claude Code host-local copy | ## Core SDK diff --git a/docs/en/rfcs/1304_experience_skill_review_page.md b/docs/en/rfcs/1304_experience_skill_review_page.md new file mode 100644 index 000000000..af2e09e19 --- /dev/null +++ b/docs/en/rfcs/1304_experience_skill_review_page.md @@ -0,0 +1,578 @@ +- Proposal Name: `experience_skill_review_page` +- Start Date: 2026-08-20 +- RFC PR: [oceanbase/powercontext#1304](https://github.com/oceanbase/powercontext/pull/1304) +- Related RFCs: [RFC 0050](0050_artifact_candidate_review_inbox.md), + [RFC 0051](0051_experience_skill_artifact_families.md), + [RFC 0072](0072_scoped_statistics_and_usage.md) + +# Summary + +This RFC adds a Server-owned Review page for Experience and PowerContext-managed Skill Candidates. The page is a +user-facing projection of the existing Candidate and Review lifecycle. It does not create another review model, +change Candidate persistence, or bypass the existing HTTP operations. + +PowerContext already exposes a personal Dashboard, configured Dashboard scopes, Bearer authentication, and Review +operations for listing, reading, revising, approving, and rejecting Candidates. The proposed `/reviews` page combines +those capabilities into one scoped Review Inbox. A reviewer can: + +1. select one configured scope; +2. filter current Candidate heads by status and Family; +3. inspect the typed Experience or Skill proposal and its exact evidence references; +4. revise the proposal without changing its evidence; +5. approve the exact current version or reject it with a reason; and +6. explicitly publish an approved managed Skill as a configured local Agent Skill package and verify discovery; and +7. recover explicitly when another reviewer changes the Candidate first. + +Pending remains the default view. Approved and rejected Candidates are available as read-only views. Experience and +Skill share one page because they share one Candidate lifecycle, while each Family retains its own rendering and edit +form. The first version adds no Candidate generation, evidence-content preview, reviewer identity, RBAC, assignment, +notification, bulk action, or Skill execution capability. Publication is a separate explicit action after approval and +can write only to a host-local Agent target that configuration marks as writable. + +# Motivation + +Experience and managed Skill are intentionally gated by Review. A generated proposal is untrusted, does not receive +final Artifact identity, and cannot enter retrieval or PreparedContext until a reviewer approves it. This boundary is +already implemented across HTTP, the Python Client, CLI, and MCP. + +The Server Dashboard currently shows how many Candidates are pending, but it does not let a user inspect or act on +them. Completing the review requires command-line or MCP calls with exact IDs, versions, proposal shapes, and evidence +references. That is suitable for automation and debugging, but it makes routine human governance hard to discover and +easy to postpone. + +Review also differs by Family. An Experience reviewer must judge whether the situation, action, outcome, and lesson form +a reusable conclusion. A Skill reviewer must inspect a name, description, instructions, and validation checklist, while +remembering that approval governs content only and grants no installation or execution authority. A generic JSON editor +would expose the transport shape without helping either decision. + +The smallest useful product slice is therefore not a new workflow engine. It is a scoped, structured page over the +existing Review contract: + +```text +Dashboard pending count + -> Review Inbox + -> select scope and Candidate + -> inspect typed proposal and exact evidence references + -> approve | reject with reason | revise then approve + -> approved Artifact Revision + -> explicit publish -> standard SKILL.md package -> verified local Registry discovery +``` + +# Guide-level explanation + +## Enter the Review Inbox + +When the Dashboard is enabled, the Server navigation contains a **Review** entry next to Dashboard and Handoff Report. +Opening it loads `/reviews` from the same Server origin. It reuses the Dashboard login and Bearer token behavior; the +page does not introduce another credential store or authentication flow. + +The reviewer first selects one of the scopes configured by `POWERCONTEXT_SERVER_DASHBOARD_SCOPES`. If no scopes are +configured, the page explains that Review requires at least one Dashboard scope and performs no Candidate request. + +Changing the scope clears the current list, selected Candidate, pagination cursor, conflict state, and unsaved revision +draft before loading the new scope. A delayed response from the previous scope must not update the page. + +## Work through one unified queue + +The page defaults to: + +```text +status = pending +family = all +limit = 50 +``` + +The reviewer may choose Experience, Skill, or all Families, and may switch among pending, approved, and rejected +statuses. Changing either filter starts again from the first cursor page. A **Load more** action follows +`next_cursor`; the page does not invent offset pagination or a total count that the API does not provide. + +The list and detail pane are shown together on a wide screen and stacked on a narrow screen. Each list row contains only +stable fields available in the Candidate contract: + +- Family and status; +- Candidate ID and current version; +- Experience situation and lesson, or Skill name and description; and +- the Candidate reason when present. + +The contract has no creation or update timestamp, so the page does not display or sort by a fabricated date. It keeps +the server-provided cursor order. + +## Review an Experience + +An Experience detail view renders the four typed fields separately: + +| Field | Review question | +| --- | --- | +| `situation` | Is the situation specific enough to know when this applies? | +| `action` | Does it describe what was actually done? | +| `outcome` | Does it state the observed result without overstating it? | +| `lesson` | Is the conclusion reusable and supported by the evidence? | + +The page also shows the Candidate reason, target Artifact when present, exact Source references, and exact Artifact +references. The first version displays these references as structured identifiers with a copy action. It does not fetch +or render Source bodies because the public HTTP contract does not provide a general exact Source-read operation. + +For example, a reviewer may see: + +```text +Candidate: cand_exp_123@2 +Situation: The OpenAPI source contract changed. +Action: Regenerate the checked-in client and run contract tests. +Outcome: Generated operations and the bundle stayed in sync. +Lesson: Treat contract generation and contract tests as one change. +Evidence: source:task-outcome/run_42 +``` + +The reviewer can approve version 2, reject it with a reason, or open the structured revision form. + +## Review a managed Skill + +A Skill detail view renders: + +- `name`; +- `description`; +- `instructions` as untrusted plain text; and +- each `validation` item as a separate checklist entry. + +Instructions are never interpreted as HTML or executed by the page. Approval only creates or replaces a governed Skill +Artifact Revision. After approval, the page switches to that approved Candidate and shows a separate publication area; +publication still requires another reviewer confirmation. + +The approved Candidate remains immutable. Its delivery area also offers **Create revision**, which requires a short +change-evidence note and copies the approved Skill content into an editable form. Saving captures the note as bounded +Source evidence and submits a new pending Skill Candidate whose `target` and Artifact evidence identify the exact +approved Skill Revision. It does not modify the approved Revision or published package. The new Candidate must pass +through Review before its resulting Revision can be published as an update. + +This distinction is visible beside the approval action: + +```text +Approval governs this Skill content. Publication is separate, and publication does not execute the Skill or grant authority. +``` + +## Publish an approved managed Skill + +The publication area appears only for an approved Skill with an exact `result_artifact`. Targets come from Codex or +Claude Code entries in `POWERCONTEXT_SERVER_EXTERNAL_SKILLS` with `allow_managed_publish=true`; the page cannot submit +an arbitrary filesystem path. + +The first publication creates a standard package named after the Skill under the selected root. It contains `SKILL.md` +and `powercontext.json`; the latter records the exact Artifact Revision and `SKILL.md` digest. The Server immediately +refreshes the current scope's External Skill Registry. The page reports package Revision and locator/fingerprint-backed +Registry availability separately. + +A later approved Revision can explicitly update the same PowerContext-owned package, including a valid name change. +Before updating, the Server verifies the manifest, Artifact identity, Revision, and content digest. It refuses to +overwrite when: + +- the destination belongs to an external Skill or cannot be proven to come from the current managed Artifact; +- `SKILL.md`, the manifest, or the package file set was modified locally; +- one root contains multiple projections for the same Artifact; +- a higher Revision is already published; or +- managed content violates the selected Agent's name, description, or package constraints. + +Publication does not load or execute instructions and does not bypass Agent discovery, approval, sandbox, tool, or +secret policy. It only places a standard package in the configured local root. The host decides whether a running Agent +needs a new session to observe it. + +## Revise before approval + +Selecting **Revise** opens a Family-specific form initialized from the current proposal. Experience uses four required +text areas. Skill uses required name, description, and instructions fields plus an ordered validation list. + +Saving a revision sends a complete replacement proposal with the current `expected_version`. The first version of the +page preserves the Candidate's existing Source references, Artifact references, target, and reason exactly. It does not +provide a general evidence or lineage editor. A reviewer who needs to change lineage must use an existing programmatic +surface or create a new Candidate through the owning generation flow. + +A successful revision produces the next immutable pending version. The page then renders that returned version. It does +not approve automatically; the reviewer must inspect and approve the revised content as a separate action. + +## Approve or reject + +Approve requires a short confirmation that identifies the Candidate and version. The page sends no content changes with +approval. On success, the Candidate becomes approved and identifies the exact result Artifact. + +Reject requires a non-empty reason of at most 2,000 characters. On success, no Artifact is written and the Candidate +becomes terminal. + +After a decision, the page switches to the returned terminal filter and keeps the decided Candidate selected. An +approved Skill can therefore create a successor Candidate or continue to explicit publication. Approved Experience +and rejected Candidate content remains read-only, and the page cannot reopen a terminal Candidate. + +## Handle concurrent review explicitly + +Every revise, approve, and reject request uses the version currently displayed in the detail pane. If the Server returns +a Candidate or Artifact conflict, the page does not retry the write and does not merge content automatically. + +It fetches the current Candidate head and explains that another write won the race. For an approval or rejection, the +reviewer must inspect the new version before acting again. For a revision, the page retains the unsaved local text until +the reviewer either discards it or manually applies it to the new current proposal. + +# Reference-level explanation + +## Goals and non-goals + +The first version has these goals: + +- make the existing Experience and managed Skill Review lifecycle usable from the Server UI; +- keep scope selection explicit and limited to configured Dashboard scopes; +- render each Family as a reviewable domain object rather than generic JSON; +- preserve exact Candidate-version and target CAS behavior; +- keep untrusted content inert and keep approval separate from execution authority; +- let an approved managed Skill be explicitly published to a configured local Agent target with package and Registry verification; +- support English and Chinese, keyboard use, narrow screens, and the existing light and dark themes; and +- remain a thin projection over the current OpenAPI contract. + +The following are out of scope: + +- generating, incubating, importing, or forking Candidates; +- reviewing Memory or Handoff; +- editing Candidate evidence, target, lineage, or generation reason; +- rendering Source content or arbitrary Artifact evidence previews; +- automatic publication, arbitrary-path export, Skill execution, runtime hot loading, or rollback; +- reviewer identity, RBAC, SSO, assignment, notifications, service-level targets, and bulk actions; +- Candidate retention, reopening, deletion, semantic diff, or version-history browsing; +- a generic form renderer for future Artifact Families; and +- a new frontend framework or standalone web application. + +## Existing foundation + +The design reuses current Server behavior: + +| Existing surface | Use on the Review page | +| --- | --- | +| `GET /dashboard/scopes` | List the scopes deliberately exposed by Server configuration | +| `POST /v1/artifact-candidates/list` | Page current heads by scope, status, Family, and cursor | +| `POST /v1/artifact-candidates/get` | Refresh one current Candidate head | +| `POST /v1/artifact-candidates/revise` | Append one complete replacement proposal | +| `POST /v1/artifact-candidates/approve` | Approve the exact displayed version atomically | +| `POST /v1/artifact-candidates/reject` | Reject the exact displayed version with a reason | +| managed Skill exact read and Agent projection helper | Read an approved Revision and render a standard `SKILL.md` package | +| `POST /dashboard/skill-projections/status` | Inspect package Revision, integrity, and Registry state in configured targets | +| `POST /dashboard/skill-projections/publish` | Explicitly create or safely update a package, then refresh the scoped Registry | +| Dashboard authentication utilities | Send the existing Bearer token to same-origin requests | +| Dashboard page UI utilities | Reuse locale, theme, status, and stale-request handling patterns | + +No OpenAPI change, generated client change, database migration, or new public persistence contract is required. Like +`/dashboard/scopes`, the two `/dashboard/skill-projections/*` endpoints are authenticated Server UI supporting surfaces. +They operate on explicitly configured roots on the Server host, are not a cross-host PowerContext API, and never accept +a caller-provided path. Portable exact reads remain on the public `get_skill` contract, and CLI export remains available. + +## Page availability and routing + +The Review page is part of the personal Dashboard feature: + +- route: `GET /reviews`; +- availability: mounted only when `DashboardConfig.enabled` is true; +- scopes: the same ordered `DashboardConfig.scopes` used by the statistics Dashboard; +- authentication: the same Server Bearer policy and same-origin request helper; and +- navigation order: Dashboard, Review, Handoff Report when all three are available. +- publication targets: only explicit `AgentSkillTarget` entries with `allow_managed_publish=true`; legacy + `CodexSkillRoot` entries remain a Codex-only compatibility form, and no target is writable by default. + +Disabling the Dashboard removes both the Dashboard and Review routes. Handoff Report may remain independently +available under its existing configuration. + +The Review page does not accept an arbitrary `scope_id` from a query parameter in the first version. It selects the +first configured scope initially and lets the reviewer switch through the configured picker. This avoids presenting +scope-shaped input as authorization and avoids a deep link that may expose an unconfigured scope. + +## Page state and request ordering + +The page maintains these client-side values: + +```text +authentication state +configured scopes +selected scope +selected family filter +selected status filter +Candidate rows and next cursor +selected Candidate ID and current head +optional revision draft +optional managed Skill projection state and selected publication root +optional conflict or request error +``` + +Scope changes cancel or invalidate every in-flight list, detail, and decision response and reset all Candidate state. +Filter changes invalidate list and detail responses and reset pagination. Selecting a row fetches its current head before +enabling a write action, so a stale row does not become an immediate approval request. + +Only one decision or publication request may be active for the selected Candidate. Its write controls are disabled while +it runs. A delayed success from an earlier selection, scope, or Artifact Revision must not update the new selection. + +## List, pagination, and selection + +The list request is: + +```json +{ + "scope_id": "project:powercontext", + "status": "pending", + "family": null, + "cursor": null, + "limit": 50 +} +``` + +`family` is omitted or `null` for the combined queue and is `experience` or `skill` for a Family filter. The page appends +rows only when a **Load more** response belongs to the same scope, filters, and request generation. Candidate ID is the +row key; version changes replace the current row rather than creating a duplicate. + +After a pending decision, the page switches to the returned terminal status and reselects the same Candidate in the +refreshed list. This lets an approved Skill continue to publication while a rejected Candidate remains available for +decision-reason verification. The scope is unchanged, and the refreshed list remains authoritative. + +## Family-specific rendering and editing + +The page dispatches on the closed current Family set: + +| Family | Summary | Detail and revision fields | +| --- | --- | --- | +| Experience | `situation`, then `lesson` | `situation`, `action`, `outcome`, `lesson` | +| Skill | `name`, then `description` | `name`, `description`, `instructions`, ordered `validation` | + +The implementation must reject an unknown Family or a proposal shape that does not match its Family. It shows an +unsupported-content error and disables all decision actions. It must not guess a generic form and submit data it cannot +validate. + +Revision uses the limits already enforced by the public contract: + +- each Experience field is required and at most 8,000 characters; +- Skill name is at most 128 characters; +- Skill description and each validation item are at most 2,000 characters; +- Skill instructions are at most 32,000 characters; and +- Skill validation contains 1 through 32 non-empty items. + +Client validation improves feedback but does not replace Server validation. A `422` response is displayed beside the +form without changing the current Candidate head. + +## Evidence and trust boundary + +Candidate proposal, reason, rejection reason, instructions, and reference identifiers are untrusted data. The page: + +- inserts them through text nodes or form values, never `innerHTML`; +- does not render Candidate Markdown or load remote resources named by Candidate content; +- does not execute instructions or convert them into links; +- does not log proposal bodies, reasons, or evidence identifiers from browser code; and +- retains the Server's existing restrictive Content Security Policy. + +Source and Artifact references are displayed as exact structured values. The page does not infer local paths, URLs, +permissions, or availability from an identifier. `scope_id` remains a business partition, not an ACL. Listing a scope in +Dashboard configuration controls UI discovery only; Server authentication and deployment policy remain responsible for +access control. + +Pending and rejected content remains excluded from Artifact discovery and PreparedContext. The page never calls an +approved Artifact read as a substitute for reviewing a pending Candidate. + +## Review actions and concurrency + +The UI maps actions to the existing lifecycle: + +```text +pending version N --revise(expected=N)--> pending version N+1 +pending version N --approve(expected=N)-> approved + exact result Artifact +pending version N --reject(expected=N)--> rejected + decision reason +approved Skill Revision --create revision-> new pending Candidate targeting that exact Revision +approved Skill Revision --publish(target_id)-> exact Agent-local package + refreshed Registry +``` + +Approve and reject are available only for a current pending head. Revise is available only for a current pending head +with a supported Family shape. Approved and rejected heads remain read-only; creating a Skill revision produces a new +Candidate rather than reopening or mutating the terminal head. + +For `409 Conflict`: + +1. stop the attempted transition; +2. keep an unsaved revision draft in page memory when one exists; +3. fetch the Candidate current head; +4. show the old and new version numbers and the conflict category returned by the Server; and +5. require a new explicit reviewer action. + +The page never changes `expected_version`, retries, approves, or merges automatically after a conflict. + +## Publication action and overwrite boundary + +A publication status request selects an exact approved ArtifactRef: + +```json +{ + "scope_id": "project:powercontext", + "candidate_id": "cand_123", + "artifact": {"family": "skill", "artifact_id": "skill_123", "revision": 2} +} +``` + +The Server first verifies that the Artifact is the exact `result_artifact` of the identified approved Skill Candidate. +It then returns targets only for configured Dashboard scopes and Agent targets that allow managed publication. Each +target carries `target_id`, `agent_kind`, and installation scope, plus a stable package state: `unpublished`, `current`, +`update_available`, `conflict`, `drifted`, or `incompatible`. +Discovery is reported independently as `available`, `unavailable`, or `not_published`. + +A publish request adds `target_id`, never an Agent kind or destination path supplied by the browser. The Server resolves +both from configuration, reads the exact approved Skill again, rechecks filesystem state, and stages within the same +target. An existing projection can be moved aside and replaced only when its +manifest identity and digest match. A failure restores the previous package. Repeating publication of the same Revision +is idempotent but still refreshes the Registry. If file or Revision state changed, the endpoint returns `409`; the page +reloads status without broadening overwrite authority. + +The operation manages only the generated `SKILL.md` and `powercontext.json`. Managed content does not carry arbitrary +scripts, references, or assets in this version, so extra package files count as drift and are never deleted. + +## Loading, empty, and failure states + +The page distinguishes: + +| State | Behavior | +| --- | --- | +| No configured scopes | Explain the Dashboard scope configuration requirement; send no Candidate request | +| Empty filtered page | Explain which scope, status, and Family have no Candidates | +| Loading list | Keep filters visible and mark the list busy | +| Loading detail | Keep the selected row visible and mark the detail pane busy | +| `401` | Clear the stored tab token and return to the existing login screen | +| `404` on detail | Remove the stale row and refresh the current filtered page | +| `409` | Follow the explicit conflict flow and perform no automatic write | +| `422` | Preserve the form and show validation feedback | +| No publication root | Keep the approved Skill readable and explain that an explicit writable target is required | +| Projection conflict or drift | Block publication, preserve the directory, and show a safe error | +| Current package but unavailable Registry binding | Allow an explicit discovery refresh without rewriting content | +| `503` or network failure | Preserve scope and filters; provide an explicit retry | + +A list failure must not erase a previously rendered list from another scope and make it appear current. Stale content is +hidden as soon as scope changes. + +## Accessibility, localization, and responsive behavior + +English and Chinese strings ship together. Family and status values are translated for display but submitted using +their stable API values. Candidate content and identifiers are never translated. + +The page supports: + +- a logical heading order and a named primary navigation region; +- explicit labels and error associations for every form control; +- keyboard list selection and visible focus states; +- focus return to the next row after a decision; +- an announced status region for successful decisions, validation errors, and conflicts; +- native buttons and form controls instead of clickable generic containers; +- no color-only distinction among statuses; and +- a stacked list/detail flow on narrow screens without hiding review fields or actions. + +Theme and locale use the existing Server page utilities. The page does not create Review-specific preference storage. + +## Implementation slices + +Implementation should proceed as five reviewable slices: + +1. **Read-only Inbox**: route, navigation, authentication, scope picker, filters, pagination, list, and typed detail; +2. **Decisions**: approve and reject with expected-version confirmation and pending-list advancement; +3. **Revision and conflict**: Family forms, complete replacement proposal, local draft preservation, and explicit `409` + recovery; and +4. **Managed Skill publication**: explicit root allowlist, status, safe create/update, manifest integrity, and Registry refresh; and +5. **Product hardening**: English/Chinese parity, responsive behavior, accessibility, packaging, and browser tests. + +Each slice uses the real Server endpoints. Mocked unit tests may cover rendering helpers, but they do not replace an +acceptance scenario that persists a Candidate, loads it through the page, performs a decision, and verifies the +resulting Candidate and Artifact state. + +## Acceptance + +| Scenario | Passing condition | +| --- | --- | +| Availability | `/reviews` exists only when the Dashboard is enabled and appears in primary navigation | +| Authentication | The existing optional Bearer flow protects page data and handles `401` without another token store | +| Scope isolation | Switching scopes clears rows, detail, cursor, conflicts, and drafts before another response renders | +| Default Inbox | The first request lists pending Experience and Skill current heads for the first configured scope | +| Filtering | Family or status changes restart pagination and never mix rows from different filters | +| Pagination | Load more follows `next_cursor`, preserves server order, and deduplicates by Candidate ID | +| Experience | The four typed fields, reason, target, and exact evidence references are readable | +| Skill | Name, description, instructions, validation, reason, target, and exact evidence references are readable | +| Revise | A complete replacement proposal creates version N+1, preserves lineage fields, and remains pending | +| Approve | Only the exact current version succeeds; the response identifies the committed Artifact | +| Reject | A non-empty reason produces a rejected terminal Candidate and no Artifact | +| Decision continuation | A decision switches to the returned terminal view and reselects the same Candidate | +| Conflict | A stale write is not retried; the current head is loaded and any local revision draft is preserved | +| Successor revision | An approved Skill can seed a new pending Candidate targeting its exact current Artifact Revision | +| Publish target | The page lists only roots with `allow_managed_publish=true` and accepts no arbitrary path | +| First publication | An exact approved Revision creates standard `SKILL.md` and manifest files and is Registry-available | +| Safe update | A later Revision updates only an identity/digest-matching PowerContext-owned package | +| Drift and conflict | Foreign directories, local modifications, duplicate projections, and Revision rollback are not overwritten | +| Trust boundary | Candidate text stays inert; approval does not publish, and publication grants no Skill execution authority | +| Terminal views | Terminal content is read-only; approved Skill can create a successor Candidate or publish its exact result | +| Accessibility | Core review, revision, and decision flow is usable with keyboard and announced to assistive technology | +| Responsive UI | The same fields and actions remain available in the stacked narrow-screen layout | +| Localization | English and Chinese cover the same states, actions, errors, and authority warnings | +| Packaging | Templates and static assets are present in the built wheel and work from the installed Server | + +The implementation pull request must run `make check`, `make test`, and `make docs-test`, plus focused Server-page tests +and a real browser flow covering both Families, scope switching, all three decisions, a stale-version conflict, an +approved Skill successor Revision and publication update, optional authentication, both locales, and a narrow viewport. + +# Drawbacks + +- The page adds another Server-owned JavaScript state machine and duplicates some authentication, scope, and status + patterns already used by Dashboard and Handoff Report. +- Exact references without Source-body preview limit how much evidence a reviewer can inspect in one screen. +- A unified Inbox needs Family-specific rendering and validation branches even though the lifecycle is shared. +- Keeping evidence immutable in the first revision form means some corrections still require CLI, MCP, or a new + Candidate. +- The page improves access to governance but does not provide reviewer attribution, authorization separation, or an + organizational audit log. +- Host-local publication affects the Server process host; a remote browser does not publish to its own device. + +# Rationale and alternatives + +| Option | Decision | +| --- | --- | +| Keep Review in CLI and MCP only | Rejected; the Dashboard exposes pending work without a human completion path | +| Build separate Experience and Skill pages | Rejected; it duplicates one lifecycle and fragments one scoped queue | +| Use one unified Inbox with typed Family details | **Adopted**; it shares navigation and actions without hiding domain shape | +| Render and edit arbitrary Candidate JSON | Rejected; it exposes transport details and makes unsafe submissions easier | +| Add a Review-specific backend or persistence table | Rejected; the existing OpenAPI and Candidate store already own the lifecycle | +| Replace Server pages with a new SPA framework | Rejected for this slice; the current packaged HTML/static model is sufficient | +| Add evidence-body reads to this RFC | Deferred; general Source reading needs its own trust, retention, and authorization contract | +| Support bulk approval | Rejected for the first version; each Candidate requires content and evidence judgment | +| Publish automatically after approval | Rejected; content governance and host filesystem mutation require separate authorization | +| Let the browser submit any destination | Rejected; root IDs keep the Dashboard from becoming an arbitrary file-write API | + +Not implementing the page leaves the governance contract technically complete but operationally hidden. Reviewers can +still use existing programmatic surfaces, but pending Experience and Skill are more likely to accumulate or be approved +without an ergonomic structured inspection flow. + +# Prior art + +- RFC 0050 defines the Family-neutral Candidate lifecycle, expected-version writes, terminal states, and Review Inbox + query model. This RFC presents that contract without changing it. +- RFC 0051 defines Experience and managed Skill proposal shapes, lineage, and the boundary between Skill approval and + execution authority. This RFC gives those shapes separate review views. +- RFC 0072 and the existing Dashboard establish configured scope discovery, scoped pending counts, authentication, + localization, theme, and Server-owned static delivery. +- The Handoff Report page demonstrates that a focused workflow can share Server navigation and page utilities without + becoming part of the statistics Dashboard itself. + +No external review product is adopted as a protocol or compatibility target. The design follows PowerContext's current +Candidate contract rather than copying issue-tracker or code-review semantics that do not have the same Artifact gate. + +# Unresolved questions + +No unresolved question blocks the first version. The following decisions are intentionally deferred: + +- whether a future exact Source-read contract can safely support evidence previews; +- whether reviewer identity and decision attribution belong in Candidate persistence or a separate audit stream; +- whether a stable Candidate deep-link contract is useful after authorization and scope discovery are stronger; and +- whether repeated review volume justifies assignment, notification, or bulk triage without bulk approval. + +# Future possibilities + +Natural extensions include: + +- safe, bounded evidence previews backed by an explicit exact-read and redaction contract; +- Candidate version history and a semantic diff between the generated and revised proposal; +- reviewer identity, decision attribution, RBAC, assignment, notification, and service-level reporting; +- URL-addressable Candidate details after scope authorization is explicit; +- saved filters and queue triage for large installations; +- governed rollback, unpublish, and cross-host publication receipts; and +- read-only links from Dashboard pending counts directly to the corresponding Review filter. + +These extensions must preserve the central boundary: a Candidate is untrusted until approval, and approving managed +Skill content never grants installation or execution authority. diff --git a/docs/zh/docs/reference/configuration.md b/docs/zh/docs/reference/configuration.md index 12f86c33d..9203934db 100644 --- a/docs/zh/docs/reference/configuration.md +++ b/docs/zh/docs/reference/configuration.md @@ -1,6 +1,6 @@ --- title: 配置 -description: PowerContext 路径、Server、Client、推理和 Codex 环境变量。 +description: PowerContext 路径、Server、Client、推理和 Agent 集成环境变量。 --- # 配置 @@ -52,7 +52,7 @@ Server 配置使用 `POWERCONTEXT_SERVER_` 前缀。 | `POWERCONTEXT_SERVER_INFERENCE_GENERATION_TIMEOUT_SECONDS` | `30` | Generation 超时 | | `POWERCONTEXT_SERVER_INFERENCE_EMBEDDING_BATCH_SIZE` | `10` | 单次 embedding 请求最多发送的文本数量 | | `POWERCONTEXT_SERVER_RUNTIME_EXPERIENCE_SCHEDULE_SECONDS` | 未设置 | Experience 孵化间隔;未设置即不启用该 job | -| `POWERCONTEXT_SERVER_EXTERNAL_SKILLS` | 未设置 | 包含 host identity 和显式 Codex Skill roots 的 JSON object | +| `POWERCONTEXT_SERVER_EXTERNAL_SKILLS` | 未设置 | 包含 host identity 和显式 Agent Skill targets 的 JSON object | 静态 Bearer 鉴权默认关闭。启用后,API 和 MCP 请求必须携带 `Authorization: Bearer `;liveness 和 readiness endpoint 仍然公开。明文 HTTP 应只用于 loopback 地址;通过网络暴露启用鉴权的 Server 前必须配置 TLS。 @@ -101,33 +101,53 @@ search request 最终 `limit` 的结果。它不会修改已存储 Memory 或索 external Skill import/fork。未配置模型时,这些 operation 会在持久化 Candidate 前返回 capability error; Candidate Review、exact read 和 external Skill scan/list/resolve 仍可使用。 -Experience 孵化使用独立的 APScheduler job 和持久化 Source cursor,需要同时设置 -`POWERCONTEXT_SERVER_RUNTIME_EXPERIENCE_SCHEDULE_SECONDS` 与 `POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL`。 -每次 activation 最多检查 32 条 Source,并且只把 metadata 包含 `"kind": "task-outcome"` 的 Content Source -暴露给模型。Memory 和 Experience job 共用 `POWERCONTEXT_HOME` 下的 scheduler sidecar,但拥有独立的 job identity -和业务 cursor;取消其中一个 interval 只会移除对应 job。 +Experience 孵化使用独立的 APScheduler job 和持久化 Source cursor,可通过以下配置启用: + +```bash +export POWERCONTEXT_SERVER_RUNTIME_EXPERIENCE_SCHEDULE_SECONDS=30 +export POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL=provider:model-name +powercontext server run +``` + +每次 activation 固定检查最多 32 条 Source,并且只把 metadata 包含 `"kind": "task-outcome"` 的 Content Source +暴露给模型。该 job 会在 Review Inbox 中创建 pending Experience Candidate;它不会自动批准、进入 +PreparedContext、创建 managed Skill、将它导出到 Agent target 或执行任何内容。Memory 和 Experience job 共用 +`POWERCONTEXT_HOME` 下的 APScheduler sidecar,但拥有独立的 job identity 和业务 cursor;取消其中一个 interval +只会移除对应 job。 设置与验证步骤见[创建并审核 Experience](../how-to/create-and-review-experience.md)。 -### 外部 Codex Skill +### Agent Skill 目标 -通过一个 JSON 值配置 host-local roots: +通过一个 JSON 值配置 Codex 和 Claude Code 的 host-local target: ```bash export POWERCONTEXT_SERVER_EXTERNAL_SKILLS='{ "host_id": "workstation-1", - "codex_roots": [ + "targets": [ + { + "target_id": "codex-project", + "agent_kind": "codex", + "installation_scope": "project", + "path": "/srv/project/.agents/skills", + "allow_managed_publish": true + }, { - "root_id": "repository", + "target_id": "claude-project", + "agent_kind": "claude_code", "installation_scope": "project", - "path": "/srv/project/.agents/skills" + "path": "/srv/project/.claude/skills", + "allow_managed_publish": true } ] }' ``` -每个 root ID 必须唯一;支持的 installation scope 是 `user`、`project` 和 `plugin`。PowerContext 只扫描这些 -显式 root 的直接 Skill package 子目录,不会推断 home 目录、安装 package 或授予执行权限。`host_id`、locator -和 registration 都是本地环境状态,不是跨 host 或跨 Agent contract。 +每个 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 +页面可以把 approved managed Skill 显式创建或安全更新到该 target。页面仍不能提交任意路径,也不会覆盖外部或 +已被修改的 package。`host_id`、locator 和 registration 都是本地环境状态,不是跨 host contract。已有的 +`codex_roots` 配置继续作为 Codex-only 兼容格式被接受;新配置应使用 `targets`。 Server 始终创建 non-recording OpenTelemetry request context,从 inbound span 派生 `X-PowerContext-Request-ID`。如需为 CLI 管理的 Server 启用 recording 和 export,请安装 `powercontext[cli,server,tracing-otlp]`、启用 tracing, diff --git a/docs/zh/docs/reference/interfaces.md b/docs/zh/docs/reference/interfaces.md index 9aaa10d11..b2cf9530b 100644 --- a/docs/zh/docs/reference/interfaces.md +++ b/docs/zh/docs/reference/interfaces.md @@ -1,6 +1,6 @@ --- title: 接口 -description: 在 Codex 插件、DeepSeek Harness 插件、Pi package、CLI、Python SDK、HTTP 和 MCP 之间选择。 +description: 在 Codex 和 Claude Code 插件、DeepSeek Harness 插件、Pi package、CLI、Python SDK、HTTP 和 MCP 之间选择。 --- # 接口 @@ -188,19 +188,39 @@ Experience Revision 仍不会进入 PreparedContext。 ## 后台 Experience 孵化 -Scheduler 只接收 metadata 含 `"kind": "task-outcome"` 的 Content Source,使用独立的持久化 cursor,并创建 -pending Experience Candidate。Candidate 写入和 cursor 推进在同一事务提交。该 job 不会批准内容,也不会把 pending -内容放入 `PreparedContext`。设置与验证步骤见[创建并审核 Experience](../how-to/create-and-review-experience.md)。 +Integration 可以把已完成任务采集为 metadata 含 `"kind": "task-outcome"` 的 Content Source。启用 +Experience schedule 后,APScheduler 会扫描有上限的 Source window,并让配置好的 schema-bound pipeline +生成可复用的 situation、action、outcome 和 lesson。每条 proposal 都引用精确 Source,并以 pending +Experience Candidate 进入 Review Inbox。 -## 把 managed Skill 导出给 Codex +Experience 孵化使用独立于 Memory extraction 的持久化 Source cursor。Candidate 写入和 cursor 推进会在同一 +事务提交;generation 或写入失败时,该 window 保留给下次重试。普通 Prompt Source 不是 Task Outcome, +不会进入这个 job。 -批准会创建不可变的 Skill Revision,但不会安装 Skill 或授予执行权限。Codex exporter 将 `SKILL.md` 和 -`powercontext.json` 写入新的目标目录,并拒绝覆盖已有目录。Manifest 将 projection 绑定到一个精确 Artifact Revision -及其渲染内容哈希。操作步骤见[创建并导出 managed Skill](../how-to/create-and-export-skill.md)。 +后台流程止于审核边界:它不会批准 Experience、把 pending 内容放入 PreparedContext、派生 managed Skill、 +把 Skill 导出到 Agent target,或执行 instructions。只有支撑它的 Experience 获批后,Skill authoring 和导出才作为 +显式步骤继续。 +设置与验证步骤见[创建并审核 Experience](../how-to/create-and-review-experience.md)。 + +## 把 managed Skill 导出到 Agent target + +配置好的生成器可通过 `generate_skill` 生成完整 managed Skill;已经拥有完整类型化内容的人或 integration +可通过 `propose_skill` 提交。proposal 包括名称、用于发现的描述、instructions、validation,以及精确的 +Source 或 Artifact lineage。在 reviewer 批准精确 Candidate version 之前,它始终只是 Candidate。 + +批准会创建不可变的 Skill Revision,但不会安装 Skill,也不会授予执行权限。要让 Codex 或 Claude Code 使用某个 +已批准 Revision,必须把它显式发布到配置好的代码库级、用户级或插件级 Skill target。projection 会生成 +`SKILL.md` 和 `powercontext.json`;manifest 会记录 Agent kind、精确 Artifact 引用和渲染内容哈希。目标目录已存在时会 +拒绝覆盖,更新必须是一次明确的新导出,不能静默替换。 + +Codex 可以发现 `.agents/skills//SKILL.md` 下的代码库级导出。Artifact Revision 始终是内容权威,目录 +只是 host-local projection;Claude Code 对应的项目级 target 是 `.claude/skills//SKILL.md`。两者都可以从 +同一个精确 Revision 重建。 +操作步骤见[创建并导出 managed Skill](../how-to/create-and-export-skill.md)。 ## 外部 Agent-native Skill -外部 Skill 的原始本地 package 始终是内容权威。显式配置 Codex roots 后,Server 可以扫描 scope-local、 +外部 Skill 的原始本地 package 始终是内容权威。显式配置 Agent target 后,Server 可以扫描 scope-local、 可重建的 Registry,并记录名称、描述、provider、Agent kind、host、installation scope、locator 和整个 package 的 fingerprint。只有同一 package 在已配置 host 上仍可读且 fingerprint 一致时,exact resolve 才成功;它不会 安装 package,也不会回退到其他版本。 @@ -216,8 +236,8 @@ Discovery 不进入 Review。显式调用 `import_external_skill` 并提供精 | --- | --- | --- | --- | --- | | 外部 Agent-native Skill | 原始 package | scan/list/resolve 不需要;import/fork 需要 | discovery 不需要;import/fork 后需要 | host-local Registry 和 exact resolve | | Experience | 精确 approved Artifact Revision | generate/evolve 需要;类型化 `propose` 不需要 | 需要 | exact read 与 PreparedContext approved-head FTS recall | -| managed Skill | 精确 approved Artifact Revision | generate/evolve/import/fork 需要;类型化 `propose` 不需要 | 需要 | exact read 与显式 Codex projection | -| Codex projection | 对应的 managed Skill Revision | 不需要 | 不增加额外 Review | 可重建的 host-local copy | +| managed Skill | 精确 approved Artifact Revision | generate/evolve/import/fork 需要;类型化 `propose` 不需要 | 需要 | exact read 与显式 Agent projection | +| Agent projection | 对应的 managed Skill Revision | 不需要 | 不增加额外 Review | 可重建的 Codex 或 Claude Code host-local copy | ## Core SDK diff --git a/docs/zh/rfcs/1304_experience_skill_review_page.md b/docs/zh/rfcs/1304_experience_skill_review_page.md new file mode 100644 index 000000000..4e3fe1e37 --- /dev/null +++ b/docs/zh/rfcs/1304_experience_skill_review_page.md @@ -0,0 +1,544 @@ +- Proposal Name: `experience_skill_review_page` +- Start Date: 2026-08-20 +- RFC PR: [oceanbase/powercontext#1304](https://github.com/oceanbase/powercontext/pull/1304) +- Related RFCs: [RFC 0050](0050_artifact_candidate_review_inbox.md)、 + [RFC 0051](0051_experience_skill_artifact_families.md)、 + [RFC 0072](0072_scoped_statistics_and_usage.md) + +# Summary + +本 RFC 为 Experience 和 PowerContext-managed Skill Candidate 新增由 Server 托管的 Review 页面。该页面是现有 +Candidate/Review 生命周期的用户界面投影,不会创建另一套审核模型、改变 Candidate 持久化,也不会绕过现有 HTTP +operation。 + +PowerContext 已经提供个人 Dashboard、配置好的 Dashboard scope、Bearer authentication,以及列出、读取、修改、批准和 +拒绝 Candidate 的 Review operation。提议的 `/reviews` 页面把这些能力组合成一个 scoped Review Inbox。审核者可以: + +1. 选择一个已配置的 scope; +2. 按状态和 Family 筛选当前 Candidate head; +3. 查看类型化 Experience 或 Skill proposal 及其精确证据引用; +4. 在不改变证据的情况下修改 proposal; +5. 批准精确的当前版本,或填写原因后拒绝; +6. 将 approved managed Skill 显式发布为配置好的本地 Agent Skill package,并验证本地发现状态; +7. 当另一位审核者先修改 Candidate 时,显式处理并发冲突。 + +页面默认显示 pending。approved 和 rejected Candidate 作为只读视图提供。Experience 和 Skill 共用一个页面,因为它们 +共享同一 Candidate 生命周期;每个 Family 仍保留自己的展示方式和编辑表单。首版不增加 Candidate generation、证据内容 +预览、审核者身份、RBAC、任务分派、通知、批量操作或 Skill 执行能力。发布是 approval 之后的独立显式操作,只能写入 +配置中明确允许的 host-local Agent target。 + +# Motivation + +Experience 和 managed Skill 被有意置于 Review gate 后。生成的 proposal 不受信任,不会获得最终 Artifact identity,也不能 +在审核者批准前进入检索或 PreparedContext。该边界已经在 HTTP、Python Client、CLI 和 MCP 中实现。 + +Server Dashboard 目前会显示 pending Candidate 的数量,却不能让用户查看或处理它们。完成审核需要通过命令行或 MCP +调用,并准确提供 ID、版本、proposal shape 和证据引用。这适合自动化和调试,但日常人工治理难以发现,也容易被搁置。 + +不同 Family 的审核判断也不同。Experience 审核者需要判断 situation、action、outcome 和 lesson 能否构成可复用的结论。 +Skill 审核者需要查看 name、description、instructions 和 validation checklist,同时记住批准只治理内容,不授予安装或执行 +权限。通用 JSON editor 只会暴露 transport shape,无法帮助任何一种判断。 + +因此,最小有用产品切片不是新的工作流引擎,而是现有 Review contract 上的 scoped、structured 页面: + +```text +Dashboard pending count + -> Review Inbox + -> 选择 scope 和 Candidate + -> 查看类型化 proposal 与精确证据引用 + -> approve | 填写原因后 reject | revise 后 approve + -> approved Artifact Revision + -> 显式 publish -> 标准 SKILL.md package -> 本地 Registry 验证可发现 +``` + +# Guide-level explanation + +## 进入 Review Inbox + +Dashboard 启用时,Server 主导航会在 Dashboard 和 Handoff Report 旁增加 **Review** 入口。打开后从同一个 Server +origin 加载 `/reviews`。它复用 Dashboard 的登录方式和 Bearer token 行为,不会引入另一套凭据存储或认证流程。 + +审核者首先从 `POWERCONTEXT_SERVER_DASHBOARD_SCOPES` 配置的 scope 中选择一个。如果没有配置 scope,页面会说明 +Review 至少需要一个 Dashboard scope,并且不会发出 Candidate 请求。 + +切换 scope 时,页面会在加载新 scope 前清空当前列表、选中的 Candidate、pagination cursor、冲突状态和未保存的修改 +draft。来自前一个 scope 的延迟响应不能更新页面。 + +## 处理一个统一队列 + +页面默认使用: + +```text +status = pending +family = all +limit = 50 +``` + +审核者可以选择 Experience、Skill 或所有 Family,也可以在 pending、approved 和 rejected 状态之间切换。改变任一筛选条件 +都会从第一个 cursor page 重新开始。**Load more** 操作沿用 `next_cursor`;页面不会虚构 offset pagination 或 API 未提供的 +total count。 + +宽屏使用列表和详情并列布局,窄屏使用上下堆叠布局。每一行只包含 Candidate contract 中已有的稳定字段: + +- Family 和状态; +- Candidate ID 和当前版本; +- Experience 的 situation 和 lesson,或 Skill 的 name 和 description; +- 存在时显示 Candidate reason。 + +contract 不包含创建或更新时间,因此页面不会展示或按照虚构日期排序,而是保留 Server 返回的 cursor order。 + +## 审核 Experience + +Experience 详情会分别展示四个类型化字段: + +| 字段 | 审核问题 | +| --- | --- | +| `situation` | 情境是否足够具体,能够判断何时适用? | +| `action` | 是否描述了实际执行的动作? | +| `outcome` | 是否陈述了观察到的结果,没有过度推断? | +| `lesson` | 结论是否可复用,并且有证据支持? | + +页面还会展示 Candidate reason、存在时的 target Artifact、精确 Source 引用和精确 Artifact 引用。首版把这些引用展示为 +可复制的结构化 identifier。它不会读取或渲染 Source body,因为公开 HTTP contract 目前没有通用的精确 Source-read +operation。 + +例如,审核者可能看到: + +```text +Candidate: cand_exp_123@2 +Situation: OpenAPI source contract 发生变化。 +Action: 重新生成 checked-in client 并运行 contract tests。 +Outcome: generated operations 与 bundle 保持同步。 +Lesson: 将 contract generation 和 contract tests 作为一个整体变更。 +Evidence: source:task-outcome/run_42 +``` + +审核者可以批准版本 2、填写原因后拒绝,或打开结构化 revision form。 + +## 审核 managed Skill + +Skill 详情会展示: + +- `name`; +- `description`; +- 以不受信任纯文本展示的 `instructions`; +- 每个 `validation` item,分别作为 checklist 条目。 + +页面不会把 instructions 解释成 HTML,也不会执行它们。批准只会创建或替换受治理的 Skill Artifact Revision。批准成功后, +页面切到该 approved Candidate,并显示独立的发布区域;发布仍需要审核者再次确认。 + +approved Candidate 本身保持不可变。交付区域同时提供 **创建新修订**:页面要求填写简短的修改证据,并用已批准 Skill 内容 +初始化编辑表单。保存时先将说明捕获为有界 Source evidence,再创建一项新的 pending Skill Candidate;其 `target` 和 Artifact +evidence 都指向精确的 approved Skill Revision。该操作不会修改已批准 Revision 或已发布 package;新的 Candidate 必须重新经过 +Review,产生的 Revision 才能作为更新发布。 + +批准操作旁会显示该区别: + +```text +批准只治理此 Skill 的内容;发布是独立操作,发布也不会执行 Skill 或授予新的权限。 +``` + +## 发布 approved managed Skill + +发布区域只对带有精确 `result_artifact` 的 approved Skill 显示。目标来自 +`POWERCONTEXT_SERVER_EXTERNAL_SKILLS` 中 `allow_managed_publish=true` 的 Codex 或 Claude Code target;页面不能提交任意 +文件系统路径。 + +首次发布会在目标 root 下创建与 Skill name 同名的标准 package,包含 `SKILL.md` 和 `powercontext.json`。后者记录精确 +Artifact Revision 和 `SKILL.md` digest。发布完成后 Server 立即刷新当前 scope 的 External Skill Registry;页面分别显示 +package 是否是当前 Revision,以及 binding 是否已通过 locator 和 fingerprint 校验为 available。 + +后续 approved Revision 可以显式更新同一 PowerContext-owned package,包括合法的名称变化。更新前必须验证现有 manifest、 +Artifact identity、Revision 和内容 digest。以下情况拒绝覆盖并显示冲突: + +- 目标目录属于外部 Skill 或无法证明由当前 managed Artifact 创建; +- `SKILL.md`、manifest 或 package 文件集合已被本地修改; +- 同一 Artifact 在一个 root 中出现多个 projection; +- 目标中已经发布了更高 Revision; +- managed content 不满足所选 Agent 的 name、description 或 package 约束。 + +发布不会加载或执行 instructions,也不会绕过 Agent 的 discovery、approval、sandbox、tool 或 secret policy。它只让标准 +Skill package 出现在配置好的本地 root 中;正在运行的 Agent 是否需要新会话才能看到更新,由宿主决定。 + +## 批准前修改 + +选择 **Revise** 后会打开根据当前 proposal 初始化的 Family-specific form。Experience 使用四个必填 textarea;Skill 使用 +必填的 name、description 和 instructions 字段,以及有序 validation list。 + +保存 revision 时,页面使用当前 `expected_version` 发送完整 replacement proposal。首版页面会原样保留 Candidate 现有的 +Source references、Artifact references、target 和 reason,不提供通用 evidence/lineage editor。需要修改 lineage 的审核者必须 +使用现有 programmatic surface,或通过所属 generation flow 创建新的 Candidate。 + +revision 成功后会产生下一个不可变 pending version,页面随后展示返回的新版本。它不会自动批准;审核者必须再次查看并 +单独批准修改后的内容。 + +## 批准或拒绝 + +Approve 需要简短确认,其中明确 Candidate 和版本。页面不会在批准请求中发送内容修改。成功后 Candidate 变为 approved, +并标识精确 result Artifact。 + +Reject 需要提供非空且不超过 2,000 个字符的原因。成功后不会写入 Artifact,Candidate 进入 terminal 状态。 + +决策成功后,页面切换到对应 terminal filter 并继续显示刚处理的 Candidate。approved Skill 因此可以创建 successor Candidate +或立即进入显式发布步骤;approved Experience 和 rejected Candidate 保持只读。页面不能重新打开 terminal Candidate。 + +## 显式处理并发审核 + +每个 revise、approve 和 reject 请求都使用详情中当前显示的版本。如果 Server 返回 Candidate 或 Artifact conflict,页面不会 +重试写入,也不会自动合并内容。 + +页面会读取当前 Candidate head,并说明另一项写入已经赢得并发竞争。对于 approval 或 rejection,审核者必须查看新版本后 +才能再次操作。对于 revision,页面会保留未保存的本地文本,直到审核者主动丢弃,或手动应用到新的当前 proposal。 + +# Reference-level explanation + +## Goals and non-goals + +首版目标如下: + +- 让现有 Experience 和 managed Skill Review 生命周期可以从 Server UI 使用; +- 明确选择 scope,并将选择范围限制为配置好的 Dashboard scope; +- 将每个 Family 展示为可审核的 domain object,而不是通用 JSON; +- 保留精确 Candidate-version 和 target CAS 行为; +- 让不受信任内容保持 inert,并把批准与执行权限分离; +- 让 approved managed Skill 可以显式发布到配置好的本地 Agent target,并验证 package 与 Registry 状态; +- 支持英文和中文、键盘操作、窄屏以及现有明暗主题; +- 保持为当前 OpenAPI contract 上的薄投影。 + +以下内容不在范围内: + +- generation、incubation、import 或 fork Candidate; +- 审核 Memory 或 Handoff; +- 编辑 Candidate evidence、target、lineage 或 generation reason; +- 渲染 Source 内容或任意 Artifact evidence preview; +- 自动发布、任意路径导出、Skill 执行、运行时热加载或回滚; +- reviewer identity、RBAC、SSO、分派、通知、服务级目标和批量操作; +- Candidate retention、reopen、delete、semantic diff 或 version-history 浏览; +- 面向未来 Artifact Family 的 generic form renderer; +- 新的前端框架或独立 Web application。 + +## Existing foundation + +本设计复用当前 Server 行为: + +| 现有 surface | Review 页面用途 | +| --- | --- | +| `GET /dashboard/scopes` | 列出 Server 配置显式暴露的 scope | +| `POST /v1/artifact-candidates/list` | 按 scope、状态、Family 和 cursor 分页读取当前 head | +| `POST /v1/artifact-candidates/get` | 刷新一个当前 Candidate head | +| `POST /v1/artifact-candidates/revise` | 追加一个完整 replacement proposal | +| `POST /v1/artifact-candidates/approve` | 原子批准精确的当前展示版本 | +| `POST /v1/artifact-candidates/reject` | 填写原因后拒绝精确的当前展示版本 | +| managed Skill exact read 与 Agent projection helper | 读取 approved Revision 并生成标准 `SKILL.md` package | +| `POST /dashboard/skill-projections/status` | 检查配置目标中的 package Revision、完整性和 Registry 状态 | +| `POST /dashboard/skill-projections/publish` | 显式创建或安全更新 package,然后刷新当前 scope 的 Registry | +| Dashboard authentication utilities | 将现有 Bearer token 附加到 same-origin request | +| Dashboard page UI utilities | 复用 locale、theme、status 和 stale-request handling 模式 | + +本 RFC 不需要 OpenAPI 变更、generated client 变更、数据库 migration 或新的公开 persistence contract。两个 +`/dashboard/skill-projections/*` endpoint 与 `/dashboard/scopes` 一样,是 authenticated Server UI supporting surface:它们 +操作 Server host 上明确配置的本地 root,不是跨 host 的 PowerContext API,也不接受调用方提供的路径。可移植的 exact-read +仍由公开 `get_skill` contract 提供,CLI export 保持可用。 + +## Page availability and routing + +Review 页面属于个人 Dashboard feature: + +- route:`GET /reviews`; +- availability:仅在 `DashboardConfig.enabled` 为 true 时 mount; +- scopes:使用 statistics Dashboard 的同一组有序 `DashboardConfig.scopes`; +- authentication:使用相同 Server Bearer policy 和 same-origin request helper; +- navigation order:三者都可用时依次为 Dashboard、Review、Handoff Report。 +- publication targets:只包含 `allow_managed_publish=true` 的显式 `AgentSkillTarget`;旧的 `CodexSkillRoot` 继续作为 + Codex-only 兼容格式,默认没有可写目标。 + +禁用 Dashboard 会同时移除 Dashboard 和 Review route。Handoff Report 仍可按其现有配置独立使用。 + +首版 Review 页面不接受 query parameter 中任意传入的 `scope_id`。它默认选择第一个已配置 scope,并允许审核者通过已配置的 +picker 切换。这可以避免把形似 scope 的输入表现成权限,也避免 deep link 暴露未配置的 scope。 + +## Page state and request ordering + +页面维护以下 client-side value: + +```text +authentication state +configured scopes +selected scope +selected family filter +selected status filter +Candidate rows and next cursor +selected Candidate ID and current head +optional revision draft +optional managed Skill projection state and selected publish root +optional conflict or request error +``` + +scope 切换会取消或作废所有 in-flight list、detail 和 decision response,并重置全部 Candidate state。filter 切换会作废 list 和 +detail response,并重置 pagination。选中列表行时,页面会先读取 current head 再启用写入操作,避免 stale row 直接变成 +approval request。 + +同一时刻只允许一个针对当前 Candidate 的 decision 或 publish request。请求执行期间禁用相应写入控件。来自之前选择对象、 +scope 或 Artifact Revision 的延迟成功响应不能更新新的选择。 + +## List, pagination, and selection + +list request 为: + +```json +{ + "scope_id": "project:powercontext", + "status": "pending", + "family": null, + "cursor": null, + "limit": 50 +} +``` + +合并队列省略 `family` 或传 `null`;Family filter 则传 `experience` 或 `skill`。只有当 **Load more** response 属于相同 +scope、filters 和 request generation 时,页面才追加列表行。Candidate ID 是 row key;版本变化时替换当前行,而不是创建 +重复行。 + +完成 pending decision 后,页面切换到 returned terminal status,并在刷新后的列表中重新选择同一个 Candidate。这样 +approved Skill 可以继续发布,而 rejected Candidate 仍可核对 decision reason。页面不会重置 scope;刷新后的列表始终是 +权威状态。 + +## Family-specific rendering and editing + +页面根据当前封闭 Family set 分派: + +| Family | 摘要 | 详情和 revision 字段 | +| --- | --- | --- | +| Experience | `situation`,然后是 `lesson` | `situation`、`action`、`outcome`、`lesson` | +| Skill | `name`,然后是 `description` | `name`、`description`、`instructions`、有序 `validation` | + +实现必须拒绝未知 Family,或与其 Family 不匹配的 proposal shape。页面显示 unsupported-content error 并禁用所有 decision +action,不能猜测 generic form 并提交无法验证的数据。 + +revision 使用公开 contract 已经强制的限制: + +- 每个 Experience 字段必填,最多 8,000 个字符; +- Skill name 最多 128 个字符; +- Skill description 和每个 validation item 最多 2,000 个字符; +- Skill instructions 最多 32,000 个字符; +- Skill validation 包含 1 至 32 个非空 item。 + +client validation 用于改善反馈,但不能替代 Server validation。`422` response 在表单旁展示,不会改变当前 Candidate head。 + +## Evidence and trust boundary + +Candidate proposal、reason、rejection reason、instructions 和 reference identifier 都是不受信任数据。页面: + +- 通过 text node 或 form value 插入它们,绝不使用 `innerHTML`; +- 不渲染 Candidate Markdown,也不加载 Candidate 内容指定的 remote resource; +- 不执行 instructions,也不把它们转换为 link; +- browser code 不记录 proposal body、reason 或 evidence identifier; +- 保留 Server 当前严格的 Content Security Policy。 + +Source 和 Artifact reference 以精确结构化 value 展示。页面不会根据 identifier 推断本地路径、URL、permission 或 +availability。`scope_id` 仍是业务 partition,不是 ACL。把一个 scope 加入 Dashboard 配置只控制 UI discovery;Server +authentication 和 deployment policy 仍负责访问控制。 + +pending 和 rejected 内容仍被排除在 Artifact discovery 与 PreparedContext 之外。页面绝不会用读取 approved Artifact 代替 +审核 pending Candidate。 + +## Review actions and concurrency + +UI 将 action 映射到现有生命周期: + +```text +pending version N --revise(expected=N)--> pending version N+1 +pending version N --approve(expected=N)-> approved + exact result Artifact +pending version N --reject(expected=N)--> rejected + decision reason +approved Skill Revision --create revision-> new pending Candidate targeting that exact Revision +approved Skill Revision --publish(target_id)-> exact Agent-local package + refreshed Registry +``` + +Approve 和 reject 仅对 current pending head 可用。Revise 仅对 proposal shape 受支持的 current pending head 可用。approved 和 +rejected head 保持只读;创建 Skill 新修订会产生新的 Candidate,不会重新打开或修改 terminal head。 + +对于 `409 Conflict`: + +1. 停止尝试的 transition; +2. 存在未保存 revision draft 时,将其保留在页面内存; +3. 读取 Candidate current head; +4. 显示旧版本号、新版本号和 Server 返回的 conflict category; +5. 要求新的显式审核操作。 + +发生冲突后,页面不会自动修改 `expected_version`、重试、批准或合并。 + +## Publication action and overwrite boundary + +publication status request 使用精确 approved ArtifactRef: + +```json +{ + "scope_id": "project:powercontext", + "candidate_id": "cand_123", + "artifact": {"family": "skill", "artifact_id": "skill_123", "revision": 2} +} +``` + +Server 首先验证该 Artifact 是指定 approved Skill Candidate 的精确 `result_artifact`,随后只对配置好的 Dashboard scope 和允许 +managed publish 的 Agent target 返回目标。每个目标携带 `target_id`、`agent_kind` 和 installation scope,并返回稳定 state:`unpublished`、 +`current`、`update_available`、`conflict`、`drifted` 或 `incompatible`,并独立返回 discovery 的 `available`、 +`unavailable` 或 `not_published`。 + +publish request 额外携带 `target_id`,browser 不提交 Agent kind 或 destination path;Server 从配置中解析两者,再次读取 exact +approved Skill、重新检查文件状态,随后在同一 target 内 staging。已有 projection 只有在 manifest identity 与 digest 完整匹配时 +才可被临时移出并替换;失败时恢复旧 +package。相同 Revision 的重复 publish 是幂等的,但仍会刷新 Registry。文件或版本状态改变时返回 `409`,页面重新读取状态, +不会扩大覆盖范围。 + +该操作只管理 PowerContext 自己生成的 `SKILL.md` 与 `powercontext.json`。首版 managed content 不承载 arbitrary scripts、 +references 或 assets,因此检测到额外 package 文件也视为 drift,不会删除它们。 + +## Loading, empty, and failure states + +页面区分: + +| 状态 | 行为 | +| --- | --- | +| 没有配置 scope | 说明 Dashboard scope 配置要求,不发送 Candidate request | +| filtered page 为空 | 说明哪个 scope、status 和 Family 没有 Candidate | +| 正在加载 list | 保持 filter 可见,并将 list 标记为 busy | +| 正在加载 detail | 保持 selected row 可见,并将 detail pane 标记为 busy | +| `401` | 清除当前 tab 保存的 token,返回现有 login screen | +| detail 返回 `404` | 移除 stale row,刷新当前 filtered page | +| `409` | 执行显式 conflict flow,不自动写入 | +| `422` | 保留 form 并展示 validation feedback | +| 没有 publish root | approved Skill 保持可读,并说明需要显式配置可写目标 | +| projection conflict/drift | 禁止发布,保留现有目录并展示安全错误 | +| package current 但 Registry unavailable | 允许显式刷新 discovery,不重写相同内容 | +| `503` 或 network failure | 保留 scope 和 filters,提供显式 retry | + +list failure 不能保留其他 scope 的既有列表并让它看起来仍然有效。scope 一旦切换,stale content 必须立即隐藏。 + +## Accessibility, localization, and responsive behavior + +英文和中文文案同步发布。Family 和 status value 在展示时翻译,但提交时使用稳定 API value。Candidate content 和 identifier +绝不翻译。 + +页面支持: + +- 合理的 heading order 和有名称的 primary navigation region; +- 每个 form control 都有显式 label 和 error association; +- keyboard list selection 和可见 focus state; +- decision 后将 focus 返回下一行; +- 使用 announced status region 呈现成功决策、validation error 和 conflict; +- 使用原生 button 和 form control,而非可点击的通用 container; +- 不仅依靠颜色区分状态; +- 窄屏使用堆叠 list/detail flow,不隐藏任何审核字段或 action。 + +theme 和 locale 使用现有 Server page utility。页面不创建 Review-specific preference storage。 + +## Implementation slices + +实现应分为五个可独立评审的 slice: + +1. **Read-only Inbox**:route、navigation、authentication、scope picker、filters、pagination、list 和 typed detail; +2. **Decisions**:approve/reject、expected-version confirmation 和 pending-list advancement; +3. **Revision and conflict**:Family form、完整 replacement proposal、本地 draft preservation 和显式 `409` recovery; +4. **Managed Skill publication**:显式 root allowlist、status、safe create/update、manifest integrity 和 Registry refresh; +5. **Product hardening**:中英文一致性、responsive behavior、accessibility、packaging 和 browser tests。 + +每个 slice 都使用真实 Server endpoint。mocked unit test 可以覆盖 rendering helper,但不能替代以下 acceptance scenario:持久化 +Candidate、通过页面加载、执行决策,并验证最终 Candidate 与 Artifact state。 + +## Acceptance + +| 场景 | 通过条件 | +| --- | --- | +| Availability | `/reviews` 仅在 Dashboard 启用时存在,并出现在 primary navigation | +| Authentication | 现有 optional Bearer flow 保护页面数据并处理 `401`,不增加 token store | +| Scope isolation | 切换 scope 时在其他响应渲染前清除 rows、detail、cursor、conflicts 和 drafts | +| Default Inbox | 首个请求列出第一个配置 scope 下 pending Experience 和 Skill current head | +| Filtering | Family 或 status 变化会重置 pagination,不混合不同 filter 的 row | +| Pagination | Load more 沿用 `next_cursor`,保留 Server order,并按 Candidate ID 去重 | +| Experience | 四个类型化字段、reason、target 和精确 evidence reference 可读 | +| Skill | name、description、instructions、validation、reason、target 和精确 evidence reference 可读 | +| Revise | 完整 replacement proposal 创建 N+1 版本,保留 lineage 字段并保持 pending | +| Approve | 只有精确 current version 成功;response 标识 committed Artifact | +| Reject | 非空 reason 产生 rejected terminal Candidate,不产生 Artifact | +| Decision continuation | decision 成功后切到 returned terminal view,并重新选择同一 Candidate | +| Conflict | stale write 不会重试;读取 current head,并保留本地 revision draft | +| Successor revision | approved Skill 可以创建一项以精确 current Artifact Revision 为 target 的新 pending Candidate | +| Publish target | 页面只列出显式配置 `allow_managed_publish=true` 的 root,不接受任意路径 | +| First publication | exact approved Revision 生成标准 `SKILL.md` 与 manifest,并在 Registry 中 available | +| Safe update | 后续 Revision 只更新 identity/digest 完整匹配的 PowerContext-owned package | +| Drift and conflict | 外部目录、本地修改、重复 projection 和版本倒退均不被覆盖 | +| Trust boundary | Candidate text 保持 inert;批准不发布,发布也不授予 Skill execution 权限 | +| Terminal views | terminal content 只读;approved Skill 可以创建 successor Candidate 或发布其精确 result | +| Accessibility | 核心 review、revision 和 decision flow 可通过键盘使用,并向辅助技术播报 | +| Responsive UI | 窄屏堆叠布局仍提供相同字段和 action | +| Localization | 英文和中文覆盖相同状态、action、error 和 authority warning | +| Packaging | built wheel 包含 template 和 static asset,并可从安装后的 Server 使用 | + +实现 pull request 必须运行 `make check`、`make test` 和 `make docs-test`,以及 focused Server-page tests 和真实浏览器流程。 +浏览器流程覆盖两个 Family、scope 切换、三种 decision、stale-version conflict、approved Skill successor Revision 和 +publication update、optional authentication、两种 locale 和窄屏 viewport。 + +# Drawbacks + +- 页面会增加另一个 Server-owned JavaScript state machine,并重复 Dashboard/Handoff Report 已使用的部分 authentication、 + scope 和 status 模式。 +- 只有精确 reference 而没有 Source-body preview,限制了审核者在单个页面中查看证据的深度。 +- 统一 Inbox 虽共享生命周期,仍需要 Family-specific rendering 和 validation branch。 +- 首版 revision form 不允许修改 evidence,因此部分修正仍需要 CLI、MCP 或新的 Candidate。 +- 页面改善了治理入口,但不提供 reviewer attribution、authorization separation 或组织级 audit log。 +- host-local publish 只对 Server 进程所在主机有效;远程浏览器操作的是 Server host,不是浏览器所在设备。 + +# Rationale and alternatives + +| 方案 | 决定 | +| --- | --- | +| Review 只保留在 CLI 和 MCP | 拒绝;Dashboard 展示 pending 工作却没有人工完成路径 | +| 分别构建 Experience 和 Skill 页面 | 拒绝;会重复同一生命周期并拆散一个 scoped queue | +| 统一 Inbox + typed Family detail | **采用**;共享导航和 action,同时保留 domain shape | +| 展示和编辑任意 Candidate JSON | 拒绝;会暴露 transport detail,并更容易提交不安全内容 | +| 增加 Review-specific backend 或 persistence table | 拒绝;现有 OpenAPI 和 Candidate store 已经拥有生命周期 | +| 用新 SPA framework 替换 Server page | 本阶段拒绝;当前 packaged HTML/static model 已足够 | +| 在本 RFC 增加 evidence-body read | 延后;通用 Source reading 需要独立的 trust、retention 和 authorization contract | +| 支持 bulk approval | 首版拒绝;每个 Candidate 都需要内容和证据判断 | +| Approve 后自动发布 | 不采用;内容治理与 host filesystem mutation 必须是两个显式授权步骤 | +| 让浏览器传任意 destination | 不采用;只能引用配置好的 root ID,避免把 Dashboard 变成任意文件写入接口 | + +不实现该页面会让治理 contract 在技术上完整,却在操作上难以发现。审核者仍可使用现有 programmatic surface,但 pending +Experience 和 Skill 更可能堆积,或在没有符合 domain 的结构化查看流程时被批准。 + +# Prior art + +- RFC 0050 定义 Family-neutral Candidate 生命周期、expected-version write、terminal state 和 Review Inbox query model。 + 本 RFC 展示该 contract,不改变它。 +- RFC 0051 定义 Experience 和 managed Skill proposal shape、lineage,以及 Skill approval 与 execution authority 的边界。 + 本 RFC 为这些 shape 提供独立 review view。 +- RFC 0072 和现有 Dashboard 建立 configured scope discovery、scoped pending count、authentication、localization、theme 和 + Server-owned static delivery。 +- Handoff Report 页面证明:focused workflow 可以共享 Server navigation 和 page utility,而无需成为 statistics Dashboard + 的一部分。 + +本设计不采用任何外部 review product 作为 protocol 或 compatibility target,而是遵循 PowerContext 当前 Candidate contract, +不会复制与 Artifact gate 不同的 issue tracker 或 code review semantics。 + +# Unresolved questions + +没有未决问题阻碍首版。以下决定被有意延后: + +- future exact Source-read contract 是否能安全支持 evidence preview; +- reviewer identity 和 decision attribution 应属于 Candidate persistence 还是独立 audit stream; +- 在更强的 authorization 和 scope discovery 完成后,稳定 Candidate deep-link contract 是否有价值; +- 当 review volume 增长时,是否需要 assignment、notification 或 bulk triage,但仍不提供 bulk approval。 + +# Future possibilities + +自然扩展包括: + +- 由显式 exact-read 和 redaction contract 支持的安全、有界 evidence preview; +- Candidate version history,以及 generated proposal 与 revised proposal 之间的 semantic diff; +- reviewer identity、decision attribution、RBAC、assignment、notification 和服务级报告; +- scope authorization 明确后的 URL-addressable Candidate detail; +- 面向大型安装的 saved filter 和 queue triage; +- 受治理的 rollback、unpublish 和跨 host publication receipt; +- 从 Dashboard pending count 跳转到对应 Review filter 的只读 link。 + +这些扩展必须保留核心边界:Candidate 在批准前不受信任;批准 managed Skill content 永远不授予安装或执行权限。 diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 5f7fb1dee..2c8681f99 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -3105,10 +3105,10 @@ components: pattern: '^[\x21-\x7E]+$' provider: type: string - enum: [codex] + enum: [codex, claude_code] agent_kind: type: string - enum: [codex] + enum: [codex, claude_code] host_id: type: string minLength: 1 diff --git a/src/powercontext/builtin/artifacts/skill/__init__.py b/src/powercontext/builtin/artifacts/skill/__init__.py index 839c5ae83..a94954c66 100644 --- a/src/powercontext/builtin/artifacts/skill/__init__.py +++ b/src/powercontext/builtin/artifacts/skill/__init__.py @@ -22,6 +22,9 @@ MAX_EXTERNAL_SKILL_MANIFEST_BYTES, MAX_EXTERNAL_SKILL_NAME_LENGTH, MAX_EXTERNAL_SKILL_PACKAGE_BYTES, + AgentKind, + AgentSkillProvider, + AgentSkillTarget, CodexSkillProvider, CodexSkillRoot, ExternalSkillInstallationScope, @@ -70,6 +73,9 @@ "MAX_SKILL_VALIDATION_ITEM_LENGTH", "SKILL_GENERATION_INSTRUCTIONS", "SKILL_GENERATION_INSTRUCTIONS_VERSION", + "AgentKind", + "AgentSkillProvider", + "AgentSkillTarget", "CodexSkillProvider", "CodexSkillRoot", "ExternalSkillInstallationScope", diff --git a/src/powercontext/builtin/artifacts/skill/external.py b/src/powercontext/builtin/artifacts/skill/external.py index ee8a6e453..8d1d663b5 100644 --- a/src/powercontext/builtin/artifacts/skill/external.py +++ b/src/powercontext/builtin/artifacts/skill/external.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Host-local discovery and exact resolution for external Codex Skills.""" +"""Host-local discovery and exact resolution for Agent-native Skills.""" from __future__ import annotations @@ -38,6 +38,7 @@ MAX_EXTERNAL_SKILL_PACKAGE_BYTES = 4 * 1024 * 1024 MAX_EXTERNAL_SKILL_MANIFEST_BYTES = 128 * 1024 +AgentKind = Literal["codex", "claude_code"] ExternalSkillInstallationScope = Literal["user", "project", "plugin"] ExternalSkillId = Annotated[str, Field(min_length=1, max_length=MAX_ARTIFACT_ID_LENGTH)] ExternalSkillName = Annotated[str, Field(min_length=1, max_length=MAX_EXTERNAL_SKILL_NAME_LENGTH)] @@ -84,8 +85,8 @@ class ExternalSkillRegistration(BaseModel): model_config = ConfigDict(frozen=True) external_skill_id: ExternalSkillId - provider: Literal["codex"] = "codex" - agent_kind: Literal["codex"] = "codex" + provider: AgentKind = "codex" + agent_kind: AgentKind = "codex" host_id: ExternalSkillHostId installation_scope: ExternalSkillInstallationScope locator: ExternalSkillLocator @@ -129,68 +130,94 @@ class ExternalSkillProvider(Protocol): name: str agent_kind: str host_id: str + provider_names: tuple[str, ...] def scan(self) -> ExternalSkillProviderScan: ... def resolve(self, registration: ExternalSkillRegistration, /) -> ExternalSkillResolution: ... +class AgentSkillTarget(BaseModel): + """One explicitly configured host-local Agent Skill target.""" + + model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) + + target_id: str = Field(min_length=1, max_length=64, pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + agent_kind: AgentKind + installation_scope: ExternalSkillInstallationScope + path: Path + allow_managed_publish: bool = False + + class CodexSkillRoot(BaseModel): - """One explicitly configured Codex installation root.""" + """Legacy Codex-only configuration accepted for backwards compatibility.""" model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) root_id: str = Field(min_length=1, max_length=64, pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$") installation_scope: ExternalSkillInstallationScope path: Path + allow_managed_publish: bool = False + def as_agent_target(self) -> AgentSkillTarget: + """Return the equivalent unified Codex target.""" -class CodexSkillProvider: - """Read Codex-native packages without copying or rewriting their content.""" + return AgentSkillTarget( + target_id=self.root_id, + agent_kind="codex", + installation_scope=self.installation_scope, + path=self.path, + allow_managed_publish=self.allow_managed_publish, + ) - name = "codex" - agent_kind = "codex" - def __init__(self, *, host_id: str, roots: tuple[CodexSkillRoot, ...]) -> None: +class AgentSkillProvider: + """Read Agent-native packages without copying or rewriting their content.""" + + name = "agent-targets" + agent_kind = "multi" + provider_names: tuple[str, ...] = ("codex", "claude_code") + + def __init__(self, *, host_id: str, targets: tuple[AgentSkillTarget, ...]) -> None: self.host_id = _HOST_ID_ADAPTER.validate_python(host_id) - root_ids = [root.root_id for root in roots] - if len(root_ids) != len(set(root_ids)): - raise ValueError("Codex Skill root IDs must be unique") # noqa: TRY003 - self._roots = tuple( - root.model_copy(update={"path": root.path.expanduser().resolve(strict=False)}) for root in roots + target_ids = [target.target_id for target in targets] + if len(target_ids) != len(set(target_ids)): + raise ValueError("Agent Skill target IDs must be unique") # noqa: TRY003 + self._targets = tuple( + target.model_copy(update={"path": target.path.expanduser().resolve(strict=False)}) for target in targets ) def scan(self) -> ExternalSkillProviderScan: registrations: list[ExternalSkillRegistration] = [] skipped = 0 - for root in self._roots: - if not root.path.is_dir(): + for target in self._targets: + if not target.path.is_dir(): continue - for package in sorted(root.path.iterdir(), key=lambda value: value.name): + for package in sorted(target.path.iterdir(), key=lambda value: value.name): if not package.is_dir() or package.is_symlink(): continue try: - registrations.append(self._registration(root, package)) + registrations.append(self._registration(target, package)) except (OSError, UnicodeError, ValueError): skipped += 1 return ExternalSkillProviderScan(registrations=tuple(registrations), skipped=skipped) def resolve(self, registration: ExternalSkillRegistration, /) -> ExternalSkillResolution: if ( - registration.provider != self.name - or registration.agent_kind != self.agent_kind + registration.provider not in self.provider_names + or registration.agent_kind != registration.provider or registration.host_id != self.host_id ): return _unavailable(registration) - root = self._root_for(registration) - if root is None: + target = self._target_for(registration) + if target is None: return _unavailable(registration) package = Path(registration.locator) try: resolved = package.resolve(strict=True) - if resolved.parent != root.path or not resolved.is_dir() or resolved.is_symlink(): + if resolved.parent != target.path or not resolved.is_dir() or resolved.is_symlink(): return _unavailable(registration) - current = self._registration(root, resolved) + current = self._registration(target, resolved) except (OSError, UnicodeError, ValueError): return _unavailable(registration) if ( @@ -204,30 +231,34 @@ def resolve(self, registration: ExternalSkillRegistration, /) -> ExternalSkillRe entrypoint=str(resolved / "SKILL.md"), ) - def _root_for(self, registration: ExternalSkillRegistration) -> CodexSkillRoot | None: - prefix = f"codex:{registration.installation_scope}:" + def _target_for(self, registration: ExternalSkillRegistration) -> AgentSkillTarget | None: + prefix = f"{registration.agent_kind}:{registration.installation_scope}:" if not registration.external_skill_id.startswith(prefix): return None - root_id = registration.external_skill_id.removeprefix(prefix).split("/", 1)[0] + target_id = registration.external_skill_id.removeprefix(prefix).split("/", 1)[0] return next( ( - root - for root in self._roots - if root.root_id == root_id and root.installation_scope == registration.installation_scope + target + for target in self._targets + if target.target_id == target_id + and target.agent_kind == registration.agent_kind + and target.installation_scope == registration.installation_scope ), None, ) - def _registration(self, root: CodexSkillRoot, package: Path) -> ExternalSkillRegistration: - if package.parent != root.path: - raise ValueError("Codex Skill package must be an immediate child of its configured root") # noqa: TRY003 + def _registration(self, target: AgentSkillTarget, package: Path) -> ExternalSkillRegistration: + if package.parent != target.path: + raise ValueError("Agent Skill package must be an immediate child of its configured target") # noqa: TRY003 manifest = package / "SKILL.md" - name, description = _skill_metadata(manifest) - external_skill_id = f"codex:{root.installation_scope}:{root.root_id}/{package.name}" + name, description = _skill_metadata(manifest, package.name, target.agent_kind) + external_skill_id = f"{target.agent_kind}:{target.installation_scope}:{target.target_id}/{package.name}" return ExternalSkillRegistration( external_skill_id=external_skill_id, + provider=target.agent_kind, + agent_kind=target.agent_kind, host_id=self.host_id, - installation_scope=root.installation_scope, + installation_scope=target.installation_scope, locator=str(package), fingerprint=_package_fingerprint(package), name=name, @@ -235,6 +266,17 @@ def _registration(self, root: CodexSkillRoot, package: Path) -> ExternalSkillReg ) +class CodexSkillProvider(AgentSkillProvider): + """Compatibility wrapper for an exclusively Codex target set.""" + + name = "codex" + agent_kind = "codex" + + def __init__(self, *, host_id: str, roots: tuple[CodexSkillRoot, ...]) -> None: + super().__init__(host_id=host_id, targets=tuple(root.as_agent_target() for root in roots)) + self.provider_names = ("codex",) + + def _unavailable(registration: ExternalSkillRegistration) -> ExternalSkillResolution: return ExternalSkillResolution( registration=registration, @@ -242,15 +284,15 @@ def _unavailable(registration: ExternalSkillRegistration) -> ExternalSkillResolu ) -def _skill_metadata(manifest: Path) -> tuple[str, str]: +def _skill_metadata(manifest: Path, package_name: str, agent_kind: AgentKind) -> tuple[str, str]: if manifest.is_symlink(): - raise ValueError("Codex Skill manifest must not be a symlink") # noqa: TRY003 + raise ValueError("Agent Skill manifest must not be a symlink") # noqa: TRY003 content = manifest.read_bytes() if len(content) > MAX_EXTERNAL_SKILL_MANIFEST_BYTES: - raise ValueError("Codex Skill manifest exceeds the supported size") # noqa: TRY003 + raise ValueError("Agent Skill manifest exceeds the supported size") # noqa: TRY003 lines = content.decode("utf-8").splitlines() if not lines or lines[0].strip() != "---": - raise ValueError("Codex Skill manifest is missing frontmatter") # noqa: TRY003 + raise ValueError("Agent Skill manifest is missing frontmatter") # noqa: TRY003 metadata: dict[str, str] = {} for line in lines[1:]: if line.strip() == "---": @@ -259,23 +301,25 @@ def _skill_metadata(manifest: Path) -> tuple[str, str]: if separator and field in {"name", "description"}: metadata[field] = _frontmatter_scalar(raw_value.strip()) else: - raise ValueError("Codex Skill frontmatter is not terminated") # noqa: TRY003 + raise ValueError("Agent Skill frontmatter is not terminated") # noqa: TRY003 try: - return metadata["name"], metadata["description"] + name = metadata["name"] if agent_kind == "codex" else metadata.get("name", package_name) + return name, metadata["description"] except KeyError as error: - raise ValueError("Codex Skill frontmatter requires name and description") from error # noqa: TRY003 + required = "name and description" if agent_kind == "codex" else "description" + raise ValueError(f"{agent_kind} Skill frontmatter requires {required}") from error # noqa: TRY003 def _frontmatter_scalar(value: str) -> str: if not value: - raise ValueError("Codex Skill frontmatter values must not be empty") # noqa: TRY003 + raise ValueError("Agent Skill frontmatter values must not be empty") # noqa: TRY003 if value.startswith(('"', "'")): try: parsed = json.loads(value) if value.startswith('"') else value[1:-1] except (json.JSONDecodeError, IndexError) as error: - raise ValueError("Codex Skill frontmatter contains an invalid scalar") from error # noqa: TRY003 + raise ValueError("Agent Skill frontmatter contains an invalid scalar") from error # noqa: TRY003 if not isinstance(parsed, str): - raise ValueError("Codex Skill frontmatter values must be strings") # noqa: TRY003 + raise ValueError("Agent Skill frontmatter values must be strings") # noqa: TRY003 return parsed return value @@ -283,7 +327,7 @@ def _frontmatter_scalar(value: str) -> str: def _package_fingerprint(package: Path) -> str: files = tuple(_package_files(package)) if not files or len(files) > MAX_EXTERNAL_SKILL_FILES: - raise ValueError("Codex Skill package has an unsupported file count") # noqa: TRY003 + raise ValueError("Agent Skill package has an unsupported file count") # noqa: TRY003 digest = hashlib.sha256() total_bytes = 0 for path in files: @@ -291,7 +335,7 @@ def _package_fingerprint(package: Path) -> str: content = path.read_bytes() total_bytes += len(content) if total_bytes > MAX_EXTERNAL_SKILL_PACKAGE_BYTES: - raise ValueError("Codex Skill package exceeds the supported size") # noqa: TRY003 + raise ValueError("Agent Skill package exceeds the supported size") # noqa: TRY003 digest.update(len(relative).to_bytes(4, "big")) digest.update(relative) digest.update(len(content).to_bytes(8, "big")) @@ -302,7 +346,7 @@ def _package_fingerprint(package: Path) -> str: def _package_files(package: Path) -> Iterable[Path]: for path in sorted(package.rglob("*"), key=lambda value: value.relative_to(package).as_posix()): if path.is_symlink(): - raise ValueError("Codex Skill packages containing symlinks are not supported") # noqa: TRY003 + raise ValueError("Agent Skill packages containing symlinks are not supported") # noqa: TRY003 if path.is_file(): yield path @@ -315,6 +359,9 @@ def _package_files(package: Path) -> Iterable[Path]: "MAX_EXTERNAL_SKILL_MANIFEST_BYTES", "MAX_EXTERNAL_SKILL_NAME_LENGTH", "MAX_EXTERNAL_SKILL_PACKAGE_BYTES", + "AgentKind", + "AgentSkillProvider", + "AgentSkillTarget", "CodexSkillProvider", "CodexSkillRoot", "ExternalSkillInstallationScope", diff --git a/src/powercontext/builtin/artifacts/skill/projection.py b/src/powercontext/builtin/artifacts/skill/projection.py new file mode 100644 index 000000000..9aa3bfac2 --- /dev/null +++ b/src/powercontext/builtin/artifacts/skill/projection.py @@ -0,0 +1,350 @@ +# 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. + +"""Project exact managed Skill Revisions into host-local Agent targets.""" + +from __future__ import annotations + +import hashlib +import json +import re +import shutil +import tempfile +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path + +from powercontext.artifacts import ArtifactRef +from powercontext.builtin.artifacts.skill.external import AgentKind, AgentSkillTarget +from powercontext.builtin.artifacts.skill.models import SkillContent + +PROJECTION_SCHEMA = "powercontext.agent-skill-projection.v1" +LEGACY_CODEX_PROJECTION_SCHEMA = "powercontext.codex-skill-projection.v1" +MAX_CODEX_SKILL_NAME_LENGTH = 64 +MAX_CODEX_SKILL_DESCRIPTION_LENGTH = 1_024 +MAX_CLAUDE_CODE_SKILL_NAME_LENGTH = 64 +MAX_CLAUDE_CODE_SKILL_DESCRIPTION_LENGTH = 1_536 +_CODEX_SKILL_NAME = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +_CLAUDE_CODE_SKILL_NAME = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + +class AgentSkillProjectionState(StrEnum): + """Observable state of one managed Skill in a configured Agent target.""" + + UNPUBLISHED = "unpublished" + CURRENT = "current" + UPDATE_AVAILABLE = "update_available" + CONFLICT = "conflict" + DRIFTED = "drifted" + INCOMPATIBLE = "incompatible" + + +@dataclass(frozen=True) +class AgentSkillProjectionStatus: + """One exact managed Skill projection state derived from local files.""" + + state: AgentSkillProjectionState + destination: Path + published_artifact: ArtifactRef | None = None + reason: str | None = None + + +class AgentSkillProjectionConflictError(RuntimeError): + """Raised when publication would replace content outside its exact authority.""" + + def __init__(self, status: AgentSkillProjectionStatus) -> None: + super().__init__(status.reason or status.state.value) + self.status = status + + +def project_skill(artifact: ArtifactRef, content: SkillContent, target: AgentSkillTarget, /) -> Path: + """Create a new host-local Agent projection without replacing existing content.""" + + if artifact.family != "skill": + raise ValueError("artifact must identify a managed Skill") # noqa: TRY003 + destination = target.path.expanduser().resolve(strict=False) / content.name + return _project_skill_to(artifact, content, target.agent_kind, destination) + + +def _project_skill_to( + artifact: ArtifactRef, + content: SkillContent, + agent_kind: AgentKind, + destination: Path, +) -> Path: + destination = destination.resolve(strict=False) + _validate_agent_projection(content, destination, agent_kind) + if destination.exists(): + raise FileExistsError(destination) + destination.parent.mkdir(parents=True, exist_ok=True) + skill_text = _skill_markdown(artifact, content) + manifest = { + "schema": PROJECTION_SCHEMA, + "agent_kind": agent_kind, + "artifact": artifact.model_dump(mode="json"), + "skill_sha256": hashlib.sha256(skill_text.encode("utf-8")).hexdigest(), + } + with tempfile.TemporaryDirectory(prefix=".powercontext-skill-", dir=destination.parent) as temporary: + staging = Path(temporary) / "projection" + staging.mkdir() + (staging / "SKILL.md").write_text(skill_text, encoding="utf-8") + (staging / "powercontext.json").write_text( + f"{json.dumps(manifest, indent=2, sort_keys=True)}\n", + encoding="utf-8", + ) + shutil.copytree(staging, destination) + return destination + + +def inspect_skill_projection( + artifact: ArtifactRef, + content: SkillContent, + target: AgentSkillTarget, + /, +) -> AgentSkillProjectionStatus: + """Inspect an exact managed Skill projection without changing local files.""" + + resolved_root = target.path.expanduser().resolve(strict=False) + destination = resolved_root / content.name + try: + _validate_agent_projection(content, destination, target.agent_kind) + except ValueError as error: + return AgentSkillProjectionStatus( + state=AgentSkillProjectionState.INCOMPATIBLE, + destination=destination, + reason=str(error), + ) + + projections = _managed_projections(resolved_root, artifact.artifact_id, target.agent_kind) + if len(projections) > 1: + return AgentSkillProjectionStatus( + state=AgentSkillProjectionState.CONFLICT, + destination=destination, + reason="multiple managed projections identify this Artifact", + ) + if not projections: + if destination.exists() or destination.is_symlink(): + return AgentSkillProjectionStatus( + state=AgentSkillProjectionState.CONFLICT, + destination=destination, + reason="the target Skill directory is already occupied", + ) + return AgentSkillProjectionStatus( + state=AgentSkillProjectionState.UNPUBLISHED, + destination=destination, + ) + + package, published = projections[0] + if not _projection_is_intact(package, published, target.agent_kind): + return AgentSkillProjectionStatus( + state=AgentSkillProjectionState.DRIFTED, + destination=destination, + published_artifact=published, + reason="the published package no longer matches its PowerContext manifest", + ) + if package != destination and (destination.exists() or destination.is_symlink()): + return AgentSkillProjectionStatus( + state=AgentSkillProjectionState.CONFLICT, + destination=destination, + published_artifact=published, + reason="the renamed target Skill directory is already occupied", + ) + if published.revision > artifact.revision: + return AgentSkillProjectionStatus( + state=AgentSkillProjectionState.CONFLICT, + destination=destination, + published_artifact=published, + reason="a newer managed Skill Revision is already published", + ) + if published.revision == artifact.revision: + if package == destination: + return AgentSkillProjectionStatus( + state=AgentSkillProjectionState.CURRENT, + destination=destination, + published_artifact=published, + ) + return AgentSkillProjectionStatus( + state=AgentSkillProjectionState.DRIFTED, + destination=destination, + published_artifact=published, + reason="the exact managed Skill Revision is published under a different directory name", + ) + return AgentSkillProjectionStatus( + state=AgentSkillProjectionState.UPDATE_AVAILABLE, + destination=destination, + published_artifact=published, + ) + + +def publish_skill_projection( + artifact: ArtifactRef, + content: SkillContent, + target: AgentSkillTarget, + /, + *, + expected: AgentSkillProjectionStatus | None = None, +) -> AgentSkillProjectionStatus: + """Publish or safely update one exact managed Skill in a configured Agent target.""" + + current = inspect_skill_projection(artifact, content, target) + if expected is not None and current != expected: + raise AgentSkillProjectionConflictError(current) + if current.state is AgentSkillProjectionState.CURRENT: + return current + if current.state not in { + AgentSkillProjectionState.UNPUBLISHED, + AgentSkillProjectionState.UPDATE_AVAILABLE, + }: + raise AgentSkillProjectionConflictError(current) + + resolved_root = target.path.expanduser().resolve(strict=False) + resolved_root.mkdir(parents=True, exist_ok=True) + destination = resolved_root / content.name + existing = None + if current.published_artifact is not None: + projections = _managed_projections(resolved_root, artifact.artifact_id, target.agent_kind) + if len(projections) != 1: + raise AgentSkillProjectionConflictError(inspect_skill_projection(artifact, content, target)) + existing = projections[0][0] + + temporary = Path(tempfile.mkdtemp(prefix=".powercontext-publish-", dir=resolved_root)) + backup = temporary / "previous" + try: + staged = _project_skill_to( + artifact, + content, + target.agent_kind, + temporary / "staged" / content.name, + ) + if existing is not None: + existing.rename(backup) + try: + staged.rename(destination) + except Exception: + if existing is not None and backup.exists() and not existing.exists(): + backup.rename(existing) + raise + finally: + shutil.rmtree(temporary, ignore_errors=True) + + published = inspect_skill_projection(artifact, content, target) + if published.state is not AgentSkillProjectionState.CURRENT: + raise AgentSkillProjectionConflictError(published) + return published + + +def _validate_agent_projection(content: SkillContent, destination: Path, agent_kind: AgentKind) -> None: + maximum_name_length = MAX_CODEX_SKILL_NAME_LENGTH if agent_kind == "codex" else MAX_CLAUDE_CODE_SKILL_NAME_LENGTH + name_pattern = _CODEX_SKILL_NAME if agent_kind == "codex" else _CLAUDE_CODE_SKILL_NAME + if len(content.name) > maximum_name_length or name_pattern.fullmatch(content.name) is None: + raise ValueError( # noqa: TRY003 + f"managed Skill name must be at most {maximum_name_length} lowercase letters, digits, " + f"and single hyphens for {_agent_label(agent_kind)}" + ) + if destination.name != content.name: + raise ValueError(f"{_agent_label(agent_kind)} Skill directory name must match the managed Skill name") # noqa: TRY003 + maximum_description_length = ( + MAX_CODEX_SKILL_DESCRIPTION_LENGTH if agent_kind == "codex" else MAX_CLAUDE_CODE_SKILL_DESCRIPTION_LENGTH + ) + invalid_codex_description = agent_kind == "codex" and any(value in content.description for value in "<>") + if len(content.description) > maximum_description_length or invalid_codex_description: + suffix = " and contain no angle brackets" if agent_kind == "codex" else "" + raise ValueError( # noqa: TRY003 + f"managed Skill description must be at most {maximum_description_length} characters{suffix} " + f"for {_agent_label(agent_kind)}" + ) + + +def _agent_label(agent_kind: AgentKind) -> str: + return "Codex" if agent_kind == "codex" else "Claude Code" + + +def _skill_markdown(artifact: ArtifactRef, content: SkillContent) -> str: + validation = "\n".join(f"- {item}" for item in content.validation) + exact_ref = f"artifact:{artifact.family}/{artifact.artifact_id}@{artifact.revision}" + return ( + "---\n" + f"name: {json.dumps(content.name, ensure_ascii=False)}\n" + f"description: {json.dumps(content.description, ensure_ascii=False)}\n" + "---\n\n" + f"\n\n" + f"{content.instructions.rstrip()}\n\n" + "## Validation\n\n" + f"{validation}\n" + ) + + +def _managed_projections(root: Path, artifact_id: str, agent_kind: AgentKind) -> list[tuple[Path, ArtifactRef]]: + if not root.is_dir(): + return [] + projections: list[tuple[Path, ArtifactRef]] = [] + for package in root.iterdir(): + if not package.is_dir() or package.is_symlink(): + continue + published = _published_artifact(package, agent_kind) + if published is not None and published.family == "skill" and published.artifact_id == artifact_id: + projections.append((package, published)) + return projections + + +def _published_artifact(package: Path, agent_kind: AgentKind) -> ArtifactRef | None: + try: + manifest = json.loads((package / "powercontext.json").read_text(encoding="utf-8")) + if not isinstance(manifest, dict) or not _manifest_matches_agent(manifest, agent_kind): + return None + return ArtifactRef.model_validate(manifest.get("artifact")) + except (OSError, UnicodeError, ValueError, TypeError): + return None + + +def _projection_is_intact(package: Path, artifact: ArtifactRef, agent_kind: AgentKind) -> bool: + try: + if {item.name for item in package.iterdir()} != {"SKILL.md", "powercontext.json"}: + return False + manifest = json.loads((package / "powercontext.json").read_text(encoding="utf-8")) + if not isinstance(manifest, dict) or not _manifest_matches_agent(manifest, agent_kind): + return False + if ArtifactRef.model_validate(manifest.get("artifact")) != artifact: + return False + skill_text = (package / "SKILL.md").read_text(encoding="utf-8") + return manifest.get("skill_sha256") == hashlib.sha256(skill_text.encode("utf-8")).hexdigest() + except (OSError, UnicodeError, ValueError, TypeError): + return False + + +def _manifest_matches_agent(manifest: dict[str, object], agent_kind: AgentKind) -> bool: + schema = manifest.get("schema") + if schema == PROJECTION_SCHEMA: + return manifest.get("agent_kind") == agent_kind + return schema == LEGACY_CODEX_PROJECTION_SCHEMA and agent_kind == "codex" + + +# Compatibility aliases for callers that imported the original Codex-specific names. +CodexSkillProjectionConflictError = AgentSkillProjectionConflictError +CodexSkillProjectionState = AgentSkillProjectionState +CodexSkillProjectionStatus = AgentSkillProjectionStatus + + +__all__ = [ + "PROJECTION_SCHEMA", + "AgentSkillProjectionConflictError", + "AgentSkillProjectionState", + "AgentSkillProjectionStatus", + "CodexSkillProjectionConflictError", + "CodexSkillProjectionState", + "CodexSkillProjectionStatus", + "inspect_skill_projection", + "project_skill", + "publish_skill_projection", +] diff --git a/src/powercontext/builtin/artifacts/skill/registry.py b/src/powercontext/builtin/artifacts/skill/registry.py index dcff20987..75b3db77d 100644 --- a/src/powercontext/builtin/artifacts/skill/registry.py +++ b/src/powercontext/builtin/artifacts/skill/registry.py @@ -51,14 +51,19 @@ def __init__( async def scan(self) -> ExternalSkillProviderScan: snapshot = await asyncio.to_thread(self._provider.scan) + provider_names = getattr(self._provider, "provider_names", (self._provider.name,)) async with self._database.transaction() as connection: - await self._repository.replace( - connection, - self._scope_id, - self._provider.name, - self._provider.host_id, - snapshot.registrations, - ) + for provider_name in provider_names: + registrations = tuple( + registration for registration in snapshot.registrations if registration.provider == provider_name + ) + await self._repository.replace( + connection, + self._scope_id, + provider_name, + self._provider.host_id, + registrations, + ) return snapshot async def list(self, /, *, include_unavailable: bool = False) -> tuple[ExternalSkillResolution, ...]: diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 5a9d4e0aa..72bc0f525 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -37,7 +37,7 @@ MemoryRerankDecision, MemoryReranker, ) -from powercontext.builtin.artifacts.skill import CodexSkillProvider, ExternalSkillProvider, SkillGenerator +from powercontext.builtin.artifacts.skill import AgentSkillProvider, ExternalSkillProvider, SkillGenerator from powercontext.builtin.handoff_report.adapters import RuntimeHandoffReadAdapter, RuntimeWorkContinuityReadAdapter from powercontext.builtin.handoff_report.application import HandoffReportApplication from powercontext.builtin.handoff_report.sqlite import HANDOFF_REPORT_TABLES @@ -582,11 +582,11 @@ def _required(value: ValueT | None) -> ValueT: def _external_skill_provider(settings: ExternalSkillsConfig) -> ExternalSkillProvider | None: - if not settings.codex_roots: + if not settings.agent_targets: return None if settings.host_id is None: raise BuiltinConfigurationError("external-skill-host") - return CodexSkillProvider(host_id=settings.host_id, roots=settings.codex_roots) + return AgentSkillProvider(host_id=settings.host_id, targets=settings.agent_targets) def _search_modes(capabilities: MemoryCapabilities) -> tuple[MemorySearchMode, ...]: diff --git a/src/powercontext/builtin/runtime/config.py b/src/powercontext/builtin/runtime/config.py index 02b69dd2a..96605c7a9 100644 --- a/src/powercontext/builtin/runtime/config.py +++ b/src/powercontext/builtin/runtime/config.py @@ -22,7 +22,7 @@ from pydantic import BaseModel, Field, field_validator, model_validator from powercontext.builtin.artifacts.memory.prompts import MemoryExtractionProfile -from powercontext.builtin.artifacts.skill import CodexSkillRoot +from powercontext.builtin.artifacts.skill import AgentSkillTarget, CodexSkillRoot from powercontext.builtin.persistence.oceanbase import OceanBaseConfig from powercontext.builtin.persistence.seekdb import SeekDBConfig from powercontext.builtin.persistence.sqlite import SQLiteConfig @@ -89,9 +89,10 @@ def validate_embedding_profile(self) -> Self: class ExternalSkillsConfig(BaseModel): - """Explicit host-local roots used by the external Codex Skill provider.""" + """Explicit host-local targets used by Agent-native Skill providers.""" host_id: str | None = Field(default=None, min_length=1, max_length=128) + targets: tuple[AgentSkillTarget, ...] = () codex_roots: tuple[CodexSkillRoot, ...] = () @field_validator("host_id") @@ -105,10 +106,20 @@ def validate_host_id(cls, value: str | None) -> str | None: @model_validator(mode="after") def require_host_for_roots(self) -> ExternalSkillsConfig: - if self.codex_roots and self.host_id is None: - raise ValueError("external Skill host_id is required when Codex roots are configured") # noqa: TRY003 + targets = self.agent_targets + if targets and self.host_id is None: + raise ValueError("external Skill host_id is required when Agent targets are configured") # noqa: TRY003 + target_ids = [target.target_id for target in targets] + if len(target_ids) != len(set(target_ids)): + raise ValueError("external Skill Agent target IDs must be unique") # noqa: TRY003 return self + @property + def agent_targets(self) -> tuple[AgentSkillTarget, ...]: + """Return unified targets, including legacy Codex root configuration.""" + + return (*self.targets, *(root.as_agent_target() for root in self.codex_roots)) + DatabaseConfig = SQLiteConfig | OceanBaseConfig | SeekDBConfig diff --git a/src/powercontext/client/projections/__init__.py b/src/powercontext/client/projections/__init__.py index 66f46e18a..95e8f0006 100644 --- a/src/powercontext/client/projections/__init__.py +++ b/src/powercontext/client/projections/__init__.py @@ -21,6 +21,7 @@ from pathlib import Path from powercontext.artifacts import ArtifactRef +from powercontext.client.projections.claude_code import project_skill as export_claude_code_skill from powercontext.client.projections.codex import project_skill as export_codex_skill from powercontext.http import SkillProposal @@ -29,11 +30,13 @@ class SkillExportTarget(StrEnum): """Agent integrations supported by managed Skill export.""" CODEX = "codex" + CLAUDE_CODE = "claude_code" _SkillExporter = Callable[[ArtifactRef, SkillProposal, Path], Path] _SKILL_EXPORTERS: dict[SkillExportTarget, _SkillExporter] = { SkillExportTarget.CODEX: export_codex_skill, + SkillExportTarget.CLAUDE_CODE: export_claude_code_skill, } diff --git a/src/powercontext/client/projections/claude_code.py b/src/powercontext/client/projections/claude_code.py new file mode 100644 index 000000000..d19c148ae --- /dev/null +++ b/src/powercontext/client/projections/claude_code.py @@ -0,0 +1,98 @@ +# 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. + +"""Client adapters for host-local Claude Code Skill projections.""" + +from __future__ import annotations + +from pathlib import Path + +from powercontext.artifacts import ArtifactRef +from powercontext.builtin.artifacts.skill.external import AgentSkillTarget +from powercontext.builtin.artifacts.skill.models import SkillContent +from powercontext.builtin.artifacts.skill.projection import ( + PROJECTION_SCHEMA, + AgentSkillProjectionConflictError, + AgentSkillProjectionState, + AgentSkillProjectionStatus, +) +from powercontext.builtin.artifacts.skill.projection import inspect_skill_projection as _inspect_skill_projection +from powercontext.builtin.artifacts.skill.projection import project_skill as _project_skill +from powercontext.builtin.artifacts.skill.projection import publish_skill_projection as _publish_skill_projection +from powercontext.http import SkillProposal + + +def project_skill(artifact: ArtifactRef, content: SkillProposal, destination: Path, /) -> Path: + """Create a new host-local Claude Code projection without replacing existing content.""" + + return _project_skill(artifact, _runtime_content(content), _claude_code_target(destination.parent)) + + +def inspect_skill_projection( + artifact: ArtifactRef, + content: SkillProposal, + root: Path, + /, +) -> AgentSkillProjectionStatus: + """Inspect an exact managed Skill projection without changing local files.""" + + return _inspect_skill_projection(artifact, _runtime_content(content), _claude_code_target(root)) + + +def publish_skill_projection( + artifact: ArtifactRef, + content: SkillProposal, + root: Path, + /, + *, + expected: AgentSkillProjectionStatus | None = None, +) -> AgentSkillProjectionStatus: + """Publish or safely update one exact managed Skill in a configured Claude Code target.""" + + return _publish_skill_projection( + artifact, + _runtime_content(content), + _claude_code_target(root), + expected=expected, + ) + + +def _claude_code_target(root: Path) -> AgentSkillTarget: + return AgentSkillTarget( + target_id="client", + agent_kind="claude_code", + installation_scope="project", + path=root, + allow_managed_publish=True, + ) + + +def _runtime_content(content: SkillProposal) -> SkillContent: + return SkillContent( + name=content.name, + description=content.description, + instructions=content.instructions, + validation=tuple(item.root for item in content.validation), + ) + + +__all__ = [ + "PROJECTION_SCHEMA", + "AgentSkillProjectionConflictError", + "AgentSkillProjectionState", + "AgentSkillProjectionStatus", + "inspect_skill_projection", + "project_skill", + "publish_skill_projection", +] diff --git a/src/powercontext/client/projections/codex.py b/src/powercontext/client/projections/codex.py index 0ca8e0372..1bf9adb32 100644 --- a/src/powercontext/client/projections/codex.py +++ b/src/powercontext/client/projections/codex.py @@ -12,87 +12,82 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Project one exact managed Skill Revision into a Codex skill directory.""" +"""Client adapters for host-local Codex Skill projections.""" from __future__ import annotations -import hashlib -import json -import re -import shutil -import tempfile from pathlib import Path from powercontext.artifacts import ArtifactRef +from powercontext.builtin.artifacts.skill.external import AgentSkillTarget +from powercontext.builtin.artifacts.skill.models import SkillContent +from powercontext.builtin.artifacts.skill.projection import ( + PROJECTION_SCHEMA, + CodexSkillProjectionConflictError, + CodexSkillProjectionState, + CodexSkillProjectionStatus, +) +from powercontext.builtin.artifacts.skill.projection import inspect_skill_projection as _inspect_skill_projection +from powercontext.builtin.artifacts.skill.projection import project_skill as _project_skill +from powercontext.builtin.artifacts.skill.projection import publish_skill_projection as _publish_skill_projection from powercontext.http import SkillProposal -PROJECTION_SCHEMA = "powercontext.codex-skill-projection.v1" -MAX_CODEX_SKILL_NAME_LENGTH = 64 -MAX_CODEX_SKILL_DESCRIPTION_LENGTH = 1_024 -_CODEX_SKILL_NAME = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +def project_skill(artifact: ArtifactRef, content: SkillProposal, destination: Path, /) -> Path: + """Create a new host-local Codex projection without replacing existing content.""" + + return _project_skill(artifact, _runtime_content(content), _codex_target(destination.parent)) -def project_skill( + +def inspect_skill_projection( artifact: ArtifactRef, content: SkillProposal, - destination: Path, + root: Path, /, -) -> Path: - """Create a new host-local Codex projection without replacing existing content.""" +) -> CodexSkillProjectionStatus: + """Inspect an exact managed Skill projection without changing local files.""" + + return _inspect_skill_projection(artifact, _runtime_content(content), _codex_target(root)) + + +def publish_skill_projection( + artifact: ArtifactRef, + content: SkillProposal, + root: Path, + /, + *, + expected: CodexSkillProjectionStatus | None = None, +) -> CodexSkillProjectionStatus: + """Publish or safely update one exact managed Skill in a configured Codex root.""" + + return _publish_skill_projection(artifact, _runtime_content(content), _codex_target(root), expected=expected) + + +def _codex_target(root: Path) -> AgentSkillTarget: + return AgentSkillTarget( + target_id="client", + agent_kind="codex", + installation_scope="project", + path=root, + allow_managed_publish=True, + ) + - if artifact.family != "skill": - raise ValueError("artifact must identify a managed Skill") # noqa: TRY003 - target = destination.resolve(strict=False) - _validate_codex_projection(content, target) - if target.exists(): - raise FileExistsError(target) - target.parent.mkdir(parents=True, exist_ok=True) - skill_text = _skill_markdown(artifact, content) - manifest = { - "schema": PROJECTION_SCHEMA, - "artifact": artifact.model_dump(mode="json"), - "skill_sha256": hashlib.sha256(skill_text.encode("utf-8")).hexdigest(), - } - with tempfile.TemporaryDirectory(prefix=".powercontext-skill-", dir=target.parent) as temporary: - staging = Path(temporary) / "projection" - staging.mkdir() - (staging / "SKILL.md").write_text(skill_text, encoding="utf-8") - (staging / "powercontext.json").write_text( - f"{json.dumps(manifest, indent=2, sort_keys=True)}\n", - encoding="utf-8", - ) - shutil.copytree(staging, target) - return target - - -def _validate_codex_projection(content: SkillProposal, destination: Path) -> None: - if len(content.name) > MAX_CODEX_SKILL_NAME_LENGTH or _CODEX_SKILL_NAME.fullmatch(content.name) is None: - raise ValueError( # noqa: TRY003 - "managed Skill name must be at most 64 lowercase letters, digits, and single hyphens for Codex" - ) - if destination.name != content.name: - raise ValueError("Codex skill directory name must match the managed Skill name") # noqa: TRY003 - if len(content.description) > MAX_CODEX_SKILL_DESCRIPTION_LENGTH or any( - value in content.description for value in "<>" - ): - raise ValueError( # noqa: TRY003 - "managed Skill description must be at most 1024 characters and contain no angle brackets for Codex" - ) - - -def _skill_markdown(artifact: ArtifactRef, content: SkillProposal) -> str: - validation = "\n".join(f"- {item.root}" for item in content.validation) - exact_ref = f"artifact:{artifact.family}/{artifact.artifact_id}@{artifact.revision}" - return ( - "---\n" - f"name: {json.dumps(content.name, ensure_ascii=False)}\n" - f"description: {json.dumps(content.description, ensure_ascii=False)}\n" - "---\n\n" - f"\n\n" - f"{content.instructions.rstrip()}\n\n" - "## Validation\n\n" - f"{validation}\n" +def _runtime_content(content: SkillProposal) -> SkillContent: + return SkillContent( + name=content.name, + description=content.description, + instructions=content.instructions, + validation=tuple(item.root for item in content.validation), ) -__all__ = ["PROJECTION_SCHEMA", "project_skill"] +__all__ = [ + "PROJECTION_SCHEMA", + "CodexSkillProjectionConflictError", + "CodexSkillProjectionState", + "CodexSkillProjectionStatus", + "inspect_skill_projection", + "project_skill", + "publish_skill_projection", +] diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 2a198669b..ad2a598c1 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -325,10 +325,12 @@ class SkillValidationItem(RootModel[StrictStr]): class Provider(StrEnum): CODEX = "codex" + CLAUDE_CODE = "claude_code" class AgentKind(StrEnum): CODEX = "codex" + CLAUDE_CODE = "claude_code" class ErrorDetail(BaseModel): diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 3a689ac71..6be425400 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -2533,8 +2533,8 @@ "minLength": 1, "pattern": "^[\\x21-\\x7E]+$", }, - "provider": {"type": "string", "enum": ["codex"]}, - "agent_kind": {"type": "string", "enum": ["codex"]}, + "provider": {"type": "string", "enum": ["codex", "claude_code"]}, + "agent_kind": {"type": "string", "enum": ["codex", "claude_code"]}, "host_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^\\S(?:.*\\S)?$"}, "installation_scope": {"$ref": "#/components/schemas/ExternalSkillInstallationScope"}, "locator": { diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index 4b4a2b6d2..c37aa2a05 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -186,6 +186,7 @@ def _mount_optional_web_ui(app: FastAPI, settings: ServerSettings) -> None: dashboard_enabled=settings.dashboard.enabled, handoff_report_enabled=settings.handoff_report.enabled, authentication_required=settings.auth.enabled, + agent_skill_targets=settings.external_skills.agent_targets, ) if settings.dashboard.enabled: app.state.dashboard_started = True diff --git a/src/powercontext/server/middleware.py b/src/powercontext/server/middleware.py index 9b181164e..abd9891cf 100644 --- a/src/powercontext/server/middleware.py +++ b/src/powercontext/server/middleware.py @@ -25,7 +25,7 @@ from powercontext.http import ErrorDetail, ErrorResponse from powercontext.server.context import is_internal_bridge -_PUBLIC_PATHS = frozenset({"/", "/handoff-reports", "/health/live", "/health/ready"}) +_PUBLIC_PATHS = frozenset({"/", "/handoff-reports", "/reviews", "/skills", "/health/live", "/health/ready"}) _PUBLIC_PATH_PREFIXES = ("/static/",) diff --git a/src/powercontext/server/static/dashboard.js b/src/powercontext/server/static/dashboard.js index 6804549c1..b4e9f34e9 100644 --- a/src/powercontext/server/static/dashboard.js +++ b/src/powercontext/server/static/dashboard.js @@ -28,6 +28,8 @@ const translations = { en: { pageTitle: "PowerContext Dashboard", dashboardTitle: "Dashboard", + skillsTitle: "Skills", + reviewTitle: "Review", handoffReportTitle: "Handoff Report", brandHomeLabel: "PowerContext Dashboard", primaryNavigation: "Primary navigation", @@ -87,6 +89,8 @@ const translations = { zh: { pageTitle: "PowerContext 仪表盘", dashboardTitle: "仪表盘", + skillsTitle: "技能", + reviewTitle: "审核", handoffReportTitle: "交接报告", brandHomeLabel: "PowerContext 仪表盘", primaryNavigation: "主导航", diff --git a/src/powercontext/server/static/handoff-report.js b/src/powercontext/server/static/handoff-report.js index 23506d927..8d0cac06b 100644 --- a/src/powercontext/server/static/handoff-report.js +++ b/src/powercontext/server/static/handoff-report.js @@ -36,6 +36,8 @@ const translations = { en: { pageTitle: "PowerContext Handoff Report", dashboardTitle: "Dashboard", + skillsTitle: "Skills", + reviewTitle: "Review", handoffReportTitle: "Handoff Report", brandHomeLabel: "PowerContext Dashboard", primaryNavigation: "Primary navigation", @@ -236,6 +238,8 @@ const translations = { zh: { pageTitle: "PowerContext 项目交接报告", dashboardTitle: "仪表盘", + skillsTitle: "技能", + reviewTitle: "审核", handoffReportTitle: "交接报告", brandHomeLabel: "PowerContext 仪表盘", primaryNavigation: "主导航", diff --git a/src/powercontext/server/static/review.js b/src/powercontext/server/static/review.js new file mode 100644 index 000000000..ee7a4e665 --- /dev/null +++ b/src/powercontext/server/static/review.js @@ -0,0 +1,1935 @@ +/* + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use strict"; + +import { + clearServerToken, + fetchWithBearer, + readServerToken, + storeServerToken +} from "./auth.js?v=optional-auth"; +import {createPageUi, createRequestGate} from "./page-ui.js?v=locale-complete"; + +const translations = { + en: { + pageTitle: "PowerContext Review", + dashboardTitle: "Dashboard", + skillsTitle: "Skills", + reviewTitle: "Review", + handoffReportTitle: "Handoff Report", + brandHomeLabel: "PowerContext Dashboard", + primaryNavigation: "Primary navigation", + maintainedBy: "Maintained by OceanBase.", + signOut: "Sign out", + switchDark: "Switch to dark mode", + switchLight: "Switch to light mode", + switchChinese: "Switch to Chinese", + switchEnglish: "Switch to English", + languageChinese: "中文", + languageEnglish: "EN", + authTitle: "Connect to PowerContext", + authIntro: "Enter the bearer token configured for this PowerContext Server. The token stays in this browser tab.", + tokenLabel: "Server token", + continue: "Continue", + reviewInboxTitle: "Experience and Skill review", + reviewIntro: "Inspect evidence, revise proposals, and make explicit decisions.", + selectScope: "Scope", + searchScopesPlaceholder: "Search by scope name or ID", + scopeSearchCount: "{count} scopes", + scopeSearchMatches: "{count} matching scopes", + scopeSearchLimited: "Showing {shown} of {total} matching scopes", + noMatchingScopes: "No scopes match this search.", + reviewFilters: "Review filters", + family: "Family", + allFamilies: "All families", + experience: "Experience", + skill: "Skill", + status: "Status", + pending: "Pending", + approved: "Approved", + rejected: "Rejected", + refresh: "Refresh", + candidateQueue: "Candidates", + loadedCandidates: "{count} loaded", + noCandidates: "No Candidates match these filters.", + noCandidatesHint: "Try another family or status.", + loadMore: "Load more", + candidateDetail: "Candidate detail", + selectCandidate: "Select a Candidate to inspect it.", + selectCandidateHint: "The full proposal and exact evidence references will appear here.", + proposal: "Proposal", + evidence: "Evidence", + sourceReferences: "Source references", + artifactReferences: "Artifact references", + noSourceReferences: "No Source references", + noArtifactReferences: "No Artifact references", + lineage: "Lineage and decision", + reason: "Proposal reason", + target: "Target Artifact", + resultArtifact: "Result Artifact", + decisionReason: "Decision reason", + managedSkillPublication: "Managed Skill delivery", + publicationIntro: "Create a reviewed revision or publish the approved revision to an Agent Skill target.", + createSkillRevision: "Create revision", + createSkillRevisionTitle: "Create a new Skill revision", + createSkillRevisionNote: "Describe the change evidence, then edit the Skill. Saving creates a Pending Candidate and leaves the approved revision unchanged.", + changeEvidence: "Change evidence", + basedOnSkillRevision: "Based on Skill revision {revision}", + createCandidate: "Create Candidate", + publishTarget: "Publish target", + 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", + discovery: "Discovery", + publishSkill: "Publish Skill", + updateSkill: "Publish 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.", + projectionUnpublished: "Not published", + projectionCurrent: "Current", + projectionUpdateAvailable: "Update available", + projectionConflict: "Target conflict", + projectionDrifted: "Locally modified", + projectionIncompatible: "Not compatible", + projectionConflictHint: "The destination is occupied or a newer Revision is already present. PowerContext will not overwrite it.", + projectionDriftedHint: "This PowerContext package was modified locally. Restore it or choose another target before publishing.", + projectionIncompatibleHint: "Revise the Skill name or description to satisfy the selected Agent's package constraints, then approve a new Revision.", + discoveryAvailable: "Available in the configured Agent target", + discoveryUnavailable: "Package exists; discovery needs refresh", + 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}.", + publicationConflict: "The publication target changed or contains content PowerContext will not overwrite.", + publishing: "Publishing Skill...", + creatingSkillRevision: "Creating revision Candidate...", + skillRevisionCreated: "A new Skill revision Candidate was created and is ready for review.", + skillRevisionTargetChanged: "A newer Skill revision is already current. Keep this draft, then open the latest approved Skill and create its next revision.", + notProvided: "Not provided", + version: "Version {version}", + situation: "Situation", + action: "Action", + outcome: "Outcome", + lesson: "Lesson", + name: "Name", + description: "Description", + instructions: "Instructions", + validation: "Validation", + revise: "Revise", + approve: "Approve", + reject: "Reject", + reviseProposal: "Revise proposal", + revisionEvidenceNote: "Evidence and lineage references remain unchanged.", + editingVersion: "Editing version {version}", + saveRevision: "Save revision", + cancel: "Cancel", + addValidation: "Add validation item", + removeValidation: "Remove", + validationItem: "Validation item {number}", + approveCandidate: "Approve Candidate?", + approveConfirmation: "This creates or updates the managed Artifact from the current proposal.", + rejectCandidate: "Reject Candidate?", + rejectConfirmation: "Record a clear reason for rejecting this proposal.", + rejectionReason: "Rejection reason", + resumeDraft: "Resume revision", + discardDraft: "Discard draft", + revisionSaved: "Revision saved as version {version}.", + candidateApproved: "Candidate approved.", + candidateRejected: "Candidate rejected.", + draftConflict: "The Candidate changed while you were editing. The latest version is shown. Resume to apply your draft to it, or discard the draft.", + candidateChanged: "The Candidate changed. The latest version is shown; review it before deciding again.", + candidateTerminal: "The Candidate was already decided. Its latest state is shown.", + candidateMissing: "The Candidate no longer exists. The queue has been refreshed.", + unsupportedCandidate: "This Candidate shape is not supported by this page. Decisions are disabled.", + requestFailed: "The Review request failed with HTTP {status}.", + validationFailed: "Check the proposal fields and try again.", + serverUnavailable: "The Server is unavailable.", + authRejected: "The Server rejected this token.", + retry: "Retry", + noScopes: "No Dashboard scopes are configured.", + scopeUnavailable: "The selected scope is not available.", + loading: "Loading...", + loadingMore: "Loading more...", + saving: "Saving...", + deciding: "Recording decision...", + queueLoadFailed: "The Candidate queue could not be loaded. HTTP {status}.", + detailLoadFailed: "The Candidate detail could not be loaded. HTTP {status}." + }, + zh: { + pageTitle: "PowerContext 审核", + dashboardTitle: "仪表盘", + skillsTitle: "技能", + reviewTitle: "审核", + handoffReportTitle: "交接报告", + brandHomeLabel: "PowerContext 仪表盘", + primaryNavigation: "主导航", + maintainedBy: "由 OceanBase 维护。", + signOut: "退出", + switchDark: "切换至深色模式", + switchLight: "切换至浅色模式", + switchChinese: "切换至中文", + switchEnglish: "切换至英文", + languageChinese: "中文", + languageEnglish: "EN", + authTitle: "连接 PowerContext", + authIntro: "请输入 PowerContext 服务器配置的访问令牌。令牌仅保留在当前浏览器标签页。", + tokenLabel: "服务器访问令牌", + continue: "继续", + reviewInboxTitle: "经验与技能审核", + reviewIntro: "检查证据、修订提案,并作出明确决策。", + selectScope: "作用域", + searchScopesPlaceholder: "按作用域名称或标识符搜索", + scopeSearchCount: "共 {count} 个作用域", + scopeSearchMatches: "找到 {count} 个作用域", + scopeSearchLimited: "显示 {total} 个匹配项中的前 {shown} 个", + noMatchingScopes: "没有匹配的作用域。", + reviewFilters: "审核筛选条件", + family: "类型", + allFamilies: "全部类型", + experience: "经验", + skill: "技能", + status: "状态", + pending: "待审核", + approved: "已批准", + rejected: "已拒绝", + refresh: "刷新", + candidateQueue: "候选列表", + loadedCandidates: "已加载 {count} 项", + noCandidates: "没有符合筛选条件的候选。", + noCandidatesHint: "请尝试其他类型或状态。", + loadMore: "加载更多", + candidateDetail: "候选详情", + selectCandidate: "请选择一项候选进行检查。", + selectCandidateHint: "此处将显示完整提案和准确的证据引用。", + proposal: "提案", + evidence: "证据", + sourceReferences: "数据源引用", + artifactReferences: "制品引用", + noSourceReferences: "无数据源引用", + noArtifactReferences: "无制品引用", + lineage: "沿袭关系与决策", + reason: "提案原因", + target: "目标制品", + resultArtifact: "结果制品", + decisionReason: "决策原因", + managedSkillPublication: "受管技能交付", + publicationIntro: "创建待审核的新修订,或将已批准修订发布到代理技能目录。", + createSkillRevision: "创建新修订", + createSkillRevisionTitle: "创建新的技能修订", + createSkillRevisionNote: "请先说明本次修改的证据,再编辑技能内容。保存后会创建一项待审核候选,已批准修订保持不变。", + changeEvidence: "修改证据", + basedOnSkillRevision: "基于技能第 {revision} 版", + createCandidate: "创建候选", + publishTarget: "发布目标", + agentCodex: "Codex", + agentClaudeCode: "Claude Code", + installationUser: "用户级", + installationProject: "项目级", + installationPlugin: "插件级", + noPublishTargets: "未配置可写的技能目标。", + noPublishTargetsHint: "请在一个明确的本地技能目录上启用受管发布。", + publishedRevision: "已发布修订", + destination: "目标位置", + discovery: "发现状态", + publishSkill: "发布技能", + updateSkill: "发布更新", + refreshDiscovery: "刷新发现状态", + publishSkillCandidate: "发布这项受管技能?", + publishConfirmation: "将第 {revision} 版发布到 {target}。系统只会安全更新由 PowerContext 管理的内容,不会覆盖外部内容或已被本地修改的内容。", + projectionUnpublished: "尚未发布", + projectionCurrent: "已是当前版本", + projectionUpdateAvailable: "有更新可发布", + projectionConflict: "目标存在冲突", + projectionDrifted: "已被本地修改", + projectionIncompatible: "格式不兼容", + projectionConflictHint: "目标已被占用或包含更高修订,系统不会覆盖它。", + projectionDriftedHint: "该受管技能包已在本地被修改。请先恢复它,或选择其他目标。", + projectionIncompatibleHint: "请修订技能名称或说明以满足目标格式要求,再批准新的修订。", + discoveryAvailable: "已在配置的技能目录中可用", + discoveryUnavailable: "技能包已存在,需要刷新发现状态", + discoveryNotPublished: "尚不可用", + publicationLoading: "正在检查发布状态……", + publicationLoadFailed: "无法加载发布状态(HTTP {status})。", + publicationSucceeded: "受管技能第 {revision} 版已发布并可被发现。", + publicationFailed: "无法发布受管技能(HTTP {status})。", + publicationConflict: "发布目标已发生变化,或包含系统不会覆盖的内容。", + publishing: "正在发布技能……", + creatingSkillRevision: "正在创建修订候选……", + skillRevisionCreated: "新的技能修订候选已创建,可以开始审核。", + skillRevisionTargetChanged: "当前已有更新的技能修订。请保留该草稿,打开最新的已批准技能后再创建下一修订。", + notProvided: "未提供", + version: "第 {version} 版", + situation: "情境", + action: "行动", + outcome: "结果", + lesson: "经验总结", + name: "名称", + description: "说明", + instructions: "使用指引", + validation: "验证条件", + revise: "修订", + approve: "批准", + reject: "拒绝", + reviseProposal: "修订提案", + revisionEvidenceNote: "证据与沿袭引用保持不变。", + editingVersion: "正在编辑第 {version} 版", + saveRevision: "保存修订", + cancel: "取消", + addValidation: "添加验证条件", + removeValidation: "移除", + validationItem: "验证条件 {number}", + approveCandidate: "批准这项候选?", + approveConfirmation: "此操作将依据当前提案创建或更新受管理的制品。", + rejectCandidate: "拒绝这项候选?", + rejectConfirmation: "请记录拒绝该提案的明确原因。", + rejectionReason: "拒绝原因", + resumeDraft: "继续修订", + discardDraft: "放弃草稿", + revisionSaved: "修订已保存为第 {version} 版。", + candidateApproved: "候选已批准。", + candidateRejected: "候选已拒绝。", + draftConflict: "你编辑期间候选已发生变化。当前显示最新内容。继续修订可将草稿应用到最新内容,也可以放弃草稿。", + candidateChanged: "候选已发生变化。当前显示最新内容,请重新检查后再作决定。", + candidateTerminal: "该候选已完成决策。当前显示其最新状态。", + candidateMissing: "该候选已不存在,列表已刷新。", + unsupportedCandidate: "该候选的数据结构暂不受此页面支持,决策操作已禁用。", + requestFailed: "审核请求失败(HTTP {status})。", + validationFailed: "请检查提案字段后重试。", + serverUnavailable: "服务器无法访问。", + authRejected: "服务器拒绝了该访问令牌。", + retry: "重试", + noScopes: "未配置仪表盘作用域。", + scopeUnavailable: "选中的作用域不可用。", + loading: "正在加载……", + loadingMore: "正在加载更多……", + saving: "正在保存……", + deciding: "正在记录决策……", + queueLoadFailed: "无法加载候选列表(HTTP {status})。", + detailLoadFailed: "无法加载候选详情(HTTP {status})。" + } +}; + +class ReviewRequestError extends Error { + constructor(status, code = "", details = null) { + super(`Review request failed with HTTP ${status}`); + this.status = status; + this.code = code; + this.details = details; + } +} + +const authShell = document.getElementById("auth-shell"); +const authForm = document.getElementById("auth-form"); +const authError = document.getElementById("auth-error"); +const tokenInput = document.getElementById("token"); +const pageStatus = document.getElementById("page-status"); +const pageStatusMessage = document.getElementById("page-status-message"); +const pageStatusRetry = document.getElementById("page-status-retry"); +const reviewInbox = document.getElementById("review-inbox"); +const signOut = document.getElementById("sign-out"); +const scopeCombobox = document.getElementById("review-scope-combobox"); +const scopeSearchInput = document.getElementById("review-scope-search"); +const scopeOptions = document.getElementById("review-scope-options"); +const scopeSearchStatus = document.getElementById("review-scope-search-status"); +const familyFilter = document.getElementById("review-family-filter"); +const statusFilter = document.getElementById("review-status-filter"); +const refreshButton = document.getElementById("review-refresh"); +const liveStatus = document.getElementById("review-live-status"); +const queueCaption = document.getElementById("review-queue-caption"); +const candidateList = document.getElementById("review-list"); +const emptyState = document.getElementById("review-empty"); +const loadMoreButton = document.getElementById("review-load-more"); +const detailEmpty = document.getElementById("review-detail-empty"); +const detailContent = document.getElementById("review-detail-content"); +const detailFamily = document.getElementById("review-detail-family"); +const candidateTitle = document.getElementById("review-candidate-title"); +const candidateId = document.getElementById("review-candidate-id"); +const detailStatus = document.getElementById("review-detail-status"); +const detailVersion = document.getElementById("review-detail-version"); +const alertBox = document.getElementById("review-alert"); +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 sourceRefs = document.getElementById("review-source-refs"); +const artifactRefs = document.getElementById("review-artifact-refs"); +const lineageFields = document.getElementById("review-lineage-fields"); +const publicationSection = document.getElementById("review-publication"); +const publicationState = document.getElementById("review-publication-state"); +const publicationStatus = document.getElementById("review-publication-status"); +const publicationEmpty = document.getElementById("review-publication-empty"); +const publicationContent = document.getElementById("review-publication-content"); +const publicationTarget = document.getElementById("review-publication-target"); +const publishedRevision = document.getElementById("review-published-revision"); +const publicationDestination = document.getElementById("review-publication-destination"); +const publicationDiscovery = document.getElementById("review-publication-discovery"); +const createSkillRevisionButton = document.getElementById("review-create-skill-revision"); +const publishSkillButton = document.getElementById("review-publish-skill"); +const reviewActions = document.getElementById("review-actions"); +const editButton = document.getElementById("review-edit"); +const approveButton = document.getElementById("review-approve"); +const rejectButton = document.getElementById("review-reject"); +const revisionForm = document.getElementById("review-revision-form"); +const revisionTitle = document.getElementById("review-revision-title"); +const revisionNote = document.getElementById("review-revision-note"); +const revisionFields = document.getElementById("review-form-fields"); +const draftVersion = document.getElementById("review-draft-version"); +const saveRevisionButton = document.getElementById("review-save-revision"); +const cancelRevisionButton = document.getElementById("review-cancel-revision"); +const approveDialog = document.getElementById("review-approve-dialog"); +const confirmApproveButton = document.getElementById("review-confirm-approve"); +const rejectDialog = document.getElementById("review-reject-dialog"); +const rejectForm = document.getElementById("review-reject-form"); +const rejectReason = document.getElementById("review-reject-reason"); +const cancelRejectButton = document.getElementById("review-cancel-reject"); +const publishDialog = document.getElementById("review-publish-dialog"); +const publishConfirmation = document.getElementById("review-publish-confirmation"); +const confirmPublishButton = document.getElementById("review-confirm-publish"); +const authenticationRequired = document.documentElement.dataset.serverAuthRequired === "true"; +const reviewDeepLink = readReviewDeepLink(); + +let scopes = []; +let candidates = []; +let nextCursor = null; +let selectedCandidate = null; +let selectedCandidateId = ""; +let currentScopeId = ""; +let currentAuthError = null; +let currentPageStatus = null; +let currentNotice = null; +let draft = null; +let conflictDraft = null; +let projectionView = null; +let projectionLoading = false; +let busy = false; +let scopeActiveIndex = -1; + +const scopeOptionRenderLimit = 50; + +const scopeRequests = createRequestGate(); +const listRequests = createRequestGate(); +const detailRequests = createRequestGate(); +const actionRequests = createRequestGate(); +const projectionRequests = createRequestGate(); +const ui = createPageUi(translations, () => { + renderAuthError(); + renderPageStatus(); + renderScopeCombobox(); + renderFilters(); + renderQueue(); + renderDetail(); + renderNotice(); +}); +const {formatNumber, translate} = ui; + +scopeSearchInput.addEventListener("focus", () => { + if (scopeOptions.hidden) { + scopeSearchInput.value = ""; + } + openScopeOptions(); +}); + +scopeSearchInput.addEventListener("input", () => { + scopeActiveIndex = -1; + renderScopeOptionsList(); + openScopeOptions(); +}); + +scopeSearchInput.addEventListener("keydown", (event) => { + handleScopeSearchKeydown(event); +}); + +scopeCombobox.addEventListener("focusout", (event) => { + if (!scopeCombobox.contains(event.relatedTarget)) { + closeScopeOptions({restoreSelection: true}); + } +}); + +familyFilter.addEventListener("change", () => { + resetQueue(); + void loadCandidates(false); +}); + +statusFilter.addEventListener("change", () => { + resetQueue(); + void loadCandidates(false); +}); + +refreshButton.addEventListener("click", () => { + resetQueue(); + void loadCandidates(false); +}); + +loadMoreButton.addEventListener("click", () => { + void loadCandidates(true); +}); + +pageStatusRetry.addEventListener("click", () => { + void authenticate(readServerToken(), currentScopeId); +}); + +authForm.addEventListener("submit", (event) => { + event.preventDefault(); + authError.textContent = ""; + void authenticate(tokenInput.value); +}); + +signOut.addEventListener("click", () => { + clearServerToken(); + tokenInput.value = ""; + showLogin(); +}); + +editButton.addEventListener("click", () => { + if (selectedCandidate && isSupportedCandidate(selectedCandidate)) { + startRevision(selectedCandidate.proposal, selectedCandidate.version); + } +}); + +cancelRevisionButton.addEventListener("click", () => { + draft = null; + revisionForm.hidden = true; + renderDetail(); +}); + +revisionForm.addEventListener("submit", (event) => { + event.preventDefault(); + void saveRevision(); +}); + +approveButton.addEventListener("click", () => { + if (canDecide(selectedCandidate)) { + approveDialog.showModal(); + } +}); + +confirmApproveButton.addEventListener("click", (event) => { + event.preventDefault(); + approveDialog.close(); + void decideCandidate("approve"); +}); + +rejectButton.addEventListener("click", () => { + if (canDecide(selectedCandidate)) { + rejectReason.value = ""; + rejectDialog.showModal(); + rejectReason.focus(); + } +}); + +cancelRejectButton.addEventListener("click", () => { + rejectDialog.close(); +}); + +rejectForm.addEventListener("submit", (event) => { + event.preventDefault(); + if (!rejectForm.reportValidity()) { + return; + } + rejectDialog.close(); + void decideCandidate("reject", rejectReason.value.trim()); +}); + +publicationTarget.addEventListener("change", () => { + renderPublication(); +}); + +createSkillRevisionButton.addEventListener("click", () => { + if (isPublishableCandidate(selectedCandidate)) { + startSkillRevision(selectedCandidate); + } +}); + +publishSkillButton.addEventListener("click", () => { + const target = selectedProjectionTarget(); + if (!selectedCandidate?.result_artifact || !target || !canPublishProjection(target)) { + return; + } + publishConfirmation.textContent = translate("publishConfirmation", { + revision: selectedCandidate.result_artifact.revision, + target: `${agentLabel(target.agent_kind)} · ${target.target_id}` + }); + publishDialog.showModal(); +}); + +confirmPublishButton.addEventListener("click", (event) => { + event.preventDefault(); + publishDialog.close(); + void publishSelectedSkill(); +}); + +resumeDraftButton.addEventListener("click", () => { + if (!selectedCandidate || !conflictDraft) { + return; + } + const preservedDraft = conflictDraft; + const proposal = structuredClone(preservedDraft.proposal); + conflictDraft = null; + currentNotice = null; + startRevision(proposal, selectedCandidate.version, preservedDraft.mode); +}); + +discardDraftButton.addEventListener("click", () => { + conflictDraft = null; + currentNotice = null; + renderNotice(); +}); + +async function authenticate(token, preferredScopeId = "") { + if (authenticationRequired && !token) { + showLogin(); + return; + } + if (authenticationRequired) { + storeServerToken(token); + } + tokenInput.value = ""; + currentAuthError = null; + const request = scopeRequests.start(); + scopeSearchInput.disabled = true; + try { + const response = await fetchWithBearer("/dashboard/scopes", token); + if (!request.isCurrent()) { + return; + } + if (response.status === 401) { + clearServerToken(); + showLogin("authRejected"); + return; + } + if (!response.ok) { + showPageStatus("requestFailed", {status: response.status}, true); + return; + } + scopes = await response.json(); + if (!request.isCurrent()) { + return; + } + if (scopes.length === 0) { + showPageStatus("noScopes", {}, true); + return; + } + const selectedScopeId = scopes.some((scope) => scope.scope_id === preferredScopeId) + ? preferredScopeId + : scopes[0].scope_id; + currentScopeId = selectedScopeId; + showReview(); + renderScopeCombobox(); + if (reviewDeepLink.family === "experience" || reviewDeepLink.family === "skill") { + familyFilter.value = reviewDeepLink.family; + } + if (["pending", "approved", "rejected"].includes(reviewDeepLink.status)) { + statusFilter.value = reviewDeepLink.status; + } + renderFilters(); + resetQueue(); + await loadCandidates(false, reviewDeepLink.candidate); + if ( + reviewDeepLink.action === "create-revision" + && selectedCandidate?.candidate_id === reviewDeepLink.candidate + ) { + startSkillRevision(selectedCandidate); + reviewDeepLink.action = ""; + } + } catch (error) { + if (request.isCurrent()) { + showPageStatus("serverUnavailable", {}, true); + } + } finally { + if (request.isCurrent()) { + scopeSearchInput.disabled = false; + } + } +} + +async function loadCandidates(append, preferredCandidateId = "") { + if (!currentScopeId || busy) { + return; + } + const request = listRequests.start(); + const cursor = append ? nextCursor : null; + setBusy(true, append ? "loadingMore" : "loading"); + try { + const body = { + scope_id: currentScopeId, + status: statusFilter.value, + limit: 50 + }; + if (familyFilter.value) { + body.family = familyFilter.value; + } + if (cursor) { + body.cursor = cursor; + } + const page = await requestJson("/v1/artifact-candidates/list", body); + if (!request.isCurrent()) { + return; + } + candidates = append ? candidates.concat(page.candidates) : page.candidates; + nextCursor = page.next_cursor; + currentNotice = null; + let preferred = candidates.find((candidate) => candidate.candidate_id === preferredCandidateId) || null; + if (!append && preferredCandidateId && !preferred) { + try { + const candidate = await requestJson("/v1/artifact-candidates/get", { + scope_id: currentScopeId, + candidate_id: preferredCandidateId + }); + if ( + candidate.status === statusFilter.value + && (!familyFilter.value || candidate.family === familyFilter.value) + ) { + candidates.unshift(candidate); + preferred = candidate; + } + } catch (error) { + if (!(error instanceof ReviewRequestError) || error.status !== 404) { + throw error; + } + } + } + renderQueue(); + if (!append || !selectedCandidateId) { + const first = preferred || candidates[0]; + if (first) { + await selectCandidate(first.candidate_id); + } else { + clearDetail(); + } + } + } catch (error) { + if (!request.isCurrent() || handleAuthenticationError(error)) { + return; + } + setNotice(error instanceof ReviewRequestError ? "queueLoadFailed" : "serverUnavailable", { + status: error.status + }); + renderQueue(); + } finally { + if (request.isCurrent()) { + setBusy(false); + } + } +} + +async function selectCandidate(id) { + selectedCandidateId = id; + draft = null; + conflictDraft = null; + revisionForm.hidden = true; + projectionRequests.cancel(); + projectionView = null; + projectionLoading = false; + currentNotice = null; + renderQueue(); + const request = detailRequests.start(); + setBusy(true, "loading"); + try { + const candidate = await requestJson("/v1/artifact-candidates/get", { + scope_id: currentScopeId, + candidate_id: id + }); + if (!request.isCurrent() || selectedCandidateId !== id) { + return; + } + selectedCandidate = candidate; + replaceCandidate(candidate); + renderQueue(); + renderDetail(); + await loadProjectionStatus(candidate); + } catch (error) { + if (!request.isCurrent() || handleAuthenticationError(error)) { + return; + } + if (error instanceof ReviewRequestError && error.status === 404) { + removeCandidate(id); + await selectFirstAvailable(); + setNotice("candidateMissing"); + return; + } + setNotice(error instanceof ReviewRequestError ? "detailLoadFailed" : "serverUnavailable", { + status: error.status + }); + } finally { + if (request.isCurrent()) { + setBusy(false); + } + } +} + +async function loadProjectionStatus(candidate) { + if (!isPublishableCandidate(candidate)) { + projectionView = null; + projectionLoading = false; + renderPublication(); + return; + } + const request = projectionRequests.start(); + projectionLoading = true; + projectionView = null; + renderPublication(); + try { + const view = await requestJson("/dashboard/skill-projections/status", { + scope_id: currentScopeId, + candidate_id: candidate.candidate_id, + artifact: candidate.result_artifact + }); + if (!request.isCurrent() || selectedCandidateId !== candidate.candidate_id) { + return; + } + projectionView = view; + } catch (error) { + if (!request.isCurrent() || handleAuthenticationError(error)) { + return; + } + setNotice(error instanceof ReviewRequestError ? "publicationLoadFailed" : "serverUnavailable", { + status: error.status + }); + } finally { + if (request.isCurrent()) { + projectionLoading = false; + renderPublication(); + } + } +} + +async function publishSelectedSkill() { + const candidate = selectedCandidate; + const target = selectedProjectionTarget(); + if (!isPublishableCandidate(candidate) || !target || !canPublishProjection(target)) { + return; + } + const request = projectionRequests.start(); + let refreshAfterConflict = false; + setBusy(true, "publishing"); + try { + const view = await requestJson("/dashboard/skill-projections/publish", { + scope_id: currentScopeId, + candidate_id: candidate.candidate_id, + artifact: candidate.result_artifact, + target_id: target.target_id + }); + if (!request.isCurrent() || selectedCandidateId !== candidate.candidate_id) { + return; + } + projectionView = view; + setNotice("publicationSucceeded", {revision: candidate.result_artifact.revision}, "success"); + } catch (error) { + if (!request.isCurrent() || handleAuthenticationError(error)) { + return; + } + if (error instanceof ReviewRequestError && error.status === 409) { + setNotice("publicationConflict"); + refreshAfterConflict = true; + } else { + setNotice(error instanceof ReviewRequestError ? "publicationFailed" : "serverUnavailable", { + status: error.status + }); + } + } finally { + if (request.isCurrent()) { + setBusy(false); + renderPublication(); + if (refreshAfterConflict) { + void loadProjectionStatus(candidate); + } + } + } +} + +async function saveRevision() { + const createsSkillRevision = draft?.mode === "create-skill-revision"; + const canSave = createsSkillRevision + ? isPublishableCandidate(selectedCandidate) + : canDecide(selectedCandidate); + if (!canSave || !revisionForm.reportValidity()) { + return; + } + const proposal = collectProposal(); + if (!proposal) { + setNotice("validationFailed"); + return; + } + const changeEvidence = createsSkillRevision + ? revisionForm.elements.namedItem("changeEvidence").value.trim() + : null; + if (createsSkillRevision && !changeEvidence) { + setNotice("validationFailed"); + return; + } + draft = { + ...draft, + candidateId: selectedCandidate.candidate_id, + baseVersion: selectedCandidate.version, + proposal: structuredClone(proposal), + changeEvidence + }; + if (createsSkillRevision) { + await createSkillRevisionCandidate(proposal, changeEvidence); + return; + } + setBusy(true, "saving"); + const request = actionRequests.start(); + try { + const revised = await requestJson("/v1/artifact-candidates/revise", { + scope_id: currentScopeId, + candidate_id: selectedCandidate.candidate_id, + expected_version: selectedCandidate.version, + proposal, + source_refs: selectedCandidate.source_refs, + artifact_refs: selectedCandidate.artifact_refs, + target: selectedCandidate.target, + reason: selectedCandidate.reason + }); + if (!request.isCurrent()) { + return; + } + selectedCandidate = revised; + draft = null; + conflictDraft = null; + replaceCandidate(revised); + revisionForm.hidden = true; + setNotice("revisionSaved", {version: revised.version}, "success"); + renderQueue(); + renderDetail(); + } catch (error) { + if (!request.isCurrent() || handleAuthenticationError(error)) { + return; + } + if (isCandidateConflict(error)) { + const preservedDraft = draft; + await loadLatestCandidate(selectedCandidate.candidate_id); + conflictDraft = preservedDraft; + draft = null; + revisionForm.hidden = true; + setNotice(error.code === "candidate_terminal" ? "candidateTerminal" : "draftConflict"); + renderDetail(); + return; + } + if (error instanceof ReviewRequestError && error.status === 404) { + draft = null; + await handleMissingCandidate(); + return; + } + setNotice( + error instanceof ReviewRequestError && error.status === 422 ? "validationFailed" : + (error instanceof ReviewRequestError ? "requestFailed" : "serverUnavailable"), + {status: error.status} + ); + } finally { + if (request.isCurrent()) { + setBusy(false); + } + } +} + +async function createSkillRevisionCandidate(proposal, changeEvidence) { + const candidate = selectedCandidate; + if (!isPublishableCandidate(candidate)) { + return; + } + const target = candidate.result_artifact; + const request = actionRequests.start(); + setBusy(true, "creatingSkillRevision"); + try { + let source = draft?.capturedEvidence === changeEvidence ? draft.sourceRef : null; + if (!source) { + const captured = await requestJson("/v1/sources/content", { + scope_id: currentScopeId, + source_id: `review-skill-revision-${crypto.randomUUID()}`, + content: changeEvidence + }); + if (!request.isCurrent()) { + return; + } + source = captured.source; + draft = {...draft, sourceRef: source, capturedEvidence: changeEvidence}; + } + const created = await requestJson("/v1/skill/propose", { + scope_id: currentScopeId, + proposal, + source_refs: [source], + artifact_refs: [target], + target, + reason: changeEvidence + }); + if (!request.isCurrent()) { + return; + } + draft = null; + revisionForm.hidden = true; + setBusy(false); + familyFilter.value = "skill"; + statusFilter.value = "pending"; + resetQueue(false); + await loadCandidates(false, created.candidate_id); + setNotice("skillRevisionCreated", {}, "success"); + } catch (error) { + if (!request.isCurrent() || handleAuthenticationError(error)) { + return; + } + if (error instanceof ReviewRequestError && error.status === 409 && error.code === "artifact_conflict") { + setNotice("skillRevisionTargetChanged"); + return; + } + setNotice( + error instanceof ReviewRequestError && error.status === 422 ? "validationFailed" : + (error instanceof ReviewRequestError ? "requestFailed" : "serverUnavailable"), + {status: error.status} + ); + } finally { + if (request.isCurrent()) { + setBusy(false); + } + } +} + +async function decideCandidate(decision, reason = "") { + if (!canDecide(selectedCandidate)) { + return; + } + const id = selectedCandidate.candidate_id; + const body = { + scope_id: currentScopeId, + candidate_id: id, + expected_version: selectedCandidate.version + }; + if (decision === "reject") { + body.reason = reason; + } + const request = actionRequests.start(); + setBusy(true, "deciding"); + try { + const decided = await requestJson(`/v1/artifact-candidates/${decision}`, body); + if (!request.isCurrent()) { + return; + } + const noticeKey = decision === "approve" ? "candidateApproved" : "candidateRejected"; + setBusy(false); + statusFilter.value = decided.status; + resetQueue(false); + await loadCandidates(false, id); + setNotice(noticeKey, {}, "success"); + } catch (error) { + if (!request.isCurrent() || handleAuthenticationError(error)) { + return; + } + if (isCandidateConflict(error)) { + await loadLatestCandidate(id); + setNotice(error.code === "candidate_terminal" ? "candidateTerminal" : "candidateChanged"); + renderDetail(); + return; + } + if (error instanceof ReviewRequestError && error.status === 404) { + await handleMissingCandidate(); + return; + } + setNotice(error instanceof ReviewRequestError ? "requestFailed" : "serverUnavailable", { + status: error.status + }); + } finally { + if (request.isCurrent()) { + setBusy(false); + } + } +} + +async function loadLatestCandidate(id) { + try { + const candidate = await requestJson("/v1/artifact-candidates/get", { + scope_id: currentScopeId, + candidate_id: id + }); + selectedCandidate = candidate; + selectedCandidateId = candidate.candidate_id; + replaceCandidate(candidate); + renderQueue(); + renderDetail(); + await loadProjectionStatus(candidate); + } catch (error) { + if (handleAuthenticationError(error)) { + return; + } + if (error instanceof ReviewRequestError && error.status === 404) { + await handleMissingCandidate(); + return; + } + setNotice(error instanceof ReviewRequestError ? "detailLoadFailed" : "serverUnavailable", { + status: error.status + }); + } +} + +async function handleMissingCandidate() { + removeCandidate(selectedCandidateId); + await selectFirstAvailable(); + setNotice("candidateMissing"); +} + +async function selectFirstAvailable() { + const first = candidates[0]; + if (first) { + await selectCandidate(first.candidate_id); + } else { + clearDetail(); + renderQueue(); + } +} + +async function requestJson(path, body) { + const response = await fetchWithBearer(path, readServerToken(), { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(body) + }); + let payload = null; + try { + payload = await response.json(); + } catch (error) { + // The status code still gives the page a safe error path. + } + if (!response.ok) { + throw new ReviewRequestError( + response.status, + payload?.error?.code || "", + payload?.error?.details || null + ); + } + return payload; +} + +function handleAuthenticationError(error) { + if (!(error instanceof ReviewRequestError) || error.status !== 401) { + return false; + } + clearServerToken(); + showLogin("authRejected"); + return true; +} + +function isCandidateConflict(error) { + return error instanceof ReviewRequestError && error.status === 409 && [ + "candidate_conflict", + "artifact_conflict", + "candidate_terminal" + ].includes(error.code); +} + +function resetQueue(clearNotice = true) { + listRequests.cancel(); + detailRequests.cancel(); + actionRequests.cancel(); + candidates = []; + nextCursor = null; + selectedCandidate = null; + selectedCandidateId = ""; + draft = null; + conflictDraft = null; + if (clearNotice) { + currentNotice = null; + } + clearDetail(); + renderQueue(); +} + +function showLogin(messageKey = "") { + scopeRequests.cancel(); + resetQueue(); + scopes = []; + currentScopeId = ""; + currentPageStatus = null; + currentAuthError = messageKey ? {key: messageKey, values: {}} : null; + renderAuthError(); + authShell.hidden = false; + pageStatus.hidden = true; + reviewInbox.hidden = true; + signOut.hidden = true; + tokenInput.focus(); +} + +function showPageStatus(messageKey, values = {}, retryable = false) { + currentPageStatus = {key: messageKey, values, retryable}; + renderPageStatus(); + authShell.hidden = true; + pageStatus.hidden = false; + reviewInbox.hidden = true; + signOut.hidden = !authenticationRequired; +} + +function showReview() { + currentPageStatus = null; + authShell.hidden = true; + pageStatus.hidden = true; + reviewInbox.hidden = false; + signOut.hidden = !authenticationRequired; +} + +function renderAuthError() { + authError.textContent = currentAuthError ? translate(currentAuthError.key, currentAuthError.values) : ""; +} + +function renderPageStatus() { + if (!currentPageStatus) { + pageStatusMessage.textContent = ""; + pageStatusRetry.hidden = true; + return; + } + pageStatusMessage.textContent = translate(currentPageStatus.key, currentPageStatus.values); + pageStatusRetry.hidden = !currentPageStatus.retryable; +} + +function renderScopeCombobox() { + const selected = scopes.find((scope) => scope.scope_id === currentScopeId) || null; + if (scopeOptions.hidden) { + scopeSearchInput.value = selected?.display_name || ""; + scopeSearchStatus.textContent = translate("scopeSearchCount", {count: formatNumber(scopes.length)}); + return; + } + renderScopeOptionsList(); +} + +function normalizedScopeQuery(value) { + return value.trim().toLocaleLowerCase(); +} + +function matchingScopes() { + const query = normalizedScopeQuery(scopeSearchInput.value); + if (!query) { + return scopes; + } + return scopes.filter((scope) => ( + `${scope.display_name}\n${scope.scope_id}`.toLocaleLowerCase().includes(query) + )); +} + +function renderScopeOptionsList() { + const matches = matchingScopes(); + const visible = matches.slice(0, scopeOptionRenderLimit); + scopeOptions.replaceChildren(); + scopeActiveIndex = Math.min(scopeActiveIndex, visible.length - 1); + for (const [index, scope] of visible.entries()) { + const option = document.createElement("button"); + option.className = "scope-option"; + option.id = `review-scope-option-${index}`; + option.type = "button"; + option.role = "option"; + option.tabIndex = -1; + option.dataset.scopeId = scope.scope_id; + option.setAttribute("aria-selected", String(scope.scope_id === currentScopeId)); + + const name = document.createElement("strong"); + name.textContent = scope.display_name; + const identity = document.createElement("code"); + identity.textContent = scope.scope_id; + option.append(name, identity); + option.addEventListener("click", () => { + void selectScope(scope.scope_id); + }); + scopeOptions.append(option); + } + if (matches.length === 0) { + const empty = document.createElement("p"); + empty.className = "scope-options-empty"; + empty.textContent = translate("noMatchingScopes"); + scopeOptions.append(empty); + } + if (matches.length > visible.length) { + scopeSearchStatus.textContent = translate("scopeSearchLimited", { + shown: formatNumber(visible.length), + total: formatNumber(matches.length) + }); + } else { + scopeSearchStatus.textContent = translate( + scopeSearchInput.value ? "scopeSearchMatches" : "scopeSearchCount", + {count: formatNumber(matches.length)} + ); + } + updateActiveScopeOption(); +} + +function openScopeOptions() { + if (scopeSearchInput.disabled || scopes.length === 0) { + return; + } + scopeOptions.hidden = false; + scopeSearchInput.setAttribute("aria-expanded", "true"); + renderScopeOptionsList(); +} + +function closeScopeOptions({restoreSelection = false} = {}) { + scopeOptions.hidden = true; + scopeSearchInput.setAttribute("aria-expanded", "false"); + scopeSearchInput.removeAttribute("aria-activedescendant"); + scopeActiveIndex = -1; + if (restoreSelection) { + const selected = scopes.find((scope) => scope.scope_id === currentScopeId); + scopeSearchInput.value = selected?.display_name || ""; + scopeSearchStatus.textContent = translate("scopeSearchCount", {count: formatNumber(scopes.length)}); + } +} + +function handleScopeSearchKeydown(event) { + if (event.key === "Escape") { + event.preventDefault(); + closeScopeOptions({restoreSelection: true}); + return; + } + if (!["ArrowDown", "ArrowUp", "Enter", "Home", "End"].includes(event.key)) { + return; + } + if (scopeOptions.hidden) { + openScopeOptions(); + } + const options = Array.from(scopeOptions.querySelectorAll(".scope-option")); + if (options.length === 0) { + return; + } + event.preventDefault(); + if (event.key === "Enter") { + const target = options[scopeActiveIndex] || options[0]; + void selectScope(target.dataset.scopeId); + return; + } + if (event.key === "Home") { + scopeActiveIndex = 0; + } else if (event.key === "End") { + scopeActiveIndex = options.length - 1; + } else if (event.key === "ArrowDown") { + scopeActiveIndex = Math.min(scopeActiveIndex + 1, options.length - 1); + } else { + scopeActiveIndex = scopeActiveIndex <= 0 ? options.length - 1 : scopeActiveIndex - 1; + } + updateActiveScopeOption(); +} + +function updateActiveScopeOption() { + const options = Array.from(scopeOptions.querySelectorAll(".scope-option")); + for (const [index, option] of options.entries()) { + option.dataset.active = String(index === scopeActiveIndex); + } + const active = options[scopeActiveIndex]; + if (!active) { + scopeSearchInput.removeAttribute("aria-activedescendant"); + return; + } + scopeSearchInput.setAttribute("aria-activedescendant", active.id); + active.scrollIntoView({block: "nearest"}); +} + +async function selectScope(scopeId) { + const selected = scopes.find((scope) => scope.scope_id === scopeId); + if (!selected) { + return; + } + scopeSearchInput.value = selected.display_name; + closeScopeOptions(); + if (scopeId === currentScopeId) { + return; + } + currentScopeId = scopeId; + resetQueue(); + await loadCandidates(false); +} + +function renderFilters() { + for (const option of familyFilter.options) { + if (option.dataset.i18n) { + option.textContent = translate(option.dataset.i18n); + } + } + for (const option of statusFilter.options) { + if (option.dataset.i18n) { + option.textContent = translate(option.dataset.i18n); + } + } +} + +function renderQueue() { + candidateList.replaceChildren(); + for (const candidate of candidates) { + const row = document.createElement("button"); + row.type = "button"; + row.className = "review-list-item"; + row.setAttribute("role", "option"); + row.setAttribute("aria-selected", String(candidate.candidate_id === selectedCandidateId)); + row.addEventListener("click", () => void selectCandidate(candidate.candidate_id)); + + const heading = document.createElement("span"); + heading.className = "review-list-heading"; + const family = document.createElement("span"); + family.className = "review-family-label"; + family.textContent = translate(candidate.family); + const status = document.createElement("span"); + status.className = `status-badge review-status-${candidate.status}`; + status.textContent = translate(candidate.status); + heading.append(family, status); + + const title = document.createElement("strong"); + title.textContent = candidateDisplayTitle(candidate); + const summary = document.createElement("span"); + summary.className = "review-list-summary"; + summary.textContent = candidateSummary(candidate); + const version = document.createElement("span"); + version.className = "review-list-version"; + version.textContent = translate("version", {version: candidate.version}); + row.append(heading, title, summary, version); + candidateList.append(row); + } + queueCaption.textContent = translate("loadedCandidates", {count: formatNumber(candidates.length)}); + emptyState.hidden = candidates.length !== 0 || busy; + loadMoreButton.hidden = !nextCursor; + loadMoreButton.disabled = busy; +} + +function renderDetail() { + if (!selectedCandidate) { + clearDetail(); + return; + } + const candidate = selectedCandidate; + detailEmpty.hidden = true; + detailContent.hidden = false; + detailFamily.textContent = translate(candidate.family); + candidateTitle.textContent = candidateDisplayTitle(candidate); + candidateId.textContent = candidate.candidate_id; + detailStatus.className = `status-badge review-status-${candidate.status}`; + detailStatus.textContent = translate(candidate.status); + detailVersion.textContent = translate("version", {version: candidate.version}); + renderProposal(candidate); + renderEvidence(candidate); + renderLineage(candidate); + renderPublication(); + if (!revisionForm.hidden && draft) { + renderRevisionFormHeader(draft.mode, draft.baseVersion); + } + const decisionEnabled = canDecide(candidate); + reviewActions.hidden = !decisionEnabled || !revisionForm.hidden; + editButton.disabled = busy; + approveButton.disabled = busy; + rejectButton.disabled = busy; + if (!isSupportedCandidate(candidate) && !currentNotice) { + currentNotice = {key: "unsupportedCandidate", values: {}, tone: "error"}; + } + renderNotice(); +} + +function clearDetail() { + selectedCandidate = null; + selectedCandidateId = ""; + detailEmpty.hidden = false; + detailContent.hidden = true; + revisionForm.hidden = true; + proposalFields.replaceChildren(); + sourceRefs.replaceChildren(); + artifactRefs.replaceChildren(); + lineageFields.replaceChildren(); + projectionRequests.cancel(); + projectionView = null; + projectionLoading = false; + publicationSection.hidden = true; + renderNotice(); +} + +function renderProposal(candidate) { + proposalFields.replaceChildren(); + if (!isSupportedCandidate(candidate)) { + appendDefinition(proposalFields, "proposal", JSON.stringify(candidate.proposal)); + return; + } + const keys = candidate.family === "experience" + ? ["situation", "action", "outcome", "lesson"] + : ["name", "description", "instructions", "validation"]; + for (const key of keys) { + const value = candidate.proposal[key]; + appendDefinition(proposalFields, key, Array.isArray(value) ? value.join("\n") : value); + } +} + +function renderEvidence(candidate) { + renderReferenceList( + sourceRefs, + candidate.source_refs, + (reference) => `${reference.name}/${reference.source_id}`, + "noSourceReferences" + ); + renderReferenceList( + artifactRefs, + candidate.artifact_refs, + formatArtifactReference, + "noArtifactReferences" + ); +} + +function renderReferenceList(list, references, formatter, emptyKey) { + list.replaceChildren(); + if (!references.length) { + const item = document.createElement("li"); + item.className = "review-reference-empty"; + item.textContent = translate(emptyKey); + list.append(item); + return; + } + for (const reference of references) { + const item = document.createElement("li"); + const code = document.createElement("code"); + code.textContent = formatter(reference); + item.append(code); + list.append(item); + } +} + +function renderLineage(candidate) { + lineageFields.replaceChildren(); + appendDefinition(lineageFields, "reason", candidate.reason || translate("notProvided")); + appendDefinition(lineageFields, "target", candidate.target ? formatArtifactReference(candidate.target) : translate("notProvided")); + appendDefinition( + lineageFields, + "resultArtifact", + candidate.result_artifact ? formatArtifactReference(candidate.result_artifact) : translate("notProvided") + ); + appendDefinition(lineageFields, "decisionReason", candidate.decision_reason || translate("notProvided")); +} + +function renderPublication() { + if (!isPublishableCandidate(selectedCandidate) || !revisionForm.hidden) { + publicationSection.hidden = true; + return; + } + publicationSection.hidden = false; + createSkillRevisionButton.disabled = busy; + publishSkillButton.hidden = true; + publicationStatus.textContent = projectionLoading ? translate("publicationLoading") : ""; + publicationState.hidden = projectionLoading || !projectionView; + publicationEmpty.hidden = true; + publicationContent.hidden = true; + if (projectionLoading || !projectionView) { + return; + } + if (projectionView.targets.length === 0) { + publicationEmpty.hidden = false; + return; + } + + const selectedTargetId = projectionView.targets.some((target) => target.target_id === publicationTarget.value) + ? publicationTarget.value + : projectionView.targets[0].target_id; + publicationTarget.replaceChildren(); + 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.selected = target.target_id === selectedTargetId; + publicationTarget.append(option); + } + const target = selectedProjectionTarget(); + if (!target) { + return; + } + publicationContent.hidden = false; + publicationState.hidden = false; + publicationState.className = `status-badge review-projection-${target.state}`; + publicationState.textContent = translate(projectionStateKey(target.state)); + publicationStatus.textContent = projectionHintKey(target.state) + ? translate(projectionHintKey(target.state)) + : ""; + publishedRevision.textContent = target.published_revision === null + ? translate("notProvided") + : translate("version", {version: target.published_revision}); + publicationDestination.textContent = target.destination; + publicationDiscovery.textContent = translate(discoveryStateKey(target.discovery)); + publishSkillButton.textContent = translate(publicationActionKey(target)); + const canPublish = canPublishProjection(target); + publishSkillButton.hidden = !canPublish; + publishSkillButton.disabled = busy || !canPublish; +} + +function selectedProjectionTarget() { + if (!projectionView) { + return null; + } + return projectionView.targets.find((target) => target.target_id === publicationTarget.value) + || projectionView.targets[0] + || null; +} + +function agentLabel(agentKind) { + return translate(agentKind === "claude_code" ? "agentClaudeCode" : "agentCodex"); +} + +function isPublishableCandidate(candidate) { + return Boolean( + candidate + && candidate.family === "skill" + && candidate.status === "approved" + && candidate.result_artifact + ); +} + +function canPublishProjection(target) { + return ["unpublished", "update_available"].includes(target.state) + || (target.state === "current" && target.discovery !== "available"); +} + +function publicationActionKey(target) { + if (target.state === "update_available") { + return "updateSkill"; + } + if (target.state === "current") { + return "refreshDiscovery"; + } + return "publishSkill"; +} + +function projectionStateKey(state) { + return { + unpublished: "projectionUnpublished", + current: "projectionCurrent", + update_available: "projectionUpdateAvailable", + conflict: "projectionConflict", + drifted: "projectionDrifted", + incompatible: "projectionIncompatible" + }[state] || "projectionConflict"; +} + +function projectionHintKey(state) { + return { + conflict: "projectionConflictHint", + drifted: "projectionDriftedHint", + incompatible: "projectionIncompatibleHint" + }[state] || ""; +} + +function discoveryStateKey(state) { + return { + available: "discoveryAvailable", + unavailable: "discoveryUnavailable", + not_published: "discoveryNotPublished" + }[state] || "discoveryNotPublished"; +} + +function capitalize(value) { + return `${value.charAt(0).toUpperCase()}${value.slice(1)}`; +} + +function appendDefinition(list, key, value) { + const term = document.createElement("dt"); + term.textContent = translate(key); + const description = document.createElement("dd"); + description.textContent = String(value ?? ""); + list.append(term, description); +} + +function startRevision(proposal, version, mode = "revise-candidate") { + if (!selectedCandidate) { + return; + } + draft = { + mode, + candidateId: selectedCandidate.candidate_id, + baseVersion: version, + proposal: structuredClone(proposal), + changeEvidence: mode === "create-skill-revision" ? "" : null, + sourceRef: null, + capturedEvidence: null + }; + currentNotice = null; + renderRevisionFields(selectedCandidate.family, draft.proposal, mode, draft.changeEvidence); + renderRevisionFormHeader(mode, version); + revisionForm.hidden = false; + reviewActions.hidden = true; + renderPublication(); + renderNotice(); + revisionForm.scrollIntoView({block: "start"}); + revisionForm.querySelector("input, textarea")?.focus(); +} + +function startSkillRevision(candidate) { + if (!isPublishableCandidate(candidate) || !isSupportedCandidate(candidate)) { + return; + } + startRevision(candidate.proposal, candidate.result_artifact.revision, "create-skill-revision"); +} + +function renderRevisionFormHeader(mode, version) { + const createsSkillRevision = mode === "create-skill-revision"; + revisionTitle.textContent = translate(createsSkillRevision ? "createSkillRevisionTitle" : "reviseProposal"); + revisionNote.textContent = translate(createsSkillRevision ? "createSkillRevisionNote" : "revisionEvidenceNote"); + draftVersion.textContent = translate( + createsSkillRevision ? "basedOnSkillRevision" : "editingVersion", + createsSkillRevision ? {revision: version} : {version} + ); + saveRevisionButton.textContent = translate(createsSkillRevision ? "createCandidate" : "saveRevision"); +} + +function renderRevisionFields(family, proposal, mode = "revise-candidate", changeEvidence = "") { + revisionFields.replaceChildren(); + if (family === "experience") { + for (const key of ["situation", "action", "outcome", "lesson"]) { + addTextareaField(key, proposal[key], 8000); + } + return; + } + if (mode === "create-skill-revision") { + addTextareaField("changeEvidence", changeEvidence, 2000, "review-short-textarea"); + } + addInputField("name", proposal.name, 128); + addTextareaField("description", proposal.description, 2000, "review-short-textarea"); + addTextareaField("instructions", proposal.instructions, 32000, "review-tall-textarea"); + const group = document.createElement("fieldset"); + group.className = "review-validation-editor"; + const legend = document.createElement("legend"); + legend.textContent = translate("validation"); + const list = document.createElement("div"); + list.id = "review-validation-items"; + const values = Array.isArray(proposal.validation) && proposal.validation.length ? proposal.validation : [""]; + for (const value of values) { + addValidationRow(list, value); + } + const add = document.createElement("button"); + add.type = "button"; + add.className = "secondary-button review-add-validation"; + add.textContent = translate("addValidation"); + add.addEventListener("click", () => { + if (list.children.length < 32) { + addValidationRow(list, ""); + updateValidationRows(list); + list.lastElementChild.querySelector("input").focus(); + } + }); + group.append(legend, list, add); + revisionFields.append(group); + updateValidationRows(list); +} + +function addInputField(key, value, maxLength) { + const label = document.createElement("label"); + label.className = "review-form-field"; + const caption = document.createElement("span"); + caption.textContent = translate(key); + const input = document.createElement("input"); + input.name = key; + input.value = value; + input.maxLength = maxLength; + input.required = true; + label.append(caption, input); + revisionFields.append(label); +} + +function addTextareaField(key, value, maxLength, className = "") { + const label = document.createElement("label"); + label.className = "review-form-field"; + const caption = document.createElement("span"); + caption.textContent = translate(key); + const textarea = document.createElement("textarea"); + textarea.name = key; + textarea.value = value; + textarea.maxLength = maxLength; + textarea.required = true; + textarea.className = className; + label.append(caption, textarea); + revisionFields.append(label); +} + +function addValidationRow(list, value) { + const row = document.createElement("div"); + row.className = "review-validation-row"; + const label = document.createElement("label"); + const caption = document.createElement("span"); + const input = document.createElement("input"); + input.value = value; + input.maxLength = 2000; + input.required = true; + label.append(caption, input); + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "secondary-button"; + remove.textContent = translate("removeValidation"); + remove.addEventListener("click", () => { + if (list.children.length > 1) { + row.remove(); + updateValidationRows(list); + } + }); + row.append(label, remove); + list.append(row); +} + +function updateValidationRows(list) { + [...list.children].forEach((row, index) => { + const label = translate("validationItem", {number: index + 1}); + row.querySelector("span").textContent = label; + row.querySelector("input").setAttribute("aria-label", label); + row.querySelector("button").disabled = list.children.length === 1; + }); +} + +function collectProposal() { + if (!selectedCandidate || !draft) { + return null; + } + const readField = (name) => revisionForm.elements.namedItem(name).value.trim(); + if (selectedCandidate.family === "experience") { + const proposal = { + situation: readField("situation"), + action: readField("action"), + outcome: readField("outcome"), + lesson: readField("lesson") + }; + return Object.values(proposal).every(Boolean) ? proposal : null; + } + if (selectedCandidate.family === "skill") { + const validation = [...document.querySelectorAll("#review-validation-items input")] + .map((input) => input.value.trim()); + const proposal = { + name: readField("name"), + description: readField("description"), + instructions: readField("instructions"), + validation + }; + return proposal.name && proposal.description && proposal.instructions && validation.every(Boolean) ? proposal : null; + } + return null; +} + +function renderNotice() { + if (!currentNotice || !selectedCandidate) { + alertBox.hidden = true; + alertBox.textContent = ""; + } else { + alertBox.hidden = false; + alertBox.dataset.tone = currentNotice.tone || "error"; + alertBox.textContent = translate(currentNotice.key, currentNotice.values || {}); + } + conflictActions.hidden = !conflictDraft || !selectedCandidate; +} + +function setNotice(key, values = {}, tone = "error") { + currentNotice = {key, values, tone}; + liveStatus.textContent = selectedCandidate ? "" : translate(key, values); + renderNotice(); +} + +function setBusy(value, statusKey = "") { + busy = value; + liveStatus.textContent = statusKey ? translate(statusKey) : ""; + scopeSearchInput.disabled = value; + if (value) { + closeScopeOptions({restoreSelection: true}); + } + familyFilter.disabled = value; + statusFilter.disabled = value; + refreshButton.disabled = value; + loadMoreButton.disabled = value; + saveRevisionButton.disabled = value; + editButton.disabled = value; + approveButton.disabled = value; + rejectButton.disabled = value; + createSkillRevisionButton.disabled = value; + publishSkillButton.disabled = value; + renderQueue(); + renderPublication(); +} + +function replaceCandidate(candidate) { + const index = candidates.findIndex((item) => item.candidate_id === candidate.candidate_id); + if (index >= 0) { + candidates[index] = candidate; + } +} + +function removeCandidate(id) { + candidates = candidates.filter((candidate) => candidate.candidate_id !== id); + if (selectedCandidateId === id) { + selectedCandidate = null; + selectedCandidateId = ""; + } +} + +function candidateDisplayTitle(candidate) { + if (candidate.family === "skill" && typeof candidate.proposal?.name === "string") { + return candidate.proposal.name; + } + if (candidate.family === "experience" && typeof candidate.proposal?.situation === "string") { + return compactText(candidate.proposal.situation, 88); + } + return candidate.candidate_id; +} + +function candidateSummary(candidate) { + if (candidate.family === "skill") { + return compactText(candidate.proposal?.description || "", 132); + } + if (candidate.family === "experience") { + return compactText(candidate.proposal?.lesson || candidate.proposal?.outcome || "", 132); + } + return ""; +} + +function compactText(value, limit) { + const text = String(value).replace(/\s+/g, " ").trim(); + return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`; +} + +function formatArtifactReference(reference) { + return `${reference.family}/${reference.artifact_id}@${reference.revision}`; +} + +function readReviewDeepLink() { + const params = new URLSearchParams(window.location.search); + return { + scope: params.get("scope") || "", + family: params.get("family") || "", + status: params.get("status") || "", + candidate: params.get("candidate") || "", + action: params.get("action") || "" + }; +} + +function canDecide(candidate) { + return Boolean(candidate && candidate.status === "pending" && isSupportedCandidate(candidate)); +} + +function isSupportedCandidate(candidate) { + if (!candidate || !candidate.proposal) { + return false; + } + if (candidate.family === "experience") { + return ["situation", "action", "outcome", "lesson"].every( + (key) => typeof candidate.proposal[key] === "string" + ); + } + if (candidate.family === "skill") { + return ["name", "description", "instructions"].every( + (key) => typeof candidate.proposal[key] === "string" + ) && Array.isArray(candidate.proposal.validation) && candidate.proposal.validation.every( + (item) => typeof item === "string" + ); + } + return false; +} + +renderFilters(); +ui.initialize(); +void authenticate(readServerToken(), reviewDeepLink.scope); diff --git a/src/powercontext/server/static/site.css b/src/powercontext/server/static/site.css index 78c9aaebf..9426768cc 100644 --- a/src/powercontext/server/static/site.css +++ b/src/powercontext/server/static/site.css @@ -477,11 +477,13 @@ button:disabled { border-color: var(--pc-accent); } -.project-combobox { +.project-combobox, +.scope-combobox { position: relative; } -.project-combobox > input { +.project-combobox > input, +.scope-combobox > input { width: 100%; min-height: 40px; border: 1px solid var(--pc-rule-strong); @@ -492,11 +494,14 @@ button:disabled { } .project-combobox > input:hover, -.project-combobox > input:focus { +.project-combobox > input:focus, +.scope-combobox > input:hover, +.scope-combobox > input:focus { border-color: var(--pc-accent); } -.project-options { +.project-options, +.scope-options { position: absolute; z-index: 10; top: calc(100% + 6px); @@ -510,7 +515,8 @@ button:disabled { overflow-y: auto; } -.project-option { +.project-option, +.scope-option { display: grid; width: 100%; gap: 2px; @@ -523,38 +529,46 @@ button:disabled { text-align: left; } -.project-option:last-child { +.project-option:last-child, +.scope-option:last-child { border-bottom: 0; } .project-option:hover, -.project-option[data-active="true"] { +.project-option[data-active="true"], +.scope-option:hover, +.scope-option[data-active="true"] { background: var(--pc-accent-soft); } -.project-option[aria-selected="true"] { +.project-option[aria-selected="true"], +.scope-option[aria-selected="true"] { box-shadow: inset 3px 0 0 var(--pc-accent); } -.project-option strong { +.project-option strong, +.scope-option strong { font-size: 13px; overflow-wrap: anywhere; } -.project-option code { +.project-option code, +.scope-option code { color: var(--pc-muted); font: 10px/1.4 var(--pc-font-code); overflow-wrap: anywhere; } -.project-options-empty { +.project-options-empty, +.scope-options-empty { margin: 0; color: var(--pc-muted); padding: 18px 12px; text-align: center; } -.project-search-status { +.project-search-status, +.scope-search-status { min-height: 17px; color: var(--pc-muted); font-size: 11px; @@ -2592,6 +2606,1278 @@ button:disabled { overflow-wrap: anywhere; } +.review-hero { + align-items: flex-start; + margin-bottom: 24px; +} + +.review-scope-picker { + width: min(380px, 100%); +} + +.review-intro { + max-width: 640px; + margin: 8px 0 0; + color: var(--pc-muted); +} + +.review-toolbar { + display: flex; + min-height: 72px; + align-items: flex-end; + gap: 12px; + border-top: 1px solid var(--pc-rule-strong); + border-bottom: 1px solid var(--pc-rule-strong); + padding: 14px 0; +} + +.review-toolbar label { + display: grid; + min-width: 180px; + gap: 4px; + color: var(--pc-muted); + font-size: 12px; + font-weight: 600; +} + +.review-toolbar select, +.review-publication select, +.review-form-field :is(input, textarea), +.review-validation-row input, +.review-dialog textarea { + width: 100%; + border: 1px solid var(--pc-rule-strong); + border-radius: var(--pc-radius-control); + outline: 0; + background: var(--pc-surface); + color: var(--pc-ink); +} + +.review-toolbar select { + min-height: 38px; + padding: 7px 30px 7px 10px; +} + +.review-publication select { + min-height: 40px; + padding: 7px 30px 7px 10px; +} + +.review-toolbar select:hover, +.review-toolbar select:focus, +.review-publication select:hover, +.review-publication select:focus, +.review-form-field :is(input, textarea):hover, +.review-form-field :is(input, textarea):focus, +.review-validation-row input:hover, +.review-validation-row input:focus, +.review-dialog textarea:hover, +.review-dialog textarea:focus { + border-color: var(--pc-accent); +} + +.review-refresh { + min-width: 76px; + border: 1px solid var(--pc-rule-strong); + padding: 0 12px; +} + +.review-live-status { + min-height: 23px; + margin: 0 0 7px auto; + color: var(--pc-muted); + font-size: 12px; +} + +.review-workspace { + display: grid; + grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); + min-height: 620px; + margin-top: 28px; + border: 1px solid var(--pc-rule-strong); + border-radius: var(--pc-radius); + background: var(--pc-surface); + overflow: hidden; +} + +.review-queue { + min-width: 0; + border-right: 1px solid var(--pc-rule-strong); + background: var(--pc-surface-secondary); +} + +.review-panel-heading { + min-height: 78px; + border-bottom: 1px solid var(--pc-rule); + padding: 16px 18px; +} + +.review-panel-heading h2, +.review-detail h2, +.review-detail h3, +.review-detail h4, +.review-dialog h2 { + margin: 0; + color: var(--pc-ink); + font-weight: 650; +} + +.review-panel-heading h2, +.review-detail h2, +.review-dialog h2 { + font-size: 18px; + letter-spacing: -0.012em; +} + +.review-panel-heading p { + margin: 2px 0 0; + color: var(--pc-muted); + font-size: 12px; +} + +.review-list { + display: grid; + max-height: 720px; + overflow-y: auto; +} + +.review-list-item { + display: grid; + min-width: 0; + gap: 5px; + border: 0; + border-bottom: 1px solid var(--pc-rule); + background: transparent; + color: var(--pc-ink); + padding: 15px 18px; + text-align: left; +} + +.review-list-item:hover { + background: var(--pc-nav-hover); +} + +.review-list-item[aria-selected="true"] { + box-shadow: inset 3px 0 0 var(--pc-accent); + background: var(--pc-accent-soft); +} + +.review-list-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.review-family-label, +.review-list-version { + color: var(--pc-muted); + font-size: 11px; + font-weight: 600; +} + +.review-list-item strong, +.review-list-summary { + overflow: hidden; + text-overflow: ellipsis; +} + +.review-list-item strong { + font-size: 14px; + white-space: nowrap; +} + +.review-list-summary { + display: -webkit-box; + color: var(--pc-muted); + font-size: 12px; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.review-status-pending { + background: var(--pc-warning-soft); + color: var(--pc-warning); +} + +.review-status-approved { + background: var(--pc-success-soft); + color: var(--pc-success); +} + +.review-status-rejected { + background: var(--pc-danger-soft); + color: var(--pc-danger); +} + +.review-queue .empty-state p { + margin: 3px 0 0; + font-size: 12px; +} + +.review-load-more { + width: calc(100% - 36px); + margin: 16px 18px; + border: 1px solid var(--pc-rule-strong); +} + +.review-detail { + min-width: 0; + background: var(--pc-surface); +} + +.review-detail-empty { + display: grid; + min-height: 440px; + align-content: center; + justify-items: center; + padding: 36px; + text-align: center; +} + +.review-detail-empty h2 { + max-width: 460px; + margin-top: 2px; +} + +.review-detail-empty > p:last-child { + max-width: 500px; + margin: 8px 0 0; + color: var(--pc-muted); +} + +.review-detail-header { + display: flex; + min-height: 112px; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + border-bottom: 1px solid var(--pc-rule); + padding: 22px 28px; +} + +.review-detail-header h2 { + margin-top: 2px; + overflow-wrap: anywhere; +} + +.review-detail-header code { + display: block; + margin-top: 4px; + color: var(--pc-muted); + font: 11px/1.5 var(--pc-font-code); + overflow-wrap: anywhere; +} + +.review-detail-state { + display: grid; + flex: 0 0 auto; + justify-items: end; + gap: 5px; + color: var(--pc-muted); + font-size: 11px; +} + +.review-alert { + margin: 20px 28px 0; + border: 1px solid var(--pc-danger); + border-radius: var(--pc-radius-control); + background: var(--pc-danger-soft); + color: var(--pc-danger); + padding: 10px 12px; +} + +.review-alert[data-tone="success"] { + border-color: var(--pc-success); + background: var(--pc-success-soft); + color: var(--pc-success); +} + +.review-conflict-actions { + display: flex; + gap: 8px; + margin: 12px 28px 0; +} + +.review-conflict-actions .secondary-button { + border: 1px solid var(--pc-rule-strong); + padding: 0 12px; +} + +.review-proposal, +.review-evidence, +.review-lineage, +.review-publication, +.review-revision-form { + padding: 24px 28px; +} + +.review-evidence, +.review-lineage, +.review-publication, +.review-revision-form { + border-top: 1px solid var(--pc-rule); +} + +.review-detail h3 { + margin-bottom: 14px; + font-size: 14px; +} + +.review-detail h4 { + font-size: 12px; +} + +.review-proposal dl, +.review-lineage dl { + display: grid; + grid-template-columns: minmax(112px, 0.24fr) minmax(0, 1fr); + gap: 10px 22px; + margin: 0; +} + +.review-proposal dt, +.review-lineage dt { + color: var(--pc-muted); + font-size: 12px; + font-weight: 600; +} + +.review-proposal dd, +.review-lineage dd { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.review-publication-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + border-bottom: 1px solid var(--pc-rule); + padding-bottom: 16px; +} + +.review-publication-heading h3 { + margin-bottom: 0; +} + +.review-publication-heading p, +.review-publication-status, +.review-publication-empty p { + margin: 5px 0 0; + color: var(--pc-muted); + font-size: 12px; +} + +.review-publication-heading p { + max-width: 620px; +} + +.review-publication-status:empty { + display: none; +} + +.review-publication-empty { + margin-top: 16px; + border: 1px dashed var(--pc-rule-strong); + border-radius: var(--pc-radius); + background: var(--pc-surface-secondary); + padding: 16px; +} + +.review-publication-overview { + display: grid; + grid-template-columns: minmax(220px, 0.8fr) minmax(0, 1.2fr); + gap: 14px; + margin-top: 18px; +} + +.review-publication-target { + display: grid; + min-width: 0; + align-content: start; + gap: 7px; + margin: 0; + border: 1px solid var(--pc-rule); + border-radius: var(--pc-radius); + background: var(--pc-surface-secondary); + color: var(--pc-muted); + font-size: 12px; + font-weight: 600; + padding: 14px; +} + +.review-publication-facts { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 0; + border: 1px solid var(--pc-rule); + border-radius: var(--pc-radius); + background: var(--pc-surface-secondary); + overflow: hidden; +} + +.review-publication-facts > div { + min-width: 0; + padding: 14px 16px; +} + +.review-publication-facts > div + div { + border-left: 1px solid var(--pc-rule); +} + +.review-publication-facts dt, +.review-publication-path > span { + color: var(--pc-muted); + font-size: 12px; + font-weight: 600; +} + +.review-publication-facts dd { + min-width: 0; + margin: 5px 0 0; + color: var(--pc-ink); + font-size: 13px; + overflow-wrap: anywhere; +} + +.review-publication-path { + display: grid; + gap: 7px; + margin-top: 14px; +} + +.review-publication-path code { + display: block; + min-width: 0; + border: 1px solid var(--pc-rule); + border-radius: var(--pc-radius-control); + background: var(--pc-surface-secondary); + color: var(--pc-muted); + font: 11px/1.55 var(--pc-font-code); + overflow-wrap: anywhere; + padding: 10px 12px; + white-space: normal; +} + +.review-publication-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 18px; + border-top: 1px solid var(--pc-rule); + padding-top: 16px; +} + +.review-publication-actions .secondary-button { + border: 1px solid var(--pc-rule-strong); + padding: 0 14px; +} + +.review-publication-actions .primary-button { + margin-left: auto; +} + +.review-projection-current { + background: var(--pc-success-soft); + color: var(--pc-success); +} + +.review-projection-unpublished, +.review-projection-update_available { + background: var(--pc-warning-soft); + color: var(--pc-warning); +} + +.review-projection-conflict, +.review-projection-drifted, +.review-projection-incompatible { + background: var(--pc-danger-soft); + color: var(--pc-danger); +} + +.review-evidence-groups { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 24px; +} + +.review-evidence ul { + display: grid; + gap: 5px; + margin: 8px 0 0; + padding: 0; + list-style: none; +} + +.review-evidence code { + color: var(--pc-muted); + font: 11px/1.5 var(--pc-font-code); + overflow-wrap: anywhere; +} + +.review-reference-empty { + color: var(--pc-tertiary); + font-size: 12px; +} + +.review-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + border-top: 1px solid var(--pc-rule-strong); + padding: 18px 28px; +} + +.review-actions .secondary-button { + margin-right: auto; + border: 1px solid var(--pc-rule-strong); + padding: 0 14px; +} + +.review-form-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + margin-bottom: 18px; +} + +.review-form-heading h3 { + margin-bottom: 0; +} + +.review-form-heading p, +.review-form-heading > span { + margin: 3px 0 0; + color: var(--pc-muted); + font-size: 12px; +} + +.review-form-heading > span { + white-space: nowrap; +} + +.review-form-fields { + display: grid; + gap: 16px; +} + +.review-form-field { + display: grid; + gap: 5px; +} + +.review-form-field > span, +.review-validation-row label > span { + color: var(--pc-muted); + font-size: 12px; + font-weight: 600; +} + +.review-form-field :is(input, textarea), +.review-validation-row input, +.review-dialog textarea { + padding: 9px 10px; +} + +.review-form-field textarea { + min-height: 132px; + resize: vertical; +} + +.review-form-field .review-short-textarea { + min-height: 92px; +} + +.review-form-field .review-tall-textarea { + min-height: 240px; + font: 13px/1.6 var(--pc-font-code); +} + +.review-validation-editor { + display: grid; + gap: 10px; + margin: 0; + border: 0; + padding: 0; +} + +.review-validation-editor legend { + margin-bottom: 5px; + padding: 0; + color: var(--pc-muted); + font-size: 12px; + font-weight: 600; +} + +#review-validation-items { + display: grid; + gap: 8px; +} + +.review-validation-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 8px; +} + +.review-validation-row label { + display: grid; + gap: 4px; +} + +.review-validation-row .secondary-button, +.review-add-validation { + border: 1px solid var(--pc-rule-strong); + padding: 0 12px; +} + +.review-add-validation { + justify-self: start; +} + +.review-form-actions, +.review-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.review-form-actions { + margin-top: 20px; +} + +.review-form-actions .secondary-button, +.review-dialog-actions .secondary-button { + border: 1px solid var(--pc-rule-strong); + padding: 0 14px; +} + +.review-dialog { + width: min(500px, calc(100% - 32px)); + border: 1px solid var(--pc-rule-strong); + border-radius: var(--pc-radius); + background: var(--pc-surface); + color: var(--pc-ink); + padding: 0; + box-shadow: 0 24px 64px color-mix(in srgb, var(--pc-chrome) 32%, transparent); +} + +.review-dialog::backdrop { + background: color-mix(in srgb, var(--pc-chrome) 58%, transparent); +} + +.review-dialog form { + padding: 24px; +} + +.review-dialog p { + margin: 8px 0 20px; + color: var(--pc-muted); +} + +.review-dialog textarea { + min-height: 120px; + resize: vertical; +} + +.review-dialog-actions { + margin-top: 22px; +} + +.skills-hero { + align-items: flex-start; + margin-bottom: 24px; +} + +.skills-scope-picker { + width: min(380px, 100%); +} + +.skills-intro { + max-width: 680px; + margin: 8px 0 0; + color: var(--pc-muted); +} + +.skills-toolbar { + display: flex; + min-height: 72px; + align-items: flex-end; + gap: 12px; + border-top: 1px solid var(--pc-rule-strong); + border-bottom: 1px solid var(--pc-rule-strong); + padding: 14px 0; +} + +.skills-toolbar label { + display: grid; + min-width: 190px; + gap: 4px; + color: var(--pc-muted); + font-size: 12px; + font-weight: 600; +} + +.skills-toolbar .skills-search-field { + width: min(420px, 100%); +} + +.skills-toolbar :is(input, select), +.skills-delivery 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-toolbar select, +.skills-delivery select { + padding-right: 30px; +} + +.skills-toolbar :is(input, select):hover, +.skills-toolbar :is(input, select):focus, +.skills-delivery select:hover, +.skills-delivery select:focus { + border-color: var(--pc-accent); +} + +.skills-refresh { + min-width: 76px; + border: 1px solid var(--pc-rule-strong); + padding: 0 12px; +} + +.skills-live-status { + min-height: 23px; + margin: 0 0 7px auto; + color: var(--pc-muted); + font-size: 12px; +} + +.skills-summary { + display: grid; + grid-template-columns: repeat(2, minmax(120px, 180px)) minmax(280px, 1fr); + align-items: stretch; + border-bottom: 1px solid var(--pc-rule-strong); +} + +.skills-summary > div { + display: grid; + gap: 1px; + border-right: 1px solid var(--pc-rule); + padding: 16px 20px 16px 0; +} + +.skills-summary > div + div { + padding-left: 20px; +} + +.skills-summary span { + color: var(--pc-muted); + font-size: 11px; + font-weight: 600; +} + +.skills-summary strong { + color: var(--pc-ink); + font: 650 22px/1.2 var(--pc-font-code); +} + +.skills-summary p { + align-self: center; + max-width: 620px; + margin: 0; + color: var(--pc-muted); + padding: 14px 0 14px 22px; + font-size: 12px; + line-height: 1.55; +} + +.skills-workspace { + display: grid; + grid-template-columns: minmax(300px, 380px) minmax(0, 1fr); + min-height: 660px; + margin-top: 28px; + border: 1px solid var(--pc-rule-strong); + border-radius: var(--pc-radius); + background: var(--pc-surface); + overflow: hidden; +} + +.skills-index { + min-width: 0; + border-right: 1px solid var(--pc-rule-strong); + background: var(--pc-surface-secondary); +} + +.skills-panel-heading { + min-height: 78px; + border-bottom: 1px solid var(--pc-rule); + padding: 16px 18px; +} + +.skills-panel-heading h2, +.skills-detail h2, +.skills-detail h3, +.skills-detail h4 { + margin: 0; + color: var(--pc-ink); + font-weight: 650; +} + +.skills-panel-heading h2, +.skills-detail h2 { + font-size: 18px; + letter-spacing: -0.012em; +} + +.skills-panel-heading p { + margin: 2px 0 0; + color: var(--pc-muted); + font-size: 12px; +} + +.skills-list { + display: grid; + max-height: 760px; + overflow-y: auto; +} + +.skills-list-item { + display: grid; + min-width: 0; + gap: 5px; + border: 0; + border-bottom: 1px solid var(--pc-rule); + background: transparent; + color: var(--pc-ink); + padding: 15px 18px; + text-align: left; +} + +.skills-list-item:hover { + background: var(--pc-nav-hover); +} + +.skills-list-item[aria-selected="true"] { + box-shadow: inset 3px 0 0 var(--pc-accent); + background: var(--pc-accent-soft); +} + +.skills-list-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.skills-authority-label { + color: var(--pc-muted); + font-size: 11px; + font-weight: 700; +} + +.skills-authority-managed { + color: var(--pc-accent); +} + +.skills-list-item > strong, +.skills-list-summary, +.skills-list-item > code { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.skills-list-item > strong { + font-size: 14px; + white-space: nowrap; +} + +.skills-list-summary { + display: -webkit-box; + color: var(--pc-muted); + font-size: 12px; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.skills-list-item > code { + color: var(--pc-tertiary); + font: 10px/1.5 var(--pc-font-code); + white-space: nowrap; +} + +.skills-status-approved, +.skills-status-available, +.skills-projection-current { + background: var(--pc-success-soft); + color: var(--pc-success); +} + +.skills-status-unavailable, +.skills-projection-conflict, +.skills-projection-drifted, +.skills-projection-incompatible { + background: var(--pc-danger-soft); + color: var(--pc-danger); +} + +.skills-projection-unpublished, +.skills-projection-update_available { + background: var(--pc-warning-soft); + color: var(--pc-warning); +} + +.skills-empty p { + margin: 3px 0 0; + font-size: 12px; +} + +.skills-loading { + display: grid; +} + +.skills-loading span { + display: block; + height: 92px; + border-bottom: 1px solid var(--pc-rule); + background: linear-gradient(90deg, var(--pc-surface-secondary), var(--pc-surface), var(--pc-surface-secondary)); + background-size: 240% 100%; +} + +.skills-detail { + min-width: 0; + background: var(--pc-surface); +} + +.skills-detail-empty { + display: grid; + min-height: 480px; + align-content: center; + justify-items: center; + padding: 36px; + text-align: center; +} + +.skills-detail-empty h2 { + max-width: 460px; + margin-top: 2px; +} + +.skills-detail-empty > p:last-child { + max-width: 500px; + margin: 8px 0 0; + color: var(--pc-muted); +} + +.skills-detail-header { + display: flex; + min-height: 112px; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + border-bottom: 1px solid var(--pc-rule); + padding: 22px 28px; +} + +.skills-detail-header h2 { + margin-top: 3px; + overflow-wrap: anywhere; +} + +.skills-detail-header code { + display: block; + margin-top: 4px; + color: var(--pc-muted); + font: 11px/1.5 var(--pc-font-code); + overflow-wrap: anywhere; +} + +.skills-alert { + margin: 20px 28px 0; + border: 1px solid var(--pc-danger); + border-radius: var(--pc-radius-control); + background: var(--pc-danger-soft); + color: var(--pc-danger); + padding: 10px 12px; +} + +.skills-alert[data-tone="success"] { + border-color: var(--pc-success); + background: var(--pc-success-soft); + color: var(--pc-success); +} + +.skills-alert[data-tone="warning"] { + border-color: var(--pc-warning); + background: var(--pc-warning-soft); + color: var(--pc-warning); +} + +.skills-overview, +.skills-instructions, +.skills-lineage, +.skills-delivery { + padding: 24px 28px; +} + +.skills-instructions, +.skills-lineage, +.skills-delivery { + border-top: 1px solid var(--pc-rule); +} + +.skills-detail h3 { + margin-bottom: 14px; + font-size: 14px; +} + +.skills-detail h4 { + margin-top: 18px; + font-size: 12px; +} + +.skills-overview > p { + max-width: 76ch; + margin: 0 0 18px; + color: var(--pc-muted); + line-height: 1.6; +} + +.skills-overview dl { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px 22px; + margin: 0; +} + +.skills-overview dl > div { + min-width: 0; + border-left: 2px solid var(--pc-rule-strong); + padding-left: 10px; +} + +.skills-overview dt { + color: var(--pc-muted); + font-size: 11px; + font-weight: 600; +} + +.skills-overview dd { + margin: 4px 0 0; + color: var(--pc-ink); + overflow-wrap: anywhere; +} + +.skills-overview dd code { + color: var(--pc-muted); + font: 10px/1.55 var(--pc-font-code); +} + +.skills-instructions pre { + max-height: 420px; + margin: 0; + border: 1px solid var(--pc-rule); + border-radius: var(--pc-radius-control); + background: var(--pc-surface-secondary); + color: var(--pc-ink); + font: 12px/1.7 var(--pc-font-code); + overflow: auto; + padding: 16px; + white-space: pre-wrap; +} + +.skills-instructions ol { + display: grid; + gap: 7px; + margin: 9px 0 0; + padding-left: 22px; +} + +.skills-instructions li { + padding-left: 3px; + line-height: 1.55; +} + +.skills-reference-groups { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 24px; +} + +.skills-reference-groups h4 { + margin-top: 0; +} + +.skills-reference-groups ul { + display: grid; + gap: 5px; + margin: 8px 0 0; + padding: 0; + list-style: none; +} + +.skills-reference-groups code { + color: var(--pc-muted); + font: 11px/1.5 var(--pc-font-code); + overflow-wrap: anywhere; +} + +.skills-reference-empty { + color: var(--pc-tertiary); + font-size: 12px; +} + +.skills-delivery-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + border-bottom: 1px solid var(--pc-rule); + padding-bottom: 16px; +} + +.skills-delivery-heading h3 { + margin-bottom: 0; +} + +.skills-delivery-heading p, +.skills-delivery-status, +.skills-delivery-empty p { + margin: 5px 0 0; + color: var(--pc-muted); + font-size: 12px; +} + +.skills-delivery-status:empty { + display: none; +} + +.skills-delivery-empty { + margin-top: 16px; + border: 1px dashed var(--pc-rule-strong); + border-radius: var(--pc-radius); + background: var(--pc-surface-secondary); + padding: 16px; +} + +#skills-delivery-content { + display: grid; + grid-template-columns: minmax(220px, 0.8fr) minmax(0, 1.2fr); + gap: 14px; + margin-top: 18px; +} + +.skills-delivery-target { + display: grid; + min-width: 0; + align-content: start; + gap: 7px; + border: 1px solid var(--pc-rule); + border-radius: var(--pc-radius); + background: var(--pc-surface-secondary); + color: var(--pc-muted); + padding: 14px; + font-size: 12px; + font-weight: 600; +} + +.skills-delivery-facts { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 0; + border: 1px solid var(--pc-rule); + border-radius: var(--pc-radius); + background: var(--pc-surface-secondary); + overflow: hidden; +} + +.skills-delivery-facts > div { + min-width: 0; + padding: 14px 16px; +} + +.skills-delivery-facts > div + div { + border-left: 1px solid var(--pc-rule); +} + +.skills-delivery-facts dt, +.skills-delivery-path > span { + color: var(--pc-muted); + font-size: 12px; + font-weight: 600; +} + +.skills-delivery-facts dd { + margin: 5px 0 0; + color: var(--pc-ink); + font-size: 13px; + overflow-wrap: anywhere; +} + +.skills-delivery-path { + display: grid; + grid-column: 1 / -1; + gap: 7px; +} + +.skills-delivery-path code { + display: block; + min-width: 0; + border: 1px solid var(--pc-rule); + border-radius: var(--pc-radius-control); + background: var(--pc-surface-secondary); + color: var(--pc-muted); + font: 11px/1.55 var(--pc-font-code); + overflow-wrap: anywhere; + padding: 10px 12px; + white-space: normal; +} + +.skills-detail-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 18px; + border-top: 1px solid var(--pc-rule); + padding-top: 16px; +} + +.skills-detail-actions .secondary-button { + border: 1px solid var(--pc-rule-strong); + padding: 0 14px; +} + +.skills-detail-actions .primary-button { + margin-left: auto; +} + +@media (prefers-reduced-motion: no-preference) { + .skills-loading span { + animation: skills-loading-shimmer 1.4s ease-in-out infinite; + } +} + +@keyframes skills-loading-shimmer { + to { + background-position: -240% 0; + } +} + [hidden] { display: none !important; } @@ -2691,6 +3977,124 @@ button:disabled { gap: 12px; } + .review-scope-picker { + width: 100%; + } + + .skills-scope-picker { + width: 100%; + } + + .skills-toolbar { + align-items: stretch; + flex-wrap: wrap; + } + + .skills-toolbar label { + flex: 1 1 190px; + } + + .skills-toolbar .skills-search-field { + width: auto; + } + + .skills-live-status { + width: 100%; + margin: 0; + } + + .skills-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .skills-summary p { + grid-column: 1 / -1; + border-top: 1px solid var(--pc-rule); + padding-left: 0; + } + + .skills-workspace { + grid-template-columns: 1fr; + } + + .skills-index { + border-right: 0; + border-bottom: 1px solid var(--pc-rule-strong); + } + + .skills-list { + max-height: 420px; + } + + .skills-detail-header, + .skills-delivery-heading { + flex-direction: column; + gap: 10px; + } + + .skills-overview dl, + .skills-reference-groups, + #skills-delivery-content { + grid-template-columns: 1fr; + } + + .skills-delivery-facts { + grid-template-columns: 1fr; + } + + .skills-delivery-facts > div + div { + border-top: 1px solid var(--pc-rule); + border-left: 0; + } + + .review-toolbar { + align-items: stretch; + flex-wrap: wrap; + } + + .review-toolbar label { + flex: 1 1 180px; + } + + .review-live-status { + width: 100%; + margin: 0; + } + + .review-workspace { + grid-template-columns: 1fr; + } + + .review-queue { + border-right: 0; + border-bottom: 1px solid var(--pc-rule-strong); + } + + .review-list { + max-height: 420px; + } + + .review-detail-header, + .review-form-heading, + .review-publication-heading { + flex-direction: column; + gap: 10px; + } + + .review-detail-state { + grid-auto-flow: column; + align-items: center; + justify-items: start; + } + + .review-evidence-groups { + grid-template-columns: 1fr; + } + + .review-publication-overview { + grid-template-columns: 1fr; + } + .hero-meta { text-align: left; } @@ -2738,7 +4142,8 @@ button:disabled { min-width: 0; } - .project-options { + .project-options, + .scope-options { max-height: min(320px, 52vh); } @@ -2754,6 +4159,75 @@ button:disabled { display: none; } + .review-toolbar label { + flex-basis: 100%; + } + + .review-refresh { + width: 100%; + } + + .review-proposal, + .review-evidence, + .review-lineage, + .review-publication, + .review-revision-form, + .review-detail-header, + .review-actions { + padding-right: 18px; + padding-left: 18px; + } + + .review-alert, + .review-conflict-actions { + margin-right: 18px; + margin-left: 18px; + } + + .review-proposal dl, + .review-lineage dl { + grid-template-columns: 1fr; + gap: 3px; + } + + .review-proposal dd, + .review-lineage dd { + margin-bottom: 10px; + } + + .review-actions { + align-items: stretch; + flex-direction: column; + } + + .review-actions .secondary-button { + width: 100%; + margin-right: 0; + } + + .review-publication-facts { + grid-template-columns: 1fr; + } + + .review-publication-facts > div + div { + border-top: 1px solid var(--pc-rule); + border-left: 0; + } + + .review-publication-actions { + align-items: stretch; + flex-direction: column; + } + + .review-publication-actions :is(.primary-button, .secondary-button) { + width: 100%; + margin-left: 0; + } + + .review-validation-row { + grid-template-columns: 1fr; + } + .brand { gap: 6px; } diff --git a/src/powercontext/server/static/skills.js b/src/powercontext/server/static/skills.js new file mode 100644 index 000000000..9d5468f48 --- /dev/null +++ b/src/powercontext/server/static/skills.js @@ -0,0 +1,1288 @@ +/* + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use strict"; + +import { + clearServerToken, + fetchWithBearer, + readServerToken, + storeServerToken +} from "./auth.js?v=optional-auth"; +import {createPageUi, createRequestGate} from "./page-ui.js?v=locale-complete"; + +const translations = { + en: { + pageTitle: "PowerContext Skills Library", + dashboardTitle: "Dashboard", + skillsTitle: "Skills", + reviewTitle: "Review", + handoffReportTitle: "Handoff Report", + brandHomeLabel: "PowerContext Dashboard", + primaryNavigation: "Primary navigation", + maintainedBy: "Maintained by OceanBase.", + signOut: "Sign out", + switchDark: "Switch to dark mode", + switchLight: "Switch to light mode", + switchChinese: "Switch to Chinese", + switchEnglish: "Switch to English", + languageChinese: "中文", + languageEnglish: "EN", + authTitle: "Connect to PowerContext", + authIntro: "Enter the bearer token configured for this PowerContext Server. The token stays in this browser tab.", + tokenLabel: "Server token", + continue: "Continue", + skillsLibraryTitle: "Skills Library", + skillsIntro: "Browse approved PowerContext Skills and Agent-local packages available in this scope.", + selectScope: "Scope", + searchScopesPlaceholder: "Search by scope name or ID", + scopeSearchCount: "{count} scopes", + scopeSearchMatches: "{count} matching scopes", + scopeSearchLimited: "Showing {shown} of {total} matching scopes", + noMatchingScopes: "No scopes match this search.", + skillsFilters: "Skills filters", + searchSkills: "Search", + searchSkillsPlaceholder: "Search by name, description, or identity", + authority: "Authority", + allSkills: "All Skills", + managedSkill: "Managed", + externalSkill: "External", + refresh: "Refresh", + skillsSummary: "Skills summary", + managedSkills: "Managed Skills", + externalSkills: "External Skills", + authorityNote: "Managed Skills are governed Artifact Revisions. External Skills remain Agent-local packages.", + libraryInventory: "Library inventory", + loadedSkills: "{count} shown of {total}", + noSkills: "No Skills match these filters.", + noSkillsHint: "Try another search or refresh local discovery.", + skillDetail: "Skill detail", + selectSkill: "Select a Skill to inspect it.", + selectSkillHint: "Content, authority, lineage, and availability will appear here.", + overview: "Overview", + description: "Description", + status: "Status", + approved: "Approved", + available: "Available", + unavailable: "Unavailable", + artifact: "Artifact", + revision: "Revision", + candidate: "Candidate", + provider: "Provider", + installationScope: "Installation scope", + host: "Host", + fingerprint: "Fingerprint", + locator: "Locator", + entrypoint: "Entrypoint", + instructions: "Instructions", + validation: "Validation", + 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", + 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", + discovery: "Discovery", + publishSkill: "Publish Skill", + updateSkill: "Publish 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.", + cancel: "Cancel", + projectionUnpublished: "Not published", + projectionCurrent: "Current", + projectionUpdateAvailable: "Update available", + projectionConflict: "Target conflict", + projectionDrifted: "Locally modified", + projectionIncompatible: "Not compatible", + projectionConflictHint: "The destination is occupied or a newer Revision is already present. PowerContext will not overwrite it.", + projectionDriftedHint: "This PowerContext package was modified locally. Restore it or choose another target before publishing.", + projectionIncompatibleHint: "Revise the Skill name or description to satisfy the package constraints, then approve a new Revision.", + discoveryAvailable: "Available in the configured Agent target", + discoveryUnavailable: "Package exists; discovery needs refresh", + 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}.", + publicationConflict: "The publication target changed or contains content PowerContext will not overwrite.", + publishing: "Publishing Skill...", + loading: "Loading Skills...", + refreshing: "Refreshing local discovery...", + externalDiscoveryUnavailable: "Agent-local Skill discovery is not configured. Managed Skills are still shown.", + 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}.", + serverUnavailable: "The Server is unavailable.", + retry: "Retry", + noScopes: "No Dashboard scopes are configured.", + scopeUnavailable: "The selected scope is not available." + }, + zh: { + pageTitle: "PowerContext 技能库", + dashboardTitle: "仪表盘", + skillsTitle: "技能", + reviewTitle: "审核", + handoffReportTitle: "交接报告", + brandHomeLabel: "PowerContext 仪表盘", + primaryNavigation: "主导航", + maintainedBy: "由 OceanBase 维护。", + signOut: "退出", + switchDark: "切换至深色模式", + switchLight: "切换至浅色模式", + switchChinese: "切换至中文", + switchEnglish: "切换至英文", + languageChinese: "中文", + languageEnglish: "EN", + authTitle: "连接 PowerContext", + authIntro: "请输入 PowerContext 服务器配置的访问令牌。令牌仅保留在当前浏览器标签页。", + tokenLabel: "服务器访问令牌", + continue: "继续", + skillsLibraryTitle: "技能库", + skillsIntro: "浏览当前作用域中已批准的受管技能,以及代理本地可用的技能包。", + selectScope: "作用域", + searchScopesPlaceholder: "按作用域名称或标识符搜索", + scopeSearchCount: "共 {count} 个作用域", + scopeSearchMatches: "找到 {count} 个作用域", + scopeSearchLimited: "显示 {total} 个匹配项中的前 {shown} 个", + noMatchingScopes: "没有匹配的作用域。", + skillsFilters: "技能筛选条件", + searchSkills: "搜索", + searchSkillsPlaceholder: "按名称、描述或标识搜索", + authority: "权威来源", + allSkills: "全部技能", + managedSkill: "受管技能", + externalSkill: "外部技能", + refresh: "刷新", + skillsSummary: "技能概览", + managedSkills: "受管技能", + externalSkills: "外部技能", + authorityNote: "受管技能以制品修订为权威来源;外部技能以代理本地技能包为权威来源。", + libraryInventory: "技能清单", + loadedSkills: "显示 {total} 项中的 {count} 项", + noSkills: "没有符合筛选条件的技能。", + noSkillsHint: "请尝试其他搜索条件,或刷新本地发现状态。", + skillDetail: "技能详情", + selectSkill: "请选择一项技能进行检查。", + selectSkillHint: "此处将显示内容、权威来源、沿袭关系和可用状态。", + overview: "概览", + description: "描述", + status: "状态", + approved: "已批准", + available: "可用", + unavailable: "不可用", + artifact: "制品", + revision: "修订", + candidate: "候选", + provider: "提供方", + installationScope: "安装范围", + host: "主机", + fingerprint: "内容指纹", + locator: "本地位置", + entrypoint: "入口文件", + instructions: "使用说明", + validation: "验证要求", + lineage: "沿袭关系", + sourceReferences: "数据源引用", + artifactReferences: "制品引用", + noSourceReferences: "无数据源引用", + noArtifactReferences: "无制品引用", + delivery: "交付", + deliveryIntro: "检查发布状态,或将已批准修订发布到明确配置的代理技能目录。", + createSkillRevision: "创建新修订", + publishTarget: "发布目标", + agentCodex: "Codex", + agentClaudeCode: "Claude Code", + installationUser: "用户级", + installationProject: "项目级", + installationPlugin: "插件级", + noPublishTargets: "未配置可写的技能目标。", + noPublishTargetsHint: "请在一个明确的本地技能目录上启用受管发布。", + publishedRevision: "已发布修订", + destination: "目标位置", + discovery: "发现状态", + publishSkill: "发布技能", + updateSkill: "发布更新", + refreshDiscovery: "刷新发现状态", + publishSkillCandidate: "发布这项受管技能?", + publishConfirmation: "将第 {revision} 版发布到 {target}。系统只会安全更新由 PowerContext 管理的内容,不会覆盖外部内容或已被本地修改的内容。", + cancel: "取消", + projectionUnpublished: "尚未发布", + projectionCurrent: "已是当前版本", + projectionUpdateAvailable: "有更新可发布", + projectionConflict: "目标存在冲突", + projectionDrifted: "已被本地修改", + projectionIncompatible: "格式不兼容", + projectionConflictHint: "目标位置已被占用,或其中已有更新的修订。系统不会覆盖该内容。", + projectionDriftedHint: "该受管技能包已在本地被修改。请先恢复内容,或选择其他目标。", + projectionIncompatibleHint: "请修订技能名称或描述以满足技能包约束,然后批准新的修订。", + discoveryAvailable: "已在配置的技能目录中可用", + discoveryUnavailable: "技能包已存在,需要刷新发现状态", + discoveryNotPublished: "尚不可用", + publicationLoading: "正在检查发布状态...", + publicationLoadFailed: "无法加载发布状态(HTTP {status})。", + publicationSucceeded: "受管技能第 {revision} 版已发布并可被发现。", + publicationFailed: "无法发布该受管技能(HTTP {status})。", + publicationConflict: "发布目标已经变化,或包含系统不会覆盖的内容。", + publishing: "正在发布技能...", + loading: "正在加载技能...", + refreshing: "正在刷新本地发现状态...", + externalDiscoveryUnavailable: "未配置代理本地技能发现,仍会显示受管技能。", + externalSkillUnavailable: "该本地技能包已无法按登记的内容指纹精确解析。", + authRejected: "服务器拒绝了该访问令牌。", + requestFailed: "技能请求失败(HTTP {status})。", + serverUnavailable: "服务器无法访问。", + retry: "重试", + noScopes: "未配置仪表盘作用域。", + scopeUnavailable: "选中的作用域不可用。" + } +}; + +class SkillsRequestError extends Error { + constructor(status, code = "", details = null) { + super(`Skills request failed with HTTP ${status}`); + this.status = status; + this.code = code; + this.details = details; + } +} + +const authShell = document.getElementById("auth-shell"); +const authForm = document.getElementById("auth-form"); +const authError = document.getElementById("auth-error"); +const tokenInput = document.getElementById("token"); +const pageStatus = document.getElementById("page-status"); +const pageStatusMessage = document.getElementById("page-status-message"); +const pageStatusRetry = document.getElementById("page-status-retry"); +const library = document.getElementById("skills-library"); +const signOut = document.getElementById("sign-out"); +const scopeCombobox = document.getElementById("skills-scope-combobox"); +const scopeSearchInput = document.getElementById("skills-scope-search"); +const scopeOptions = document.getElementById("skills-scope-options"); +const scopeSearchStatus = document.getElementById("skills-scope-search-status"); +const searchInput = document.getElementById("skills-search"); +const authorityFilter = document.getElementById("skills-authority-filter"); +const refreshButton = document.getElementById("skills-refresh"); +const liveStatus = document.getElementById("skills-live-status"); +const managedCount = document.getElementById("skills-managed-count"); +const externalCount = document.getElementById("skills-external-count"); +const indexCaption = document.getElementById("skills-index-caption"); +const loadingState = document.getElementById("skills-loading"); +const skillList = document.getElementById("skills-list"); +const emptyState = document.getElementById("skills-empty"); +const detailEmpty = document.getElementById("skills-detail-empty"); +const detailContent = document.getElementById("skills-detail-content"); +const detailAuthority = document.getElementById("skills-detail-authority"); +const detailName = document.getElementById("skills-detail-name"); +const detailIdentity = document.getElementById("skills-detail-identity"); +const detailStatus = document.getElementById("skills-detail-status"); +const alert = document.getElementById("skills-alert"); +const description = document.getElementById("skills-description"); +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 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 projectionState = document.getElementById("skills-projection-state"); +const deliveryStatus = document.getElementById("skills-delivery-status"); +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 destination = document.getElementById("skills-destination"); +const createRevisionButton = document.getElementById("skills-create-revision"); +const publishButton = document.getElementById("skills-publish"); +const publishDialog = document.getElementById("skills-publish-dialog"); +const publishConfirmation = document.getElementById("skills-publish-confirmation"); +const confirmPublishButton = document.getElementById("skills-confirm-publish"); + +const authenticationRequired = document.documentElement.dataset.serverAuthRequired === "true"; +const scopePreferenceKey = "powercontext.skills.scope"; +const scopeOptionRenderLimit = 50; + +let scopes = []; +let records = []; +let currentScopeId = ""; +let selectedKey = ""; +let projectionView = null; +let currentAlert = null; +let currentPageStatus = null; +let currentAuthError = null; +let libraryBusy = false; +let projectionBusy = false; +let actionBusy = false; +let scopeActiveIndex = -1; + +const scopeRequests = createRequestGate(); +const libraryRequests = createRequestGate(); +const projectionRequests = createRequestGate(); +const ui = createPageUi(translations, () => { + renderAuthError(); + renderPageStatus(); + renderScopeCombobox(); + renderLibrary(); + renderDetail(); +}); +const {formatNumber, translate} = ui; + +scopeSearchInput.addEventListener("focus", () => { + if (scopeOptions.hidden) { + scopeSearchInput.value = ""; + } + openScopeOptions(); +}); + +scopeSearchInput.addEventListener("input", () => { + scopeActiveIndex = -1; + renderScopeOptionsList(); + openScopeOptions(); +}); + +scopeSearchInput.addEventListener("keydown", handleScopeSearchKeydown); + +scopeCombobox.addEventListener("focusout", (event) => { + if (!scopeCombobox.contains(event.relatedTarget)) { + closeScopeOptions({restoreSelection: true}); + } +}); + +searchInput.addEventListener("input", () => { + selectedKey = filteredRecords().some((record) => record.key === selectedKey) ? selectedKey : ""; + renderLibrary(); + ensureSelection(); +}); + +authorityFilter.addEventListener("change", () => { + selectedKey = filteredRecords().some((record) => record.key === selectedKey) ? selectedKey : ""; + renderLibrary(); + ensureSelection(); +}); + +refreshButton.addEventListener("click", () => { + void loadLibrary({refreshDiscovery: true, preserveSelection: true}); +}); + +deliveryTarget.addEventListener("change", renderDelivery); + +createRevisionButton.addEventListener("click", () => { + const record = selectedRecord(); + if (!record || record.authority !== "managed") { + 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()}`); +}); + +publishButton.addEventListener("click", () => { + const record = selectedRecord(); + const target = selectedProjectionTarget(); + if (!record || record.authority !== "managed" || !target || !canPublishProjection(target)) { + return; + } + publishConfirmation.textContent = translate("publishConfirmation", { + revision: record.candidate.result_artifact.revision, + target: `${agentLabel(target.agent_kind)} · ${target.target_id}` + }); + publishDialog.showModal(); +}); + +confirmPublishButton.addEventListener("click", (event) => { + event.preventDefault(); + publishDialog.close(); + void publishSelectedSkill(); +}); + +pageStatusRetry.addEventListener("click", () => { + void authenticate(readServerToken(), currentScopeId); +}); + +authForm.addEventListener("submit", (event) => { + event.preventDefault(); + authError.textContent = ""; + void authenticate(tokenInput.value, preferredScopeId()); +}); + +signOut.addEventListener("click", () => { + clearServerToken(); + tokenInput.value = ""; + showLogin(); +}); + +async function authenticate(token, preferred = "") { + if (authenticationRequired && !token) { + showLogin(); + return; + } + if (authenticationRequired) { + storeServerToken(token); + } + tokenInput.value = ""; + currentAuthError = null; + const request = scopeRequests.start(); + scopeSearchInput.disabled = true; + try { + const response = await fetchWithBearer("/dashboard/scopes", token); + if (!request.isCurrent()) { + return; + } + if (response.status === 401) { + clearServerToken(); + showLogin("authRejected"); + return; + } + if (!response.ok) { + showPageStatus("requestFailed", {status: response.status}, true); + return; + } + scopes = await response.json(); + if (!request.isCurrent()) { + return; + } + if (scopes.length === 0) { + showPageStatus("noScopes", {}, true); + return; + } + currentScopeId = scopes.some((scope) => scope.scope_id === preferred) + ? preferred + : scopes[0].scope_id; + rememberScope(currentScopeId); + showLibrary(); + renderScopeCombobox(); + await loadLibrary(); + } catch (error) { + if (request.isCurrent()) { + showPageStatus("serverUnavailable", {}, true); + } + } finally { + if (request.isCurrent()) { + scopeSearchInput.disabled = false; + } + } +} + +async function loadLibrary({refreshDiscovery = false, preserveSelection = false} = {}) { + if (!currentScopeId || libraryBusy) { + return; + } + const request = libraryRequests.start(); + const previousSelection = preserveSelection ? selectedKey : ""; + setLibraryBusy(true, refreshDiscovery ? "refreshing" : "loading"); + currentAlert = null; + try { + const managedPromise = loadApprovedManagedSkills(); + const externalPromise = loadExternalSkills(refreshDiscovery); + const [managedResult, externalResult] = await Promise.allSettled([managedPromise, externalPromise]); + if (!request.isCurrent()) { + return; + } + if (managedResult.status === "rejected") { + throw managedResult.reason; + } + const externalRecords = externalResult.status === "fulfilled" ? externalResult.value : []; + if (externalResult.status === "rejected") { + if (handleAuthenticationError(externalResult.reason)) { + return; + } + 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; + renderLibrary(); + renderDetail(); + await loadProjectionStatus(); + } catch (error) { + if (!request.isCurrent() || handleAuthenticationError(error)) { + return; + } + showPageStatus(error instanceof SkillsRequestError ? "requestFailed" : "serverUnavailable", { + status: error.status + }, true); + } finally { + if (request.isCurrent()) { + setLibraryBusy(false); + } + } +} + +async function loadApprovedManagedSkills() { + const candidates = []; + let cursor = null; + do { + const body = { + scope_id: currentScopeId, + family: "skill", + status: "approved", + limit: 100 + }; + 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() + })); +} + +async function loadExternalSkills(refreshDiscovery) { + if (refreshDiscovery) { + await requestJson("/v1/external-skills/scan", {scope_id: currentScopeId}); + } + const response = await requestJson("/v1/external-skills/list", { + scope_id: currentScopeId, + include_unavailable: true + }); + return response.skills.map((resolution) => { + const registration = resolution.registration; + return { + authority: "external", + resolution, + key: `external:${registration.external_skill_id}`, + name: registration.name, + description: registration.description, + identity: registration.external_skill_id, + searchText: [ + registration.name, + registration.description, + registration.external_skill_id, + registration.locator, + registration.fingerprint + ].join("\n").toLocaleLowerCase() + }; + }); +} + +async function loadProjectionStatus() { + projectionRequests.cancel(); + projectionView = null; + const record = selectedRecord(); + renderDelivery(); + if (!record || record.authority !== "managed") { + return; + } + const request = projectionRequests.start(); + projectionBusy = true; + renderDelivery(); + try { + const view = await requestJson("/dashboard/skill-projections/status", { + scope_id: currentScopeId, + candidate_id: record.candidate.candidate_id, + artifact: record.candidate.result_artifact + }); + if (!request.isCurrent() || selectedKey !== record.key) { + return; + } + projectionView = view; + } catch (error) { + if (!request.isCurrent() || handleAuthenticationError(error)) { + return; + } + currentAlert = { + key: error instanceof SkillsRequestError ? "publicationLoadFailed" : "serverUnavailable", + values: {status: error.status}, + tone: "error" + }; + } finally { + if (request.isCurrent()) { + projectionBusy = false; + renderDetail(); + } + } +} + +async function publishSelectedSkill() { + const record = selectedRecord(); + const target = selectedProjectionTarget(); + if (!record || record.authority !== "managed" || !target || !canPublishProjection(target)) { + return; + } + const request = projectionRequests.start(); + actionBusy = true; + currentAlert = null; + liveStatus.textContent = translate("publishing"); + renderDelivery(); + try { + const view = await requestJson("/dashboard/skill-projections/publish", { + 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: "publicationSucceeded", + values: {revision: record.candidate.result_artifact.revision}, + 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 ? "publicationFailed" : "serverUnavailable", + values: {status: error.status}, + tone: "error" + }; + } finally { + if (request.isCurrent()) { + actionBusy = false; + liveStatus.textContent = ""; + renderDetail(); + } + } +} + +function renderLibrary() { + const filtered = filteredRecords(); + const managedTotal = records.filter((record) => record.authority === "managed").length; + const externalTotal = records.filter((record) => record.authority === "external").length; + managedCount.textContent = formatNumber(managedTotal); + externalCount.textContent = formatNumber(externalTotal); + indexCaption.textContent = translate("loadedSkills", { + count: formatNumber(filtered.length), + total: formatNumber(records.length) + }); + loadingState.hidden = !libraryBusy || records.length > 0; + skillList.replaceChildren(); + for (const record of filtered) { + const row = document.createElement("button"); + row.type = "button"; + row.className = "skills-list-item"; + row.role = "option"; + row.setAttribute("aria-selected", String(record.key === selectedKey)); + row.addEventListener("click", () => selectRecord(record.key)); + + const heading = document.createElement("span"); + heading.className = "skills-list-heading"; + const authority = document.createElement("span"); + authority.className = `skills-authority-label skills-authority-${record.authority}`; + authority.textContent = translate(record.authority === "managed" ? "managedSkill" : "externalSkill"); + const state = document.createElement("span"); + state.className = `status-badge skills-status-${recordStatus(record)}`; + state.textContent = translate(recordStatus(record)); + heading.append(authority, state); + + const name = document.createElement("strong"); + name.textContent = record.name; + const summary = document.createElement("span"); + summary.className = "skills-list-summary"; + summary.textContent = compactText(record.description, 132); + const identity = document.createElement("code"); + identity.textContent = record.identity; + row.append(heading, name, summary, identity); + skillList.append(row); + } + emptyState.hidden = filtered.length !== 0 || libraryBusy; +} + +function renderDetail() { + const record = selectedRecord(); + if (!record) { + clearDetail(); + return; + } + detailEmpty.hidden = true; + detailContent.hidden = false; + detailAuthority.className = `skills-authority-label skills-authority-${record.authority}`; + detailAuthority.textContent = translate(record.authority === "managed" ? "managedSkill" : "externalSkill"); + detailName.textContent = record.name; + detailIdentity.textContent = record.identity; + detailStatus.className = `status-badge skills-status-${recordStatus(record)}`; + detailStatus.textContent = translate(recordStatus(record)); + description.textContent = record.description; + renderFacts(record); + renderAlert(record); + + const isManaged = record.authority === "managed"; + managedContent.hidden = !isManaged; + lineage.hidden = !isManaged; + delivery.hidden = !isManaged; + if (isManaged) { + instructions.textContent = record.candidate.proposal.instructions; + renderValidation(record.candidate.proposal.validation); + renderReferences(sourceRefs, record.candidate.source_refs, formatSourceReference, "noSourceReferences"); + renderReferences(artifactRefs, record.candidate.artifact_refs, formatArtifactReference, "noArtifactReferences"); + renderDelivery(); + } +} + +function clearDetail() { + detailEmpty.hidden = false; + detailContent.hidden = true; + facts.replaceChildren(); + validation.replaceChildren(); + sourceRefs.replaceChildren(); + artifactRefs.replaceChildren(); + projectionRequests.cancel(); + projectionView = null; +} + +function renderFacts(record) { + facts.replaceChildren(); + appendDefinition(facts, "authority", translate(record.authority === "managed" ? "managedSkill" : "externalSkill")); + 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); + 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, "fingerprint", registration.fingerprint, true); + appendDefinition(facts, "locator", registration.locator, true); + appendDefinition(facts, "entrypoint", record.resolution.entrypoint || translate("unavailable"), true); +} + +function renderValidation(items) { + validation.replaceChildren(); + for (const item of items) { + const row = document.createElement("li"); + row.textContent = item; + validation.append(row); + } +} + +function renderReferences(list, references, formatter, emptyKey) { + list.replaceChildren(); + if (!references.length) { + const item = document.createElement("li"); + item.className = "skills-reference-empty"; + item.textContent = translate(emptyKey); + list.append(item); + return; + } + for (const reference of references) { + const item = document.createElement("li"); + const code = document.createElement("code"); + code.textContent = formatter(reference); + item.append(code); + list.append(item); + } +} + +function renderAlert(record) { + const recordAlert = record.authority === "external" && record.resolution.status === "unavailable" + ? {key: "externalSkillUnavailable", tone: "warning"} + : null; + const notice = currentAlert || recordAlert; + alert.hidden = !notice; + if (!notice) { + alert.textContent = ""; + return; + } + alert.dataset.tone = notice.tone || "error"; + alert.textContent = translate(notice.key, notice.values || {}); +} + +function renderDelivery() { + const record = selectedRecord(); + if (!record || record.authority !== "managed") { + delivery.hidden = true; + return; + } + delivery.hidden = false; + createRevisionButton.disabled = libraryBusy || projectionBusy || actionBusy; + publishButton.hidden = true; + deliveryStatus.textContent = projectionBusy ? translate("publicationLoading") : ""; + projectionState.hidden = projectionBusy || !projectionView; + deliveryEmpty.hidden = true; + deliveryContent.hidden = true; + if (projectionBusy || !projectionView) { + return; + } + if (projectionView.targets.length === 0) { + deliveryEmpty.hidden = false; + return; + } + const selectedTargetId = projectionView.targets.some((target) => target.target_id === deliveryTarget.value) + ? deliveryTarget.value + : projectionView.targets[0].target_id; + deliveryTarget.replaceChildren(); + 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.selected = target.target_id === selectedTargetId; + deliveryTarget.append(option); + } + const target = selectedProjectionTarget(); + if (!target) { + return; + } + deliveryContent.hidden = false; + projectionState.hidden = false; + projectionState.className = `status-badge skills-projection-${target.state}`; + projectionState.textContent = translate(projectionStateKey(target.state)); + deliveryStatus.textContent = projectionHintKey(target.state) ? translate(projectionHintKey(target.state)) : ""; + publishedRevision.textContent = target.published_revision === null + ? translate("unavailable") + : String(target.published_revision); + discovery.textContent = translate(discoveryStateKey(target.discovery)); + destination.textContent = target.destination; + publishButton.textContent = translate(publicationActionKey(target)); + const canPublish = canPublishProjection(target); + publishButton.hidden = !canPublish; + publishButton.disabled = libraryBusy || projectionBusy || actionBusy || !canPublish; +} + +function selectRecord(key) { + if (key === selectedKey) { + return; + } + selectedKey = key; + projectionView = null; + currentAlert = null; + renderLibrary(); + renderDetail(); + void loadProjectionStatus(); +} + +function ensureSelection() { + const filtered = filteredRecords(); + if (!filtered.some((record) => record.key === selectedKey)) { + selectedKey = filtered[0]?.key || ""; + projectionView = null; + } + renderLibrary(); + renderDetail(); + void loadProjectionStatus(); +} + +function filteredRecords() { + const query = searchInput.value.trim().toLocaleLowerCase(); + return records.filter((record) => ( + (!authorityFilter.value || record.authority === authorityFilter.value) + && (!query || record.searchText.includes(query)) + )); +} + +function selectedRecord() { + return records.find((record) => record.key === selectedKey) || null; +} + +function selectedProjectionTarget() { + if (!projectionView) { + return null; + } + return projectionView.targets.find((target) => target.target_id === deliveryTarget.value) + || projectionView.targets[0] + || null; +} + +function agentLabel(agentKind) { + return translate(agentKind === "claude_code" ? "agentClaudeCode" : "agentCodex"); +} + +function recordStatus(record) { + return record.authority === "managed" ? "approved" : record.resolution.status; +} + +function compareRecords(left, right) { + const byName = left.name.localeCompare(right.name, undefined, {sensitivity: "base"}); + return byName || left.authority.localeCompare(right.authority) || left.identity.localeCompare(right.identity); +} + +function compactText(value, limit) { + const text = String(value).replace(/\s+/g, " ").trim(); + return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`; +} + +function formatArtifactReference(reference) { + return `${reference.family}/${reference.artifact_id}@${reference.revision}`; +} + +function formatSourceReference(reference) { + return `${reference.name}/${reference.source_id}`; +} + +function appendDefinition(list, key, value, code = false) { + const container = document.createElement("div"); + const term = document.createElement("dt"); + term.textContent = translate(key); + const description = document.createElement("dd"); + if (code) { + const codeElement = document.createElement("code"); + codeElement.textContent = String(value ?? ""); + description.append(codeElement); + } else { + description.textContent = String(value ?? ""); + } + container.append(term, description); + list.append(container); +} + +function canPublishProjection(target) { + return ["unpublished", "update_available"].includes(target.state) + || (target.state === "current" && target.discovery !== "available"); +} + +function publicationActionKey(target) { + if (target.state === "update_available") { + return "updateSkill"; + } + if (target.state === "current") { + return "refreshDiscovery"; + } + return "publishSkill"; +} + +function projectionStateKey(state) { + return { + unpublished: "projectionUnpublished", + current: "projectionCurrent", + update_available: "projectionUpdateAvailable", + conflict: "projectionConflict", + drifted: "projectionDrifted", + incompatible: "projectionIncompatible" + }[state] || "projectionConflict"; +} + +function projectionHintKey(state) { + return { + conflict: "projectionConflictHint", + drifted: "projectionDriftedHint", + incompatible: "projectionIncompatibleHint" + }[state] || ""; +} + +function discoveryStateKey(state) { + return { + available: "discoveryAvailable", + unavailable: "discoveryUnavailable", + not_published: "discoveryNotPublished" + }[state] || "discoveryNotPublished"; +} + +function capitalize(value) { + return `${value.charAt(0).toUpperCase()}${value.slice(1)}`; +} + +async function requestJson(path, body) { + const response = await fetchWithBearer(path, readServerToken(), { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(body) + }); + let payload = null; + try { + payload = await response.json(); + } catch (error) { + // The response status still gives the page a safe error path. + } + if (!response.ok) { + throw new SkillsRequestError( + response.status, + payload?.error?.code || "", + payload?.error?.details || null + ); + } + return payload; +} + +function handleAuthenticationError(error) { + if (!(error instanceof SkillsRequestError) || error.status !== 401) { + return false; + } + clearServerToken(); + showLogin("authRejected"); + return true; +} + +function setLibraryBusy(value, statusKey = "") { + libraryBusy = value; + liveStatus.textContent = statusKey ? translate(statusKey) : ""; + scopeSearchInput.disabled = value; + searchInput.disabled = value; + authorityFilter.disabled = value; + refreshButton.disabled = value; + if (value) { + closeScopeOptions({restoreSelection: true}); + } + renderLibrary(); + renderDelivery(); +} + +function showLogin(messageKey = "") { + scopeRequests.cancel(); + libraryRequests.cancel(); + projectionRequests.cancel(); + currentScopeId = ""; + currentAuthError = messageKey ? {key: messageKey, values: {}} : null; + renderAuthError(); + authShell.hidden = false; + pageStatus.hidden = true; + library.hidden = true; + signOut.hidden = true; + tokenInput.focus(); +} + +function showPageStatus(messageKey, values = {}, retryable = false) { + currentPageStatus = {key: messageKey, values, retryable}; + renderPageStatus(); + authShell.hidden = true; + pageStatus.hidden = false; + library.hidden = true; + signOut.hidden = !authenticationRequired; +} + +function showLibrary() { + currentPageStatus = null; + authShell.hidden = true; + pageStatus.hidden = true; + library.hidden = false; + signOut.hidden = !authenticationRequired; +} + +function renderAuthError() { + authError.textContent = currentAuthError ? translate(currentAuthError.key, currentAuthError.values) : ""; +} + +function renderPageStatus() { + if (!currentPageStatus) { + pageStatusMessage.textContent = ""; + pageStatusRetry.hidden = true; + return; + } + pageStatusMessage.textContent = translate(currentPageStatus.key, currentPageStatus.values); + pageStatusRetry.hidden = !currentPageStatus.retryable; +} + +function renderScopeCombobox() { + const selected = scopes.find((scope) => scope.scope_id === currentScopeId) || null; + if (scopeOptions.hidden) { + scopeSearchInput.value = selected?.display_name || ""; + scopeSearchStatus.textContent = translate("scopeSearchCount", {count: formatNumber(scopes.length)}); + return; + } + renderScopeOptionsList(); +} + +function matchingScopes() { + const query = scopeSearchInput.value.trim().toLocaleLowerCase(); + if (!query) { + return scopes; + } + return scopes.filter((scope) => ( + `${scope.display_name}\n${scope.scope_id}`.toLocaleLowerCase().includes(query) + )); +} + +function renderScopeOptionsList() { + const matches = matchingScopes(); + const visible = matches.slice(0, scopeOptionRenderLimit); + scopeOptions.replaceChildren(); + scopeActiveIndex = Math.min(scopeActiveIndex, visible.length - 1); + for (const [index, scope] of visible.entries()) { + const option = document.createElement("button"); + option.className = "scope-option"; + option.id = `skills-scope-option-${index}`; + option.type = "button"; + option.role = "option"; + option.tabIndex = -1; + option.dataset.scopeId = scope.scope_id; + option.setAttribute("aria-selected", String(scope.scope_id === currentScopeId)); + const name = document.createElement("strong"); + name.textContent = scope.display_name; + const identity = document.createElement("code"); + identity.textContent = scope.scope_id; + option.append(name, identity); + option.addEventListener("click", () => void selectScope(scope.scope_id)); + scopeOptions.append(option); + } + if (matches.length === 0) { + const empty = document.createElement("p"); + empty.className = "scope-options-empty"; + empty.textContent = translate("noMatchingScopes"); + scopeOptions.append(empty); + } + scopeSearchStatus.textContent = matches.length > scopeOptionRenderLimit + ? translate("scopeSearchLimited", {shown: formatNumber(visible.length), total: formatNumber(matches.length)}) + : translate(scopeSearchInput.value ? "scopeSearchMatches" : "scopeSearchCount", { + count: formatNumber(matches.length) + }); + updateScopeActiveDescendant(); +} + +function openScopeOptions() { + if (scopeSearchInput.disabled || scopes.length === 0) { + return; + } + scopeOptions.hidden = false; + scopeSearchInput.setAttribute("aria-expanded", "true"); + renderScopeOptionsList(); +} + +function closeScopeOptions({restoreSelection = false} = {}) { + scopeOptions.hidden = true; + scopeSearchInput.setAttribute("aria-expanded", "false"); + scopeSearchInput.removeAttribute("aria-activedescendant"); + scopeActiveIndex = -1; + if (restoreSelection) { + const selected = scopes.find((scope) => scope.scope_id === currentScopeId); + scopeSearchInput.value = selected?.display_name || ""; + scopeSearchStatus.textContent = translate("scopeSearchCount", {count: formatNumber(scopes.length)}); + } +} + +function handleScopeSearchKeydown(event) { + if (event.key === "Escape") { + event.preventDefault(); + closeScopeOptions({restoreSelection: true}); + return; + } + if (!["ArrowDown", "ArrowUp", "Enter", "Home", "End"].includes(event.key)) { + return; + } + event.preventDefault(); + if (scopeOptions.hidden) { + openScopeOptions(); + } + const options = Array.from(scopeOptions.querySelectorAll(".scope-option")); + if (!options.length) { + return; + } + if (event.key === "Enter") { + const target = options[scopeActiveIndex] || options[0]; + void selectScope(target.dataset.scopeId); + return; + } + if (event.key === "Home") { + scopeActiveIndex = 0; + } else if (event.key === "End") { + scopeActiveIndex = options.length - 1; + } else if (event.key === "ArrowDown") { + scopeActiveIndex = Math.min(scopeActiveIndex + 1, options.length - 1); + } else { + scopeActiveIndex = scopeActiveIndex <= 0 ? options.length - 1 : scopeActiveIndex - 1; + } + updateScopeActiveDescendant(); +} + +function updateScopeActiveDescendant() { + const options = Array.from(scopeOptions.querySelectorAll(".scope-option")); + for (const [index, option] of options.entries()) { + option.dataset.active = String(index === scopeActiveIndex); + } + const active = options[scopeActiveIndex]; + if (!active) { + scopeSearchInput.removeAttribute("aria-activedescendant"); + return; + } + scopeSearchInput.setAttribute("aria-activedescendant", active.id); + active.scrollIntoView({block: "nearest"}); +} + +async function selectScope(scopeId) { + const selected = scopes.find((scope) => scope.scope_id === scopeId); + if (!selected) { + showPageStatus("scopeUnavailable", {}, true); + return; + } + closeScopeOptions(); + scopeSearchInput.value = selected.display_name; + if (scopeId === currentScopeId) { + return; + } + currentScopeId = scopeId; + rememberScope(scopeId); + records = []; + selectedKey = ""; + projectionView = null; + renderLibrary(); + renderDetail(); + await loadLibrary(); +} + +function preferredScopeId() { + const queryScope = new URLSearchParams(window.location.search).get("scope"); + if (queryScope) { + return queryScope; + } + try { + return sessionStorage.getItem(scopePreferenceKey) || ""; + } catch (error) { + return ""; + } +} + +function rememberScope(scopeId) { + try { + sessionStorage.setItem(scopePreferenceKey, scopeId); + } catch (error) { + // The current page still retains the selected scope. + } +} + +ui.initialize(); +void authenticate(readServerToken(), preferredScopeId()); diff --git a/src/powercontext/server/templates/base.html b/src/powercontext/server/templates/base.html index f7be17961..b0cb58c92 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/components/header.html b/src/powercontext/server/templates/components/header.html index 5e6e0ef31..3e84bb753 100644 --- a/src/powercontext/server/templates/components/header.html +++ b/src/powercontext/server/templates/components/header.html @@ -31,6 +31,12 @@ {% if dashboard_enabled %} Dashboard {% endif %} + {% if skills_enabled %} + Skills + {% endif %} + {% if review_enabled %} + Review + {% endif %} {% if handoff_report_enabled %} Handoff Report {% endif %} diff --git a/src/powercontext/server/templates/pages/review.html b/src/powercontext/server/templates/pages/review.html new file mode 100644 index 000000000..7a33cfaff --- /dev/null +++ b/src/powercontext/server/templates/pages/review.html @@ -0,0 +1,247 @@ + + +{% extends "base.html" %} + +{% block title %}PowerContext Review{% endblock %} + +{% block content %} +{% include "components/login.html" %} + +{% set status_title_key = "reviewTitle" %} +{% set status_title = "Review" %} +{% include "components/status.html" %} + +
+
+
+

Review

+

Experience and Skill review

+

Inspect evidence, revise proposals, and make explicit decisions.

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

+
+ +
+
+
+
+

Candidates

+

+
+
+
+ + +
+ +
+
+

Candidate detail

+

Select a Candidate to inspect it.

+

The full proposal and exact evidence references will appear here.

+
+ + +
+
+
+ + +
+

Approve Candidate?

+

This creates or updates the managed Artifact from the current proposal.

+
+ + +
+
+
+ + +
+

Publish this managed Skill?

+

+
+ + +
+
+
+ + +
+

Reject Candidate?

+

Record a clear reason for rejecting this proposal.

+ + +
+ + +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/src/powercontext/server/templates/pages/skills.html b/src/powercontext/server/templates/pages/skills.html new file mode 100644 index 000000000..9f8a43b51 --- /dev/null +++ b/src/powercontext/server/templates/pages/skills.html @@ -0,0 +1,213 @@ + + +{% extends "base.html" %} + +{% block title %}PowerContext Skills Library{% endblock %} + +{% block content %} +{% include "components/login.html" %} + +{% set status_title_key = "skillsTitle" %} +{% set status_title = "Skills" %} +{% include "components/status.html" %} + +
+
+
+

Skills

+

Skills Library

+

Browse approved PowerContext Skills and Agent-local packages available in this scope.

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

+
+ +
+
+ Managed Skills + 0 +
+
+ External Skills + 0 +
+

Managed Skills are governed Artifact Revisions. External Skills remain Agent-local packages.

+
+ +
+
+
+
+

Library inventory

+

+
+
+ +
+ +
+ +
+
+

Skill detail

+

Select a Skill to inspect it.

+

Content, authority, lineage, and availability will appear here.

+
+ + +
+
+ + +
+

Publish this managed Skill?

+

+
+ + +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/src/powercontext/server/web.py b/src/powercontext/server/web.py index 255a5a53e..023b2a4b0 100644 --- a/src/powercontext/server/web.py +++ b/src/powercontext/server/web.py @@ -16,15 +16,30 @@ from __future__ import annotations +import asyncio from collections.abc import Mapping from functools import cache +from typing import Literal from fastapi import APIRouter, FastAPI, Request, Response -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from jinja2 import Environment, PackageLoader, select_autoescape -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from powercontext.artifacts import ArtifactRef +from powercontext.builtin.artifacts.skill import AgentKind, AgentSkillTarget, ExternalSkillResolutionStatus +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.limits import MAX_ARTIFACT_ID_LENGTH _PAGE_HEADERS = { "Cache-Control": "no-store", @@ -44,6 +59,111 @@ class DashboardScope(BaseModel): display_name: str +class DashboardSkillProjectionRequest(BaseModel): + """Select one exact approved managed Skill Revision from the Review UI.""" + + 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) + artifact: ArtifactRef + + @model_validator(mode="after") + def require_skill_artifact(self) -> DashboardSkillProjectionRequest: + if self.artifact.family != "skill": + raise ValueError("artifact must identify a managed Skill") # noqa: TRY003 + return self + + +class DashboardSkillPublishRequest(DashboardSkillProjectionRequest): + """Explicitly publish one exact approved managed Skill Revision.""" + + target_id: str = Field(min_length=1, max_length=64) + + +class DashboardSkillProjectionTarget(BaseModel): + """One configured host-local Agent publication target and its exact state.""" + + model_config = ConfigDict(extra="forbid") + + target_id: str + agent_kind: AgentKind + installation_scope: Literal["user", "project", "plugin"] + destination: str + state: AgentSkillProjectionState + published_revision: int | None = None + reason: str | None = None + discovery: Literal["available", "unavailable", "not_published"] + external_skill_id: str | None = None + + +class DashboardSkillProjection(BaseModel): + """Publication state for one exact approved managed Skill Revision.""" + + model_config = ConfigDict(extra="forbid") + + artifact: ArtifactRef + name: str + targets: list[DashboardSkillProjectionTarget] + + +class _DashboardSkillProjectionRoutes: + def __init__(self, scope_ids: frozenset[str], targets: tuple[AgentSkillTarget, ...]) -> None: + self._scope_ids = scope_ids + self._targets = targets + + async def inspect( + self, + request: DashboardSkillProjectionRequest, + 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 + return await _skill_projection_response(application, request.scope_id, skill, self._targets) + + async def publish( + self, + request: DashboardSkillPublishRequest, + 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." + ) + 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, + ) + except AgentSkillProjectionConflictError as error: + return _web_error( + 409, + "skill_projection_conflict", + "The Agent Skill publication target changed or cannot be updated 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 published to the configured Agent target.", + details={"reason": str(error)}, + ) + await application.external_skills.for_scope(request.scope_id).scan() + return await _skill_projection_response(application, request.scope_id, skill, self._targets) + + def mount_web_ui( app: FastAPI, *, @@ -51,13 +171,19 @@ def mount_web_ui( dashboard_enabled: bool = False, handoff_report_enabled: bool = False, authentication_required: bool = False, + agent_skill_targets: tuple[AgentSkillTarget, ...] = (), ) -> None: """Mount Server-owned pages, static assets, and UI support endpoints.""" dashboard_scopes = tuple(DashboardScope(scope_id=scope_id, display_name=name) for scope_id, name in scopes.items()) + dashboard_scope_ids = frozenset(scopes) + publish_targets = tuple(target for target in agent_skill_targets if target.allow_managed_publish) + skill_projection_routes = _DashboardSkillProjectionRoutes(dashboard_scope_ids, publish_targets) templates = _templates() if dashboard_enabled: templates.env.get_template("pages/dashboard.html") + templates.env.get_template("pages/review.html") + templates.env.get_template("pages/skills.html") if handoff_report_enabled: templates.env.get_template("pages/handoff_report.html") static_files = StaticFiles(packages=[("powercontext.server", "static")]) @@ -71,6 +197,40 @@ async def dashboard_page(request: Request) -> Response: context={ "active_page": "dashboard", "dashboard_enabled": True, + "skills_enabled": True, + "review_enabled": True, + "handoff_report_enabled": handoff_report_enabled, + "home_route": "dashboard_home", + "authentication_required": authentication_required, + }, + headers=_PAGE_HEADERS, + ) + + async def skills_page(request: Request) -> Response: + return templates.TemplateResponse( + request=request, + name="pages/skills.html", + context={ + "active_page": "skills", + "dashboard_enabled": True, + "skills_enabled": True, + "review_enabled": True, + "handoff_report_enabled": handoff_report_enabled, + "home_route": "dashboard_home", + "authentication_required": authentication_required, + }, + headers=_PAGE_HEADERS, + ) + + async def review_page(request: Request) -> Response: + return templates.TemplateResponse( + request=request, + name="pages/review.html", + context={ + "active_page": "review", + "dashboard_enabled": True, + "skills_enabled": True, + "review_enabled": True, "handoff_report_enabled": handoff_report_enabled, "home_route": "dashboard_home", "authentication_required": authentication_required, @@ -85,6 +245,8 @@ async def handoff_report_page(request: Request) -> Response: context={ "active_page": "handoff_report", "dashboard_enabled": dashboard_enabled, + "skills_enabled": dashboard_enabled, + "review_enabled": dashboard_enabled, "handoff_report_enabled": True, "home_route": "dashboard_home" if dashboard_enabled else "handoff_report_dashboard", "authentication_required": authentication_required, @@ -111,6 +273,34 @@ async def list_dashboard_scopes(response: Response) -> tuple[DashboardScope, ... response_model=list[DashboardScope], name="dashboard_scopes", ) + router.add_api_route( + "/skills", + skills_page, + methods=["GET"], + response_class=HTMLResponse, + name="skills_library", + ) + router.add_api_route( + "/reviews", + review_page, + methods=["GET"], + response_class=HTMLResponse, + name="review_inbox", + ) + router.add_api_route( + "/dashboard/skill-projections/status", + skill_projection_routes.inspect, + methods=["POST"], + response_model=DashboardSkillProjection, + name="dashboard_skill_projection_status", + ) + router.add_api_route( + "/dashboard/skill-projections/publish", + skill_projection_routes.publish, + methods=["POST"], + response_model=DashboardSkillProjection, + name="dashboard_skill_projection_publish", + ) if handoff_report_enabled: router.add_api_route( "/handoff-reports", @@ -137,4 +327,95 @@ def _templates() -> Jinja2Templates: return Jinja2Templates(env=environment) -__all__ = ["DashboardScope", "mount_web_ui"] +async def _dashboard_managed_skill( + request: Request, + selection: DashboardSkillProjectionRequest, + dashboard_scope_ids: frozenset[str], +): + 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.") + 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 + ): + return _web_error( + 409, + "skill_projection_not_approved", + "The selected Artifact is not the exact approved result of this Skill Candidate.", + ) + skill = await application.skill.for_scope(selection.scope_id).get(GetSkillRequest(artifact=selection.artifact)) + return application, skill + + +async def _skill_projection_response( + application, + scope_id: str, + skill, + targets_config: tuple[AgentSkillTarget, ...], +) -> DashboardSkillProjection: + registrations = ( + () + if not targets_config + else await application.external_skills.for_scope(scope_id).list( + ListExternalSkillsRequest(include_unavailable=True) + ) + ) + targets = [] + for target in targets_config: + status = await asyncio.to_thread(inspect_skill_projection, skill.as_ref(), skill.content, target) + registration = next( + ( + item + for item in registrations + if item.status is ExternalSkillResolutionStatus.AVAILABLE + and item.registration.agent_kind == target.agent_kind + and item.registration.locator == str(status.destination) + ), + None, + ) + if status.state is AgentSkillProjectionState.CURRENT: + discovery = "available" if registration is not None else "unavailable" + else: + discovery = "not_published" + targets.append( + DashboardSkillProjectionTarget( + target_id=target.target_id, + agent_kind=target.agent_kind, + installation_scope=target.installation_scope, + destination=str(status.destination), + state=status.state, + published_revision=(None if status.published_artifact is None else status.published_artifact.revision), + reason=status.reason, + discovery=discovery, + external_skill_id=(None if registration is None else registration.registration.external_skill_id), + ) + ) + return DashboardSkillProjection(artifact=skill.as_ref(), name=skill.content.name, targets=targets) + + +def _web_error( + response_status: int, + code: str, + message: str, + *, + details: dict[str, object] | None = None, +) -> JSONResponse: + error = ErrorResponse(error=ErrorDetail(code=code, message=message, details=details)) + return JSONResponse(status_code=response_status, content=error.model_dump(mode="json")) + + +__all__ = [ + "DashboardScope", + "DashboardSkillProjection", + "DashboardSkillProjectionRequest", + "DashboardSkillProjectionTarget", + "DashboardSkillPublishRequest", + "mount_web_ui", +] diff --git a/tests/builtin/artifacts/skill/test_external.py b/tests/builtin/artifacts/skill/test_external.py index 14b44d3e8..9cd948de2 100644 --- a/tests/builtin/artifacts/skill/test_external.py +++ b/tests/builtin/artifacts/skill/test_external.py @@ -18,6 +18,8 @@ import pytest from powercontext.builtin.artifacts.skill import ( + AgentSkillProvider, + AgentSkillTarget, CodexSkillProvider, CodexSkillRoot, ExternalSkillResolutionStatus, @@ -131,7 +133,7 @@ def test_scan_skips_invalid_or_symlinked_packages(tmp_path: Path) -> None: def test_codex_provider_requires_unique_stable_root_ids(tmp_path: Path) -> None: - with pytest.raises(ValueError, match="root IDs"): + with pytest.raises(ValueError, match="target IDs"): CodexSkillProvider( host_id="workstation-1", roots=( @@ -139,3 +141,41 @@ def test_codex_provider_requires_unique_stable_root_ids(tmp_path: Path) -> None: CodexSkillRoot(root_id="repository", installation_scope="user", path=tmp_path), ), ) + + +def test_agent_provider_discovers_codex_and_claude_code_targets(tmp_path: Path) -> None: + codex_package = _write_skill(tmp_path / ".agents" / "skills", "codex-review") + claude_package = tmp_path / ".claude" / "skills" / "claude-review" + claude_package.mkdir(parents=True) + (claude_package / "SKILL.md").write_text( + "---\ndescription: Review a change with Claude Code.\n---\n\nReview the change.\n", + encoding="utf-8", + ) + provider = AgentSkillProvider( + host_id="workstation-1", + targets=( + AgentSkillTarget( + target_id="codex-project", + agent_kind="codex", + installation_scope="project", + path=codex_package.parent, + ), + AgentSkillTarget( + target_id="claude-project", + agent_kind="claude_code", + installation_scope="project", + path=claude_package.parent, + ), + ), + ) + + scan = provider.scan() + + assert scan.skipped == 0 + assert [registration.external_skill_id for registration in scan.registrations] == [ + "codex:project:codex-project/codex-review", + "claude_code:project:claude-project/claude-review", + ] + assert scan.registrations[1].name == "claude-review" + assert scan.registrations[1].provider == "claude_code" + assert provider.resolve(scan.registrations[1]).entrypoint == str(claude_package / "SKILL.md") diff --git a/tests/builtin/artifacts/skill/test_registry.py b/tests/builtin/artifacts/skill/test_registry.py index 87097d260..42f493f72 100644 --- a/tests/builtin/artifacts/skill/test_registry.py +++ b/tests/builtin/artifacts/skill/test_registry.py @@ -18,6 +18,8 @@ import pytest from powercontext.builtin.artifacts.skill import ( + AgentSkillProvider, + AgentSkillTarget, CodexSkillProvider, CodexSkillRoot, ExternalSkillNotFoundError, @@ -110,3 +112,53 @@ async def exercise() -> None: await second.resolve(registration.external_skill_id, registration.fingerprint) asyncio.run(exercise()) + + +def test_agent_registry_removes_a_provider_when_its_targets_are_unconfigured(tmp_path: Path) -> None: + async def exercise() -> None: + codex_root = tmp_path / ".agents" / "skills" + claude_root = tmp_path / ".claude" / "skills" + _write_skill(codex_root) + _write_skill(claude_root) + codex_target = AgentSkillTarget( + target_id="codex-project", + agent_kind="codex", + installation_scope="project", + path=codex_root, + ) + mixed = AgentSkillProvider( + host_id="workstation-1", + targets=( + codex_target, + AgentSkillTarget( + target_id="claude-project", + agent_kind="claude_code", + installation_scope="project", + path=claude_root, + ), + ), + ) + codex_only = AgentSkillProvider(host_id="workstation-1", targets=(codex_target,)) + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + repository = ExternalSkillRepository() + mixed_service = ExternalSkillRegistryService( + database=profile.database, + scope_id="project:example", + repository=repository, + provider=mixed, + ) + codex_service = ExternalSkillRegistryService( + database=profile.database, + scope_id="project:example", + repository=repository, + provider=codex_only, + ) + + await mixed_service.scan() + assert {skill.registration.provider for skill in await mixed_service.list()} == {"codex", "claude_code"} + + await codex_service.scan() + remaining = await codex_service.list(include_unavailable=True) + assert [skill.registration.provider for skill in remaining] == ["codex"] + + asyncio.run(exercise()) diff --git a/tests/codex_plugin/test_skill_projection.py b/tests/codex_plugin/test_skill_projection.py index 15d0152d4..67f8290c8 100644 --- a/tests/codex_plugin/test_skill_projection.py +++ b/tests/codex_plugin/test_skill_projection.py @@ -18,7 +18,13 @@ import pytest from powercontext.artifacts import ArtifactRef -from powercontext.client.projections.codex import project_skill +from powercontext.client.projections.codex import ( + CodexSkillProjectionConflictError, + CodexSkillProjectionState, + inspect_skill_projection, + project_skill, + publish_skill_projection, +) from powercontext.http import SkillProposal, SkillValidationItem @@ -43,7 +49,8 @@ def test_exact_managed_skill_projects_to_a_new_codex_skill_directory(tmp_path) - assert "Generated from artifact:skill/skill-123@2" in skill_text assert "- make contract-test passes" in skill_text assert manifest == { - "schema": "powercontext.codex-skill-projection.v1", + "schema": "powercontext.agent-skill-projection.v1", + "agent_kind": "codex", "artifact": {"family": "skill", "artifact_id": "skill-123", "revision": 2}, "skill_sha256": hashlib.sha256(skill_text.encode()).hexdigest(), } @@ -66,6 +73,66 @@ def test_projection_never_overwrites_an_existing_directory(tmp_path) -> None: ) +def test_managed_projection_can_be_inspected_and_safely_updated(tmp_path) -> None: + root = tmp_path / ".agents" / "skills" + first = ArtifactRef(family="skill", artifact_id="skill-123", revision=1) + second = ArtifactRef(family="skill", artifact_id="skill-123", revision=2) + original = SkillProposal( + name="safe-skill", + description="Use for a bounded task.", + instructions="Perform the bounded task.", + validation=[SkillValidationItem("The expected result exists.")], + ) + updated = original.model_copy( + update={"name": "safe-skill-v2", "instructions": "Perform the bounded task and inspect the result."} + ) + + unpublished = inspect_skill_projection(first, original, root) + published = publish_skill_projection(first, original, root, expected=unpublished) + update_available = inspect_skill_projection(second, updated, root) + current = publish_skill_projection(second, updated, root, expected=update_available) + + assert unpublished.state is CodexSkillProjectionState.UNPUBLISHED + assert published.state is CodexSkillProjectionState.CURRENT + assert update_available.state is CodexSkillProjectionState.UPDATE_AVAILABLE + assert update_available.published_artifact == first + assert current.state is CodexSkillProjectionState.CURRENT + assert current.published_artifact == second + assert not root.joinpath("safe-skill").exists() + assert "inspect the result" in (current.destination / "SKILL.md").read_text(encoding="utf-8") + + +def test_managed_projection_refuses_to_replace_modified_or_foreign_content(tmp_path) -> None: + root = tmp_path / ".agents" / "skills" + artifact = ArtifactRef(family="skill", artifact_id="skill-123", revision=1) + content = SkillProposal( + name="safe-skill", + description="Use for a bounded task.", + instructions="Perform the bounded task.", + validation=[SkillValidationItem("The expected result exists.")], + ) + published = publish_skill_projection(artifact, content, root) + (published.destination / "SKILL.md").write_text("locally edited\n", encoding="utf-8") + + drifted = inspect_skill_projection(artifact, content, root) + + assert drifted.state is CodexSkillProjectionState.DRIFTED + with pytest.raises(CodexSkillProjectionConflictError): + publish_skill_projection(artifact, content, root) + + foreign = content.model_copy(update={"name": "foreign-skill"}) + foreign_destination = root / foreign.name + foreign_destination.mkdir() + assert ( + inspect_skill_projection( + ArtifactRef(family="skill", artifact_id="skill-456", revision=1), + foreign, + root, + ).state + is CodexSkillProjectionState.CONFLICT + ) + + @pytest.mark.parametrize( ("name", "description"), [ @@ -89,3 +156,25 @@ def test_projection_rejects_managed_content_that_is_not_a_valid_codex_skill( ), tmp_path / name, ) + + +def test_exact_managed_skill_projects_to_claude_code(tmp_path) -> None: + from powercontext.client.projections.claude_code import project_skill as project_claude_code_skill + + content = SkillProposal( + name="review-change", + description="Use when reviewing a bounded change.", + instructions="Review the change and report concrete findings.", + validation=[SkillValidationItem("The findings cite exact files.")], + ) + + projected = project_claude_code_skill( + ArtifactRef(family="skill", artifact_id="skill-claude", revision=1), + content, + tmp_path / ".claude" / "skills" / content.name, + ) + + manifest = json.loads((projected / "powercontext.json").read_text(encoding="utf-8")) + assert projected == tmp_path / ".claude" / "skills" / "review-change" + assert manifest["agent_kind"] == "claude_code" + assert manifest["schema"] == "powercontext.agent-skill-projection.v1" diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index b91a0bd56..f6de65ff2 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -20,8 +20,9 @@ from fastapi.testclient import TestClient from pydantic import SecretStr +from powercontext.builtin.artifacts.skill import AgentSkillTarget from powercontext.builtin.persistence.sqlite import SQLiteConfig -from powercontext.builtin.runtime.config import HandoffReportConfig +from powercontext.builtin.runtime.config import ExternalSkillsConfig, HandoffReportConfig from powercontext.server.factory import create_server_app from powercontext.server.settings import ( BearerAuthConfig, @@ -50,11 +51,17 @@ def test_dashboard_is_enabled_by_default_without_authentication_or_scopes(tmp_pa with TestClient(app) as client: home = client.get("/") + skills = client.get("/skills") + review = client.get("/reviews") scopes = client.get("/dashboard/scopes") assert settings.dashboard.enabled is True assert settings.dashboard.scopes == [] 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 '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 assert scopes.status_code == 200 @@ -72,9 +79,13 @@ def test_dashboard_can_be_disabled_explicitly(tmp_path) -> None: with TestClient(app) as client: home = client.get("/") + skills = client.get("/skills") + review = client.get("/reviews") health = client.get("/health/live") assert home.status_code == 404 + assert skills.status_code == 404 + assert review.status_code == 404 assert health.status_code == 200 @@ -121,11 +132,15 @@ def test_dashboard_is_the_authenticated_server_ui_entry(tmp_path) -> None: with TestClient(app) as client: home = client.get("/") + skills = client.get("/skills") + review = client.get("/reviews") removed_dashboard_alias = client.get("/dashboard", headers=_AUTH_HEADERS) missing_scopes = client.get("/dashboard/scopes") scopes = client.get("/dashboard/scopes", headers=_AUTH_HEADERS) assert home.status_code == 200 + assert skills.status_code == 200 + assert review.status_code == 200 assert removed_dashboard_alias.status_code == 404 assert missing_scopes.status_code == 401 assert scopes.status_code == 200 @@ -141,12 +156,238 @@ def test_dashboard_is_the_authenticated_server_ui_entry(tmp_path) -> None: assert 'data-i18n-aria-label="scopeOverview"' in home.text assert 'data-i18n-aria-label="activityAria"' in home.text assert "dashboard.js?v=default-startup-locale-v1" in home.text + assert 'data-i18n="skillsTitle"' in skills.text + assert 'aria-current="page" data-i18n="skillsTitle"' in skills.text + assert 'id="skills-scope-search"' in skills.text + assert 'role="combobox"' in skills.text + assert 'aria-controls="skills-scope-options"' in skills.text + assert 'id="skills-scope-options" role="listbox"' in skills.text + assert 'id="skills-search"' in skills.text + assert 'id="skills-authority-filter"' in skills.text + 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-create-revision"' in skills.text + assert 'id="skills-publish-dialog"' in skills.text + assert "skills.js?v=agent-targets-v1" in skills.text + assert 'data-i18n="reviewTitle"' in review.text + assert 'aria-current="page" data-i18n="reviewTitle"' in review.text + assert 'id="review-scope-select"' not in review.text + assert 'id="review-scope-search"' in review.text + assert 'role="combobox"' in review.text + assert 'aria-controls="review-scope-options"' in review.text + assert 'id="review-scope-options" role="listbox"' in review.text + assert 'id="review-family-filter"' in review.text + assert 'id="review-status-filter"' in review.text + assert 'id="review-list" role="listbox"' in review.text + assert 'id="review-revision-form" hidden' in review.text + assert 'id="review-approve-dialog"' in review.text + assert 'id="review-reject-dialog"' in review.text + assert 'id="review-publication"' in review.text + assert 'id="review-create-skill-revision"' in review.text + assert 'id="review-revision-title"' in review.text + assert 'id="review-publish-dialog"' in review.text + assert "review.js?v=agent-targets-v1" in review.text assert scopes.json() == [ {"scope_id": "person:psiace", "display_name": "PsiACE"}, {"scope_id": "project:powercontext", "display_name": "PowerContext"}, ] +def test_review_publishes_an_approved_managed_skill_into_configured_agent_targets(tmp_path) -> None: + codex_skill_root = tmp_path / "repository" / ".agents" / "skills" + claude_skill_root = tmp_path / "repository" / ".claude" / "skills" + settings = ServerSettings( + auth=BearerAuthConfig(enabled=True, token=SecretStr("dashboard-secret")), + dashboard=DashboardConfig( + enabled=True, + scopes=[DashboardScopeConfig(scope_id="project:powercontext", display_name="PowerContext")], + ), + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'managed-skill-publish.db'}"), + external_skills=ExternalSkillsConfig( + host_id="dashboard-test", + targets=( + AgentSkillTarget( + target_id="codex-project", + agent_kind="codex", + installation_scope="project", + path=codex_skill_root, + allow_managed_publish=True, + ), + AgentSkillTarget( + target_id="claude-project", + agent_kind="claude_code", + installation_scope="project", + path=claude_skill_root, + allow_managed_publish=True, + ), + ), + ), + mcp=McpConfig(enabled=False), + ) + app = create_server_app(settings=settings) + + with TestClient(app) as client: + source = client.post( + "/v1/sources/content", + headers=_AUTH_HEADERS, + json={ + "scope_id": "project:powercontext", + "source_id": "managed-skill-evidence", + "content": "The contract workflow was reviewed and its validation passed.", + }, + ).json()["source"] + candidate = client.post( + "/v1/skill/propose", + headers=_AUTH_HEADERS, + json={ + "scope_id": "project:powercontext", + "proposal": { + "name": "review-contract-change", + "description": "Use when changing the reviewed public contract.", + "instructions": "Regenerate the client and inspect the contract diff.", + "validation": ["Run the contract tests."], + }, + "source_refs": [source], + "artifact_refs": [], + }, + ).json() + approved = client.post( + "/v1/artifact-candidates/approve", + headers=_AUTH_HEADERS, + json={ + "scope_id": "project:powercontext", + "candidate_id": candidate["candidate_id"], + "expected_version": candidate["version"], + }, + ).json() + selection = { + "scope_id": "project:powercontext", + "candidate_id": approved["candidate_id"], + "artifact": approved["result_artifact"], + } + unauthenticated = client.post("/dashboard/skill-projections/status", json=selection) + wrong_revision = client.post( + "/dashboard/skill-projections/status", + headers=_AUTH_HEADERS, + json={ + **selection, + "artifact": {**approved["result_artifact"], "revision": approved["result_artifact"]["revision"] + 1}, + }, + ) + before = client.post( + "/dashboard/skill-projections/status", + headers=_AUTH_HEADERS, + json=selection, + ) + published = client.post( + "/dashboard/skill-projections/publish", + headers=_AUTH_HEADERS, + json={**selection, "target_id": "codex-project"}, + ) + claude_published = client.post( + "/dashboard/skill-projections/publish", + headers=_AUTH_HEADERS, + json={**selection, "target_id": "claude-project"}, + ) + registered = client.post( + "/v1/external-skills/list", + headers=_AUTH_HEADERS, + json={"scope_id": "project:powercontext", "include_unavailable": False}, + ) + revision_source = client.post( + "/v1/sources/content", + headers=_AUTH_HEADERS, + json={ + "scope_id": "project:powercontext", + "source_id": "managed-skill-revision-evidence", + "content": "The packaged contract must also be verified after regeneration.", + }, + ).json()["source"] + revision_candidate = client.post( + "/v1/skill/propose", + headers=_AUTH_HEADERS, + json={ + "scope_id": "project:powercontext", + "proposal": { + "name": "review-contract-change", + "description": "Use when changing the reviewed public contract.", + "instructions": "Regenerate the client, inspect the diff, and verify the packaged contract.", + "validation": ["Run the contract tests."], + }, + "source_refs": [revision_source], + "artifact_refs": [approved["result_artifact"]], + "target": approved["result_artifact"], + "reason": "Add package verification to the reviewed contract workflow.", + }, + ).json() + revision_approved = client.post( + "/v1/artifact-candidates/approve", + headers=_AUTH_HEADERS, + json={ + "scope_id": "project:powercontext", + "candidate_id": revision_candidate["candidate_id"], + "expected_version": revision_candidate["version"], + }, + ).json() + revision_selection = { + "scope_id": "project:powercontext", + "candidate_id": revision_approved["candidate_id"], + "artifact": revision_approved["result_artifact"], + } + update_available = client.post( + "/dashboard/skill-projections/status", + headers=_AUTH_HEADERS, + json=revision_selection, + ) + updated = client.post( + "/dashboard/skill-projections/publish", + headers=_AUTH_HEADERS, + json={**revision_selection, "target_id": "codex-project"}, + ) + claude_updated = client.post( + "/dashboard/skill-projections/publish", + headers=_AUTH_HEADERS, + json={**revision_selection, "target_id": "claude-project"}, + ) + + codex_destination = codex_skill_root / "review-contract-change" + claude_destination = claude_skill_root / "review-contract-change" + assert unauthenticated.status_code == 401 + assert wrong_revision.status_code == 409 + assert wrong_revision.json()["error"]["code"] == "skill_projection_not_approved" + assert before.status_code == 200 + assert before.json()["targets"][0]["state"] == "unpublished" + assert [target["agent_kind"] for target in before.json()["targets"]] == ["codex", "claude_code"] + assert published.status_code == 200 + assert published.json()["targets"][0]["state"] == "current" + assert published.json()["targets"][0]["discovery"] == "available" + assert claude_published.status_code == 200 + assert claude_published.json()["targets"][1]["state"] == "current" + assert claude_published.json()["targets"][1]["discovery"] == "available" + assert revision_approved["result_artifact"] == { + **approved["result_artifact"], + "revision": approved["result_artifact"]["revision"] + 1, + } + assert update_available.status_code == 200 + assert update_available.json()["targets"][0]["state"] == "update_available" + assert updated.status_code == 200 + assert updated.json()["targets"][0]["state"] == "current" + assert updated.json()["targets"][0]["published_revision"] == 2 + assert claude_updated.status_code == 200 + assert claude_updated.json()["targets"][1]["state"] == "current" + assert claude_updated.json()["targets"][1]["published_revision"] == 2 + assert codex_destination.joinpath("SKILL.md").is_file() + assert claude_destination.joinpath("SKILL.md").is_file() + assert "verify the packaged contract" in codex_destination.joinpath("SKILL.md").read_text(encoding="utf-8") + assert "verify the packaged contract" in claude_destination.joinpath("SKILL.md").read_text(encoding="utf-8") + assert registered.status_code == 200 + assert {skill["registration"]["locator"] for skill in registered.json()["skills"]} == { + str(codex_destination), + str(claude_destination), + } + + def test_handoff_report_page_is_available_without_the_statistics_dashboard(tmp_path) -> None: database_path = tmp_path / "handoff-dashboard.db" disabled_app = create_server_app(settings=_handoff_report_settings(database_path, enabled=False)) @@ -156,6 +397,8 @@ def test_handoff_report_page_is_available_without_the_statistics_dashboard(tmp_p disabled_page = client.get("/handoff-reports") with TestClient(enabled_app) as client: enabled_page = client.get("/handoff-reports") + disabled_skills = client.get("/skills") + disabled_review = client.get("/reviews") disabled_dashboard = client.get("/") disabled_dashboard_scopes = client.get("/dashboard/scopes", headers=_AUTH_HEADERS) protected_scopes = client.post( @@ -165,9 +408,12 @@ def test_handoff_report_page_is_available_without_the_statistics_dashboard(tmp_p assert disabled_page.status_code == 404 assert enabled_page.status_code == 200 + assert disabled_skills.status_code == 404 + assert disabled_review.status_code == 404 assert disabled_dashboard.status_code == 404 assert disabled_dashboard_scopes.status_code == 404 assert 'data-i18n="dashboardTitle"' not in enabled_page.text + assert 'data-i18n="skillsTitle"' not in enabled_page.text assert 'data-server-session="missing"' in enabled_page.text assert 'id="auth-shell"' in enabled_page.text assert 'id="auth-shell" hidden' not in enabled_page.text diff --git a/tests/test_dashboard_locale.py b/tests/test_dashboard_locale.py index 395a47793..f7f706352 100644 --- a/tests/test_dashboard_locale.py +++ b/tests/test_dashboard_locale.py @@ -23,7 +23,7 @@ _ROOT = Path(__file__).resolve().parents[1] _STATIC = _ROOT / "src" / "powercontext" / "server" / "static" _TEMPLATES = _ROOT / "src" / "powercontext" / "server" / "templates" -_ALLOWED_LATIN = {"EN", "HTTP", "Markdown", "OceanBase", "PowerContext"} +_ALLOWED_LATIN = {"Claude", "Code", "Codex", "EN", "HTTP", "Markdown", "OceanBase", "PowerContext"} _CJK = re.compile(r"[\u4e00-\u9fff]") _LATIN_WORD = re.compile(r"[A-Za-z]{2,}") _PLACEHOLDER = re.compile(r"\{[A-Za-z]+\}") @@ -51,6 +51,26 @@ _TEMPLATES / "components" / "status.html", ), ), + ( + _STATIC / "review.js", + ( + _TEMPLATES / "pages" / "review.html", + _TEMPLATES / "components" / "header.html", + _TEMPLATES / "components" / "footer.html", + _TEMPLATES / "components" / "login.html", + _TEMPLATES / "components" / "status.html", + ), + ), + ( + _STATIC / "skills.js", + ( + _TEMPLATES / "pages" / "skills.html", + _TEMPLATES / "components" / "header.html", + _TEMPLATES / "components" / "footer.html", + _TEMPLATES / "components" / "login.html", + _TEMPLATES / "components" / "status.html", + ), + ), ) diff --git a/tests/test_server.py b/tests/test_server.py index 38109993d..7388b5313 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -107,8 +107,11 @@ def test_settings_load_server_environment(monkeypatch) -> None: monkeypatch.setenv( "POWERCONTEXT_SERVER_EXTERNAL_SKILLS", ( - '{"host_id":"workstation-1","codex_roots":[' - '{"root_id":"repository","installation_scope":"project","path":"/srv/project/.agents/skills"}]}' + '{"host_id":"workstation-1","targets":[' + '{"target_id":"codex-project","agent_kind":"codex","installation_scope":"project",' + '"path":"/srv/project/.agents/skills","allow_managed_publish":true},' + '{"target_id":"claude-user","agent_kind":"claude_code","installation_scope":"user",' + '"path":"/home/example/.claude/skills"}]}' ), ) @@ -131,8 +134,11 @@ def test_settings_load_server_environment(monkeypatch) -> None: assert settings.dashboard.enabled is True assert settings.dashboard.scopes == [] assert settings.external_skills.host_id == "workstation-1" - assert settings.external_skills.codex_roots[0].root_id == "repository" - assert settings.external_skills.codex_roots[0].path.as_posix() == "/srv/project/.agents/skills" + assert settings.external_skills.targets[0].target_id == "codex-project" + assert settings.external_skills.targets[0].path.as_posix() == "/srv/project/.agents/skills" + assert settings.external_skills.targets[0].allow_managed_publish is True + assert settings.external_skills.targets[1].agent_kind == "claude_code" + assert settings.external_skills.targets[1].path.as_posix() == "/home/example/.claude/skills" def test_env_example_loads_server_settings(monkeypatch) -> None: diff --git a/zensical.toml b/zensical.toml index 05bffac55..42fe615f4 100644 --- a/zensical.toml +++ b/zensical.toml @@ -71,6 +71,7 @@ nav = [ { "0011 Remote Access Architecture" = "en/rfcs/0011_remote_access_architecture.md" }, { "0002 Core SDK Product Model" = "en/rfcs/0002_core_sdk_product_model.md" }, { "0001 Product Definition and Vision" = "en/rfcs/0001_product_definition_and_vision.md" }, + { "1304 Experience and Skill Review Page" = "en/rfcs/1304_experience_skill_review_page.md" }, { "RFC Template" = "en/rfcs/0000_example.md" }, ] }, { "Meetings" = [ @@ -141,6 +142,7 @@ nav = [ { "0011 远程访问架构" = "zh/rfcs/0011_remote_access_architecture.md" }, { "0002 Core SDK 产品模型" = "zh/rfcs/0002_core_sdk_product_model.md" }, { "0001 产品定义与构想" = "zh/rfcs/0001_product_definition_and_vision.md" }, + { "1304 Experience 与 Skill Review 页面" = "zh/rfcs/1304_experience_skill_review_page.md" }, { "RFC 模板" = "zh/rfcs/0000_example.md" }, ] }, { "会议纪要" = [