From 449e1a25c6ca72b8b127b837c8b8bf6c73cc7a74 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 11 Aug 2026 15:21:05 +0000 Subject: [PATCH] refactor(runner): collapse semantic catalogs into the frozen authority PAP-17039. Both catalog modules now derive their operation set, placement, required claims, and task-mode policy from a single hand-authored source, src/catalog/canonical-operations.ts; neither declares an independent op list. - New canonical-operations.ts is the single 41-op source of truth. tools/ and semantic-tools/ project it and supply only per-surface presentation (title, description, schema, and the scenario-only mock mapping + redaction). A bidirectional parity check fails the build on any drift. - Unify generic_api_request to one claim, test:generic_api_request (test-only, never product coverage); update scenario grants/plan/tests. - One canonical task-mode policy: the live runtime policy unioned with skill_test. Resolves the ledger's 15 task-mode divergences to zero. The live contract golden is byte-identical (allowedModes is not serialized); the live runtime allowedModes gained skill_test for the 9 standard-only ops so the eval harness can drive them. - reconciliation.ts inverts to derive from canonical-operations and recompute the drift ledger: zero claim/mode divergences; the sole surviving divergence is the reviewed intentional input-schema projection (idempotency in-band for the provider, out-of-band for the observable runtime). Focused catalog, scenario, conformance, issue-thread, mock, and package-export suites pass; golden verified byte-identical. Co-Authored-By: Claude Opus 4.8 --- .../docs/capability-semantic-tools.md | 9 +- .../spec/capability/catalog-reconciliation.md | 154 +++--- .../src/catalog/canonical-operations.ts | 272 +++++++++++ .../paperclip-runner/src/catalog/index.ts | 1 + .../src/catalog/reconciliation.test.ts | 95 ++-- .../src/catalog/reconciliation.ts | 416 +++------------- .../src/scenarios/scenario-index.ts | 2 +- .../src/scenarios/scenario-plan.ts | 2 +- .../src/semantic-tools/catalog.ts | 208 ++++---- .../tools/capability-semantic-tool-catalog.ts | 447 +++++++++--------- .../tools/capability-semantic-tools.test.ts | 4 +- 11 files changed, 834 insertions(+), 776 deletions(-) create mode 100644 packages/paperclip-runner/src/catalog/canonical-operations.ts diff --git a/packages/paperclip-runner/docs/capability-semantic-tools.md b/packages/paperclip-runner/docs/capability-semantic-tools.md index 5cfa1eb634c..8a320f06ab9 100644 --- a/packages/paperclip-runner/docs/capability-semantic-tools.md +++ b/packages/paperclip-runner/docs/capability-semantic-tools.md @@ -5,7 +5,14 @@ is a frozen set of JSON-schema descriptors — no HTTP method, path, or provider detail. Provider-specific shapes are produced only by *bindings*, so every provider sees the same operation set. -Sources: `src/tools/capability-semantic-tool-catalog.ts`, +The operation list, placement, required claims, and task-mode policy are +single-sourced from `src/catalog/canonical-operations.ts` +(`CAPABILITY_CANONICAL_OPERATIONS`); this catalog is a projection of it that adds +the scenario presentation (titles, descriptions, input schemas, mock mappings, +and redaction). See [catalog reconciliation](../spec/capability/catalog-reconciliation.md). + +Sources: `src/catalog/canonical-operations.ts`, +`src/tools/capability-semantic-tool-catalog.ts`, `src/tools/capability-semantic-tool-types.ts`, `src/tools/capability-tool-bindings.ts`, barrel `src/tools/index.ts`. diff --git a/packages/paperclip-runner/spec/capability/catalog-reconciliation.md b/packages/paperclip-runner/spec/capability/catalog-reconciliation.md index 274736d7f98..911c2ba4306 100644 --- a/packages/paperclip-runner/spec/capability/catalog-reconciliation.md +++ b/packages/paperclip-runner/spec/capability/catalog-reconciliation.md @@ -1,17 +1,23 @@ # Semantic Catalog Reconciliation -**Document status:** Canonical reconciliation ledger (PAP-17025)
+**Document status:** Canonical reconciliation ledger (PAP-17025 gate + PAP-17039 collapse)
**Date:** 2026-08-11
-**Authority:** `src/catalog/reconciliation.ts` (machine-readable), drift-checked by `src/catalog/reconciliation.test.ts` and `src/catalog/catalog-docs.test.ts`.
+**Source of truth:** `src/catalog/canonical-operations.ts` (`CAPABILITY_CANONICAL_OPERATIONS`) — the single hand-authored 41-operation list. Both catalog modules derive their operation set, placement, required claims, and task-mode policy from it.
+**Drift authority:** `src/catalog/reconciliation.ts` (machine-readable ledger), drift-checked by `src/catalog/reconciliation.test.ts` and `src/catalog/catalog-docs.test.ts`.
**Parent plan:** Runner foundation review plan (PAP-17016), deliverable D and execution step 1. ## Purpose -The runner carried two independently hand-maintained semantic catalogs. This is -the first reconciliation gate of the foundation plan: establish one -machine-readable authority over both, name every operation's full metadata, and -record every divergence with an explicit disposition so no specification calls -either catalog canonical and no stale count or unreviewed divergence can land. +The runner carried two independently hand-maintained semantic catalogs. PAP-17025 +established one machine-readable authority over both and recorded every +divergence. **PAP-17039 performed the physical collapse:** the authoritative +operation list, placement, required claims, and task-mode policy now live once in +`src/catalog/canonical-operations.ts`, and both catalog modules derive from it — +neither declares an independent operation list or an independent claim/mode +policy any longer. Each catalog supplies only its per-surface *presentation* +(title, description, input/output schema, and the scenario-only mock mapping and +redaction rules), and a bidirectional parity check fails the build if either +catalog's presentation set drifts from the canonical surface. ## The two catalogs @@ -29,20 +35,19 @@ Reconciled op-set relationship (pinned by the drift test): ## Canonical authority decision -- **Descriptor schema:** the rich `src/tools/` descriptor is the canonical shape. - It is the only one that already names placement, side-effect class, - idempotency, redaction, and mock mapping — the per-operation facts the plan - requires. The canonical authority extends it with **real-binding status** and - **PRP-evidence shape**. -- **Machine-readable authority:** `src/catalog/reconciliation.ts` enumerates the - 41-op union exactly once (`CAPABILITY_CANONICAL_CATALOG`), each entry naming - placement, claims, task modes, roles, side-effect class, idempotency, - redaction, mock mapping, real-binding status, PRP evidence, and legacy - aliases. `capabilityCatalogReconciliation()` computes the op-set summary and - the divergence ledger. -- No spec should call either `src/tools/` or `src/semantic-tools/` canonical. - Both remain in place this increment; the follow-on collapses them into - derivations of the authority. +- **Single source of truth:** `src/catalog/canonical-operations.ts` hand-authors + the 41-op union exactly once (`CAPABILITY_CANONICAL_OPERATIONS`), each entry + naming surfaces, placement, optional group, claims, task modes, roles, + side-effect class, idempotency, disabled-by-default, real-binding status, PRP + evidence, and legacy aliases. Both catalog modules import it and project it: + `src/tools/` renders the rich scenario descriptor and `src/semantic-tools/` + renders the live provider descriptor. +- **Drift ledger:** `src/catalog/reconciliation.ts` enriches the canonical list + with the scenario mock mapping and redaction flag (`CAPABILITY_CANONICAL_CATALOG`), + and `capabilityCatalogReconciliation()` recomputes the op-set summary and any + remaining metadata divergence between the two catalogs' actual descriptors. +- Neither `src/tools/` nor `src/semantic-tools/` is canonical; both are + projections of `canonical-operations.ts`. ## Real-binding status (before real Paperclip service binding) @@ -58,28 +63,43 @@ current executability is classified as: - **`test_only` (1):** `generic_api_request` — the escape hatch, which by rule **cannot count as product capability coverage**. -## Divergence ledger +## Divergence ledger (after the PAP-17039 collapse) -For every shared operation the authority compares `requiredClaims`, task modes, -and input-schema shape, and records a disposition. The drift test fails if a new -divergence appears without one. +`requiredClaims` and task modes are single-sourced from +`canonical-operations.ts`, so the two catalogs cannot diverge on them; the drift +test asserts both divergence sets are now **empty**. The reconciliation ledger +recomputes divergences from the two catalogs' actual descriptors: -- **Claims (1):** `generic_api_request` requires `test:generic_api` (scenario) - vs `test:generic_api_request` (live). Same reviewed test-only grant; unify in - the follow-on. -- **Task modes (15):** `block_task`, `comment_on_approval`, - `control_workspace_service`, `create_task`, `decide_approval`, `finish_task`, - `get_workspace_runtime`, `list_agents`, `list_approvals`, - `register_deliverable`, `report_progress`, `request_approval`, - `request_review`, `search_tasks`, `set_dependencies`. The scenario catalog - scopes modes to the coverage the suite drives (adds `skill_test`, restricts - optional reads to `standard`); the live catalog encodes the runtime exposure - policy (opens optional reads to `ask`/`planning`, keeps terminal writes - `standard`-only). Reconcile to one canonical policy in the follow-on. -- **Input-schema shape (shared mutations and some reads):** the scenario - descriptor keeps idempotency out-of-band and omits provider bounds; the live - provider descriptor threads `idempotencyKey` in-band and adds length/pattern - bounds for the model. Unify the schema in the follow-on. +- **Claims (0):** unified. `generic_api_request` carries a single claim, + `test:generic_api_request`, on both surfaces. +- **Task modes (0):** unified to one canonical policy — **the live runtime + exposure policy unioned with `skill_test`**. Reads stay open in every mode; + work-writes (`report_progress`, `write_document`, `request_human_input`, + `register_deliverable`) allow `standard`/`planning`/`skill_test`; lifecycle + and state writes (`finish_task`, `block_task`, `request_review`, `create_task`, + `set_dependencies`, request/decide/comment approvals, `control_workspace_service`) + allow `standard`/`skill_test`. The `skill_test` mode makes every operation the + eval harness must drive drivable, while the live runtime keeps its intended + per-mode exposure. +- **Input-schema shape (reviewed intentional projection):** one input contract is + projected two ways by design — the live provider descriptor threads + `idempotencyKey` in-band with provider length/pattern bounds for the model, + while the observable scenario runtime threads idempotency out-of-band. This is + a single reviewed projection recorded in the ledger, not two independently + authored schemas. + +### Effect on live provider schemas (QA gate) + +- The generated live contract golden `generated/capability/semantic-tool-contracts.json` + is **byte-identical** after the collapse: it serializes name, description, + input/output schema, exposure, and required claims — none of which changed on + the live surface (`allowedModes` is not serialized). +- The live runtime **`allowedModes`** gained `skill_test` for the nine + previously `standard`-only operations (`finish_task`, `block_task`, + `request_review`, `create_task`, `set_dependencies`, `request_approval`, + `decide_approval`, `control_workspace_service`, `schedule_wake`). This is a + behavioral change to eval-harness exposure only; real live tasks never run in + `skill_test` mode. ## Legacy MCP aliases @@ -92,33 +112,41 @@ operation for the well-known aliases (for example `paperclipListAgents` → `list_agents`, `paperclipGetAgent` → `get_agent`, `paperclipListIssues` → `search_tasks`, `paperclipCreateIssue` → `create_task`). -## Deliberate decisions recorded for the surface-ledger and eval tasks +## Collapse decisions executed by PAP-17039 -This increment makes **no runtime behavior change** — both catalogs keep their -exact current surfaces, so the live golden and scenario suites are unchanged. -The reconciliation records the following decisions to be executed by the -schema-migration follow-on and consumed by the real-surface-ledger (deliverable -B) and eval (deliverable F) tasks: +1. **One source of truth.** `src/tools/` and `src/semantic-tools/` no longer + declare independent operation lists; both project + `CAPABILITY_CANONICAL_OPERATIONS`. Placement, optional group, required claims, + task modes, allowed roles, side-effect class, idempotency, and + disabled-by-default are single-sourced. Bidirectional parity checks fail the + build on any drift between a catalog's presentation and the canonical surface. +2. **`generic_api_request` claim unified** to `test:generic_api_request` + (scenario callers/tests updated). It stays `test_only` and is excluded from + product capability coverage. +3. **One canonical task-mode policy** (live runtime policy ∪ `skill_test`); the + ledger's 15 task-mode divergences are resolved to zero. +4. **Input schema unified as one contract, projected two ways** — idempotency + in-band with provider bounds for the model, out-of-band for the observable + scenario runtime — recorded as the single reviewed intentional projection. +5. **Placement of scenario-only (13) and live-only (4) operations retained.** The + 13 scenario-only ops stay `scenario_mock` (eval-only via mock extensions); the + 4 live-only reads/continuation ops (`get_agent`, `get_approval`, + `get_approval_context`, `schedule_wake`) stay `live_codex` with no scenario + projection. No operation crossed surfaces this increment; a future real-service + binding (deliverable G) is where scenario-only ops would gain a live binding. -1. Collapse `src/tools/` and `src/semantic-tools/` catalog lists into - derivations of `src/catalog/reconciliation.ts`, so there is one source of - truth and the second module is compatibility-only. -2. Unify the `generic_api_request` claim to a single test-only grant. -3. Choose one canonical task-mode policy per operation (the ledger's 15 task-mode - divergences). -4. Unify the input-schema shape (idempotency handling and provider bounds) — - this changes the live provider schemas the model sees and the generated - `semantic-tool-contracts.json` golden, and therefore requires live/browser - re-QA. -5. Decide the placement of the 13 scenario-only and 4 live-only operations on the - unified surface (which become always/optional, which stay scenario/eval-only, - which gain a live binding). +Consumed by the real-surface-ledger (deliverable B) and eval (deliverable F) +tasks: the live contract golden is unchanged; the only behavioral delta is the +`allowedModes` `skill_test` addition described above. ## Verification -- `src/catalog/reconciliation.test.ts` — pins the op-set relationship, requires a - reconciliation entry and full metadata for every union operation, classifies - real-binding status, and requires a disposition for every divergence. +- `src/catalog/reconciliation.test.ts` — pins the op-set relationship, asserts + both catalogs derive their operation set from the canonical source, asserts + claims/modes/placement are single-sourced (no divergence), and requires a + reviewed disposition for the surviving input-schema projection. - `src/catalog/catalog-docs.test.ts` — recomputes the catalog counts and membership in `docs/capability-semantic-tools.md` from the catalog so a stale hand count fails. +- Live golden byte-identity checked by `check:semantic-contracts`; the scenario + and live runtime behavior is covered by the `test:scenarios` suites. diff --git a/packages/paperclip-runner/src/catalog/canonical-operations.ts b/packages/paperclip-runner/src/catalog/canonical-operations.ts new file mode 100644 index 00000000000..74c7e1757f2 --- /dev/null +++ b/packages/paperclip-runner/src/catalog/canonical-operations.ts @@ -0,0 +1,272 @@ +/** + * Canonical semantic-operation registry — the single source of truth. + * + * The runner historically carried two independently hand-maintained semantic + * catalogs: + * + * - `src/tools/` — the rich scenario/eval descriptor (placement, side-effect + * class, idempotency, redaction, mock mapping) consumed by the scenario and + * conformance layers and exported as the package-root default. + * - `src/semantic-tools/` — the live runtime engine (catalog + policy + + * dispatcher) that drives live Codex, the issue-thread projection, and the + * generated provider-neutral contracts. + * + * PAP-17025 froze a machine-readable authority over the union of both surfaces. + * PAP-17039 performs the physical collapse: this module hand-authors the + * 41-operation union exactly once, and **both** catalog modules now derive + * their operation list, placement, required claims, and task-mode policy from + * it. Neither catalog declares an independent operation list or an independent + * claim/mode policy any longer — they only supply per-surface presentation + * (title, description, input/output schema, and the scenario-only mock mapping + * and redaction rules). + * + * Divergences the collapse resolved (see `spec/capability/catalog-reconciliation.md`): + * + * 1. `generic_api_request` now carries a single claim, `test:generic_api_request`. + * 2. Task modes follow one canonical policy: the live runtime exposure policy + * unioned with `skill_test`, so every operation the eval harness must drive + * is drivable while the live runtime keeps its intended per-mode exposure. + * 3. Placement (`always`/`optional`), required claims, and allowed roles are + * single-sourced here; the two catalogs can no longer disagree on them. + * + * The only surviving cross-surface difference is input-schema *shape*: the live + * provider schema threads `idempotencyKey` in-band with provider bounds for the + * model, while the observable scenario runtime threads idempotency out-of-band. + * That is a single reviewed projection of one contract, not two independent + * schemas — recorded as an intentional disposition in the reconciliation ledger. + */ + +import type { + CapabilitySideEffectClass, + CapabilityToolDisposition, + CapabilityOptionalCatalogGroup, + CapabilityToolTaskMode, + ScenarioChatdempotencyBehavior, +} from "../tools/capability-semantic-tool-types.js"; + +/** Which catalog surface(s) project this operation. */ +export type CapabilityCatalogSurface = "scenario" | "live"; + +/** Where an operation is executable today, before real Paperclip binding. */ +export type CapabilityRealBindingStatus = "live_codex" | "scenario_mock" | "test_only"; + +export interface CapabilityCanonicalOperation { + readonly operationId: string; + /** Catalog surface(s) that project this operation today. */ + readonly surfaces: readonly CapabilityCatalogSurface[]; + readonly placement: CapabilityToolDisposition; + /** Scenario optional-catalog group; `null` for always tools and live-only ops. */ + readonly optionalGroup: CapabilityOptionalCatalogGroup | null; + readonly requiredClaims: readonly string[]; + readonly taskModes: readonly CapabilityToolTaskMode[]; + readonly allowedRoles?: readonly string[]; + readonly sideEffectClass: CapabilitySideEffectClass; + readonly idempotency: ScenarioChatdempotencyBehavior; + /** Live-only exposure flag; kept off the tool surface until explicitly enabled. */ + readonly disabledByDefault: boolean; + readonly realBindingStatus: CapabilityRealBindingStatus; + /** + * No semantic operation is bound to a real Paperclip service yet; the mock is + * the only backend (real-service binding is deliverable G of PAP-17016). + */ + readonly realServiceBinding: "unbound"; + /** Intended PRP evidence family carrying this operation's effect. */ + readonly prpEvidence: string; + /** The formal PRP expressiveness proof is deliverable C; entries stay pending. */ + readonly prpBindingStatus: "audit_pending"; + /** Legacy MCP alias ids folded into this operation, if any. */ + readonly legacyAliases: readonly string[]; + readonly note?: string; +} + +const ALL_MODES: readonly CapabilityToolTaskMode[] = ["standard", "ask", "planning", "skill_test"]; +const WORK_MODES: readonly CapabilityToolTaskMode[] = ["standard", "planning", "skill_test"]; +const STANDARD_MODES: readonly CapabilityToolTaskMode[] = ["standard", "skill_test"]; +const TEST_MODES: readonly CapabilityToolTaskMode[] = ["skill_test"]; + +function prpEvidenceForSideEffect(sideEffectClass: CapabilitySideEffectClass): string { + switch (sideEffectClass) { + case "read": + return "read projection surfaced via a tool-result item event; no control-plane state diff"; + case "task_write": + return "semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events"; + case "company_write": + return "semantic-operation item event plus company-entity state diff and audit record"; + case "governance": + return "approval lifecycle plus governed-wait continuation and audit events"; + case "workspace_control": + return "workspace service lifecycle event"; + case "secret_read": + return "redacted tool-result item event; secret value never reaches the wire"; + case "admin": + return "company admin/portability item event plus audit record"; + case "test_escape_hatch": + return "test-only; excluded from product PRP evidence"; + default: + return "audit_pending"; + } +} + +/** Compact authoring row; the exported catalog fills in derived fields. */ +interface Row { + readonly operationId: string; + readonly surfaces: readonly CapabilityCatalogSurface[]; + readonly placement: CapabilityToolDisposition; + readonly optionalGroup?: CapabilityOptionalCatalogGroup; + readonly requiredClaims?: readonly string[]; + readonly taskModes: readonly CapabilityToolTaskMode[]; + readonly allowedRoles?: readonly string[]; + readonly sideEffectClass: CapabilitySideEffectClass; + readonly idempotency: ScenarioChatdempotencyBehavior; + readonly disabledByDefault?: boolean; + readonly realBindingStatus: CapabilityRealBindingStatus; + readonly legacyAliases?: readonly string[]; + readonly note?: string; +} + +const SHARED: readonly CapabilityCatalogSurface[] = ["scenario", "live"]; +const SCENARIO_ONLY: readonly CapabilityCatalogSurface[] = ["scenario"]; +const LIVE_ONLY: readonly CapabilityCatalogSurface[] = ["live"]; + +// --------------------------------------------------------------------------- +// The single canonical operation list. One row per union operation. Task modes +// follow the canonical policy (live runtime exposure policy ∪ `skill_test`); +// scenario-only and live-only operations keep their single existing policy +// because there is no second surface to reconcile against. +// --------------------------------------------------------------------------- + +const ROWS: readonly Row[] = [ + // --- always-present active-task surface --- + { operationId: "get_task_context", surfaces: SHARED, placement: "always_agent_tool", taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "live_codex", legacyAliases: ["mcp:paperclipMe"] }, + { operationId: "get_task_history", surfaces: SHARED, placement: "always_agent_tool", taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "live_codex" }, + { operationId: "list_documents", surfaces: SHARED, placement: "always_agent_tool", taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "live_codex" }, + { operationId: "read_document", surfaces: SHARED, placement: "always_agent_tool", taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "live_codex" }, + { operationId: "list_document_revisions", surfaces: SHARED, placement: "always_agent_tool", taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "live_codex" }, + { operationId: "report_progress", surfaces: SHARED, placement: "always_agent_tool", taskModes: ALL_MODES, sideEffectClass: "task_write", idempotency: "required", realBindingStatus: "live_codex", legacyAliases: ["mcp:paperclipAddComment"] }, + { operationId: "answer_status_question", surfaces: SHARED, placement: "always_agent_tool", taskModes: ALL_MODES, sideEffectClass: "task_write", idempotency: "required", realBindingStatus: "live_codex" }, + { operationId: "write_document", surfaces: SHARED, placement: "always_agent_tool", taskModes: WORK_MODES, sideEffectClass: "task_write", idempotency: "required", realBindingStatus: "live_codex" }, + { operationId: "request_human_input", surfaces: SHARED, placement: "always_agent_tool", taskModes: WORK_MODES, sideEffectClass: "task_write", idempotency: "required", realBindingStatus: "live_codex" }, + { operationId: "register_deliverable", surfaces: SHARED, placement: "always_agent_tool", taskModes: WORK_MODES, sideEffectClass: "task_write", idempotency: "required", realBindingStatus: "live_codex" }, + { operationId: "finish_task", surfaces: SHARED, placement: "always_agent_tool", taskModes: STANDARD_MODES, sideEffectClass: "task_write", idempotency: "required", realBindingStatus: "live_codex" }, + { operationId: "block_task", surfaces: SHARED, placement: "always_agent_tool", taskModes: STANDARD_MODES, sideEffectClass: "task_write", idempotency: "required", realBindingStatus: "live_codex" }, + { operationId: "request_review", surfaces: SHARED, placement: "always_agent_tool", taskModes: STANDARD_MODES, sideEffectClass: "task_write", idempotency: "required", realBindingStatus: "live_codex" }, + { + operationId: "inspect_operation_result", surfaces: SCENARIO_ONLY, placement: "always_agent_tool", taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none", + realBindingStatus: "scenario_mock", + note: "Scenario/eval-only re-read of a prior operation result; the live dispatcher returns results inline.", + }, + + // --- discovery --- + { operationId: "search_tasks", surfaces: SHARED, placement: "optional_agent_tool", optionalGroup: "discovery", requiredClaims: ["discovery:tasks:read"], taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "live_codex", legacyAliases: ["mcp:paperclipListIssues"] }, + { operationId: "list_agents", surfaces: SHARED, placement: "optional_agent_tool", optionalGroup: "discovery", requiredClaims: ["discovery:agents:read"], taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "live_codex", legacyAliases: ["mcp:paperclipListAgents"] }, + { + operationId: "get_agent", surfaces: LIVE_ONLY, placement: "optional_agent_tool", requiredClaims: ["discovery:agents:read"], taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none", + realBindingStatus: "live_codex", legacyAliases: ["mcp:paperclipGetAgent"], + note: "Live-only single-actor read; the scenario suite covers actor discovery through list_agents.", + }, + { operationId: "list_projects", surfaces: SCENARIO_ONLY, placement: "optional_agent_tool", optionalGroup: "discovery", requiredClaims: ["discovery:projects:read"], taskModes: STANDARD_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "scenario_mock", note: "Scenario/eval-only discovery via mock extension; no live dispatcher binding yet." }, + { operationId: "list_goals", surfaces: SCENARIO_ONLY, placement: "optional_agent_tool", optionalGroup: "discovery", requiredClaims: ["discovery:goals:read"], taskModes: STANDARD_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "scenario_mock", note: "Scenario/eval-only discovery via mock extension; no live dispatcher binding yet." }, + + // --- delegation & dependencies --- + { operationId: "create_task", surfaces: SHARED, placement: "optional_agent_tool", optionalGroup: "delegation_dependencies", requiredClaims: ["delegation:tasks:create"], taskModes: STANDARD_MODES, sideEffectClass: "company_write", idempotency: "required", realBindingStatus: "live_codex", legacyAliases: ["mcp:paperclipCreateIssue"] }, + { operationId: "set_dependencies", surfaces: SHARED, placement: "optional_agent_tool", optionalGroup: "delegation_dependencies", requiredClaims: ["dependencies:write"], taskModes: STANDARD_MODES, sideEffectClass: "company_write", idempotency: "required", realBindingStatus: "live_codex" }, + + // --- governance --- + { operationId: "list_approvals", surfaces: SHARED, placement: "optional_agent_tool", optionalGroup: "governance", requiredClaims: ["governance:approvals:read"], taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "live_codex" }, + { operationId: "request_approval", surfaces: SHARED, placement: "optional_agent_tool", optionalGroup: "governance", requiredClaims: ["governance:approvals:request"], taskModes: STANDARD_MODES, sideEffectClass: "governance", idempotency: "required", realBindingStatus: "live_codex" }, + { operationId: "decide_approval", surfaces: SHARED, placement: "optional_agent_tool", optionalGroup: "governance", requiredClaims: ["governance:approvals:decide"], taskModes: STANDARD_MODES, allowedRoles: ["board", "approver", "security"], sideEffectClass: "governance", idempotency: "required", realBindingStatus: "live_codex" }, + { operationId: "comment_on_approval", surfaces: SHARED, placement: "optional_agent_tool", optionalGroup: "governance", requiredClaims: ["governance:approvals:comment"], taskModes: ALL_MODES, sideEffectClass: "governance", idempotency: "required", realBindingStatus: "live_codex" }, + { + operationId: "get_approval", surfaces: LIVE_ONLY, placement: "optional_agent_tool", requiredClaims: ["governance:approvals:read"], taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none", + realBindingStatus: "live_codex", + note: "Live-only single-approval read; the scenario suite covers approvals through list_approvals.", + }, + { + operationId: "get_approval_context", surfaces: LIVE_ONLY, placement: "optional_agent_tool", requiredClaims: ["governance:approvals:read"], taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none", + realBindingStatus: "live_codex", + note: "Live-only approval-plus-linked-tasks read; no scenario counterpart.", + }, + + // --- workspace runtime --- + { operationId: "get_workspace_runtime", surfaces: SHARED, placement: "optional_agent_tool", optionalGroup: "workspace_runtime", requiredClaims: ["workspace:read"], taskModes: ALL_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "live_codex" }, + { operationId: "control_workspace_service", surfaces: SHARED, placement: "optional_agent_tool", optionalGroup: "workspace_runtime", requiredClaims: ["workspace:control"], taskModes: STANDARD_MODES, sideEffectClass: "workspace_control", idempotency: "required", realBindingStatus: "live_codex" }, + + // --- control-plane wake scheduling (live-only) --- + { + operationId: "schedule_wake", surfaces: LIVE_ONLY, placement: "optional_agent_tool", requiredClaims: ["control_plane:wakes"], taskModes: STANDARD_MODES, sideEffectClass: "task_write", idempotency: "required", + realBindingStatus: "live_codex", + note: "Bounded, deterministic mock wake scheduling; requires the control_plane:wakes claim. Live-only continuation primitive.", + }, + + // --- scenario-only optional surfaces (mock extensions, no live binding) --- + { operationId: "list_cases", surfaces: SCENARIO_ONLY, placement: "optional_agent_tool", optionalGroup: "cases", requiredClaims: ["cases:read"], taskModes: STANDARD_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." }, + { operationId: "upsert_case", surfaces: SCENARIO_ONLY, placement: "optional_agent_tool", optionalGroup: "cases", requiredClaims: ["cases:write"], taskModes: STANDARD_MODES, sideEffectClass: "company_write", idempotency: "required", realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." }, + { operationId: "list_routines", surfaces: SCENARIO_ONLY, placement: "optional_agent_tool", optionalGroup: "routines", requiredClaims: ["routines:read"], taskModes: STANDARD_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." }, + { operationId: "manage_routine", surfaces: SCENARIO_ONLY, placement: "optional_agent_tool", optionalGroup: "routines", requiredClaims: ["routines:write"], taskModes: STANDARD_MODES, sideEffectClass: "admin", idempotency: "required", realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." }, + { operationId: "list_company_skills", surfaces: SCENARIO_ONLY, placement: "optional_agent_tool", optionalGroup: "company_skills", requiredClaims: ["company_skills:read"], taskModes: STANDARD_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." }, + { operationId: "sync_company_skills", surfaces: SCENARIO_ONLY, placement: "optional_agent_tool", optionalGroup: "company_skills", requiredClaims: ["company_skills:write"], taskModes: STANDARD_MODES, sideEffectClass: "admin", idempotency: "required", realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." }, + { operationId: "list_secret_metadata", surfaces: SCENARIO_ONLY, placement: "optional_agent_tool", optionalGroup: "secrets", requiredClaims: ["secrets:metadata:read"], taskModes: STANDARD_MODES, sideEffectClass: "read", idempotency: "none", realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension; metadata only." }, + { + operationId: "read_secret_value", surfaces: SCENARIO_ONLY, placement: "optional_agent_tool", optionalGroup: "secrets", requiredClaims: ["secrets:values:read"], taskModes: STANDARD_MODES, sideEffectClass: "secret_read", idempotency: "none", + realBindingStatus: "scenario_mock", + note: "Scenario/eval-only mock extension; secret value redacted from every observable sink.", + }, + { operationId: "export_company", surfaces: SCENARIO_ONLY, placement: "optional_agent_tool", optionalGroup: "portability_admin", requiredClaims: ["portability:export"], taskModes: STANDARD_MODES, sideEffectClass: "admin", idempotency: "required", realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." }, + { operationId: "administer_company", surfaces: SCENARIO_ONLY, placement: "optional_agent_tool", optionalGroup: "portability_admin", requiredClaims: ["company:admin"], taskModes: STANDARD_MODES, allowedRoles: ["board", "ceo", "admin"], sideEffectClass: "admin", idempotency: "required", realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." }, + + // --- test-only escape hatch (shared, never product coverage) --- + { + operationId: "generic_api_request", surfaces: SHARED, placement: "optional_agent_tool", optionalGroup: "test_escape_hatch", requiredClaims: ["test:generic_api_request"], taskModes: TEST_MODES, sideEffectClass: "test_escape_hatch", idempotency: "required", disabledByDefault: true, + realBindingStatus: "test_only", + note: "Test-only escape hatch; explicitly cannot count as product capability coverage (PAP-17016 deliverable D). Single unified claim test:generic_api_request.", + }, +]; + +function buildOperation(row: Row): CapabilityCanonicalOperation { + return Object.freeze({ + operationId: row.operationId, + surfaces: Object.freeze([...row.surfaces]), + placement: row.placement, + optionalGroup: row.optionalGroup ?? null, + requiredClaims: Object.freeze([...(row.requiredClaims ?? [])]), + taskModes: Object.freeze([...row.taskModes]), + ...(row.allowedRoles === undefined ? {} : { allowedRoles: Object.freeze([...row.allowedRoles]) }), + sideEffectClass: row.sideEffectClass, + idempotency: row.idempotency, + disabledByDefault: row.disabledByDefault ?? false, + realBindingStatus: row.realBindingStatus, + realServiceBinding: "unbound", + prpEvidence: prpEvidenceForSideEffect(row.sideEffectClass), + prpBindingStatus: "audit_pending", + legacyAliases: Object.freeze([...(row.legacyAliases ?? [])]), + ...(row.note === undefined ? {} : { note: row.note }), + }); +} + +/** The single canonical catalog: the union of both surfaces, sorted by id. */ +export const CAPABILITY_CANONICAL_OPERATIONS: readonly CapabilityCanonicalOperation[] = Object.freeze( + [...ROWS].map(buildOperation).sort((left, right) => left.operationId.localeCompare(right.operationId)), +); + +const byId = new Map(CAPABILITY_CANONICAL_OPERATIONS.map((operation) => [operation.operationId, operation])); +if (byId.size !== CAPABILITY_CANONICAL_OPERATIONS.length) { + throw new Error("duplicate canonical semantic operation id"); +} + +export function capabilityCanonicalOperation( + operationId: string, +): CapabilityCanonicalOperation | undefined { + return byId.get(operationId); +} + +/** Canonical operations projected onto one catalog surface, sorted by id. */ +export function capabilityCanonicalOperationsForSurface( + surface: CapabilityCatalogSurface, +): readonly CapabilityCanonicalOperation[] { + return CAPABILITY_CANONICAL_OPERATIONS.filter((operation) => operation.surfaces.includes(surface)); +} + +/** Convenience for docs/generators: the canonical operation-id list, sorted. */ +export function capabilityCanonicalOperationIds(): readonly string[] { + return CAPABILITY_CANONICAL_OPERATIONS.map((operation) => operation.operationId); +} diff --git a/packages/paperclip-runner/src/catalog/index.ts b/packages/paperclip-runner/src/catalog/index.ts index b7eeccae28e..a0a5bda6d13 100644 --- a/packages/paperclip-runner/src/catalog/index.ts +++ b/packages/paperclip-runner/src/catalog/index.ts @@ -1 +1,2 @@ +export * from "./canonical-operations.js"; export * from "./reconciliation.js"; diff --git a/packages/paperclip-runner/src/catalog/reconciliation.test.ts b/packages/paperclip-runner/src/catalog/reconciliation.test.ts index ba0a0ada434..cb99223dd99 100644 --- a/packages/paperclip-runner/src/catalog/reconciliation.test.ts +++ b/packages/paperclip-runner/src/catalog/reconciliation.test.ts @@ -6,8 +6,11 @@ import { describe, expect, it } from "vitest"; import { CAPABILITY_SEMANTIC_TOOL_CATALOG as SCENARIO_CATALOG } from "../tools/capability-semantic-tool-catalog.js"; import { CAPABILITY_SEMANTIC_TOOL_CATALOG as LIVE_CATALOG } from "../semantic-tools/catalog.js"; import { - CAPABILITY_CANONICAL_CATALOG, + CAPABILITY_CANONICAL_OPERATIONS, capabilityCanonicalOperation, +} from "./canonical-operations.js"; +import { + CAPABILITY_CANONICAL_CATALOG, capabilityCatalogReconciliation, } from "./reconciliation.js"; @@ -45,18 +48,42 @@ describe("canonical semantic-catalog reconciliation authority", () => { ]); }); - it("defines exactly one canonical entry per union operation", () => { - expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(41); - const ids = CAPABILITY_CANONICAL_CATALOG.map((operation) => operation.operationId); - expect(new Set(ids).size).toBe(ids.length); - const scenarioIds = new Set(SCENARIO_CATALOG.map((tool) => tool.operationId)); - const liveIds = new Set(LIVE_CATALOG.map((tool) => tool.operationId)); - for (const id of [...scenarioIds, ...liveIds]) { - expect(capabilityCanonicalOperation(id)).toBeDefined(); + it("is the single source both catalogs derive their operation set from", () => { + expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(41); + const canonicalIds = new Set(CAPABILITY_CANONICAL_OPERATIONS.map((operation) => operation.operationId)); + // Neither catalog may contain an operation absent from the canonical source. + for (const tool of SCENARIO_CATALOG) expect(canonicalIds.has(tool.operationId)).toBe(true); + for (const tool of LIVE_CATALOG) expect(canonicalIds.has(tool.operationId)).toBe(true); + // Every canonical operation is projected onto the surface(s) it names. + for (const operation of CAPABILITY_CANONICAL_OPERATIONS) { + if (operation.surfaces.includes("scenario")) { + expect(SCENARIO_CATALOG.some((tool) => tool.operationId === operation.operationId)).toBe(true); + } + if (operation.surfaces.includes("live")) { + expect(LIVE_CATALOG.some((tool) => tool.operationId === operation.operationId)).toBe(true); + } + } + }); + + it("single-sources claims and task modes so the two catalogs cannot diverge", () => { + for (const operation of CAPABILITY_CANONICAL_OPERATIONS) { + const scenario = SCENARIO_CATALOG.find((tool) => tool.operationId === operation.operationId); + const live = LIVE_CATALOG.find((tool) => tool.operationId === operation.operationId); + if (scenario !== undefined) { + expect([...scenario.requiredClaims].sort()).toEqual([...operation.requiredClaims].sort()); + expect([...scenario.taskModes].sort()).toEqual([...operation.taskModes].sort()); + expect(scenario.disposition).toBe(operation.placement); + } + if (live !== undefined) { + expect([...live.requiredClaims].sort()).toEqual([...operation.requiredClaims].sort()); + expect([...live.allowedModes].sort()).toEqual([...operation.taskModes].sort()); + expect(live.exposure).toBe(operation.placement === "always_agent_tool" ? "always" : "optional"); + } } }); it("names placement, claims, task modes, side-effect class, idempotency, redaction, mock mapping, real binding status, and PRP evidence for every operation", () => { + expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(41); for (const operation of CAPABILITY_CANONICAL_CATALOG) { expect(operation.placement).toMatch(/^(always|optional)_agent_tool$/); expect(Array.isArray(operation.requiredClaims)).toBe(true); @@ -85,7 +112,16 @@ describe("canonical semantic-catalog reconciliation authority", () => { expect(capabilityCanonicalOperation("generic_api_request")?.realBindingStatus).toBe("test_only"); }); - it("records a disposition for every metadata divergence between the two catalogs", () => { + it("unifies the generic_api_request claim to a single test-only grant", () => { + const canonical = capabilityCanonicalOperation("generic_api_request"); + expect(canonical?.requiredClaims).toEqual(["test:generic_api_request"]); + const scenario = SCENARIO_CATALOG.find((tool) => tool.operationId === "generic_api_request"); + const live = LIVE_CATALOG.find((tool) => tool.operationId === "generic_api_request"); + expect(scenario?.requiredClaims).toEqual(["test:generic_api_request"]); + expect(live?.requiredClaims).toEqual(["test:generic_api_request"]); + }); + + it("leaves zero claim or task-mode divergence, and only the reviewed input-schema projection", () => { const { divergences } = capabilityCatalogReconciliation(); // No divergence may be left unrecorded, regardless of field. for (const divergence of divergences) { @@ -97,33 +133,18 @@ describe("canonical semantic-catalog reconciliation authority", () => { .map((entry) => entry.operationId) .sort(); - // The only claim divergence is the reviewed test-only escape-hatch grant. - expect(idsFor("requiredClaims")).toEqual(["generic_api_request"]); + // Claims and task modes are single-sourced from canonical-operations.ts, so + // there is nothing left to reconcile on either field. + expect(idsFor("requiredClaims")).toEqual([]); + expect(idsFor("taskModes")).toEqual([]); - // Task-mode policy diverges systematically between the two surfaces; the - // exact op-set is pinned so a new, unreviewed divergence fails the gate. - expect(idsFor("taskModes")).toEqual([ - "block_task", - "comment_on_approval", - "control_workspace_service", - "create_task", - "decide_approval", - "finish_task", - "get_workspace_runtime", - "list_agents", - "list_approvals", - "register_deliverable", - "report_progress", - "request_approval", - "request_review", - "search_tasks", - "set_dependencies", - ]); - - // Input schemas legitimately differ across the two surfaces today; every one - // carries the shared schema-migration disposition. - for (const divergence of divergences.filter((entry) => entry.field === "inputSchemaShape")) { - expect(divergence.disposition).toMatch(/schema-migration follow-on/); + // Input schemas remain a single contract projected two ways (idempotency + // in-band for the provider, out-of-band for the observable runtime); every + // entry carries the reviewed projection disposition. + const inputSchema = divergences.filter((entry) => entry.field === "inputSchemaShape"); + expect(inputSchema.length).toBeGreaterThan(0); + for (const divergence of inputSchema) { + expect(divergence.disposition).toMatch(/Reviewed intentional projection/); } }); @@ -143,7 +164,7 @@ describe("canonical semantic-catalog reconciliation authority", () => { } // Every legacy alias named by the canonical authority must be a real // inventory row, so a native-mapping note cannot reference a phantom alias. - for (const operation of CAPABILITY_CANONICAL_CATALOG) { + for (const operation of CAPABILITY_CANONICAL_OPERATIONS) { for (const alias of operation.legacyAliases) { expect(aliasIds.has(alias)).toBe(true); } diff --git a/packages/paperclip-runner/src/catalog/reconciliation.ts b/packages/paperclip-runner/src/catalog/reconciliation.ts index bb9980e7d2b..5722ad302e2 100644 --- a/packages/paperclip-runner/src/catalog/reconciliation.ts +++ b/packages/paperclip-runner/src/catalog/reconciliation.ts @@ -1,72 +1,43 @@ /** - * Canonical semantic-catalog reconciliation authority. + * Semantic-catalog reconciliation ledger and drift authority. * - * The runner historically carried two independently hand-maintained semantic - * catalogs: + * The single source of truth for the operation list, placement, required + * claims, and task-mode policy is `./canonical-operations.ts` + * (`CAPABILITY_CANONICAL_OPERATIONS`). Both catalog modules — `src/tools/` + * (scenario/eval) and `src/semantic-tools/` (live provider) — now derive their + * operation set and those facts from it (PAP-17039), so neither can declare an + * independent list or an independent claim/mode policy. * - * - `src/tools/` — the rich scenario/eval descriptor (placement, side-effect - * class, idempotency, redaction, mock mapping) consumed by the scenario and - * conformance layers and exported as the package-root default. - * - `src/semantic-tools/` — the live runtime engine (catalog + policy + - * dispatcher) that drives live Codex, the issue-thread projection, and the - * generated provider-neutral contracts. - * - * PAP-17025 makes this module the single machine-readable authority over the - * union of both surfaces so no specification calls either catalog canonical. - * It enumerates every operation once, names its placement, claims, task modes, - * roles, side-effect class, idempotency rule, redaction, mock mapping, real - * binding status, and PRP-evidence shape, and computes every op-set and - * metadata divergence between the two catalogs so drift checks can fail on any - * unrecorded change. Physically collapsing the two catalog modules into - * derivations of this authority (which changes live tool schemas and generated - * goldens and needs live re-QA) is the sequenced follow-on within PAP-17025. + * This module is the drift gate on top of that source: it enriches the + * canonical list with the scenario mock mapping and redaction facts, and it + * recomputes every remaining metadata divergence between the two catalogs' + * actual descriptors so a drift check fails on any unrecorded change. After the + * collapse the only surviving divergence is input-schema *shape*: the live + * provider schema threads `idempotencyKey` in-band with provider bounds for the + * model, while the observable scenario runtime threads idempotency out-of-band. + * That is a single, reviewed projection of one contract, recorded below. */ import { CAPABILITY_SEMANTIC_TOOL_CATALOG as SCENARIO_CATALOG } from "../tools/capability-semantic-tool-catalog.js"; -import type { - CapabilityMockCommandMapping, - CapabilitySideEffectClass, - CapabilityToolDisposition, - CapabilityToolTaskMode, - ScenarioChatdempotencyBehavior, -} from "../tools/capability-semantic-tool-types.js"; +import type { CapabilityMockCommandMapping } from "../tools/capability-semantic-tool-types.js"; import { CAPABILITY_SEMANTIC_TOOL_CATALOG as LIVE_CATALOG } from "../semantic-tools/catalog.js"; +import { + CAPABILITY_CANONICAL_OPERATIONS, + capabilityCanonicalOperation, + type CapabilityCanonicalOperation, + type CapabilityRealBindingStatus, +} from "./canonical-operations.js"; -/** Where an operation is executable today, before real Paperclip binding. */ -export type CapabilityRealBindingStatus = "live_codex" | "scenario_mock" | "test_only"; +export type { CapabilityCatalogSurface, CapabilityRealBindingStatus } from "./canonical-operations.js"; -/** Which hand-maintained catalog module(s) currently define the operation. */ -export type CapabilityCatalogSurface = "scenario" | "live"; - -export interface CapabilityCanonicalOperation { - readonly operationId: string; - /** Catalog module(s) that define this operation today. */ - readonly surfaces: readonly CapabilityCatalogSurface[]; - readonly placement: CapabilityToolDisposition; - readonly requiredClaims: readonly string[]; - readonly taskModes: readonly CapabilityToolTaskMode[]; - readonly allowedRoles?: readonly string[]; - readonly sideEffectClass: CapabilitySideEffectClass; - readonly idempotency: ScenarioChatdempotencyBehavior; - /** True when the operation declares any redaction rule. */ +/** + * The canonical operation enriched with the scenario mock mapping and redaction + * flag, for consumers and drift tests that assert full per-operation metadata. + */ +export interface CapabilityReconciledOperation extends CapabilityCanonicalOperation { + /** True when the scenario descriptor declares any redaction rule. */ readonly redacts: boolean; + /** Scenario mock-command mapping; `null` for live-only operations. */ readonly mockCommandMapping: CapabilityMockCommandMapping | null; - readonly realBindingStatus: CapabilityRealBindingStatus; - /** - * No semantic operation is bound to a real Paperclip service yet; the mock is - * the only backend. The real-service binding is deliverable G of the - * foundation plan (PAP-17016), tracked per operation once it lands. - */ - readonly realServiceBinding: "unbound"; - /** Intended PRP evidence family carrying this operation's effect. */ - readonly prpEvidence: string; - /** - * The formal PRP expressiveness proof is deliverable C of the foundation - * plan; until it lands every entry is `audit_pending` rather than proven. - */ - readonly prpBindingStatus: "audit_pending"; - /** Legacy MCP alias ids folded into this operation, if any. */ - readonly legacyAliases: readonly string[]; - readonly note?: string; } export type CapabilityCatalogDivergenceField = @@ -83,320 +54,82 @@ export interface CapabilityCatalogDivergence { readonly disposition: string; } -// --------------------------------------------------------------------------- -// Reconciliation table (hand-authored decisions, one row per union operation). -// Fields the two catalogs already carry are read from the descriptors; only -// facts neither catalog encodes live here: real binding status, PRP evidence, -// legacy aliases, and any side-effect/idempotency call for live-only ops. -// --------------------------------------------------------------------------- +const scenarioById = new Map(SCENARIO_CATALOG.map((tool) => [tool.operationId, tool])); +const liveById = new Map(LIVE_CATALOG.map((tool) => [tool.operationId as string, tool])); -interface ReconciliationRow { - readonly realBindingStatus: CapabilityRealBindingStatus; - readonly legacyAliases?: readonly string[]; - readonly note?: string; - /** Required only for the four live-only ops absent from the rich catalog. */ - readonly liveOnly?: { - readonly placement: CapabilityToolDisposition; - readonly sideEffectClass: CapabilitySideEffectClass; - readonly idempotency: ScenarioChatdempotencyBehavior; - readonly prpEvidence: string; - }; -} - -const RECONCILIATION: Readonly> = { - // --- always-present active-task surface (shared) --- - get_task_context: { realBindingStatus: "live_codex", legacyAliases: ["mcp:paperclipMe"] }, - get_task_history: { realBindingStatus: "live_codex" }, - list_documents: { realBindingStatus: "live_codex" }, - read_document: { realBindingStatus: "live_codex" }, - list_document_revisions: { realBindingStatus: "live_codex" }, - report_progress: { realBindingStatus: "live_codex", legacyAliases: ["mcp:paperclipAddComment"] }, - answer_status_question: { realBindingStatus: "live_codex" }, - write_document: { realBindingStatus: "live_codex" }, - request_human_input: { realBindingStatus: "live_codex" }, - register_deliverable: { realBindingStatus: "live_codex" }, - finish_task: { realBindingStatus: "live_codex" }, - block_task: { realBindingStatus: "live_codex" }, - request_review: { realBindingStatus: "live_codex" }, - // `inspect_operation_result` is scenario-only: the live dispatcher returns - // results inline rather than exposing a re-read tool. - inspect_operation_result: { - realBindingStatus: "scenario_mock", - note: "Scenario/eval-only re-read of a prior operation result; the live dispatcher returns results inline.", - }, - - // --- discovery (shared + live-only) --- - search_tasks: { realBindingStatus: "live_codex", legacyAliases: ["mcp:paperclipListIssues"] }, - list_agents: { realBindingStatus: "live_codex", legacyAliases: ["mcp:paperclipListAgents"] }, - get_agent: { - realBindingStatus: "live_codex", - legacyAliases: ["mcp:paperclipGetAgent"], - liveOnly: { - placement: "optional_agent_tool", - sideEffectClass: "read", - idempotency: "none", - prpEvidence: "read projection surfaced via a tool-result item event; no control-plane state diff", - }, - }, - list_projects: { - realBindingStatus: "scenario_mock", - note: "Scenario/eval-only discovery via mock extension; no live dispatcher binding yet.", - }, - list_goals: { - realBindingStatus: "scenario_mock", - note: "Scenario/eval-only discovery via mock extension; no live dispatcher binding yet.", - }, - - // --- delegation & dependencies (shared) --- - create_task: { realBindingStatus: "live_codex", legacyAliases: ["mcp:paperclipCreateIssue"] }, - set_dependencies: { realBindingStatus: "live_codex" }, - - // --- governance (shared + live-only) --- - list_approvals: { realBindingStatus: "live_codex" }, - request_approval: { realBindingStatus: "live_codex" }, - decide_approval: { realBindingStatus: "live_codex" }, - comment_on_approval: { realBindingStatus: "live_codex" }, - get_approval: { - realBindingStatus: "live_codex", - liveOnly: { - placement: "optional_agent_tool", - sideEffectClass: "read", - idempotency: "none", - prpEvidence: "approval read projection surfaced via a tool-result item event; no state diff", - }, - }, - get_approval_context: { - realBindingStatus: "live_codex", - liveOnly: { - placement: "optional_agent_tool", - sideEffectClass: "read", - idempotency: "none", - prpEvidence: "approval-plus-linked-tasks read projection via a tool-result item event; no state diff", - }, - }, - - // --- workspace runtime (shared) --- - get_workspace_runtime: { realBindingStatus: "live_codex" }, - control_workspace_service: { realBindingStatus: "live_codex" }, - - // --- control-plane wake scheduling (live-only) --- - schedule_wake: { - realBindingStatus: "live_codex", - liveOnly: { - placement: "optional_agent_tool", - sideEffectClass: "task_write", - idempotency: "required", - prpEvidence: "wake scheduling recorded as a run/attention continuation event; deterministic delay", - }, - note: "Bounded, deterministic mock wake scheduling; requires the control_plane:wakes claim.", - }, - - // --- scenario-only optional surfaces (mock extensions, no live binding) --- - list_cases: { realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." }, - upsert_case: { realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." }, - list_routines: { realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." }, - manage_routine: { realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." }, - list_company_skills: { realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." }, - sync_company_skills: { realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." }, - list_secret_metadata: { realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension; metadata only." }, - read_secret_value: { - realBindingStatus: "scenario_mock", - note: "Scenario/eval-only mock extension; secret value redacted from every observable sink.", - }, - export_company: { realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." }, - administer_company: { realBindingStatus: "scenario_mock", note: "Scenario/eval-only mock extension." }, - - // --- test-only escape hatch (shared, never product coverage) --- - generic_api_request: { - realBindingStatus: "test_only", - note: "Test-only escape hatch; explicitly cannot count as product capability coverage (PAP-17016 deliverable D).", - }, -}; - -// --------------------------------------------------------------------------- -// Derivation. -// --------------------------------------------------------------------------- - -function prpEvidenceForSideEffect(sideEffectClass: CapabilitySideEffectClass): string { - switch (sideEffectClass) { - case "read": - return "read projection surfaced via a tool-result item event; no control-plane state diff"; - case "task_write": - return "semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events"; - case "company_write": - return "semantic-operation item event plus company-entity state diff and audit record"; - case "governance": - return "approval lifecycle plus governed-wait continuation and audit events"; - case "workspace_control": - return "workspace service lifecycle event"; - case "secret_read": - return "redacted tool-result item event; secret value never reaches the wire"; - case "admin": - return "company admin/portability item event plus audit record"; - case "test_escape_hatch": - return "test-only; excluded from product PRP evidence"; - default: - return "audit_pending"; - } -} - -type LiveDescriptor = (typeof LIVE_CATALOG)[number]; -const scenarioById = new Map( - SCENARIO_CATALOG.map((tool) => [tool.operationId, tool]), -); -const liveById = new Map( - LIVE_CATALOG.map((tool) => [tool.operationId as string, tool]), -); - -const UNION_IDS: readonly string[] = [ - ...new Set([...scenarioById.keys(), ...liveById.keys()]), -].sort(); - -function buildOperation(operationId: string): CapabilityCanonicalOperation { - const row = RECONCILIATION[operationId]; - if (row === undefined) { - throw new Error(`Missing reconciliation decision for operation ${operationId}.`); - } - const scenario = scenarioById.get(operationId); - const live = liveById.get(operationId); - const surfaces: CapabilityCatalogSurface[] = []; - if (scenario !== undefined) surfaces.push("scenario"); - if (live !== undefined) surfaces.push("live"); - - if (scenario !== undefined) { - return freeze({ - operationId, - surfaces, - placement: scenario.disposition, - requiredClaims: [...scenario.requiredClaims], - taskModes: [...scenario.taskModes], - ...(scenario.allowedRoles === undefined ? {} : { allowedRoles: [...scenario.allowedRoles] }), - sideEffectClass: scenario.sideEffectClass, - idempotency: scenario.idempotency, - redacts: scenario.redaction.length > 0, - mockCommandMapping: scenario.mockCommandMapping, - realBindingStatus: row.realBindingStatus, - realServiceBinding: "unbound", - prpEvidence: prpEvidenceForSideEffect(scenario.sideEffectClass), - prpBindingStatus: "audit_pending", - legacyAliases: row.legacyAliases ?? [], - ...(row.note === undefined ? {} : { note: row.note }), - }); - } - - // Live-only operation: the rich catalog does not define it, so the - // reconciliation row must supply placement, side-effect class, and evidence. - if (live === undefined || row.liveOnly === undefined) { - throw new Error(`Live-only operation ${operationId} needs an explicit reconciliation row.`); - } - return freeze({ - operationId, - surfaces, - placement: row.liveOnly.placement, - requiredClaims: [...live.requiredClaims], - taskModes: [...live.allowedModes] as CapabilityToolTaskMode[], - ...(live.allowedRoles === undefined ? {} : { allowedRoles: [...live.allowedRoles] }), - sideEffectClass: row.liveOnly.sideEffectClass, - idempotency: row.liveOnly.idempotency, - redacts: false, - mockCommandMapping: null, - realBindingStatus: row.realBindingStatus, - realServiceBinding: "unbound", - prpEvidence: row.liveOnly.prpEvidence, - prpBindingStatus: "audit_pending", - legacyAliases: row.legacyAliases ?? [], - ...(row.note === undefined ? {} : { note: row.note }), +function enrich(operation: CapabilityCanonicalOperation): CapabilityReconciledOperation { + const scenario = scenarioById.get(operation.operationId); + return Object.freeze({ + ...operation, + redacts: scenario !== undefined && scenario.redaction.length > 0, + mockCommandMapping: scenario?.mockCommandMapping ?? null, }); } -/** The single canonical catalog: the union of both surfaces, sorted by id. */ -export const CAPABILITY_CANONICAL_CATALOG: readonly CapabilityCanonicalOperation[] = Object.freeze( - UNION_IDS.map(buildOperation), +/** The single canonical catalog, enriched with scenario mock/redaction facts. */ +export const CAPABILITY_CANONICAL_CATALOG: readonly CapabilityReconciledOperation[] = Object.freeze( + CAPABILITY_CANONICAL_OPERATIONS.map(enrich), ); -const canonicalById = new Map( - CAPABILITY_CANONICAL_CATALOG.map((operation) => [operation.operationId, operation]), -); - -export function capabilityCanonicalOperation( - operationId: string, -): CapabilityCanonicalOperation | undefined { - return canonicalById.get(operationId); -} - // --------------------------------------------------------------------------- -// Divergence ledger: every metadata difference between the two catalogs for a -// shared operation, each with a recorded disposition. Drift checks pin this set -// so a new, unrecorded divergence fails. +// Divergence ledger. Placement, required claims, and task modes are now single +// sourced, so those fields cannot diverge. Input-schema shape is the one +// intentional per-surface projection; every entry carries its disposition. // --------------------------------------------------------------------------- -function inputSchemaShape(schema: { properties?: Record; required?: readonly string[] } | undefined): string { +function inputSchemaShape( + schema: { properties?: Record; required?: readonly string[] } | undefined, +): string { const properties = Object.keys(schema?.properties ?? {}).sort(); const required = [...(schema?.required ?? [])].sort(); return `properties=[${properties.join(",")}] required=[${required.join(",")}]`; } /** - * Input schemas differ by design between the two surfaces: the scenario - * descriptor keeps idempotency out-of-band and omits provider bounds, while the - * live provider descriptor threads `idempotencyKey` in-band and adds - * length/pattern bounds for the model. Unifying the schema is the - * schema-migration follow-on within PAP-17025. + * Input schemas are a single contract projected two ways by design: the live + * provider descriptor threads `idempotencyKey` in-band and carries provider + * length/pattern bounds for the model, while the observable scenario runtime + * threads idempotency out-of-band. This is a reviewed, intentional projection, + * not two independently authored schemas. */ const INPUT_SCHEMA_SHAPE_DISPOSITION = - "Input schema differs by design (scenario keeps idempotency out-of-band and omits provider bounds; the live provider descriptor threads idempotencyKey in-band with length/pattern bounds). Unify in the schema-migration follow-on."; - -/** - * The two catalogs apply task-mode policy differently: the scenario/eval - * catalog scopes modes to the coverage the suite drives (adds `skill_test`, - * restricts optional reads to `standard`), while the live catalog encodes the - * runtime exposure policy (opens optional reads to `ask`/`planning`, keeps - * terminal writes `standard`-only). The canonical target — one task-mode policy - * per operation — is decided in the schema-migration follow-on within - * PAP-17025. The exact divergent op-set is pinned by the drift test. - */ -const TASK_MODE_DISPOSITION = - "Task-mode policy differs between the scenario suite's coverage modes and the live runtime exposure policy. Reconcile to one canonical policy in the schema-migration follow-on."; - -const DIVERGENCE_DISPOSITIONS: Readonly> = { - "generic_api_request:requiredClaims": - "Test-only escape hatch. The scenario claim `test:generic_api` and the live claim `test:generic_api_request` are the same reviewed test-only grant; unified in the schema-migration follow-on.", -}; + "Reviewed intentional projection of one input contract: the live provider descriptor threads idempotencyKey in-band with provider bounds for the model; the observable scenario runtime threads idempotency out-of-band. The operation list, claims, and task modes are single-sourced from canonical-operations.ts."; function buildDivergences(): readonly CapabilityCatalogDivergence[] { const divergences: CapabilityCatalogDivergence[] = []; - for (const operationId of UNION_IDS) { - const scenario = scenarioById.get(operationId); - const live = liveById.get(operationId); + for (const operation of CAPABILITY_CANONICAL_OPERATIONS) { + const scenario = scenarioById.get(operation.operationId); + const live = liveById.get(operation.operationId); if (scenario === undefined || live === undefined) continue; const record = ( field: CapabilityCatalogDivergenceField, scenarioValue: string, liveValue: string, + disposition: string, ): void => { if (scenarioValue === liveValue) return; - const disposition = - field === "inputSchemaShape" - ? INPUT_SCHEMA_SHAPE_DISPOSITION - : field === "taskModes" - ? TASK_MODE_DISPOSITION - : (DIVERGENCE_DISPOSITIONS[`${operationId}:${field}`] ?? - "UNRECORDED — reconcile or add a disposition."); - divergences.push({ operationId, field, scenario: scenarioValue, live: liveValue, disposition }); + divergences.push({ operationId: operation.operationId, field, scenario: scenarioValue, live: liveValue, disposition }); }; record( "requiredClaims", [...scenario.requiredClaims].sort().join(","), [...live.requiredClaims].sort().join(","), + "UNRECORDED — required claims are single-sourced from canonical-operations.ts and must not diverge.", ); record( "taskModes", [...scenario.taskModes].sort().join(","), [...live.allowedModes].sort().join(","), + "UNRECORDED — task modes are single-sourced from canonical-operations.ts and must not diverge.", + ); + record( + "inputSchemaShape", + inputSchemaShape(scenario.inputSchema), + inputSchemaShape(live.inputSchema), + INPUT_SCHEMA_SHAPE_DISPOSITION, ); - record("inputSchemaShape", inputSchemaShape(scenario.inputSchema), inputSchemaShape(live.inputSchema)); } return Object.freeze(divergences); } @@ -426,13 +159,13 @@ export function capabilityCatalogReconciliation(): CapabilityCatalogReconciliati }; let alwaysCount = 0; let optionalCount = 0; - for (const operation of CAPABILITY_CANONICAL_CATALOG) { + for (const operation of CAPABILITY_CANONICAL_OPERATIONS) { byRealBindingStatus[operation.realBindingStatus] += 1; if (operation.placement === "always_agent_tool") alwaysCount += 1; else if (operation.placement === "optional_agent_tool") optionalCount += 1; } return { - unionCount: UNION_IDS.length, + unionCount: CAPABILITY_CANONICAL_OPERATIONS.length, scenarioCount: scenarioIds.size, liveCount: liveIds.size, sharedCount: [...scenarioIds].filter((id) => liveIds.has(id)).length, @@ -445,11 +178,10 @@ export function capabilityCatalogReconciliation(): CapabilityCatalogReconciliati }; } -/** Convenience for docs/generators: the canonical operation-id list, sorted. */ -export function capabilityCanonicalOperationIds(): readonly string[] { - return UNION_IDS; -} - -function freeze(value: T): T { - return Object.freeze(value); +/** The reconciled operation for one id, or undefined. */ +export function capabilityReconciledOperation( + operationId: string, +): CapabilityReconciledOperation | undefined { + const operation = capabilityCanonicalOperation(operationId); + return operation === undefined ? undefined : enrich(operation); } diff --git a/packages/paperclip-runner/src/scenarios/scenario-index.ts b/packages/paperclip-runner/src/scenarios/scenario-index.ts index cee86693f9d..ee4db9fc975 100644 --- a/packages/paperclip-runner/src/scenarios/scenario-index.ts +++ b/packages/paperclip-runner/src/scenarios/scenario-index.ts @@ -110,7 +110,7 @@ const FIXTURE_PROFILES: Record> = { "skill-test-task": { seed: "skill_test", taskMode: "skill_test", - claims: ["test:generic_api"], + claims: ["test:generic_api_request"], policy: { allowGenericEscapeHatch: true, genericEscapeHatchAllowlist: [{ method: "GET", path: "/api/issues/*" }], diff --git a/packages/paperclip-runner/src/scenarios/scenario-plan.ts b/packages/paperclip-runner/src/scenarios/scenario-plan.ts index 6d2b806a041..456e8a3982c 100644 --- a/packages/paperclip-runner/src/scenarios/scenario-plan.ts +++ b/packages/paperclip-runner/src/scenarios/scenario-plan.ts @@ -500,7 +500,7 @@ function derivedWorkSteps( const marker = `Capability scenario ${entry.id}`; // Skill-test scenarios are the only place the generic escape hatch exists; // exercising it here is what makes the test-only warning visible in the UI. - const escapeHatch: CapabilityPlanStep[] = entry.scenarioClaims.includes("test:generic_api") + const escapeHatch: CapabilityPlanStep[] = entry.scenarioClaims.includes("test:generic_api_request") ? [ read( "generic_api_request", diff --git a/packages/paperclip-runner/src/semantic-tools/catalog.ts b/packages/paperclip-runner/src/semantic-tools/catalog.ts index f44b8c1db75..adf5e2f4f36 100644 --- a/packages/paperclip-runner/src/semantic-tools/catalog.ts +++ b/packages/paperclip-runner/src/semantic-tools/catalog.ts @@ -1,12 +1,21 @@ +import { + capabilityCanonicalOperationsForSurface, + type CapabilityCanonicalOperation, +} from "../catalog/canonical-operations.js"; import type { CapabilityJsonSchema, CapabilitySemanticOperationId, CapabilitySemanticToolDescriptor, } from "./types.js"; -const ALL_MODES = ["standard", "ask", "planning", "skill_test"] as const; -const WORK_MODES = ["standard", "planning", "skill_test"] as const; -const STANDARD_MODE = ["standard"] as const; +// --------------------------------------------------------------------------- +// This catalog no longer declares an independent operation list, exposure, or +// claim/mode policy. Exposure (`always`/`optional`), required claims, allowed +// task modes, allowed roles, and the disabled-by-default flag are single-sourced +// from `src/catalog/canonical-operations.ts` (PAP-17039). This module supplies +// only the live provider *presentation*: title, description, and the in-band +// provider input/output schemas the model sees. +// --------------------------------------------------------------------------- const text = (description: string, maxLength = 20_000): CapabilityJsonSchema => ({ type: "string", @@ -45,82 +54,27 @@ const operationResult = object({ }, ["commandId", "disposition", "stateRevision", "entityRefs", "scheduledWakeIds"]); const readResult: CapabilityJsonSchema = { type: "object", additionalProperties: true }; -interface DescriptorInput { - operationId: CapabilitySemanticOperationId; - title: string; - description: string; - exposure?: "always" | "optional"; - requiredClaims?: readonly string[]; - allowedModes?: CapabilitySemanticToolDescriptor["allowedModes"]; - allowedRoles?: readonly string[]; - disabledByDefault?: boolean; - inputSchema?: CapabilityJsonSchema; - outputSchema?: CapabilityJsonSchema; -} - -function descriptor(input: DescriptorInput): CapabilitySemanticToolDescriptor { - return { - schema: "paperclip.semantic-tool.v1", - operationId: input.operationId, - version: 1, - title: input.title, - description: input.description, - exposure: input.exposure ?? "always", - requiredClaims: input.requiredClaims ?? [], - allowedModes: input.allowedModes ?? ALL_MODES, - ...(input.allowedRoles === undefined ? {} : { allowedRoles: input.allowedRoles }), - ...(input.disabledByDefault === true ? { disabledByDefault: true } : {}), - inputSchema: input.inputSchema ?? object(), - outputSchema: input.outputSchema ?? readResult, - }; -} - const idempotency = { idempotencyKey: text("Caller-stable retry key.", 240) } as const; -const descriptors: readonly CapabilitySemanticToolDescriptor[] = [ - descriptor({ - operationId: "get_task_context", - title: "Get active task context", - description: "Read the active mock task, actor, wake, ancestors, budget, and interaction results.", - }), - descriptor({ - operationId: "get_task_history", - title: "Get active task history", - description: "Read bounded comments on the active mock task.", - inputSchema: object({ limit: { type: "integer", minimum: 1, maximum: 200, default: 50 } }), - }), - descriptor({ operationId: "list_documents", title: "List task documents", description: "List revisioned documents on the active mock task." }), - descriptor({ - operationId: "read_document", - title: "Read task document", - description: "Read the current revision of one active-task document.", - inputSchema: object({ key: text("Stable issue-document key.", 120) }, ["key"]), - }), - descriptor({ - operationId: "list_document_revisions", - title: "List document revisions", - description: "Read bounded revision history for one active-task document.", - inputSchema: object({ key: text("Stable issue-document key.", 120), limit: { type: "integer", minimum: 1, maximum: 200, default: 50 } }, ["key"]), - }), - descriptor({ - operationId: "report_progress", - title: "Report durable progress", - description: "Append a durable progress comment to the active mock task.", - inputSchema: object({ ...idempotency, body: text("Multiline progress update.") }, ["idempotencyKey", "body"]), - outputSchema: operationResult, - }), - descriptor({ - operationId: "answer_status_question", - title: "Answer status question", - description: "Append the answer to a status-only wake without changing task disposition.", - inputSchema: object({ ...idempotency, body: text("Concise status answer.") }, ["idempotencyKey", "body"]), - outputSchema: operationResult, - }), - descriptor({ - operationId: "write_document", - title: "Write revisioned document", - description: "Create or update an active-task document with optimistic revision safety.", - allowedModes: WORK_MODES, +/** Per-operation live provider presentation, in golden declaration order. */ +interface LivePresentation { + readonly operationId: CapabilitySemanticOperationId; + readonly title: string; + readonly description: string; + readonly inputSchema?: CapabilityJsonSchema; + readonly outputSchema?: CapabilityJsonSchema; +} + +const PRESENTATION: readonly LivePresentation[] = [ + { operationId: "get_task_context", title: "Get active task context", description: "Read the active mock task, actor, wake, ancestors, budget, and interaction results." }, + { operationId: "get_task_history", title: "Get active task history", description: "Read bounded comments on the active mock task.", inputSchema: object({ limit: { type: "integer", minimum: 1, maximum: 200, default: 50 } }) }, + { operationId: "list_documents", title: "List task documents", description: "List revisioned documents on the active mock task." }, + { operationId: "read_document", title: "Read task document", description: "Read the current revision of one active-task document.", inputSchema: object({ key: text("Stable issue-document key.", 120) }, ["key"]) }, + { operationId: "list_document_revisions", title: "List document revisions", description: "Read bounded revision history for one active-task document.", inputSchema: object({ key: text("Stable issue-document key.", 120), limit: { type: "integer", minimum: 1, maximum: 200, default: 50 } }, ["key"]) }, + { operationId: "report_progress", title: "Report durable progress", description: "Append a durable progress comment to the active mock task.", inputSchema: object({ ...idempotency, body: text("Multiline progress update.") }, ["idempotencyKey", "body"]), outputSchema: operationResult }, + { operationId: "answer_status_question", title: "Answer status question", description: "Append the answer to a status-only wake without changing task disposition.", inputSchema: object({ ...idempotency, body: text("Concise status answer.") }, ["idempotencyKey", "body"]), outputSchema: operationResult }, + { + operationId: "write_document", title: "Write revisioned document", description: "Create or update an active-task document with optimistic revision safety.", inputSchema: object({ ...idempotency, key: text("Stable issue-document key.", 120), @@ -130,12 +84,9 @@ const descriptors: readonly CapabilitySemanticToolDescriptor[] = [ changeSummary: nullableText("Optional revision summary."), }, ["idempotencyKey", "key", "title", "body", "baseRevisionId"]), outputSchema: operationResult, - }), - descriptor({ - operationId: "request_human_input", - title: "Request structured human input", - description: "Create a typed, durable interaction on the active mock task.", - allowedModes: WORK_MODES, + }, + { + operationId: "request_human_input", title: "Request structured human input", description: "Create a typed, durable interaction on the active mock task.", inputSchema: object({ ...idempotency, interactionKind: { enum: ["confirmation", "checkbox", "questions", "suggest_tasks", "item_verdicts"] }, @@ -146,12 +97,9 @@ const descriptors: readonly CapabilitySemanticToolDescriptor[] = [ continuationPolicy: { enum: ["none", "wake_assignee", "wake_assignee_on_accept"] }, }, ["idempotencyKey", "interactionKind", "title", "prompt", "continuationPolicy"]), outputSchema: operationResult, - }), - descriptor({ - operationId: "register_deliverable", - title: "Register inspectable deliverable", - description: "Register mock attachment metadata and its artifact work product without credentials or bytes in the tool result.", - allowedModes: WORK_MODES, + }, + { + operationId: "register_deliverable", title: "Register inspectable deliverable", description: "Register mock attachment metadata and its artifact work product without credentials or bytes in the tool result.", inputSchema: object({ ...idempotency, filename: text("Display filename.", 500), @@ -162,30 +110,70 @@ const descriptors: readonly CapabilitySemanticToolDescriptor[] = [ title: text("Work-product title.", 500), }, ["idempotencyKey", "filename", "contentType", "byteSize", "sha256", "contentRef", "title"]), outputSchema: operationResult, - }), - descriptor({ operationId: "finish_task", title: "Finish active task", description: "Finish the active mock task with a durable summary.", allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, summary: text("Completion summary.") }, ["idempotencyKey", "summary"]), outputSchema: operationResult }), - descriptor({ operationId: "block_task", title: "Block active task", description: "Block the active mock task with a durable reason and optional first-class dependencies.", allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, reason: text("Block reason."), blockedByTaskIds: stringArray("Internal mock task ids that block this task.") }, ["idempotencyKey", "reason"]), outputSchema: operationResult }), - descriptor({ operationId: "request_review", title: "Request task review", description: "Move the active mock task to review with a durable summary.", allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, summary: text("Review handoff summary.") }, ["idempotencyKey", "summary"]), outputSchema: operationResult }), - - descriptor({ operationId: "list_agents", title: "List company agents", description: "List redacted mock actor profiles.", exposure: "optional", requiredClaims: ["discovery:agents:read"] }), - descriptor({ operationId: "get_agent", title: "Get company agent", description: "Read one redacted mock actor profile.", exposure: "optional", requiredClaims: ["discovery:agents:read"], inputSchema: object({ actorId: text("Mock actor id.", 200) }, ["actorId"]) }), - descriptor({ operationId: "search_tasks", title: "Search company tasks", description: "Search mock tasks by text and status within the run company.", exposure: "optional", requiredClaims: ["discovery:tasks:read"], inputSchema: object({ query: { type: "string", maxLength: 500 }, statuses: { type: "array", items: { enum: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"] }, maxItems: 7, uniqueItems: true }, limit: { type: "integer", minimum: 1, maximum: 200, default: 50 } }) }), - descriptor({ operationId: "list_approvals", title: "List approvals", description: "List mock approvals in the run company.", exposure: "optional", requiredClaims: ["governance:approvals:read"] }), - descriptor({ operationId: "get_approval", title: "Get approval", description: "Read one mock approval without protected data.", exposure: "optional", requiredClaims: ["governance:approvals:read"], inputSchema: object({ approvalId: text("Mock approval id.", 200) }, ["approvalId"]) }), - descriptor({ operationId: "get_approval_context", title: "Get approval context", description: "Read one approval, its comments, and linked mock tasks.", exposure: "optional", requiredClaims: ["governance:approvals:read"], inputSchema: object({ approvalId: text("Mock approval id.", 200) }, ["approvalId"]) }), - descriptor({ operationId: "get_workspace_runtime", title: "Get workspace runtime", description: "Read active-task mock workspace services.", exposure: "optional", requiredClaims: ["workspace:read"] }), - descriptor({ operationId: "control_workspace_service", title: "Control workspace service", description: "Start, stop, or fault one active-task mock workspace service.", exposure: "optional", requiredClaims: ["workspace:control"], allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, serviceId: text("Mock workspace service id.", 200), action: { enum: ["start", "stop", "fail"] }, url: nullableText("Optional mock service URL.") }, ["idempotencyKey", "serviceId", "action"]), outputSchema: operationResult }), - descriptor({ operationId: "set_dependencies", title: "Set task dependencies", description: "Replace the active task's first-class blocker set.", exposure: "optional", requiredClaims: ["dependencies:write"], allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, blockedByTaskIds: stringArray("Replacement blocker task ids.") }, ["idempotencyKey", "blockedByTaskIds"]), outputSchema: operationResult }), - descriptor({ operationId: "create_task", title: "Create child task", description: "Create one child mock task under the active task.", exposure: "optional", requiredClaims: ["delegation:tasks:create"], allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, title: text("Child task title.", 500), description: nullableText("Child task description."), assigneeActorId: nullableText("Optional mock actor assignee."), priority: { enum: ["critical", "high", "medium", "low"] }, blockedByTaskIds: stringArray("Initial blocker task ids.") }, ["idempotencyKey", "title"]), outputSchema: operationResult }), - descriptor({ operationId: "request_approval", title: "Request approval", description: "Create a governed mock approval and waiting posture.", exposure: "optional", requiredClaims: ["governance:approvals:request"], allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, approvalType: text("Stable approval type.", 200), payload: jsonObject }, ["idempotencyKey", "approvalType", "payload"]), outputSchema: operationResult }), - descriptor({ operationId: "decide_approval", title: "Decide approval", description: "Decide a mock approval as an explicitly authorized approver.", exposure: "optional", requiredClaims: ["governance:approvals:decide"], allowedRoles: ["board", "approver", "security"], allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, approvalId: text("Mock approval id.", 200), decision: { enum: ["approved", "rejected", "cancelled"] }, note: text("Decision note.") }, ["idempotencyKey", "approvalId", "decision", "note"]), outputSchema: operationResult }), - descriptor({ operationId: "comment_on_approval", title: "Comment on approval", description: "Add a durable comment to a mock approval.", exposure: "optional", requiredClaims: ["governance:approvals:comment"], inputSchema: object({ ...idempotency, approvalId: text("Mock approval id.", 200), body: text("Approval comment.") }, ["idempotencyKey", "approvalId", "body"]), outputSchema: operationResult }), - descriptor({ operationId: "schedule_wake", title: "Schedule bounded wake", description: "Schedule a deterministic mock continuation wake.", exposure: "optional", requiredClaims: ["control_plane:wakes"], allowedModes: STANDARD_MODE, inputSchema: object({ ...idempotency, reason: { enum: ["manual", "issue_commented", "interaction_resolved", "approval_resolved", "blockers_resolved", "scheduled_retry", "resume"] }, payload: jsonObject, delayTicks: { type: "integer", minimum: 1, maximum: 10_000 } }, ["idempotencyKey", "reason", "delayTicks"]), outputSchema: operationResult }), - descriptor({ operationId: "generic_api_request", title: "Generic API request", description: "Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.", exposure: "optional", requiredClaims: ["test:generic_api_request"], disabledByDefault: true, allowedModes: ["skill_test"], inputSchema: object({ method: { enum: ["GET", "POST", "PATCH"] }, path: { type: "string", pattern: "^/mock/", maxLength: 500 }, body: jsonObject }, ["method", "path"]) }), + }, + { operationId: "finish_task", title: "Finish active task", description: "Finish the active mock task with a durable summary.", inputSchema: object({ ...idempotency, summary: text("Completion summary.") }, ["idempotencyKey", "summary"]), outputSchema: operationResult }, + { operationId: "block_task", title: "Block active task", description: "Block the active mock task with a durable reason and optional first-class dependencies.", inputSchema: object({ ...idempotency, reason: text("Block reason."), blockedByTaskIds: stringArray("Internal mock task ids that block this task.") }, ["idempotencyKey", "reason"]), outputSchema: operationResult }, + { operationId: "request_review", title: "Request task review", description: "Move the active mock task to review with a durable summary.", inputSchema: object({ ...idempotency, summary: text("Review handoff summary.") }, ["idempotencyKey", "summary"]), outputSchema: operationResult }, + { operationId: "list_agents", title: "List company agents", description: "List redacted mock actor profiles." }, + { operationId: "get_agent", title: "Get company agent", description: "Read one redacted mock actor profile.", inputSchema: object({ actorId: text("Mock actor id.", 200) }, ["actorId"]) }, + { operationId: "search_tasks", title: "Search company tasks", description: "Search mock tasks by text and status within the run company.", inputSchema: object({ query: { type: "string", maxLength: 500 }, statuses: { type: "array", items: { enum: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"] }, maxItems: 7, uniqueItems: true }, limit: { type: "integer", minimum: 1, maximum: 200, default: 50 } }) }, + { operationId: "list_approvals", title: "List approvals", description: "List mock approvals in the run company." }, + { operationId: "get_approval", title: "Get approval", description: "Read one mock approval without protected data.", inputSchema: object({ approvalId: text("Mock approval id.", 200) }, ["approvalId"]) }, + { operationId: "get_approval_context", title: "Get approval context", description: "Read one approval, its comments, and linked mock tasks.", inputSchema: object({ approvalId: text("Mock approval id.", 200) }, ["approvalId"]) }, + { operationId: "get_workspace_runtime", title: "Get workspace runtime", description: "Read active-task mock workspace services." }, + { operationId: "control_workspace_service", title: "Control workspace service", description: "Start, stop, or fault one active-task mock workspace service.", inputSchema: object({ ...idempotency, serviceId: text("Mock workspace service id.", 200), action: { enum: ["start", "stop", "fail"] }, url: nullableText("Optional mock service URL.") }, ["idempotencyKey", "serviceId", "action"]), outputSchema: operationResult }, + { operationId: "set_dependencies", title: "Set task dependencies", description: "Replace the active task's first-class blocker set.", inputSchema: object({ ...idempotency, blockedByTaskIds: stringArray("Replacement blocker task ids.") }, ["idempotencyKey", "blockedByTaskIds"]), outputSchema: operationResult }, + { operationId: "create_task", title: "Create child task", description: "Create one child mock task under the active task.", inputSchema: object({ ...idempotency, title: text("Child task title.", 500), description: nullableText("Child task description."), assigneeActorId: nullableText("Optional mock actor assignee."), priority: { enum: ["critical", "high", "medium", "low"] }, blockedByTaskIds: stringArray("Initial blocker task ids.") }, ["idempotencyKey", "title"]), outputSchema: operationResult }, + { operationId: "request_approval", title: "Request approval", description: "Create a governed mock approval and waiting posture.", inputSchema: object({ ...idempotency, approvalType: text("Stable approval type.", 200), payload: jsonObject }, ["idempotencyKey", "approvalType", "payload"]), outputSchema: operationResult }, + { operationId: "decide_approval", title: "Decide approval", description: "Decide a mock approval as an explicitly authorized approver.", inputSchema: object({ ...idempotency, approvalId: text("Mock approval id.", 200), decision: { enum: ["approved", "rejected", "cancelled"] }, note: text("Decision note.") }, ["idempotencyKey", "approvalId", "decision", "note"]), outputSchema: operationResult }, + { operationId: "comment_on_approval", title: "Comment on approval", description: "Add a durable comment to a mock approval.", inputSchema: object({ ...idempotency, approvalId: text("Mock approval id.", 200), body: text("Approval comment.") }, ["idempotencyKey", "approvalId", "body"]), outputSchema: operationResult }, + { operationId: "schedule_wake", title: "Schedule bounded wake", description: "Schedule a deterministic mock continuation wake.", inputSchema: object({ ...idempotency, reason: { enum: ["manual", "issue_commented", "interaction_resolved", "approval_resolved", "blockers_resolved", "scheduled_retry", "resume"] }, payload: jsonObject, delayTicks: { type: "integer", minimum: 1, maximum: 10_000 } }, ["idempotencyKey", "reason", "delayTicks"]), outputSchema: operationResult }, + { operationId: "generic_api_request", title: "Generic API request", description: "Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.", inputSchema: object({ method: { enum: ["GET", "POST", "PATCH"] }, path: { type: "string", pattern: "^/mock/", maxLength: 500 }, body: jsonObject }, ["method", "path"]) }, ]; +const canonicalById = new Map( + capabilityCanonicalOperationsForSurface("live").map((operation) => [operation.operationId, operation]), +); + +function toDescriptor( + presentation: LivePresentation, + operation: CapabilityCanonicalOperation, +): CapabilitySemanticToolDescriptor { + return { + schema: "paperclip.semantic-tool.v1", + operationId: presentation.operationId, + version: 1, + title: presentation.title, + description: presentation.description, + exposure: operation.placement === "always_agent_tool" ? "always" : "optional", + requiredClaims: [...operation.requiredClaims], + allowedModes: [...operation.taskModes] as CapabilitySemanticToolDescriptor["allowedModes"], + ...(operation.allowedRoles === undefined ? {} : { allowedRoles: [...operation.allowedRoles] }), + ...(operation.disabledByDefault ? { disabledByDefault: true } : {}), + inputSchema: presentation.inputSchema ?? object(), + outputSchema: presentation.outputSchema ?? readResult, + }; +} + +// Bidirectional parity: presentation set === canonical live-surface set. +const presentedIds = new Set(PRESENTATION.map((entry) => entry.operationId)); +if (presentedIds.size !== PRESENTATION.length) throw new Error("duplicate Capability semantic operation id"); +for (const entry of PRESENTATION) { + if (!canonicalById.has(entry.operationId)) { + throw new Error(`Live presentation ${entry.operationId} is not a canonical live-surface operation.`); + } +} +for (const operationId of canonicalById.keys()) { + if (!presentedIds.has(operationId as CapabilitySemanticOperationId)) { + throw new Error(`Canonical live operation ${operationId} has no live presentation.`); + } +} + +const descriptors: readonly CapabilitySemanticToolDescriptor[] = PRESENTATION.map((presentation) => + toDescriptor(presentation, canonicalById.get(presentation.operationId)!), +); + const byId = new Map(descriptors.map((item) => [item.operationId, item])); -if (byId.size !== descriptors.length) throw new Error("duplicate Capability semantic operation id"); export const CAPABILITY_SEMANTIC_TOOL_CATALOG = deepFreeze(descriptors); diff --git a/packages/paperclip-runner/src/tools/capability-semantic-tool-catalog.ts b/packages/paperclip-runner/src/tools/capability-semantic-tool-catalog.ts index 0e692ebd276..e6aad90919c 100644 --- a/packages/paperclip-runner/src/tools/capability-semantic-tool-catalog.ts +++ b/packages/paperclip-runner/src/tools/capability-semantic-tool-catalog.ts @@ -1,13 +1,24 @@ +import { + capabilityCanonicalOperationsForSurface, + type CapabilityCanonicalOperation, +} from "../catalog/canonical-operations.js"; import type { CapabilityJsonSchema, + CapabilityMockCommandMapping, CapabilityOptionalCatalogGroup, + CapabilityRedactionRule, CapabilitySemanticToolDescriptor, - CapabilityToolTaskMode, } from "./capability-semantic-tool-types.js"; -const ALL_MODES: CapabilityToolTaskMode[] = ["standard", "ask", "planning", "skill_test"]; -const WORK_MODES: CapabilityToolTaskMode[] = ["standard", "planning", "skill_test"]; -const STANDARD_MODES: CapabilityToolTaskMode[] = ["standard", "skill_test"]; +// --------------------------------------------------------------------------- +// This catalog no longer declares an independent operation list. Placement, +// required claims, task modes, allowed roles, side-effect class, idempotency, +// and optional-group membership are single-sourced from +// `src/catalog/canonical-operations.ts` (PAP-17039). This module supplies only +// the scenario-surface *presentation*: title, description, input schema, the +// abstract mock-command mapping, and any redaction rules. +// --------------------------------------------------------------------------- + const RESULT_SCHEMA = objectSchema({ schema: stringSchema({ enum: ["paperclip.capability.tool-result.v1"] }), ok: { type: "boolean" }, @@ -17,23 +28,23 @@ const RESULT_SCHEMA = objectSchema({ commandResult: {}, authorization: {}, }, ["schema", "ok", "operationId", "operationResultId", "value", "commandResult", "authorization"]); -const SECRET_REDACTION = [ +const SECRET_REDACTION: CapabilityRedactionRule[] = [ { path: "$.value", - replacement: "[SECRET_VALUE]" as const, - appliesTo: ["output", "error", "authorization_record"] as const, + replacement: "[SECRET_VALUE]", + appliesTo: ["output", "error", "authorization_record"], }, ]; -const AUTH_REDACTION = [ +const AUTH_REDACTION: CapabilityRedactionRule[] = [ { path: "$.headers.authorization", - replacement: "[REDACTED]" as const, - appliesTo: ["input", "output", "error", "authorization_record"] as const, + replacement: "[REDACTED]", + appliesTo: ["input", "output", "error", "authorization_record"], }, { path: "$.headers.cookie", - replacement: "[REDACTED]" as const, - appliesTo: ["input", "output", "error", "authorization_record"] as const, + replacement: "[REDACTED]", + appliesTo: ["input", "output", "error", "authorization_record"], }, ]; @@ -52,161 +63,83 @@ function objectSchema( return { type: "object", properties, required, additionalProperties: false }; } -function descriptor( - value: Omit & { - redaction?: CapabilitySemanticToolDescriptor["redaction"]; - }, -): CapabilitySemanticToolDescriptor { - const { redaction, ...rest } = value; - return { version: 1, ...rest, redaction: redaction ?? [] }; +/** Per-operation scenario presentation, keyed by canonical operation id. */ +interface ScenarioPresentation { + /** Bespoke title; optional tools default to a title derived from the id. */ + readonly title?: string; + /** Bespoke description; optional tools default to a group-scoped sentence. */ + readonly description?: string; + readonly inputSchema: CapabilityJsonSchema; + readonly mockCommandMapping: CapabilityMockCommandMapping; + readonly redaction?: CapabilityRedactionRule[]; } -const ALWAYS_TOOLS: CapabilitySemanticToolDescriptor[] = [ - descriptor({ - operationId: "get_task_context", +const PRESENTATION: Readonly> = { + // --- always tools (bespoke titles/descriptions) --- + get_task_context: { title: "Get task context", description: "Read the active task, ancestors, wake context, linked results, and budget summary.", inputSchema: objectSchema({}), - outputSchema: RESULT_SCHEMA, - disposition: "always_agent_tool", - optionalGroup: null, - requiredClaims: [], - taskModes: ALL_MODES, - sideEffectClass: "read", - idempotency: "none", mockCommandMapping: { kind: "context_read", projection: "active_task" }, - }), - descriptor({ - operationId: "get_task_history", + }, + get_task_history: { title: "Get task history", description: "Read comments and audit-visible history for the active task only.", inputSchema: objectSchema({ limit: { type: "integer", minimum: 1 } }), - outputSchema: RESULT_SCHEMA, - disposition: "always_agent_tool", - optionalGroup: null, - requiredClaims: [], - taskModes: ALL_MODES, - sideEffectClass: "read", - idempotency: "none", mockCommandMapping: { kind: "snapshot_read", projection: "active_task_history" }, - }), - descriptor({ - operationId: "list_documents", + }, + list_documents: { title: "List task documents", description: "List document metadata attached to the active task.", inputSchema: objectSchema({}), - outputSchema: RESULT_SCHEMA, - disposition: "always_agent_tool", - optionalGroup: null, - requiredClaims: [], - taskModes: ALL_MODES, - sideEffectClass: "read", - idempotency: "none", mockCommandMapping: { kind: "snapshot_read", projection: "active_task_documents" }, - }), - descriptor({ - operationId: "read_document", + }, + read_document: { title: "Read task document", description: "Read one document on the active task by stable key.", inputSchema: objectSchema({ key: stringSchema({ minLength: 1 }) }, ["key"]), - outputSchema: RESULT_SCHEMA, - disposition: "always_agent_tool", - optionalGroup: null, - requiredClaims: [], - taskModes: ALL_MODES, - sideEffectClass: "read", - idempotency: "none", mockCommandMapping: { kind: "snapshot_read", projection: "active_task_document" }, - }), - descriptor({ - operationId: "list_document_revisions", + }, + list_document_revisions: { title: "List document revisions", description: "Inspect revision history for one active-task document.", inputSchema: objectSchema({ key: stringSchema({ minLength: 1 }) }, ["key"]), - outputSchema: RESULT_SCHEMA, - disposition: "always_agent_tool", - optionalGroup: null, - requiredClaims: [], - taskModes: ALL_MODES, - sideEffectClass: "read", - idempotency: "none", mockCommandMapping: { kind: "snapshot_read", projection: "active_task_document_revisions" }, - }), - descriptor({ - operationId: "report_progress", + }, + report_progress: { title: "Report progress", description: "Append a durable progress update to the active task.", inputSchema: objectSchema({ body: stringSchema({ minLength: 1 }) }, ["body"]), - outputSchema: RESULT_SCHEMA, - disposition: "always_agent_tool", - optionalGroup: null, - requiredClaims: [], - taskModes: WORK_MODES, - sideEffectClass: "task_write", - idempotency: "required", mockCommandMapping: { kind: "semantic_command", commandKind: "report_progress" }, - }), - descriptor({ - operationId: "answer_status_question", + }, + answer_status_question: { title: "Answer status question", description: "Post an answer to a status question without changing task state.", inputSchema: objectSchema({ body: stringSchema({ minLength: 1 }) }, ["body"]), - outputSchema: RESULT_SCHEMA, - disposition: "always_agent_tool", - optionalGroup: null, - requiredClaims: [], - taskModes: ALL_MODES, - sideEffectClass: "task_write", - idempotency: "required", mockCommandMapping: { kind: "semantic_command", commandKind: "report_progress" }, - }), - descriptor({ - operationId: "finish_task", + }, + finish_task: { title: "Finish task", description: "Finish the active task with a durable completion summary.", inputSchema: objectSchema({ summary: stringSchema({ minLength: 1 }) }, ["summary"]), - outputSchema: RESULT_SCHEMA, - disposition: "always_agent_tool", - optionalGroup: null, - requiredClaims: [], - taskModes: STANDARD_MODES, - sideEffectClass: "task_write", - idempotency: "required", mockCommandMapping: { kind: "semantic_command", commandKind: "finish_task" }, - }), - descriptor({ - operationId: "block_task", + }, + block_task: { title: "Block task", description: "Block the active task with a reason and optional first-class dependencies.", inputSchema: objectSchema({ reason: stringSchema({ minLength: 1 }), blockedByTaskIds: arraySchema(stringSchema({ minLength: 1 })), }, ["reason"]), - outputSchema: RESULT_SCHEMA, - disposition: "always_agent_tool", - optionalGroup: null, - requiredClaims: [], - taskModes: WORK_MODES, - sideEffectClass: "task_write", - idempotency: "required", mockCommandMapping: { kind: "semantic_command", commandKind: "block_task" }, - }), - descriptor({ - operationId: "request_review", + }, + request_review: { title: "Request task review", description: "Move the active task to review with a durable summary.", inputSchema: objectSchema({ summary: stringSchema({ minLength: 1 }) }, ["summary"]), - outputSchema: RESULT_SCHEMA, - disposition: "always_agent_tool", - optionalGroup: null, - requiredClaims: [], - taskModes: WORK_MODES, - sideEffectClass: "task_write", - idempotency: "required", mockCommandMapping: { kind: "semantic_command", commandKind: "request_review" }, - }), - descriptor({ - operationId: "write_document", + }, + write_document: { title: "Write task document", description: "Create or revision-safely update an active-task document.", inputSchema: objectSchema({ @@ -216,17 +149,9 @@ const ALWAYS_TOOLS: CapabilitySemanticToolDescriptor[] = [ baseRevisionId: { oneOf: [stringSchema({ minLength: 1 }), { type: "null" }] }, changeSummary: stringSchema(), }, ["key", "title", "body", "baseRevisionId"]), - outputSchema: RESULT_SCHEMA, - disposition: "always_agent_tool", - optionalGroup: null, - requiredClaims: [], - taskModes: WORK_MODES, - sideEffectClass: "task_write", - idempotency: "required", mockCommandMapping: { kind: "semantic_command", commandKind: "write_document" }, - }), - descriptor({ - operationId: "request_human_input", + }, + request_human_input: { title: "Request human input", description: "Create a typed confirmation, checkbox, question, task suggestion, or item-verdict request.", inputSchema: objectSchema({ @@ -237,17 +162,9 @@ const ALWAYS_TOOLS: CapabilitySemanticToolDescriptor[] = [ targetRevisionId: { oneOf: [stringSchema({ minLength: 1 }), { type: "null" }] }, continuationPolicy: stringSchema({ enum: ["none", "wake_assignee", "wake_assignee_on_accept"] }), }, ["interactionKind", "title", "prompt", "continuationPolicy"]), - outputSchema: RESULT_SCHEMA, - disposition: "always_agent_tool", - optionalGroup: null, - requiredClaims: [], - taskModes: WORK_MODES, - sideEffectClass: "task_write", - idempotency: "required", mockCommandMapping: { kind: "semantic_command", commandKind: "request_human_input" }, - }), - descriptor({ - operationId: "register_deliverable", + }, + register_deliverable: { title: "Register deliverable", description: "Register an inspectable artifact and its work product on the active task.", inputSchema: objectSchema({ @@ -258,96 +175,186 @@ const ALWAYS_TOOLS: CapabilitySemanticToolDescriptor[] = [ contentRef: stringSchema({ minLength: 1 }), title: stringSchema({ minLength: 1 }), }, ["filename", "contentType", "byteSize", "sha256", "contentRef", "title"]), - outputSchema: RESULT_SCHEMA, - disposition: "always_agent_tool", - optionalGroup: null, - requiredClaims: [], - taskModes: STANDARD_MODES, - sideEffectClass: "task_write", - idempotency: "required", mockCommandMapping: { kind: "semantic_command", commandKind: "register_deliverable" }, - }), - descriptor({ - operationId: "inspect_operation_result", + }, + inspect_operation_result: { title: "Inspect operation result", description: "Read a prior semantic operation result from this run.", inputSchema: objectSchema({ operationResultId: stringSchema({ minLength: 1 }) }, ["operationResultId"]), - outputSchema: RESULT_SCHEMA, - disposition: "always_agent_tool", - optionalGroup: null, - requiredClaims: [], - taskModes: ALL_MODES, - sideEffectClass: "read", - idempotency: "none", mockCommandMapping: { kind: "operation_result" }, - }), -]; + }, -function optional( - operationId: string, - group: CapabilityOptionalCatalogGroup, - requiredClaims: string[], - sideEffectClass: CapabilitySemanticToolDescriptor["sideEffectClass"], - mapping: CapabilitySemanticToolDescriptor["mockCommandMapping"], - inputSchema: CapabilityJsonSchema = objectSchema({}), - options: Partial> = {}, -): CapabilitySemanticToolDescriptor { - const title = operationId.split("_").map((part) => part[0]!.toUpperCase() + part.slice(1)).join(" "); - return descriptor({ - operationId, + // --- optional tools (title/description derived from id + group) --- + search_tasks: { + inputSchema: objectSchema({ query: stringSchema(), status: stringSchema() }), + mockCommandMapping: { kind: "snapshot_read", projection: "company_tasks" }, + }, + list_agents: { + inputSchema: objectSchema({}), + mockCommandMapping: { kind: "snapshot_read", projection: "company_actors" }, + }, + list_projects: { + inputSchema: objectSchema({}), + mockCommandMapping: { kind: "mock_extension", extension: "discovery.projects" }, + }, + list_goals: { + inputSchema: objectSchema({}), + mockCommandMapping: { kind: "mock_extension", extension: "discovery.goals" }, + }, + create_task: { + inputSchema: objectSchema({ + title: stringSchema({ minLength: 1 }), + description: stringSchema(), + assigneeActorId: stringSchema(), + priority: stringSchema(), + blockedByTaskIds: arraySchema(stringSchema()), + }, ["title"]), + mockCommandMapping: { kind: "semantic_command", commandKind: "create_task" }, + }, + set_dependencies: { + inputSchema: objectSchema({ blockedByTaskIds: arraySchema(stringSchema({ minLength: 1 })) }, ["blockedByTaskIds"]), + mockCommandMapping: { kind: "semantic_command", commandKind: "set_dependencies" }, + }, + list_approvals: { + inputSchema: objectSchema({}), + mockCommandMapping: { kind: "snapshot_read", projection: "company_approvals" }, + }, + request_approval: { + inputSchema: objectSchema({ approvalType: stringSchema({ minLength: 1 }), payload: {} }, ["approvalType", "payload"]), + mockCommandMapping: { kind: "semantic_command", commandKind: "request_approval" }, + }, + decide_approval: { + inputSchema: objectSchema({ + approvalId: stringSchema({ minLength: 1 }), + decision: stringSchema({ enum: ["approved", "rejected", "cancelled"] }), + note: stringSchema({ minLength: 1 }), + }, ["approvalId", "decision", "note"]), + mockCommandMapping: { kind: "semantic_command", commandKind: "decide_approval" }, + }, + comment_on_approval: { + inputSchema: objectSchema({ approvalId: stringSchema({ minLength: 1 }), body: stringSchema({ minLength: 1 }) }, ["approvalId", "body"]), + mockCommandMapping: { kind: "semantic_command", commandKind: "comment_on_approval" }, + }, + list_cases: { + inputSchema: objectSchema({}), + mockCommandMapping: { kind: "mock_extension", extension: "cases.list" }, + }, + upsert_case: { + inputSchema: objectSchema({ key: stringSchema({ minLength: 1 }), body: stringSchema() }, ["key", "body"]), + mockCommandMapping: { kind: "mock_extension", extension: "cases.upsert" }, + }, + get_workspace_runtime: { + inputSchema: objectSchema({}), + mockCommandMapping: { kind: "snapshot_read", projection: "active_task_workspace" }, + }, + control_workspace_service: { + inputSchema: objectSchema({ + serviceId: stringSchema({ minLength: 1 }), + action: stringSchema({ enum: ["start", "stop", "fail"] }), + url: stringSchema(), + }, ["serviceId", "action"]), + mockCommandMapping: { kind: "semantic_command", commandKind: "control_workspace_service" }, + }, + list_routines: { + inputSchema: objectSchema({}), + mockCommandMapping: { kind: "mock_extension", extension: "routines.list" }, + }, + manage_routine: { + inputSchema: objectSchema({}), + mockCommandMapping: { kind: "mock_extension", extension: "routines.manage" }, + }, + list_company_skills: { + inputSchema: objectSchema({}), + mockCommandMapping: { kind: "mock_extension", extension: "company_skills.list" }, + }, + sync_company_skills: { + inputSchema: objectSchema({}), + mockCommandMapping: { kind: "mock_extension", extension: "company_skills.sync" }, + }, + list_secret_metadata: { + inputSchema: objectSchema({}), + mockCommandMapping: { kind: "mock_extension", extension: "secrets.metadata" }, + }, + read_secret_value: { + inputSchema: objectSchema({ name: stringSchema({ minLength: 1 }) }, ["name"]), + mockCommandMapping: { kind: "mock_extension", extension: "secrets.value" }, + redaction: [...SECRET_REDACTION], + }, + export_company: { + inputSchema: objectSchema({}), + mockCommandMapping: { kind: "mock_extension", extension: "portability.export" }, + }, + administer_company: { + inputSchema: objectSchema({ action: stringSchema({ minLength: 1 }), payload: {} }, ["action"]), + mockCommandMapping: { kind: "mock_extension", extension: "company.admin" }, + }, + generic_api_request: { + inputSchema: objectSchema({ + method: stringSchema({ minLength: 1 }), + path: stringSchema({ minLength: 1 }), + headers: { type: "object", additionalProperties: stringSchema() }, + body: {}, + }, ["method", "path"]), + mockCommandMapping: { kind: "mock_extension", extension: "test.generic_api" }, + redaction: [...AUTH_REDACTION], + }, +}; + +function titleFor(operationId: string): string { + return operationId.split("_").map((part) => part[0]!.toUpperCase() + part.slice(1)).join(" "); +} + +function descriptionFor(title: string, group: CapabilityOptionalCatalogGroup): string { + return `${title} through the Capability ${group.replaceAll("_", " ")} capability set.`; +} + +function toDescriptor(operation: CapabilityCanonicalOperation): CapabilitySemanticToolDescriptor { + const presentation = PRESENTATION[operation.operationId]; + if (presentation === undefined) { + throw new Error(`Missing scenario presentation for operation ${operation.operationId}.`); + } + const title = presentation.title ?? titleFor(operation.operationId); + const description = presentation.description + ?? descriptionFor(title, operation.optionalGroup ?? "discovery"); + return Object.freeze({ + operationId: operation.operationId, + version: 1, title, - description: `${title} through the Capability ${group.replaceAll("_", " ")} capability set.`, - inputSchema, + description, + inputSchema: presentation.inputSchema, outputSchema: RESULT_SCHEMA, - disposition: "optional_agent_tool", - optionalGroup: group, - requiredClaims, - taskModes: options.taskModes ?? STANDARD_MODES, - sideEffectClass, - idempotency: options.idempotency ?? (sideEffectClass === "read" ? "none" : "required"), - redaction: options.redaction, - allowedRoles: options.allowedRoles, - mockCommandMapping: mapping, + disposition: operation.placement, + optionalGroup: operation.optionalGroup, + requiredClaims: [...operation.requiredClaims], + ...(operation.allowedRoles === undefined ? {} : { allowedRoles: [...operation.allowedRoles] }), + taskModes: [...operation.taskModes], + sideEffectClass: operation.sideEffectClass, + idempotency: operation.idempotency, + redaction: presentation.redaction ?? [], + mockCommandMapping: presentation.mockCommandMapping, }); } -const OPTIONAL_TOOLS: CapabilitySemanticToolDescriptor[] = [ - optional("search_tasks", "discovery", ["discovery:tasks:read"], "read", { kind: "snapshot_read", projection: "company_tasks" }, objectSchema({ query: stringSchema(), status: stringSchema() })), - optional("list_agents", "discovery", ["discovery:agents:read"], "read", { kind: "snapshot_read", projection: "company_actors" }), - optional("list_projects", "discovery", ["discovery:projects:read"], "read", { kind: "mock_extension", extension: "discovery.projects" }), - optional("list_goals", "discovery", ["discovery:goals:read"], "read", { kind: "mock_extension", extension: "discovery.goals" }), +const SCENARIO_OPERATIONS = capabilityCanonicalOperationsForSurface("scenario"); +const canonicalById = new Map(SCENARIO_OPERATIONS.map((operation) => [operation.operationId, operation])); - optional("create_task", "delegation_dependencies", ["delegation:tasks:create"], "company_write", { kind: "semantic_command", commandKind: "create_task" }, objectSchema({ title: stringSchema({ minLength: 1 }), description: stringSchema(), assigneeActorId: stringSchema(), priority: stringSchema(), blockedByTaskIds: arraySchema(stringSchema()) }, ["title"])), - optional("set_dependencies", "delegation_dependencies", ["dependencies:write"], "company_write", { kind: "semantic_command", commandKind: "set_dependencies" }, objectSchema({ blockedByTaskIds: arraySchema(stringSchema({ minLength: 1 })) }, ["blockedByTaskIds"])), - - optional("list_approvals", "governance", ["governance:approvals:read"], "read", { kind: "snapshot_read", projection: "company_approvals" }), - optional("request_approval", "governance", ["governance:approvals:request"], "governance", { kind: "semantic_command", commandKind: "request_approval" }, objectSchema({ approvalType: stringSchema({ minLength: 1 }), payload: {} }, ["approvalType", "payload"])), - optional("decide_approval", "governance", ["governance:approvals:decide"], "governance", { kind: "semantic_command", commandKind: "decide_approval" }, objectSchema({ approvalId: stringSchema({ minLength: 1 }), decision: stringSchema({ enum: ["approved", "rejected", "cancelled"] }), note: stringSchema({ minLength: 1 }) }, ["approvalId", "decision", "note"]), { allowedRoles: ["board", "approver", "security"] }), - optional("comment_on_approval", "governance", ["governance:approvals:comment"], "governance", { kind: "semantic_command", commandKind: "comment_on_approval" }, objectSchema({ approvalId: stringSchema({ minLength: 1 }), body: stringSchema({ minLength: 1 }) }, ["approvalId", "body"])), - - optional("list_cases", "cases", ["cases:read"], "read", { kind: "mock_extension", extension: "cases.list" }), - optional("upsert_case", "cases", ["cases:write"], "company_write", { kind: "mock_extension", extension: "cases.upsert" }, objectSchema({ key: stringSchema({ minLength: 1 }), body: stringSchema() }, ["key", "body"])), - - optional("get_workspace_runtime", "workspace_runtime", ["workspace:read"], "read", { kind: "snapshot_read", projection: "active_task_workspace" }), - optional("control_workspace_service", "workspace_runtime", ["workspace:control"], "workspace_control", { kind: "semantic_command", commandKind: "control_workspace_service" }, objectSchema({ serviceId: stringSchema({ minLength: 1 }), action: stringSchema({ enum: ["start", "stop", "fail"] }), url: stringSchema() }, ["serviceId", "action"])), - - optional("list_routines", "routines", ["routines:read"], "read", { kind: "mock_extension", extension: "routines.list" }), - optional("manage_routine", "routines", ["routines:write"], "admin", { kind: "mock_extension", extension: "routines.manage" }), - - optional("list_company_skills", "company_skills", ["company_skills:read"], "read", { kind: "mock_extension", extension: "company_skills.list" }), - optional("sync_company_skills", "company_skills", ["company_skills:write"], "admin", { kind: "mock_extension", extension: "company_skills.sync" }), - - optional("list_secret_metadata", "secrets", ["secrets:metadata:read"], "read", { kind: "mock_extension", extension: "secrets.metadata" }), - optional("read_secret_value", "secrets", ["secrets:values:read"], "secret_read", { kind: "mock_extension", extension: "secrets.value" }, objectSchema({ name: stringSchema({ minLength: 1 }) }, ["name"]), { redaction: [...SECRET_REDACTION], idempotency: "none" }), - - optional("export_company", "portability_admin", ["portability:export"], "admin", { kind: "mock_extension", extension: "portability.export" }), - optional("administer_company", "portability_admin", ["company:admin"], "admin", { kind: "mock_extension", extension: "company.admin" }, objectSchema({ action: stringSchema({ minLength: 1 }), payload: {} }, ["action"]), { allowedRoles: ["board", "ceo", "admin"] }), - - optional("generic_api_request", "test_escape_hatch", ["test:generic_api"], "test_escape_hatch", { kind: "mock_extension", extension: "test.generic_api" }, objectSchema({ method: stringSchema({ minLength: 1 }), path: stringSchema({ minLength: 1 }), headers: { type: "object", additionalProperties: stringSchema() }, body: {} }, ["method", "path"]), { taskModes: ["skill_test"], redaction: [...AUTH_REDACTION] }), -]; +// Bidirectional parity: every presented operation is a canonical scenario-surface +// operation, and every canonical scenario-surface operation has a presentation. +for (const key of Object.keys(PRESENTATION)) { + if (!canonicalById.has(key)) { + throw new Error(`Scenario presentation ${key} is not a canonical scenario-surface operation.`); + } +} +for (const operation of SCENARIO_OPERATIONS) { + if (PRESENTATION[operation.operationId] === undefined) { + throw new Error(`Canonical scenario operation ${operation.operationId} has no scenario presentation.`); + } +} +// Declaration order follows the presentation map so the exported surface stays +// stable; the operation set itself is single-sourced from the canonical list. export const CAPABILITY_SEMANTIC_TOOL_CATALOG: readonly CapabilitySemanticToolDescriptor[] = Object.freeze( - [...ALWAYS_TOOLS, ...OPTIONAL_TOOLS].map((tool) => Object.freeze(tool)), + Object.keys(PRESENTATION).map((operationId) => toDescriptor(canonicalById.get(operationId)!)), ); export const CAPABILITY_OPTIONAL_TOOL_CATALOGS: Readonly< @@ -370,5 +377,7 @@ export function capabilitySemanticTool(operationId: string): CapabilitySemanticT } function toolsInGroup(group: CapabilityOptionalCatalogGroup): readonly CapabilitySemanticToolDescriptor[] { - return Object.freeze(OPTIONAL_TOOLS.filter((tool) => tool.optionalGroup === group)); + return Object.freeze( + CAPABILITY_SEMANTIC_TOOL_CATALOG.filter((tool) => tool.optionalGroup === group), + ); } diff --git a/packages/paperclip-runner/src/tools/capability-semantic-tools.test.ts b/packages/paperclip-runner/src/tools/capability-semantic-tools.test.ts index 491eb711557..6b78094a487 100644 --- a/packages/paperclip-runner/src/tools/capability-semantic-tools.test.ts +++ b/packages/paperclip-runner/src/tools/capability-semantic-tools.test.ts @@ -451,12 +451,12 @@ describe("Capability security policy", () => { }); it("keeps the generic escape hatch test-only, explicitly granted, and allowlisted", async () => { - const denied = await runtimeFor({ workMode: "skill_test", scenarioGrants: ["test:generic_api"] }); + const denied = await runtimeFor({ workMode: "skill_test", scenarioGrants: ["test:generic_api_request"] }); expect(denied.runtime.visibleTools().tools.map((tool) => tool.operationId)).not.toContain("generic_api_request"); const { runtime } = await runtimeFor({ workMode: "skill_test", - scenarioGrants: ["test:generic_api"], + scenarioGrants: ["test:generic_api_request"], policy: { allowGenericEscapeHatch: true, genericEscapeHatchAllowlist: [{ method: "GET", path: "/mock/health" }],