mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
4d8297128d
commit
449e1a25c6
11 files changed
+834
-776
No files matched your search
@@ -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`.
|
||||
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
# Semantic Catalog Reconciliation
|
||||
|
||||
**Document status:** Canonical reconciliation ledger (PAP-17025)<br>
|
||||
**Document status:** Canonical reconciliation ledger (PAP-17025 gate + PAP-17039 collapse)<br>
|
||||
**Date:** 2026-08-11<br>
|
||||
**Authority:** `src/catalog/reconciliation.ts` (machine-readable), drift-checked by `src/catalog/reconciliation.test.ts` and `src/catalog/catalog-docs.test.ts`.<br>
|
||||
**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.<br>
|
||||
**Drift authority:** `src/catalog/reconciliation.ts` (machine-readable ledger), drift-checked by `src/catalog/reconciliation.test.ts` and `src/catalog/catalog-docs.test.ts`.<br>
|
||||
**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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./canonical-operations.js";
|
||||
export * from "./reconciliation.js";
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<Record<string, ReconciliationRow>> = {
|
||||
// --- 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<string, (typeof SCENARIO_CATALOG)[number]>(
|
||||
SCENARIO_CATALOG.map((tool) => [tool.operationId, tool]),
|
||||
);
|
||||
const liveById = new Map<string, LiveDescriptor>(
|
||||
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<string, unknown>; required?: readonly string[] } | undefined): string {
|
||||
function inputSchemaShape(
|
||||
schema: { properties?: Record<string, unknown>; 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<Record<string, string>> = {
|
||||
"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<T>(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);
|
||||
}
|
||||
@@ -110,7 +110,7 @@ const FIXTURE_PROFILES: Record<string, Partial<CapabilityScenarioProfile>> = {
|
||||
"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/*" }],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<CapabilitySemanticToolDescriptor, "version" | "redaction"> & {
|
||||
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<Record<string, ScenarioPresentation>> = {
|
||||
// --- 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<Pick<CapabilitySemanticToolDescriptor, "allowedRoles" | "redaction" | "taskModes" | "idempotency">> = {},
|
||||
): 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),
|
||||
);
|
||||
}
|
||||
@@ -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" }],
|
||||
|
||||
Reference in new issue
Block a user