diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md
index 583608f242b..782f4c08b6d 100644
--- a/doc/SPEC-implementation.md
+++ b/doc/SPEC-implementation.md
@@ -229,6 +229,17 @@ See `doc/project-repositories.md` for the API and UI contract.
## 7.6 `issues` (core task entity)
+Task creation accepts an omitted or blank title when the description contains a prompt.
+The server stores the first 120 characters of its whitespace-normalized prompt as
+a provisional title and sets `title_needs_generation`. The assigned agent receives
+an early instruction to use `set_task_title` (or `PUT /api/issues/:id/title`) to
+replace it with a concise title. `onlyIfProvisional: true` atomically preserves a
+user or agent title already chosen, including concurrent edits. Explicit title
+edits clear the marker. Naming is available in standard, ask, and planning modes;
+it does not change status, ownership, or the full description. Title writes are
+company-scoped and audited; agent callers must own the active run. Existing tasks
+retain their titles and default to no generation request.
+
- `id` uuid pk
- `company_id` uuid fk not null
- `project_id` uuid fk `projects.id` null
diff --git a/doc/SPEC.md b/doc/SPEC.md
index 5104a39ab60..de5de8b71c3 100644
--- a/doc/SPEC.md
+++ b/doc/SPEC.md
@@ -458,6 +458,10 @@ No separate "agent API" vs. "board API." Same endpoints, different authorization
Paperclip manages task-linked work artifacts: issue documents (rich-text plans, specs, notes attached to issues) and file attachments. Agents read and write these through the API as part of normal task execution. Full delivery infrastructure (code repos, deployments, production runtime) remains the agent's domain — Paperclip orchestrates the work, not the build pipeline.
+Users may start a task with only a prompt. Paperclip uses a short prompt slice as
+its initial title and asks the assigned agent to name the task early. Explicit
+user titles are preserved, and naming does not change task execution state.
+
Task work mode is explicit persisted state. Requesting a plan in a title or description does not switch the task into planning mode. Standard execution may produce a plan as its requested deliverable; explicit planning mode separately governs plan-only execution and its approval transition.
### Open Questions
diff --git a/doc/evals.md b/doc/evals.md
index 4c1f1a2f3b6..4ff1928ec35 100644
--- a/doc/evals.md
+++ b/doc/evals.md
@@ -23,6 +23,12 @@ idle, busy, multiple-task, and restart Agent Chat handoffs on native Claude/Code
completion delivery/result access from semantic review of the retained answer;
see the [probe contract](../tests/runner-e2e/README.md#completion-update-probes-explicit-only).
+The explicit-only [task-titles suite](../tests/runner-e2e/README.md#automatic-task-titles)
+checks that production guidance causes a real native agent to name prompt-only
+standard/Ask tasks early, while preserving user-supplied titles. Its oracle
+correlates browser creation, native tool receipts, durable titles, audit ownership,
+and the reloaded task UI; fixture prompts contain no naming instructions.
+
## Selecting a family
Use **Runner Evals** for a runner protocol, adapter, transport, native session,
diff --git a/packages/db/src/migrations/0293_broad_rattler.sql b/packages/db/src/migrations/0293_broad_rattler.sql
new file mode 100644
index 00000000000..ffbcead78db
--- /dev/null
+++ b/packages/db/src/migrations/0293_broad_rattler.sql
@@ -0,0 +1 @@
+ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "title_needs_generation" boolean DEFAULT false NOT NULL;
diff --git a/packages/db/src/migrations/meta/0288_snapshot.json b/packages/db/src/migrations/meta/0293_snapshot.json
similarity index 98%
rename from packages/db/src/migrations/meta/0288_snapshot.json
rename to packages/db/src/migrations/meta/0293_snapshot.json
index c6dde90ac28..7dee20fa0e9 100644
--- a/packages/db/src/migrations/meta/0288_snapshot.json
+++ b/packages/db/src/migrations/meta/0293_snapshot.json
@@ -1,6 +1,6 @@
{
- "id": "770b6d59-6346-4c92-8e49-0b71cf4819ab",
- "prevId": "26beb23c-50c3-42f9-8a1c-814d0c8acfbe",
+ "id": "d0c40f4e-c2c5-4efd-bc15-c6ae5632047d",
+ "prevId": "239dfc30-3531-48b5-a06f-72608de479da",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -3797,13 +3797,6 @@
"primaryKey": false,
"notNull": false
},
- "keyboard_shortcuts": {
- "name": "keyboard_shortcuts",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true,
- "default": false
- },
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
@@ -3982,6 +3975,599 @@
"checkConstraints": {},
"isRLSEnabled": false
},
+ "public.browser_use_browsers": {
+ "name": "browser_use_browsers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_browser_id": {
+ "name": "provider_browser_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "browser_use_browsers_provider_uq": {
+ "name": "browser_use_browsers_provider_uq",
+ "columns": [
+ {
+ "expression": "provider_browser_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "browser_use_browsers_session_idx": {
+ "name": "browser_use_browsers_session_idx",
+ "columns": [
+ {
+ "expression": "session_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "browser_use_browsers_company_id_companies_id_fk": {
+ "name": "browser_use_browsers_company_id_companies_id_fk",
+ "tableFrom": "browser_use_browsers",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "browser_use_browsers_session_id_browser_use_sessions_id_fk": {
+ "name": "browser_use_browsers_session_id_browser_use_sessions_id_fk",
+ "tableFrom": "browser_use_browsers",
+ "tableTo": "browser_use_sessions",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.browser_use_runs": {
+ "name": "browser_use_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "heartbeat_run_id": {
+ "name": "heartbeat_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "invocation_id": {
+ "name": "invocation_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_run_id": {
+ "name": "provider_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "recovery_cursor": {
+ "name": "recovery_cursor",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'creating'"
+ },
+ "detached_until": {
+ "name": "detached_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "event_cursor": {
+ "name": "event_cursor",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "events_drained": {
+ "name": "events_drained",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "accounted_cents": {
+ "name": "accounted_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "progress": {
+ "name": "progress",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "result": {
+ "name": "result",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "browser_use_runs_invocation_uq": {
+ "name": "browser_use_runs_invocation_uq",
+ "columns": [
+ {
+ "expression": "invocation_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "browser_use_runs_provider_uq": {
+ "name": "browser_use_runs_provider_uq",
+ "columns": [
+ {
+ "expression": "provider_run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "browser_use_runs_session_idx": {
+ "name": "browser_use_runs_session_idx",
+ "columns": [
+ {
+ "expression": "session_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "browser_use_runs_company_id_companies_id_fk": {
+ "name": "browser_use_runs_company_id_companies_id_fk",
+ "tableFrom": "browser_use_runs",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "browser_use_runs_session_id_browser_use_sessions_id_fk": {
+ "name": "browser_use_runs_session_id_browser_use_sessions_id_fk",
+ "tableFrom": "browser_use_runs",
+ "tableTo": "browser_use_sessions",
+ "columnsFrom": [
+ "session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "browser_use_runs_heartbeat_run_id_heartbeat_runs_id_fk": {
+ "name": "browser_use_runs_heartbeat_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "browser_use_runs",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "heartbeat_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.browser_use_sessions": {
+ "name": "browser_use_sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "grant_id": {
+ "name": "grant_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_session_id": {
+ "name": "provider_session_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'starting'"
+ },
+ "idle_deadline": {
+ "name": "idle_deadline",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stop_requested": {
+ "name": "stop_requested",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lease_token": {
+ "name": "lease_token",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lease_until": {
+ "name": "lease_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "next_poll_at": {
+ "name": "next_poll_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "browser_use_sessions_task_idx": {
+ "name": "browser_use_sessions_task_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "browser_use_sessions_provider_uq": {
+ "name": "browser_use_sessions_provider_uq",
+ "columns": [
+ {
+ "expression": "provider_session_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "browser_use_sessions_company_id_companies_id_fk": {
+ "name": "browser_use_sessions_company_id_companies_id_fk",
+ "tableFrom": "browser_use_sessions",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "browser_use_sessions_issue_id_issues_id_fk": {
+ "name": "browser_use_sessions_issue_id_issues_id_fk",
+ "tableFrom": "browser_use_sessions",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "browser_use_sessions_agent_id_agents_id_fk": {
+ "name": "browser_use_sessions_agent_id_agents_id_fk",
+ "tableFrom": "browser_use_sessions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "browser_use_sessions_connection_id_tool_connections_id_fk": {
+ "name": "browser_use_sessions_connection_id_tool_connections_id_fk",
+ "tableFrom": "browser_use_sessions",
+ "tableTo": "tool_connections",
+ "columnsFrom": [
+ "connection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "browser_use_sessions_grant_id_connection_grants_id_fk": {
+ "name": "browser_use_sessions_grant_id_connection_grants_id_fk",
+ "tableFrom": "browser_use_sessions",
+ "tableTo": "connection_grants",
+ "columnsFrom": [
+ "grant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.browser_use_settings": {
+ "name": "browser_use_settings",
+ "schema": "",
+ "columns": {
+ "grant_id": {
+ "name": "grant_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "allowed_profile_ids": {
+ "name": "allowed_profile_ids",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "max_cost_usd": {
+ "name": "max_cost_usd",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "browser_use_settings_grant_id_connection_grants_id_fk": {
+ "name": "browser_use_settings_grant_id_connection_grants_id_fk",
+ "tableFrom": "browser_use_settings",
+ "tableTo": "connection_grants",
+ "columnsFrom": [
+ "grant_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "browser_use_settings_company_id_companies_id_fk": {
+ "name": "browser_use_settings_company_id_companies_id_fk",
+ "tableFrom": "browser_use_settings",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
"public.budget_incidents": {
"name": "budget_incidents",
"schema": "",
@@ -12151,6 +12737,373 @@
"checkConstraints": {},
"isRLSEnabled": false
},
+ "public.company_skill_source_entries": {
+ "name": "company_skill_source_entries",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "inspection": {
+ "name": "inspection",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "skill_id": {
+ "name": "skill_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "selection": {
+ "name": "selection",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'new'"
+ },
+ "present": {
+ "name": "present",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "company_skill_source_entries_source_path_idx": {
+ "name": "company_skill_source_entries_source_path_idx",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "path",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_skill_source_entries_company_skill_idx": {
+ "name": "company_skill_source_entries_company_skill_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "skill_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "company_skill_source_entries_company_id_companies_id_fk": {
+ "name": "company_skill_source_entries_company_id_companies_id_fk",
+ "tableFrom": "company_skill_source_entries",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "company_skill_source_entries_source_id_company_skill_sources_id_fk": {
+ "name": "company_skill_source_entries_source_id_company_skill_sources_id_fk",
+ "tableFrom": "company_skill_source_entries",
+ "tableTo": "company_skill_sources",
+ "columnsFrom": [
+ "source_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "company_skill_source_entries_skill_id_company_skills_id_fk": {
+ "name": "company_skill_source_entries_skill_id_company_skills_id_fk",
+ "tableFrom": "company_skill_source_entries",
+ "tableTo": "company_skills",
+ "columnsFrom": [
+ "skill_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.company_skill_sources": {
+ "name": "company_skill_sources",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "repository_id": {
+ "name": "repository_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "repository_url": {
+ "name": "repository_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "full_name": {
+ "name": "full_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tracking_ref": {
+ "name": "tracking_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "excluded_folders": {
+ "name": "excluded_folders",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "revision": {
+ "name": "revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_attempt_at": {
+ "name": "last_attempt_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_success_at": {
+ "name": "last_success_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_scan_commit": {
+ "name": "last_scan_commit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lease_token": {
+ "name": "lease_token",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lease_expires_at": {
+ "name": "lease_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "company_skill_sources_repository_ref_idx": {
+ "name": "company_skill_sources_repository_ref_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "repository_url",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "tracking_ref",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_skill_sources_identity_ref_idx": {
+ "name": "company_skill_sources_identity_ref_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "repository_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "tracking_ref",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "company_skill_sources_company_id_companies_id_fk": {
+ "name": "company_skill_sources_company_id_companies_id_fk",
+ "tableFrom": "company_skill_sources",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "company_skill_sources_connection_id_tool_connections_id_fk": {
+ "name": "company_skill_sources_connection_id_tool_connections_id_fk",
+ "tableFrom": "company_skill_sources",
+ "tableTo": "tool_connections",
+ "columnsFrom": [
+ "connection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
"public.company_skill_comments": {
"name": "company_skill_comments",
"schema": "",
@@ -30512,6 +31465,13 @@
"primaryKey": false,
"notNull": true
},
+ "title_needs_generation": {
+ "name": "title_needs_generation",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
"description": {
"name": "description",
"type": "text",
diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json
index 0efa97f7705..71acfdde0c6 100644
--- a/packages/db/src/migrations/meta/_journal.json
+++ b/packages/db/src/migrations/meta/_journal.json
@@ -2038,6 +2038,13 @@
"when": 1790776905226,
"tag": "0292_powerful_devos",
"breakpoints": true
+ },
+ {
+ "idx": 293,
+ "version": "7",
+ "when": 1790796608171,
+ "tag": "0293_broad_rattler",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/db/src/schema/issues.ts b/packages/db/src/schema/issues.ts
index f6031774bd4..2542d94f3ca 100644
--- a/packages/db/src/schema/issues.ts
+++ b/packages/db/src/schema/issues.ts
@@ -11,6 +11,7 @@ import {
uniqueIndex,
unique,
bigint,
+ boolean,
check,
} from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
@@ -38,6 +39,7 @@ export const issues = pgTable(
goalId: uuid("goal_id").references(() => goals.id),
parentId: uuid("parent_id").references((): AnyPgColumn => issues.id),
title: text("title").notNull(),
+ titleNeedsGeneration: boolean("title_needs_generation").notNull().default(false),
description: text("description"),
status: text("status").notNull().default("backlog"),
statusVersion: bigint("status_version", { mode: "number" }).notNull().default(0),
diff --git a/packages/paperclip-runner/docs/capability-contract.md b/packages/paperclip-runner/docs/capability-contract.md
index 3bdc087203a..7a6fb336df4 100644
--- a/packages/paperclip-runner/docs/capability-contract.md
+++ b/packages/paperclip-runner/docs/capability-contract.md
@@ -47,31 +47,31 @@ The skill/reference inventory and eval cases are the only normative behavior sou
| skill:skills/paperclip/SKILL.md:conversation-tasks:30 | optional_agent_tool | skills/paperclip/SKILL.md:30 |
| skill:skills/paperclip/SKILL.md:server-verified-external-chat-turns:47 | control_plane_owned | skills/paperclip/SKILL.md:47 |
| skill:skills/paperclip/SKILL.md:the-heartbeat-procedure:87 | optional_agent_tool | skills/paperclip/SKILL.md:87 |
-| skill:skills/paperclip/SKILL.md:generated-artifacts-and-work-products:195 | always_agent_tool | skills/paperclip/SKILL.md:195 |
-| skill:skills/paperclip/SKILL.md:status-quick-guide:243 | control_plane_owned | skills/paperclip/SKILL.md:243 |
-| skill:skills/paperclip/SKILL.md:monitors-and-watchers-say-only-what-you-actually-scheduled:253 | optional_agent_tool | skills/paperclip/SKILL.md:253 |
-| skill:skills/paperclip/SKILL.md:delegating-review-tasks:266 | always_agent_tool | skills/paperclip/SKILL.md:266 |
-| skill:skills/paperclip/SKILL.md:managing-a-user-s-inbox:277 | control_plane_owned | skills/paperclip/SKILL.md:277 |
-| skill:skills/paperclip/SKILL.md:issue-dependencies-blockers:285 | control_plane_owned | skills/paperclip/SKILL.md:285 |
-| skill:skills/paperclip/SKILL.md:requesting-board-approval:310 | optional_agent_tool | skills/paperclip/SKILL.md:310 |
-| skill:skills/paperclip/SKILL.md:issue-thread-interactions:331 | optional_agent_tool | skills/paperclip/SKILL.md:331 |
-| skill:skills/paperclip/SKILL.md:standalone-decisions:360 | optional_agent_tool | skills/paperclip/SKILL.md:360 |
-| skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:464 | optional_agent_tool | skills/paperclip/SKILL.md:464 |
-| skill:skills/paperclip/SKILL.md:niche-workflow-pointers:506 | optional_agent_tool | skills/paperclip/SKILL.md:506 |
-| skill:skills/paperclip/SKILL.md:cases:516 | optional_agent_tool | skills/paperclip/SKILL.md:516 |
-| skill:skills/paperclip/SKILL.md:company-skills-workflow:521 | optional_agent_tool | skills/paperclip/SKILL.md:521 |
-| skill:skills/paperclip/SKILL.md:routines:532 | optional_agent_tool | skills/paperclip/SKILL.md:532 |
-| skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:543 | optional_agent_tool | skills/paperclip/SKILL.md:543 |
-| skill:skills/paperclip/SKILL.md:proposing-credentials-safely:550 | optional_agent_tool | skills/paperclip/SKILL.md:550 |
-| skill:skills/paperclip/SKILL.md:reading-granted-secrets:557 | optional_agent_tool | skills/paperclip/SKILL.md:557 |
-| skill:skills/paperclip/SKILL.md:critical-rules:583 | optional_agent_tool | skills/paperclip/SKILL.md:583 |
-| skill:skills/paperclip/SKILL.md:comment-style-required:604 | always_agent_tool | skills/paperclip/SKILL.md:604 |
-| skill:skills/paperclip/SKILL.md:update:636 | optional_agent_tool | skills/paperclip/SKILL.md:636 |
-| skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:646 | optional_agent_tool | skills/paperclip/SKILL.md:646 |
-| skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:679 | optional_agent_tool | skills/paperclip/SKILL.md:679 |
-| skill:skills/paperclip/SKILL.md:searching-issues:708 | optional_agent_tool | skills/paperclip/SKILL.md:708 |
-| skill:skills/paperclip/SKILL.md:full-reference:718 | optional_agent_tool | skills/paperclip/SKILL.md:718 |
-| skill:skills/paperclip/SKILL.md:conversational-confirmation-answers:722 | always_agent_tool | skills/paperclip/SKILL.md:722 |
+| skill:skills/paperclip/SKILL.md:generated-artifacts-and-work-products:197 | always_agent_tool | skills/paperclip/SKILL.md:197 |
+| skill:skills/paperclip/SKILL.md:status-quick-guide:245 | control_plane_owned | skills/paperclip/SKILL.md:245 |
+| skill:skills/paperclip/SKILL.md:monitors-and-watchers-say-only-what-you-actually-scheduled:255 | optional_agent_tool | skills/paperclip/SKILL.md:255 |
+| skill:skills/paperclip/SKILL.md:delegating-review-tasks:268 | always_agent_tool | skills/paperclip/SKILL.md:268 |
+| skill:skills/paperclip/SKILL.md:managing-a-user-s-inbox:279 | control_plane_owned | skills/paperclip/SKILL.md:279 |
+| skill:skills/paperclip/SKILL.md:issue-dependencies-blockers:287 | control_plane_owned | skills/paperclip/SKILL.md:287 |
+| skill:skills/paperclip/SKILL.md:requesting-board-approval:312 | optional_agent_tool | skills/paperclip/SKILL.md:312 |
+| skill:skills/paperclip/SKILL.md:issue-thread-interactions:333 | optional_agent_tool | skills/paperclip/SKILL.md:333 |
+| skill:skills/paperclip/SKILL.md:standalone-decisions:362 | optional_agent_tool | skills/paperclip/SKILL.md:362 |
+| skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:466 | optional_agent_tool | skills/paperclip/SKILL.md:466 |
+| skill:skills/paperclip/SKILL.md:niche-workflow-pointers:508 | optional_agent_tool | skills/paperclip/SKILL.md:508 |
+| skill:skills/paperclip/SKILL.md:cases:518 | optional_agent_tool | skills/paperclip/SKILL.md:518 |
+| skill:skills/paperclip/SKILL.md:company-skills-workflow:523 | optional_agent_tool | skills/paperclip/SKILL.md:523 |
+| skill:skills/paperclip/SKILL.md:routines:534 | optional_agent_tool | skills/paperclip/SKILL.md:534 |
+| skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:545 | optional_agent_tool | skills/paperclip/SKILL.md:545 |
+| skill:skills/paperclip/SKILL.md:proposing-credentials-safely:552 | optional_agent_tool | skills/paperclip/SKILL.md:552 |
+| skill:skills/paperclip/SKILL.md:reading-granted-secrets:559 | optional_agent_tool | skills/paperclip/SKILL.md:559 |
+| skill:skills/paperclip/SKILL.md:critical-rules:585 | optional_agent_tool | skills/paperclip/SKILL.md:585 |
+| skill:skills/paperclip/SKILL.md:comment-style-required:606 | always_agent_tool | skills/paperclip/SKILL.md:606 |
+| skill:skills/paperclip/SKILL.md:update:638 | optional_agent_tool | skills/paperclip/SKILL.md:638 |
+| skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:648 | optional_agent_tool | skills/paperclip/SKILL.md:648 |
+| skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:681 | optional_agent_tool | skills/paperclip/SKILL.md:681 |
+| skill:skills/paperclip/SKILL.md:searching-issues:710 | optional_agent_tool | skills/paperclip/SKILL.md:710 |
+| skill:skills/paperclip/SKILL.md:full-reference:720 | optional_agent_tool | skills/paperclip/SKILL.md:720 |
+| skill:skills/paperclip/SKILL.md:conversational-confirmation-answers:724 | always_agent_tool | skills/paperclip/SKILL.md:724 |
| skill:skills/paperclip/references/artifacts.md:generated-artifacts-and-work-products:1 | always_agent_tool | skills/paperclip/references/artifacts.md:1 |
| skill:skills/paperclip/references/artifacts.md:workspace-only-file-references:15 | optional_agent_tool | skills/paperclip/references/artifacts.md:15 |
| skill:skills/paperclip/references/cases.md:cases:1 | optional_agent_tool | skills/paperclip/references/cases.md:1 |
diff --git a/packages/paperclip-runner/generated/capability/capabilities.yaml b/packages/paperclip-runner/generated/capability/capabilities.yaml
index 56d11b3f3d5..450446d4c76 100644
--- a/packages/paperclip-runner/generated/capability/capabilities.yaml
+++ b/packages/paperclip-runner/generated/capability/capabilities.yaml
@@ -56,225 +56,225 @@
"expectedMockState": "runtime_decision_record"
},
{
- "id": "skill:skills/paperclip/SKILL.md:195",
+ "id": "skill:skills/paperclip/SKILL.md:197",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L195:generated-artifacts-and-work-products",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L197:generated-artifacts-and-work-products",
"heading": "Generated Artifacts and Work Products",
"primaryDisposition": "always_agent_tool",
"semanticOperation": "register_deliverable",
"expectedMockState": "operation_result"
},
{
- "id": "skill:skills/paperclip/SKILL.md:243",
+ "id": "skill:skills/paperclip/SKILL.md:245",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L243:status-quick-guide",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L245:status-quick-guide",
"heading": "Status Quick Guide",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
- "id": "skill:skills/paperclip/SKILL.md:253",
+ "id": "skill:skills/paperclip/SKILL.md:255",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L253:monitors-and-watchers-say-only-what-you-actually-scheduled",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L255:monitors-and-watchers-say-only-what-you-actually-scheduled",
"heading": "Monitors and Watchers (say only what you actually scheduled)",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
- "id": "skill:skills/paperclip/SKILL.md:266",
+ "id": "skill:skills/paperclip/SKILL.md:268",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L266:delegating-review-tasks",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L268:delegating-review-tasks",
"heading": "Delegating review tasks",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
- "id": "skill:skills/paperclip/SKILL.md:277",
+ "id": "skill:skills/paperclip/SKILL.md:279",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L277:managing-a-user-s-inbox",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L279:managing-a-user-s-inbox",
"heading": "Managing A User's Inbox",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
- "id": "skill:skills/paperclip/SKILL.md:285",
+ "id": "skill:skills/paperclip/SKILL.md:287",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L285:issue-dependencies-blockers",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L287:issue-dependencies-blockers",
"heading": "Issue Dependencies (Blockers)",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
- "id": "skill:skills/paperclip/SKILL.md:310",
+ "id": "skill:skills/paperclip/SKILL.md:312",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L310:requesting-board-approval",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L312:requesting-board-approval",
"heading": "Requesting Board Approval",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
- "id": "skill:skills/paperclip/SKILL.md:331",
+ "id": "skill:skills/paperclip/SKILL.md:333",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L331:issue-thread-interactions",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L333:issue-thread-interactions",
"heading": "Issue-Thread Interactions",
"primaryDisposition": "always_agent_tool",
"semanticOperation": "request_human_input",
"expectedMockState": "operation_result"
},
{
- "id": "skill:skills/paperclip/SKILL.md:360",
+ "id": "skill:skills/paperclip/SKILL.md:362",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L360:standalone-decisions",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L362:standalone-decisions",
"heading": "Standalone Decisions",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
- "id": "skill:skills/paperclip/SKILL.md:464",
+ "id": "skill:skills/paperclip/SKILL.md:466",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L464:mcp-tool-approval-gates",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L466:mcp-tool-approval-gates",
"heading": "MCP Tool Approval Gates",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
- "id": "skill:skills/paperclip/SKILL.md:506",
+ "id": "skill:skills/paperclip/SKILL.md:508",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L506:niche-workflow-pointers",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L508:niche-workflow-pointers",
"heading": "Niche Workflow Pointers",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
- "id": "skill:skills/paperclip/SKILL.md:516",
+ "id": "skill:skills/paperclip/SKILL.md:518",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L516:cases",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L518:cases",
"heading": "Cases",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
- "id": "skill:skills/paperclip/SKILL.md:521",
+ "id": "skill:skills/paperclip/SKILL.md:523",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L521:company-skills-workflow",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L523:company-skills-workflow",
"heading": "Company Skills Workflow",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
- "id": "skill:skills/paperclip/SKILL.md:532",
+ "id": "skill:skills/paperclip/SKILL.md:534",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L532:routines",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L534:routines",
"heading": "Routines",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
- "id": "skill:skills/paperclip/SKILL.md:543",
+ "id": "skill:skills/paperclip/SKILL.md:545",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L543:issue-workspace-runtime-controls",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L545:issue-workspace-runtime-controls",
"heading": "Issue Workspace Runtime Controls",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
- "id": "skill:skills/paperclip/SKILL.md:550",
+ "id": "skill:skills/paperclip/SKILL.md:552",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L550:proposing-credentials-safely",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L552:proposing-credentials-safely",
"heading": "Proposing Credentials Safely",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
- "id": "skill:skills/paperclip/SKILL.md:557",
+ "id": "skill:skills/paperclip/SKILL.md:559",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L557:reading-granted-secrets",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L559:reading-granted-secrets",
"heading": "Reading Granted Secrets",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
- "id": "skill:skills/paperclip/SKILL.md:583",
+ "id": "skill:skills/paperclip/SKILL.md:585",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L583:critical-rules",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L585:critical-rules",
"heading": "Critical Rules",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
- "id": "skill:skills/paperclip/SKILL.md:604",
+ "id": "skill:skills/paperclip/SKILL.md:606",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L604:comment-style-required",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L606:comment-style-required",
"heading": "Comment Style (Required)",
"primaryDisposition": "always_agent_tool",
"semanticOperation": "report_progress",
"expectedMockState": "operation_result"
},
{
- "id": "skill:skills/paperclip/SKILL.md:636",
+ "id": "skill:skills/paperclip/SKILL.md:638",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L636:update",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L638:update",
"heading": "Update",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
- "id": "skill:skills/paperclip/SKILL.md:646",
+ "id": "skill:skills/paperclip/SKILL.md:648",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L646:planning-required-when-planning-requested",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L648:planning-required-when-planning-requested",
"heading": "Planning (Required when planning requested)",
"primaryDisposition": "always_agent_tool",
"semanticOperation": "write_document",
"expectedMockState": "operation_result"
},
{
- "id": "skill:skills/paperclip/SKILL.md:679",
+ "id": "skill:skills/paperclip/SKILL.md:681",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L679:key-endpoints-hot-routes",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L681:key-endpoints-hot-routes",
"heading": "Key Endpoints (Hot Routes)",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
- "id": "skill:skills/paperclip/SKILL.md:708",
+ "id": "skill:skills/paperclip/SKILL.md:710",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L708:searching-issues",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L710:searching-issues",
"heading": "Searching Issues",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
- "id": "skill:skills/paperclip/SKILL.md:718",
+ "id": "skill:skills/paperclip/SKILL.md:720",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L718:full-reference",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L720:full-reference",
"heading": "Full Reference",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
- "id": "skill:skills/paperclip/SKILL.md:722",
+ "id": "skill:skills/paperclip/SKILL.md:724",
"kind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md#L722:conversational-confirmation-answers",
+ "sourceAnchor": "skills/paperclip/SKILL.md#L724:conversational-confirmation-answers",
"heading": "Conversational confirmation answers",
"primaryDisposition": "always_agent_tool",
"semanticOperation": "call_api",
diff --git a/packages/paperclip-runner/generated/capability/capability-contract.md b/packages/paperclip-runner/generated/capability/capability-contract.md
index 254f1c40869..e49c3c958fc 100644
--- a/packages/paperclip-runner/generated/capability/capability-contract.md
+++ b/packages/paperclip-runner/generated/capability/capability-contract.md
@@ -5,6 +5,6 @@ Generated by `scripts/generate-capability-contract.mjs`; do not edit generated f
- Skill/reference headings: 158
- Legacy MCP tools: 42
- Eval cases: 106 across 16 groups
-- Deterministic content SHA-256: `60255e688c2b172a02979b4bd305ec089c9ad93b6d4eb4ffae59051a60170d7e`
+- Deterministic content SHA-256: `1868c1fc065aa9c16a4ee97ab33fc75abb5d94a7bc49b95f768e1490c9cd0344`
Every row has exactly one primary disposition, a source anchor, a semantic operation, and a mock-state expectation.
diff --git a/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json b/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json
index 388e842c3fb..3103a31e4cb 100644
--- a/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json
+++ b/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json
@@ -1 +1 @@
-[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create or update an active-task document with optimistic revision safety.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision id, or null when creating.","maxLength":20000,"type":["string","null"]},"body":{"description":"Markdown document body.","maxLength":200000,"minLength":1,"type":"string"},"changeSummary":{"description":"Optional revision summary.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"title":{"description":"Document title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","key","title","body","baseRevisionId"],"type":"object"},"name":"write_document","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_human_input","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a durable human question or approval card on the current Paperclip task bound to this run; Paperclip renders it and authenticates the response. Use questions with continuationPolicy='wake_assignee' when an answer is needed, including otherwise tool-free chat turns. Supply a stable idempotencyKey and reuse it on retries. For one question at a time, ask only the next unanswered question and wait for its real answer. Never fabricate answers or treat ambiguous clarification as approval. For an ordinary confirmation or checkbox card, record a clear user chat answer with call_api POST /api/issues/{id}/interactions/{interactionId}/resolve-from-comment, using the source commentId and decision (accept/reject), plus explicit selectedOptionIds for checkbox acceptance. Existing resolver permissions still apply; governed tool, secret, and connection approvals are excluded. Question forms retain their dedicated answer workflow. Preserve existing review gates. Call this tool before claiming a question was asked; if creation fails, report the failure. Do not fabricate answer links or Markdown buttons, post duplicate cards, or use call_api to create the card. Use payload.questions for choices and payload.questionSet for text fields; see the payload schema for formats.","inputSchema":{"additionalProperties":false,"properties":{"continuationPolicy":{"enum":["none","wake_assignee","wake_assignee_on_accept"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"interactionKind":{"enum":["confirmation","checkbox","questions","suggest_tasks","item_verdicts"]},"payload":{"additionalProperties":true,"description":"Kind-specific interaction data. For interactionKind='questions', use version:1 and questions:[{id,prompt,selectionMode:'single'|'multi',required?,options:[{id,label,description?,freeText?}]}]. Choice questions need at least two distinct meaningful options. For an open-ended text answer, ALSO include questionSet:{schema:'paperclip.question_set.v1',questions:[{id,prompt,answerMode:'text',required?}]} with no options or customAnswer in its text questions. Keep matching IDs/prompts in both arrays; the required compatibility questions entry uses selectionMode:'single' and options:[{id:'describe',label:'Your answer',freeText:true}]. Without questionSet this incorrectly renders as a one-option choice. Never use a lone Other or describe option as the presentation. Option keys are id/label, not value. For confirmation, payload may be {}. Keep IDs stable across retries.","type":"object"},"prompt":{"description":"Question or decision prompt.","maxLength":10000,"minLength":1,"type":"string"},"targetRevisionId":{"description":"Optional bound document revision.","maxLength":20000,"type":["string","null"]},"title":{"description":"Interaction card title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","interactionKind","title","prompt","continuationPolicy"],"type":"object"},"name":"request_human_input","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"register_deliverable","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Register attachment metadata and its artifact work product without credentials or bytes in the tool result.","inputSchema":{"additionalProperties":false,"properties":{"byteSize":{"maximum":100000000,"minimum":0,"type":"integer"},"contentRef":{"description":"Opaque package-local content reference.","maxLength":2000,"minLength":1,"type":"string"},"contentType":{"description":"Media type.","maxLength":200,"minLength":1,"type":"string"},"filename":{"description":"Display filename.","maxLength":500,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"sha256":{"pattern":"^[a-fA-F0-9]{64}$","type":"string"},"title":{"description":"Work-product title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","filename","contentType","byteSize","sha256","contentRef","title"],"type":"object"},"name":"register_deliverable","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"finish_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Finish the active task with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Completion summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"finish_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"block_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Block the active task with a durable reason and optional first-class dependencies.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Internal task ids that block this task.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Block reason.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","reason"],"type":"object"},"name":"block_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_review","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Move the active task to review with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Review handoff summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"request_review","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_agents","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List redacted actor profiles.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_agents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_agent","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one redacted actor profile.","inputSchema":{"additionalProperties":false,"properties":{"actorId":{"description":"Actor id.","maxLength":200,"minLength":1,"type":"string"}},"required":["actorId"],"type":"object"},"name":"get_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"hire_agent","requiredClaims":["delegation:agents:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a persistent native Runner teammate with an identity and persona. The teammate reports to the caller and inherits the caller's native runtime; provider, adapter, environment, and credential settings are selected by Paperclip and are never caller-supplied here. Reuse a suitable teammate from list_agents when possible.","inputSchema":{"additionalProperties":false,"properties":{"capabilities":{"maxLength":2000,"type":["string","null"]},"instructions":{"maxLength":20000,"type":["string","null"]},"name":{"maxLength":200,"minLength":1,"type":"string"},"role":{"enum":["ceo","cto","cmo","cfo","security","engineer","designer","pm","qa","devops","researcher","general"],"type":"string"},"title":{"maxLength":300,"type":["string","null"]}},"required":["name"],"type":"object"},"name":"hire_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_tasks","requiredClaims":["discovery:tasks:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Search tasks by text and status within the run company.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"},"query":{"maxLength":500,"type":"string"},"statuses":{"items":{"enum":["backlog","todo","in_progress","in_review","done","blocked","cancelled"]},"maxItems":7,"type":"array","uniqueItems":true}},"required":[],"type":"object"},"name":"search_tasks","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_approvals","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List approvals in the run company.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_approvals","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval without protected data.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval_context","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval, its comments, and linked tasks.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace_runtime","requiredClaims":["workspace:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read active-task workspace services.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace_runtime","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"control_workspace_service","requiredClaims":["workspace:control"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Start, stop, or fault one active-task workspace service.","inputSchema":{"additionalProperties":false,"properties":{"action":{"enum":["start","stop","fail"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"serviceId":{"description":"Workspace service id.","maxLength":200,"minLength":1,"type":"string"},"url":{"description":"Optional service URL.","maxLength":20000,"type":["string","null"]}},"required":["idempotencyKey","serviceId","action"],"type":"object"},"name":"control_workspace_service","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_dependencies","requiredClaims":["dependencies:write"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace the active task's first-class blocker set.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Replacement blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","blockedByTaskIds"],"type":"object"},"name":"set_dependencies","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_task","requiredClaims":["delegation:tasks:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution. Set status to backlog when the user wants to save or plan work without starting it; backlog tasks never wake an agent. Omitted status means todo, subject to blockers.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"Optional agent assignee. Omit to assign the current agent.","maxLength":20000,"type":["string","null"]},"blockedByTaskIds":{"description":"Initial blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"description":{"description":"Child task description.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"initialPlan":{"description":"Relevant markdown plan saved on the new task before execution starts.","maxLength":200000,"type":["string","null"]},"priority":{"enum":["critical","high","medium","low"]},"projectId":{"description":"Project ID for the task.","type":["string","null"]},"status":{"description":"Initial status. Use backlog to save work without executing it. Defaults to todo (blocked when dependencies are unresolved).","enum":["backlog","todo"]},"title":{"description":"Child task title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","title"],"type":"object"},"name":"create_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"},"task":{"additionalProperties":false,"properties":{"assigneeActorId":{"type":["string","null"]},"id":{"minLength":1,"type":"string"},"identifier":{"type":["string","null"]},"parentId":{"minLength":1,"type":["string","null"]},"projectId":{"minLength":1,"type":["string","null"]},"status":{"minLength":1,"type":"string"}},"required":["id","identifier","parentId","status","assigneeActorId"],"type":"object"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds","task"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"request_approval","requiredClaims":["governance:approvals:request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a governed approval and waiting posture.","inputSchema":{"additionalProperties":false,"properties":{"approvalType":{"description":"Stable approval type.","maxLength":200,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","approvalType","payload"],"type":"object"},"name":"request_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"decide_approval","requiredClaims":["governance:approvals:decide"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Decide an approval as an explicitly authorized approver.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"decision":{"enum":["approved","rejected","cancelled"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"note":{"description":"Decision note.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","decision","note"],"type":"object"},"name":"decide_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"comment_on_approval","requiredClaims":["governance:approvals:comment"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Add a durable comment to an approval.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"body":{"description":"Approval comment.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","body"],"type":"object"},"name":"comment_on_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"schedule_wake","requiredClaims":["control_plane:wakes"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule a deterministic continuation wake.","inputSchema":{"additionalProperties":false,"properties":{"delayTicks":{"maximum":10000,"minimum":1,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"reason":{"enum":["manual","issue_commented","interaction_resolved","approval_resolved","blockers_resolved","scheduled_retry","resume"]}},"required":["idempotencyKey","reason","delayTicks"],"type":"object"},"name":"schedule_wake","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"generic_api_request","requiredClaims":["test:generic_api_request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.","inputSchema":{"additionalProperties":false,"properties":{"body":{"additionalProperties":true,"type":"object"},"method":{"enum":["GET","POST","PATCH"]},"path":{"maxLength":500,"pattern":"^/mock/","type":"string"}},"required":["method","path"],"type":"object"},"name":"generic_api_request","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current canonical instruction entry and its revision, or inspect a historical revision by supplying both entryFile and revisionId. Read before updating; do not edit shared instruction caches.","inputSchema":{"additionalProperties":false,"properties":{"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"revisionId":{"description":"Historical revision to inspect; also provide its entryFile.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":[],"type":"object"},"name":"read_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_api","requiredClaims":["api:discover"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them. For a persistent hire, first list_agents to reuse a suitable teammate. Search agent-hires for the hiring schema and agent-configurations for compatible adapter/runtime settings, then use call_api with the returned operationId. Supply role-specific instructionsBundle.files as a filename-to-content record. Use the returned agent.id for delegation and obey any pending approval. Provider helper threads are temporary workers, not Paperclip hires. If a hire response is uncertain, reconcile with list_agents before retrying.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":200,"type":"string"},"limit":{"default":5,"maximum":10,"minimum":1,"type":"integer"},"query":{"maxLength":500,"minLength":1,"type":"string"}},"required":["query"],"type":"object"},"name":"search_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"update_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Commit instruction content with the exact entryFile and baseRevisionId from a prior read. Requires the responsible user’s current target edit permission. On conflict, preserve your candidate and explicitly resolve it; never overwrite the newer head.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Revision read before editing; null only when no canonical entry exists. Never replace a stale base silently.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":["string","null"]},"content":{"description":"Complete UTF-8 instruction content, at most 1 MiB; empty content is valid.","maxLength":1048576,"type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile","content","baseRevisionId"],"type":"object"},"name":"update_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"call_api","requiredClaims":["api:call"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.","inputSchema":{"additionalProperties":false,"properties":{"body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"items":{},"type":"array"},{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"}],"description":"Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."},"contentType":{"maxLength":120,"type":"string"},"files":{"items":{"additionalProperties":false,"oneOf":[{"properties":{"artifactId":{}},"required":["artifactId"]},{"properties":{"path":{}},"required":["path"]}],"properties":{"artifactId":{"type":"string"},"field":{"type":"string"},"path":{"description":"File relative to the active issue workspace. Remote files must first be uploaded as an artifact.","type":"string"}},"type":"object"},"maxItems":10,"type":"array"},"operationId":{"description":"Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.","maxLength":500,"minLength":1,"type":"string"},"pathParams":{"additionalProperties":{"type":"string"},"type":"object"},"query":{"additionalProperties":true,"type":"object"},"responseText":{"additionalProperties":false,"description":"GET only: return a bounded UTF-8 text window inline, including JSON as text, without saving another artifact. Offsets and limits are bytes. Use the returned nextOffsetBytes to continue; null means complete. Prefer reading a saved artifact for a stable snapshot. New live responses have a 1 GiB capture limit and a 4 GiB per-run capture budget. Existing larger assets remain readable in bounded pages. If a live response returns an artifact, continue on its content operation for a stable snapshot.","properties":{"limitBytes":{"default":24576,"maximum":24576,"minimum":4,"type":"integer"},"offsetBytes":{"default":0,"maximum":9007199254740991,"minimum":0,"type":"integer"}},"type":"object"}},"required":["operationId"],"type":"object"},"name":"call_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_agent_instruction_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List bounded canonical instruction revision metadata, including actor, source run, and restore origin. Use read_agent_instructions with entryFile and revisionId to inspect exact historical content.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":2048,"minLength":1,"type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"limit":{"maximum":100,"minimum":1,"type":"integer"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile"],"type":"object"},"name":"get_agent_instruction_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"restore_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a historical instruction revision as the current content using the current baseRevisionId. Requires the responsible user’s current target edit permission. History remains intact; conflicts require an explicit resolution.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision read before restoring. Never replace a stale base silently.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"revisionId":{"description":"Historical revision whose exact content should be restored.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile","revisionId","baseRevisionId"],"type":"object"},"name":"restore_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_project","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.","inputSchema":{"additionalProperties":false,"properties":{"archivedAt":{"description":"Archive timestamp.","maxLength":20000,"type":["string","null"]},"color":{"description":"Project color.","maxLength":20000,"type":["string","null"]},"description":{"description":"Project outcome and context.","maxLength":20000,"type":["string","null"]},"env":{"additionalProperties":true,"type":"object"},"executionWorkspacePolicy":{"additionalProperties":true,"type":"object"},"goalId":{"description":"Goal ID.","maxLength":20000,"type":["string","null"]},"goalIds":{"description":"Goal IDs.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"icon":{"description":"Project icon.","enum":["folder","rocket","code","terminal","database","globe","package","boxes","box","layers","briefcase","compass","target","flame","zap","star","bug","wrench","hammer","lightbulb","sparkles","shield","lock","search","cog","brain","cpu","git-branch","file-code","puzzle","gem","atom","heart","mail","message-square","crown","radar","telescope","hexagon",null],"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"leadAgentId":{"description":"Lead agent ID.","maxLength":20000,"type":["string","null"]},"name":{"description":"Project name.","maxLength":500,"minLength":1,"type":"string"},"repositoryIds":{"description":"Authorized repository IDs from list_project_repositories; may contain multiple repositories.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"repositoryUrls":{"description":"Existing HTTPS GitHub repository URLs, including repos absent from the catalog.","items":{"maxLength":2000,"pattern":"^https://github\\.com/(?!\\.{1,2}/)[A-Za-z0-9_.-]+/(?!\\.{1,2}/?$)[A-Za-z0-9_.-]+/?$","type":"string"},"maxItems":100,"type":"array"},"status":{"enum":["backlog","planned","in_progress","completed","cancelled"]},"targetDate":{"description":"Target date.","maxLength":20000,"type":["string","null"]},"workspace":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","name"],"type":"object"},"name":"create_project","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_project_repositories","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_project_repositories","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_projects","requiredClaims":["discovery:projects:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Inspect available company projects before selecting a project for new work.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_projects","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a reusable single-file skill in the company library. Supply a complete SKILL.md whose name and description match the inputs. This saves the skill and shows a card; it does not assign the skill to any agent. Reuse idempotencyKey on retries.","inputSchema":{"additionalProperties":false,"properties":{"description":{"maxLength":2000,"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"description":"Complete SKILL.md including name and description frontmatter and substantive instructions.","maxLength":200000,"minLength":1,"type":"string"},"name":{"description":"Lowercase skill name, matching SKILL.md frontmatter.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"},"slug":{"description":"Optional; must equal name.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"}},"required":["idempotencyKey","name","description","markdown"],"type":"object"},"name":"create_skill","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"reassign_task","requiredClaims":["delegation:tasks:assign"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Reassign an existing task to another company agent. Read search_tasks first and supply its current assignee and statusVersion to prevent overwriting a concurrent change. Preserve the task, documents, dependencies, and blocked/backlog status. Active work is stopped before handoff. Use a stable idempotency key for retries. Cannot reassign this run’s own task, conversations, completed tasks, or pending reviews; use create_task for delegation from the current task.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"New company agent ID from list_agents.","minLength":1,"type":"string"},"expectedAssigneeActorId":{"description":"Current assigneeAgentId from search_tasks; null means unassigned.","type":["string","null"]},"expectedStatusVersion":{"description":"Current statusVersion from search_tasks.","minimum":0,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Why the task should move and context for the new owner.","maxLength":20000,"minLength":1,"type":"string"},"taskId":{"description":"Existing task ID from search_tasks.","minLength":1,"type":"string"}},"required":["idempotencyKey","taskId","assigneeActorId","expectedAssigneeActorId","expectedStatusVersion","reason"],"type":"object"},"name":"reassign_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}}]
+[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create or update an active-task document with optimistic revision safety.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision id, or null when creating.","maxLength":20000,"type":["string","null"]},"body":{"description":"Markdown document body.","maxLength":200000,"minLength":1,"type":"string"},"changeSummary":{"description":"Optional revision summary.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"title":{"description":"Document title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","key","title","body","baseRevisionId"],"type":"object"},"name":"write_document","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_human_input","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a durable human question or approval card on the current Paperclip task bound to this run; Paperclip renders it and authenticates the response. Use questions with continuationPolicy='wake_assignee' when an answer is needed, including otherwise tool-free chat turns. Supply a stable idempotencyKey and reuse it on retries. For one question at a time, ask only the next unanswered question and wait for its real answer. Never fabricate answers or treat ambiguous clarification as approval. For an ordinary confirmation or checkbox card, record a clear user chat answer with call_api POST /api/issues/{id}/interactions/{interactionId}/resolve-from-comment, using the source commentId and decision (accept/reject), plus explicit selectedOptionIds for checkbox acceptance. Existing resolver permissions still apply; governed tool, secret, and connection approvals are excluded. Question forms retain their dedicated answer workflow. Preserve existing review gates. Call this tool before claiming a question was asked; if creation fails, report the failure. Do not fabricate answer links or Markdown buttons, post duplicate cards, or use call_api to create the card. Use payload.questions for choices and payload.questionSet for text fields; see the payload schema for formats.","inputSchema":{"additionalProperties":false,"properties":{"continuationPolicy":{"enum":["none","wake_assignee","wake_assignee_on_accept"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"interactionKind":{"enum":["confirmation","checkbox","questions","suggest_tasks","item_verdicts"]},"payload":{"additionalProperties":true,"description":"Kind-specific interaction data. For interactionKind='questions', use version:1 and questions:[{id,prompt,selectionMode:'single'|'multi',required?,options:[{id,label,description?,freeText?}]}]. Choice questions need at least two distinct meaningful options. For an open-ended text answer, ALSO include questionSet:{schema:'paperclip.question_set.v1',questions:[{id,prompt,answerMode:'text',required?}]} with no options or customAnswer in its text questions. Keep matching IDs/prompts in both arrays; the required compatibility questions entry uses selectionMode:'single' and options:[{id:'describe',label:'Your answer',freeText:true}]. Without questionSet this incorrectly renders as a one-option choice. Never use a lone Other or describe option as the presentation. Option keys are id/label, not value. For confirmation, payload may be {}. Keep IDs stable across retries.","type":"object"},"prompt":{"description":"Question or decision prompt.","maxLength":10000,"minLength":1,"type":"string"},"targetRevisionId":{"description":"Optional bound document revision.","maxLength":20000,"type":["string","null"]},"title":{"description":"Interaction card title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","interactionKind","title","prompt","continuationPolicy"],"type":"object"},"name":"request_human_input","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"register_deliverable","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Register attachment metadata and its artifact work product without credentials or bytes in the tool result.","inputSchema":{"additionalProperties":false,"properties":{"byteSize":{"maximum":100000000,"minimum":0,"type":"integer"},"contentRef":{"description":"Opaque package-local content reference.","maxLength":2000,"minLength":1,"type":"string"},"contentType":{"description":"Media type.","maxLength":200,"minLength":1,"type":"string"},"filename":{"description":"Display filename.","maxLength":500,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"sha256":{"pattern":"^[a-fA-F0-9]{64}$","type":"string"},"title":{"description":"Work-product title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","filename","contentType","byteSize","sha256","contentRef","title"],"type":"object"},"name":"register_deliverable","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"finish_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Finish the active task with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Completion summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"finish_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"block_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Block the active task with a durable reason and optional first-class dependencies.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Internal task ids that block this task.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Block reason.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","reason"],"type":"object"},"name":"block_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_review","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Move the active task to review with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Review handoff summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"request_review","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_agents","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List redacted actor profiles.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_agents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_agent","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one redacted actor profile.","inputSchema":{"additionalProperties":false,"properties":{"actorId":{"description":"Actor id.","maxLength":200,"minLength":1,"type":"string"}},"required":["actorId"],"type":"object"},"name":"get_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"hire_agent","requiredClaims":["delegation:agents:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a persistent native Runner teammate with an identity and persona. The teammate reports to the caller and inherits the caller's native runtime; provider, adapter, environment, and credential settings are selected by Paperclip and are never caller-supplied here. Reuse a suitable teammate from list_agents when possible.","inputSchema":{"additionalProperties":false,"properties":{"capabilities":{"maxLength":2000,"type":["string","null"]},"instructions":{"maxLength":20000,"type":["string","null"]},"name":{"maxLength":200,"minLength":1,"type":"string"},"role":{"enum":["ceo","cto","cmo","cfo","security","engineer","designer","pm","qa","devops","researcher","general"],"type":"string"},"title":{"maxLength":300,"type":["string","null"]}},"required":["name"],"type":"object"},"name":"hire_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_tasks","requiredClaims":["discovery:tasks:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Search tasks by text and status within the run company.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"},"query":{"maxLength":500,"type":"string"},"statuses":{"items":{"enum":["backlog","todo","in_progress","in_review","done","blocked","cancelled"]},"maxItems":7,"type":"array","uniqueItems":true}},"required":[],"type":"object"},"name":"search_tasks","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_approvals","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List approvals in the run company.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_approvals","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval without protected data.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval_context","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval, its comments, and linked tasks.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace_runtime","requiredClaims":["workspace:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read active-task workspace services.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace_runtime","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"control_workspace_service","requiredClaims":["workspace:control"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Start, stop, or fault one active-task workspace service.","inputSchema":{"additionalProperties":false,"properties":{"action":{"enum":["start","stop","fail"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"serviceId":{"description":"Workspace service id.","maxLength":200,"minLength":1,"type":"string"},"url":{"description":"Optional service URL.","maxLength":20000,"type":["string","null"]}},"required":["idempotencyKey","serviceId","action"],"type":"object"},"name":"control_workspace_service","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_dependencies","requiredClaims":["dependencies:write"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace the active task's first-class blocker set.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Replacement blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","blockedByTaskIds"],"type":"object"},"name":"set_dependencies","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_task","requiredClaims":["delegation:tasks:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution. Set status to backlog when the user wants to save or plan work without starting it; backlog tasks never wake an agent. Omitted status means todo, subject to blockers.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"Optional agent assignee. Omit to assign the current agent.","maxLength":20000,"type":["string","null"]},"blockedByTaskIds":{"description":"Initial blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"description":{"description":"Child task description.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"initialPlan":{"description":"Relevant markdown plan saved on the new task before execution starts.","maxLength":200000,"type":["string","null"]},"priority":{"enum":["critical","high","medium","low"]},"projectId":{"description":"Project ID for the task.","type":["string","null"]},"status":{"description":"Initial status. Use backlog to save work without executing it. Defaults to todo (blocked when dependencies are unresolved).","enum":["backlog","todo"]},"title":{"description":"Child task title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","title"],"type":"object"},"name":"create_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"},"task":{"additionalProperties":false,"properties":{"assigneeActorId":{"type":["string","null"]},"id":{"minLength":1,"type":"string"},"identifier":{"type":["string","null"]},"parentId":{"minLength":1,"type":["string","null"]},"projectId":{"minLength":1,"type":["string","null"]},"status":{"minLength":1,"type":"string"}},"required":["id","identifier","parentId","status","assigneeActorId"],"type":"object"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds","task"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"request_approval","requiredClaims":["governance:approvals:request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a governed approval and waiting posture.","inputSchema":{"additionalProperties":false,"properties":{"approvalType":{"description":"Stable approval type.","maxLength":200,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","approvalType","payload"],"type":"object"},"name":"request_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"decide_approval","requiredClaims":["governance:approvals:decide"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Decide an approval as an explicitly authorized approver.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"decision":{"enum":["approved","rejected","cancelled"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"note":{"description":"Decision note.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","decision","note"],"type":"object"},"name":"decide_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"comment_on_approval","requiredClaims":["governance:approvals:comment"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Add a durable comment to an approval.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"body":{"description":"Approval comment.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","body"],"type":"object"},"name":"comment_on_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"schedule_wake","requiredClaims":["control_plane:wakes"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule a deterministic continuation wake.","inputSchema":{"additionalProperties":false,"properties":{"delayTicks":{"maximum":10000,"minimum":1,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"reason":{"enum":["manual","issue_commented","interaction_resolved","approval_resolved","blockers_resolved","scheduled_retry","resume"]}},"required":["idempotencyKey","reason","delayTicks"],"type":"object"},"name":"schedule_wake","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"generic_api_request","requiredClaims":["test:generic_api_request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.","inputSchema":{"additionalProperties":false,"properties":{"body":{"additionalProperties":true,"type":"object"},"method":{"enum":["GET","POST","PATCH"]},"path":{"maxLength":500,"pattern":"^/mock/","type":"string"}},"required":["method","path"],"type":"object"},"name":"generic_api_request","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current canonical instruction entry and its revision, or inspect a historical revision by supplying both entryFile and revisionId. Read before updating; do not edit shared instruction caches.","inputSchema":{"additionalProperties":false,"properties":{"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"revisionId":{"description":"Historical revision to inspect; also provide its entryFile.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":[],"type":"object"},"name":"read_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_api","requiredClaims":["api:discover"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them. For a persistent hire, first list_agents to reuse a suitable teammate. Search agent-hires for the hiring schema and agent-configurations for compatible adapter/runtime settings, then use call_api with the returned operationId. Supply role-specific instructionsBundle.files as a filename-to-content record. Use the returned agent.id for delegation and obey any pending approval. Provider helper threads are temporary workers, not Paperclip hires. If a hire response is uncertain, reconcile with list_agents before retrying.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":200,"type":"string"},"limit":{"default":5,"maximum":10,"minimum":1,"type":"integer"},"query":{"maxLength":500,"minLength":1,"type":"string"}},"required":["query"],"type":"object"},"name":"search_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"update_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Commit instruction content with the exact entryFile and baseRevisionId from a prior read. Requires the responsible user’s current target edit permission. On conflict, preserve your candidate and explicitly resolve it; never overwrite the newer head.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Revision read before editing; null only when no canonical entry exists. Never replace a stale base silently.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":["string","null"]},"content":{"description":"Complete UTF-8 instruction content, at most 1 MiB; empty content is valid.","maxLength":1048576,"type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile","content","baseRevisionId"],"type":"object"},"name":"update_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"call_api","requiredClaims":["api:call"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.","inputSchema":{"additionalProperties":false,"properties":{"body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"items":{},"type":"array"},{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"}],"description":"Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."},"contentType":{"maxLength":120,"type":"string"},"files":{"items":{"additionalProperties":false,"oneOf":[{"properties":{"artifactId":{}},"required":["artifactId"]},{"properties":{"path":{}},"required":["path"]}],"properties":{"artifactId":{"type":"string"},"field":{"type":"string"},"path":{"description":"File relative to the active issue workspace. Remote files must first be uploaded as an artifact.","type":"string"}},"type":"object"},"maxItems":10,"type":"array"},"operationId":{"description":"Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.","maxLength":500,"minLength":1,"type":"string"},"pathParams":{"additionalProperties":{"type":"string"},"type":"object"},"query":{"additionalProperties":true,"type":"object"},"responseText":{"additionalProperties":false,"description":"GET only: return a bounded UTF-8 text window inline, including JSON as text, without saving another artifact. Offsets and limits are bytes. Use the returned nextOffsetBytes to continue; null means complete. Prefer reading a saved artifact for a stable snapshot. New live responses have a 1 GiB capture limit and a 4 GiB per-run capture budget. Existing larger assets remain readable in bounded pages. If a live response returns an artifact, continue on its content operation for a stable snapshot.","properties":{"limitBytes":{"default":24576,"maximum":24576,"minimum":4,"type":"integer"},"offsetBytes":{"default":0,"maximum":9007199254740991,"minimum":0,"type":"integer"}},"type":"object"}},"required":["operationId"],"type":"object"},"name":"call_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_agent_instruction_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List bounded canonical instruction revision metadata, including actor, source run, and restore origin. Use read_agent_instructions with entryFile and revisionId to inspect exact historical content.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":2048,"minLength":1,"type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"limit":{"maximum":100,"minimum":1,"type":"integer"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile"],"type":"object"},"name":"get_agent_instruction_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"restore_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a historical instruction revision as the current content using the current baseRevisionId. Requires the responsible user’s current target edit permission. History remains intact; conflicts require an explicit resolution.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision read before restoring. Never replace a stale base silently.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"revisionId":{"description":"Historical revision whose exact content should be restored.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile","revisionId","baseRevisionId"],"type":"object"},"name":"restore_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_project","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.","inputSchema":{"additionalProperties":false,"properties":{"archivedAt":{"description":"Archive timestamp.","maxLength":20000,"type":["string","null"]},"color":{"description":"Project color.","maxLength":20000,"type":["string","null"]},"description":{"description":"Project outcome and context.","maxLength":20000,"type":["string","null"]},"env":{"additionalProperties":true,"type":"object"},"executionWorkspacePolicy":{"additionalProperties":true,"type":"object"},"goalId":{"description":"Goal ID.","maxLength":20000,"type":["string","null"]},"goalIds":{"description":"Goal IDs.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"icon":{"description":"Project icon.","enum":["folder","rocket","code","terminal","database","globe","package","boxes","box","layers","briefcase","compass","target","flame","zap","star","bug","wrench","hammer","lightbulb","sparkles","shield","lock","search","cog","brain","cpu","git-branch","file-code","puzzle","gem","atom","heart","mail","message-square","crown","radar","telescope","hexagon",null],"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"leadAgentId":{"description":"Lead agent ID.","maxLength":20000,"type":["string","null"]},"name":{"description":"Project name.","maxLength":500,"minLength":1,"type":"string"},"repositoryIds":{"description":"Authorized repository IDs from list_project_repositories; may contain multiple repositories.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"repositoryUrls":{"description":"Existing HTTPS GitHub repository URLs, including repos absent from the catalog.","items":{"maxLength":2000,"pattern":"^https://github\\.com/(?!\\.{1,2}/)[A-Za-z0-9_.-]+/(?!\\.{1,2}/?$)[A-Za-z0-9_.-]+/?$","type":"string"},"maxItems":100,"type":"array"},"status":{"enum":["backlog","planned","in_progress","completed","cancelled"]},"targetDate":{"description":"Target date.","maxLength":20000,"type":["string","null"]},"workspace":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","name"],"type":"object"},"name":"create_project","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_project_repositories","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_project_repositories","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_projects","requiredClaims":["discovery:projects:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Inspect available company projects before selecting a project for new work.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_projects","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a reusable single-file skill in the company library. Supply a complete SKILL.md whose name and description match the inputs. This saves the skill and shows a card; it does not assign the skill to any agent. Reuse idempotencyKey on retries.","inputSchema":{"additionalProperties":false,"properties":{"description":{"maxLength":2000,"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"description":"Complete SKILL.md including name and description frontmatter and substantive instructions.","maxLength":200000,"minLength":1,"type":"string"},"name":{"description":"Lowercase skill name, matching SKILL.md frontmatter.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"},"slug":{"description":"Optional; must equal name.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"}},"required":["idempotencyKey","name","description","markdown"],"type":"object"},"name":"create_skill","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"reassign_task","requiredClaims":["delegation:tasks:assign"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Reassign an existing task to another company agent. Read search_tasks first and supply its current assignee and statusVersion to prevent overwriting a concurrent change. Preserve the task, documents, dependencies, and blocked/backlog status. Active work is stopped before handoff. Use a stable idempotency key for retries. Cannot reassign this run’s own task, conversations, completed tasks, or pending reviews; use create_task for delegation from the current task.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"New company agent ID from list_agents.","minLength":1,"type":"string"},"expectedAssigneeActorId":{"description":"Current assigneeAgentId from search_tasks; null means unassigned.","type":["string","null"]},"expectedStatusVersion":{"description":"Current statusVersion from search_tasks.","minimum":0,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Why the task should move and context for the new owner.","maxLength":20000,"minLength":1,"type":"string"},"taskId":{"description":"Existing task ID from search_tasks.","minLength":1,"type":"string"}},"required":["idempotencyKey","taskId","assigneeActorId","expectedAssigneeActorId","expectedStatusVersion","reason"],"type":"object"},"name":"reassign_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_task_title","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Set a concise, descriptive title for the active task. When its titleNeedsGeneration is true, call this early with onlyIfProvisional: true to replace the initial prompt slice without overwriting a user's title. Use false only for an intentional rename. This changes no task status, ownership, or description.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Reuse this key on retries.","maxLength":240,"minLength":1,"type":"string"},"onlyIfProvisional":{"description":"True for automatic initial naming; preserves any title already chosen by a user or agent.","type":"boolean"},"title":{"description":"Short title describing the requested outcome.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","title","onlyIfProvisional"],"type":"object"},"name":"set_task_title","outputSchema":{"additionalProperties":true,"type":"object"}}]
diff --git a/packages/paperclip-runner/generated/semantic-action-catalog.json b/packages/paperclip-runner/generated/semantic-action-catalog.json
index adeb3bb5332..e506abb3718 100644
--- a/packages/paperclip-runner/generated/semantic-action-catalog.json
+++ b/packages/paperclip-runner/generated/semantic-action-catalog.json
@@ -399,6 +399,53 @@
"title": "List document revisions",
"version": 1
},
+ {
+ "allowedModes": [
+ "standard",
+ "ask",
+ "planning",
+ "skill_test"
+ ],
+ "description": "Set a concise, descriptive title for the active task. When its titleNeedsGeneration is true, call this early with onlyIfProvisional: true to replace the initial prompt slice without overwriting a user's title. Use false only for an intentional rename. This changes no task status, ownership, or description.",
+ "effect": "write",
+ "inputSchema": {
+ "additionalProperties": false,
+ "properties": {
+ "idempotencyKey": {
+ "description": "Reuse this key on retries.",
+ "maxLength": 240,
+ "minLength": 1,
+ "type": "string"
+ },
+ "onlyIfProvisional": {
+ "description": "True for automatic initial naming; preserves any title already chosen by a user or agent.",
+ "type": "boolean"
+ },
+ "title": {
+ "description": "Short title describing the requested outcome.",
+ "maxLength": 240,
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "required": [
+ "idempotencyKey",
+ "title",
+ "onlyIfProvisional"
+ ],
+ "type": "object"
+ },
+ "operationId": "set_task_title",
+ "outputSchema": {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ "placement": "optional",
+ "requiredClaims": [],
+ "schema": "paperclip.semantic-action.v1",
+ "title": "Set task title",
+ "version": 1
+ },
{
"allowedModes": [
"standard",
diff --git a/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json b/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json
index 98f7e9ea5c3..0c6a704d544 100644
--- a/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json
+++ b/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json
@@ -24,7 +24,7 @@
"prpVersion": 1,
"nativeExecutionVersion": 1,
"catalogVersion": 1,
- "catalogSha256": "sha256:994cf391ef851c37a4815db38368aa97bd3830def50218b2ffe9428966fc994f",
+ "catalogSha256": "sha256:8a0dd1013634b17288a63f745782783d086c71f7c50881c9133534e73e372627",
"driverContractVersion": 1,
"driverKind": "paperclip-deterministic",
"driverVersion": "1.0.0"
diff --git a/packages/paperclip-runner/protocol/manifest.json b/packages/paperclip-runner/protocol/manifest.json
index 41febfb9b1b..d975a0ebafe 100644
--- a/packages/paperclip-runner/protocol/manifest.json
+++ b/packages/paperclip-runner/protocol/manifest.json
@@ -160,7 +160,7 @@
},
{
"path": "fixtures/evals/native-execution-seeded.json",
- "sha256": "076427778f1ecced927c0680ab3d59e52e06d3e680a610bee3af6fd0795b6a8c",
+ "sha256": "a0f8a3804b062746cdd893d5713fb714ef137f6ef30e37102a4b1bcb7cf66ec5",
"expectation": "accept",
"compatibilityCase": "canonical"
},
diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs
index 2a41066875c..004eb555ea6 100644
--- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs
+++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs
@@ -1710,6 +1710,7 @@ pub(crate) fn sanitize_semantic_tool_input(
}
let mut checked = sanitize_bounded_display_value(input);
let prose_fields: &[&str] = match operation_id {
+ "set_task_title" => &["title"],
"create_task" => &["title", "description", "initialPlan"],
"create_project" => &["name", "description"],
"write_document" => &["title", "body", "changeSummary"],
@@ -2244,6 +2245,77 @@ fn redact_sensitive_text_values_with_context(input: &str, semantic_prose: bool)
]
.iter()
.any(|lead| token_phrase_has_lead(lead));
+ // Maintenance prose uses a closed vocabulary, including common task
+ // artifacts and environment qualifiers. Validate the entire suffix:
+ // recognizing only the first noun could hide an opaque credential after
+ // it. Unknown words, punctuation, assignments and CLI forms still fail
+ // closed. Known credential formats also use the independent scanners.
+ let is_semantic_key_maintenance = semantic_prose
+ && key == "api key"
+ && !key_is_compound
+ && (start == 0 || bytes[start - 1].is_ascii_whitespace())
+ && whitespace_start == start + key.len()
+ && separator > whitespace_start
+ && !has_assignment_separator
+ && bytes[whitespace_start..separator]
+ .iter()
+ .all(|value| matches!(value, b' ' | b'\t'))
+ && ["rotation", "replacement", "renewal", "expiration"]
+ .iter()
+ .any(|noun| {
+ normalized[separator..]
+ .strip_prefix(noun)
+ .is_some_and(|suffix| {
+ (suffix.is_empty() || suffix.starts_with(' ') || suffix == ".")
+ && !suffix.contains(['\n', '\r', '\t'])
+ && suffix
+ .strip_suffix('.')
+ .unwrap_or(suffix)
+ .split(' ')
+ .filter(|word| !word.is_empty())
+ .all(|word| {
+ matches!(
+ word,
+ "a" | "an"
+ | "the"
+ | "for"
+ | "in"
+ | "of"
+ | "and"
+ | "with"
+ | "checklist"
+ | "handover"
+ | "plan"
+ | "guide"
+ | "schedule"
+ | "steps"
+ | "staging"
+ | "production"
+ | "development"
+ | "test"
+ | "expired"
+ | "new"
+ | "old"
+ | "safe"
+ | "secure"
+ | "short"
+ | "internal"
+ | "teammate"
+ | "routine"
+ | "maintenance"
+ | "service"
+ | "access"
+ | "api"
+ | "key"
+ | "keys"
+ | "rotation"
+ | "replacement"
+ | "renewal"
+ | "expiration"
+ )
+ })
+ })
+ });
let is_benign_token_noun_phrase = key == "token"
&& (!key_is_compound || has_hyphenated_count_lead)
&& whitespace_start == start + key.len()
@@ -2307,7 +2379,8 @@ fn redact_sensitive_text_values_with_context(input: &str, semantic_prose: bool)
let has_whitespace_separator = separator > whitespace_start
&& (key != "authorization" || key_is_compound || has_authorization_scheme)
&& !is_benign_token_noun_phrase
- && !is_semantic_token_reference;
+ && !is_semantic_token_reference
+ && !is_semantic_key_maintenance;
if !has_assignment_separator && !has_whitespace_separator {
continue;
}
@@ -3461,6 +3534,86 @@ mod tests {
);
}
+ #[test]
+ fn task_title_preserves_maintenance_prose_but_rejects_credentials() {
+ for title in [
+ "API key rotation checklist",
+ "Plan API key replacement",
+ "Document API key renewal",
+ "Explain API key expiration",
+ "API key rotation schedule",
+ "API key replacement for staging",
+ "API key rotation checklist for an expired key",
+ ] {
+ let input = json!({"title": title, "onlyIfProvisional": true, "idempotencyKey": "initial-title"});
+ assert_eq!(
+ sanitize_semantic_tool_input("set_task_title", &input).unwrap(),
+ input
+ );
+ assert_ne!(
+ redact_text(title),
+ title,
+ "diagnostics retain strict redaction"
+ );
+ }
+ for title in [
+ "API key opaque-credential",
+ "API key=rotation",
+ "API key: rotation",
+ "API key \"rotation\"",
+ "--api key rotation",
+ "OPENAI_API_KEY rotation",
+ "API key rotation-secret",
+ "API key rotation opaque-credential",
+ "API key replacement ABCDEFG123456789",
+ "API key renewal checklist opaque-credential",
+ "API key expiration\nopaque-credential",
+ "API key rotation sk-proj-secretvalue123456",
+ "API key rotation Authorization: Bearer opaque-credential",
+ ] {
+ assert!(
+ sanitize_semantic_tool_input("set_task_title", &json!({"title": title})).is_err(),
+ "{title}"
+ );
+ }
+ }
+
+ #[test]
+ fn maintenance_prose_rejects_opaque_suffixes_in_every_declared_field() {
+ for (operation, field) in [
+ ("create_task", "title"),
+ ("create_task", "description"),
+ ("create_task", "initialPlan"),
+ ("create_project", "name"),
+ ("create_project", "description"),
+ ("write_document", "title"),
+ ("write_document", "body"),
+ ("write_document", "changeSummary"),
+ ("update_agent_instructions", "content"),
+ ] {
+ for text in [
+ "API key rotation",
+ "API key rotation schedule",
+ "API key replacement for staging",
+ ] {
+ let input = json!({field: text});
+ assert_eq!(
+ sanitize_semantic_tool_input(operation, &input).unwrap(),
+ input
+ );
+ }
+ for text in [
+ "API key rotation opaque-credential",
+ "API key renewal checklist ABCDEFG123456789",
+ ] {
+ assert!(
+ sanitize_semantic_tool_input(operation, &json!({field: text})).is_err(),
+ "{operation}.{field}: {text}"
+ );
+ }
+ }
+ }
+
#[test]
fn semantic_credentials_are_rejected_instead_of_executing_redacted_arguments() {
for input in [
diff --git a/packages/paperclip-runner/spec/capability/capabilities.yaml b/packages/paperclip-runner/spec/capability/capabilities.yaml
index 2f8db85bd96..5ec6b6501d7 100644
--- a/packages/paperclip-runner/spec/capability/capabilities.yaml
+++ b/packages/paperclip-runner/spec/capability/capabilities.yaml
@@ -104,9 +104,9 @@
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:generated-artifacts-and-work-products:195",
+ "id": "skill:skills/paperclip/SKILL.md:generated-artifacts-and-work-products:197",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:195",
+ "sourceAnchor": "skills/paperclip/SKILL.md:197",
"title": "Generated Artifacts and Work Products",
"expectedSemantics": "Skill guidance headed “Generated Artifacts and Work Products”.",
"primaryDisposition": "always_agent_tool",
@@ -115,13 +115,13 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:195"
+ "skill:skills/paperclip/SKILL.md:197"
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:status-quick-guide:243",
+ "id": "skill:skills/paperclip/SKILL.md:status-quick-guide:245",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:243",
+ "sourceAnchor": "skills/paperclip/SKILL.md:245",
"title": "Status Quick Guide",
"expectedSemantics": "Skill guidance headed “Status Quick Guide”.",
"primaryDisposition": "control_plane_owned",
@@ -130,13 +130,13 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:243"
+ "skill:skills/paperclip/SKILL.md:245"
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:monitors-and-watchers-say-only-what-you-actually-scheduled:253",
+ "id": "skill:skills/paperclip/SKILL.md:monitors-and-watchers-say-only-what-you-actually-scheduled:255",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:253",
+ "sourceAnchor": "skills/paperclip/SKILL.md:255",
"title": "Monitors and Watchers (say only what you actually scheduled)",
"expectedSemantics": "Skill guidance headed “Monitors and Watchers (say only what you actually scheduled)”.",
"primaryDisposition": "optional_agent_tool",
@@ -145,13 +145,13 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:253"
+ "skill:skills/paperclip/SKILL.md:255"
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:delegating-review-tasks:266",
+ "id": "skill:skills/paperclip/SKILL.md:delegating-review-tasks:268",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:266",
+ "sourceAnchor": "skills/paperclip/SKILL.md:268",
"title": "Delegating review tasks",
"expectedSemantics": "Skill guidance headed “Delegating review tasks”.",
"primaryDisposition": "always_agent_tool",
@@ -160,13 +160,13 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:266"
+ "skill:skills/paperclip/SKILL.md:268"
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:managing-a-user-s-inbox:277",
+ "id": "skill:skills/paperclip/SKILL.md:managing-a-user-s-inbox:279",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:277",
+ "sourceAnchor": "skills/paperclip/SKILL.md:279",
"title": "Managing A User's Inbox",
"expectedSemantics": "Skill guidance headed “Managing A User's Inbox”.",
"primaryDisposition": "control_plane_owned",
@@ -175,13 +175,13 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:277"
+ "skill:skills/paperclip/SKILL.md:279"
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:issue-dependencies-blockers:285",
+ "id": "skill:skills/paperclip/SKILL.md:issue-dependencies-blockers:287",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:285",
+ "sourceAnchor": "skills/paperclip/SKILL.md:287",
"title": "Issue Dependencies (Blockers)",
"expectedSemantics": "Skill guidance headed “Issue Dependencies (Blockers)”.",
"primaryDisposition": "control_plane_owned",
@@ -190,13 +190,13 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:285"
+ "skill:skills/paperclip/SKILL.md:287"
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:requesting-board-approval:310",
+ "id": "skill:skills/paperclip/SKILL.md:requesting-board-approval:312",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:310",
+ "sourceAnchor": "skills/paperclip/SKILL.md:312",
"title": "Requesting Board Approval",
"expectedSemantics": "Skill guidance headed “Requesting Board Approval”.",
"primaryDisposition": "optional_agent_tool",
@@ -205,13 +205,13 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:310"
+ "skill:skills/paperclip/SKILL.md:312"
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:issue-thread-interactions:331",
+ "id": "skill:skills/paperclip/SKILL.md:issue-thread-interactions:333",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:331",
+ "sourceAnchor": "skills/paperclip/SKILL.md:333",
"title": "Issue-Thread Interactions",
"expectedSemantics": "Skill guidance headed “Issue-Thread Interactions”.",
"primaryDisposition": "optional_agent_tool",
@@ -220,13 +220,13 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:331"
+ "skill:skills/paperclip/SKILL.md:333"
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:standalone-decisions:360",
+ "id": "skill:skills/paperclip/SKILL.md:standalone-decisions:362",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:360",
+ "sourceAnchor": "skills/paperclip/SKILL.md:362",
"title": "Standalone Decisions",
"expectedSemantics": "Skill guidance headed “Standalone Decisions”.",
"primaryDisposition": "optional_agent_tool",
@@ -235,13 +235,13 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:360"
+ "skill:skills/paperclip/SKILL.md:362"
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:464",
+ "id": "skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:466",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:464",
+ "sourceAnchor": "skills/paperclip/SKILL.md:466",
"title": "MCP Tool Approval Gates",
"expectedSemantics": "Skill guidance headed “MCP Tool Approval Gates”.",
"primaryDisposition": "optional_agent_tool",
@@ -250,13 +250,13 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:464"
+ "skill:skills/paperclip/SKILL.md:466"
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:niche-workflow-pointers:506",
+ "id": "skill:skills/paperclip/SKILL.md:niche-workflow-pointers:508",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:506",
+ "sourceAnchor": "skills/paperclip/SKILL.md:508",
"title": "Niche Workflow Pointers",
"expectedSemantics": "Skill guidance headed “Niche Workflow Pointers”.",
"primaryDisposition": "optional_agent_tool",
@@ -265,13 +265,13 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:506"
+ "skill:skills/paperclip/SKILL.md:508"
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:cases:516",
+ "id": "skill:skills/paperclip/SKILL.md:cases:518",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:516",
+ "sourceAnchor": "skills/paperclip/SKILL.md:518",
"title": "Cases",
"expectedSemantics": "Skill guidance headed “Cases”.",
"primaryDisposition": "optional_agent_tool",
@@ -280,13 +280,13 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:516"
+ "skill:skills/paperclip/SKILL.md:518"
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:company-skills-workflow:521",
+ "id": "skill:skills/paperclip/SKILL.md:company-skills-workflow:523",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:521",
+ "sourceAnchor": "skills/paperclip/SKILL.md:523",
"title": "Company Skills Workflow",
"expectedSemantics": "Skill guidance headed “Company Skills Workflow”.",
"primaryDisposition": "optional_agent_tool",
@@ -295,13 +295,13 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:521"
+ "skill:skills/paperclip/SKILL.md:523"
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:routines:532",
+ "id": "skill:skills/paperclip/SKILL.md:routines:534",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:532",
+ "sourceAnchor": "skills/paperclip/SKILL.md:534",
"title": "Routines",
"expectedSemantics": "Skill guidance headed “Routines”.",
"primaryDisposition": "optional_agent_tool",
@@ -310,13 +310,13 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:532"
+ "skill:skills/paperclip/SKILL.md:534"
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:543",
+ "id": "skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:545",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:543",
+ "sourceAnchor": "skills/paperclip/SKILL.md:545",
"title": "Issue Workspace Runtime Controls",
"expectedSemantics": "Skill guidance headed “Issue Workspace Runtime Controls”.",
"primaryDisposition": "optional_agent_tool",
@@ -325,13 +325,13 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:543"
+ "skill:skills/paperclip/SKILL.md:545"
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:proposing-credentials-safely:550",
+ "id": "skill:skills/paperclip/SKILL.md:proposing-credentials-safely:552",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:550",
+ "sourceAnchor": "skills/paperclip/SKILL.md:552",
"title": "Proposing Credentials Safely",
"expectedSemantics": "Skill guidance headed “Proposing Credentials Safely”.",
"primaryDisposition": "optional_agent_tool",
@@ -340,13 +340,13 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:550"
+ "skill:skills/paperclip/SKILL.md:552"
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:reading-granted-secrets:557",
+ "id": "skill:skills/paperclip/SKILL.md:reading-granted-secrets:559",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:557",
+ "sourceAnchor": "skills/paperclip/SKILL.md:559",
"title": "Reading Granted Secrets",
"expectedSemantics": "Skill guidance headed “Reading Granted Secrets”.",
"primaryDisposition": "optional_agent_tool",
@@ -355,13 +355,13 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:557"
+ "skill:skills/paperclip/SKILL.md:559"
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:critical-rules:583",
+ "id": "skill:skills/paperclip/SKILL.md:critical-rules:585",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:583",
+ "sourceAnchor": "skills/paperclip/SKILL.md:585",
"title": "Critical Rules",
"expectedSemantics": "Skill guidance headed “Critical Rules”.",
"primaryDisposition": "optional_agent_tool",
@@ -370,13 +370,13 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:583"
+ "skill:skills/paperclip/SKILL.md:585"
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:comment-style-required:604",
+ "id": "skill:skills/paperclip/SKILL.md:comment-style-required:606",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:604",
+ "sourceAnchor": "skills/paperclip/SKILL.md:606",
"title": "Comment Style (Required)",
"expectedSemantics": "Skill guidance headed “Comment Style (Required)”.",
"primaryDisposition": "always_agent_tool",
@@ -385,13 +385,13 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:604"
+ "skill:skills/paperclip/SKILL.md:606"
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:update:636",
+ "id": "skill:skills/paperclip/SKILL.md:update:638",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:636",
+ "sourceAnchor": "skills/paperclip/SKILL.md:638",
"title": "Update",
"expectedSemantics": "Skill guidance headed “Update”.",
"primaryDisposition": "optional_agent_tool",
@@ -400,13 +400,13 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:636"
+ "skill:skills/paperclip/SKILL.md:638"
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:646",
+ "id": "skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:648",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:646",
+ "sourceAnchor": "skills/paperclip/SKILL.md:648",
"title": "Planning (Required when planning requested)",
"expectedSemantics": "Skill guidance headed “Planning (Required when planning requested)”.",
"primaryDisposition": "optional_agent_tool",
@@ -415,13 +415,13 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:646"
+ "skill:skills/paperclip/SKILL.md:648"
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:679",
+ "id": "skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:681",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:679",
+ "sourceAnchor": "skills/paperclip/SKILL.md:681",
"title": "Key Endpoints (Hot Routes)",
"expectedSemantics": "Skill guidance headed “Key Endpoints (Hot Routes)”.",
"primaryDisposition": "optional_agent_tool",
@@ -430,13 +430,13 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:679"
+ "skill:skills/paperclip/SKILL.md:681"
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:searching-issues:708",
+ "id": "skill:skills/paperclip/SKILL.md:searching-issues:710",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:708",
+ "sourceAnchor": "skills/paperclip/SKILL.md:710",
"title": "Searching Issues",
"expectedSemantics": "Skill guidance headed “Searching Issues”.",
"primaryDisposition": "optional_agent_tool",
@@ -445,13 +445,13 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:708"
+ "skill:skills/paperclip/SKILL.md:710"
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:full-reference:718",
+ "id": "skill:skills/paperclip/SKILL.md:full-reference:720",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:718",
+ "sourceAnchor": "skills/paperclip/SKILL.md:720",
"title": "Full Reference",
"expectedSemantics": "Skill guidance headed “Full Reference”.",
"primaryDisposition": "optional_agent_tool",
@@ -460,13 +460,13 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:718"
+ "skill:skills/paperclip/SKILL.md:720"
]
},
{
- "id": "skill:skills/paperclip/SKILL.md:conversational-confirmation-answers:722",
+ "id": "skill:skills/paperclip/SKILL.md:conversational-confirmation-answers:724",
"sourceKind": "skill_heading",
- "sourceAnchor": "skills/paperclip/SKILL.md:722",
+ "sourceAnchor": "skills/paperclip/SKILL.md:724",
"title": "Conversational confirmation answers",
"expectedSemantics": "Skill guidance headed “Conversational confirmation answers”.",
"primaryDisposition": "always_agent_tool",
@@ -475,7 +475,7 @@
"control_plane_invariant"
],
"evidenceIds": [
- "skill:skills/paperclip/SKILL.md:722"
+ "skill:skills/paperclip/SKILL.md:724"
]
},
{
diff --git a/packages/paperclip-runner/spec/capability/protocol-coverage.json b/packages/paperclip-runner/spec/capability/protocol-coverage.json
index 2371a89f6df..6f3a4f0b6b4 100644
--- a/packages/paperclip-runner/spec/capability/protocol-coverage.json
+++ b/packages/paperclip-runner/spec/capability/protocol-coverage.json
@@ -7,10 +7,29 @@
"src/scenarios/scenario-plan.ts"
],
"counts": {
- "actions": 52,
+ "actions": 53,
"legacyRequirements": 106
},
"actions": [
+ {
+ "id": "set_task_title",
+ "ownership": "optional_agent_tool",
+ "surfaces": [
+ "live"
+ ],
+ "legacyAliases": [],
+ "contractCase": "protocol-action:set_task_title",
+ "contractOwner": "src/catalog/protocol-action-contracts.test.ts::set_task_title has a schema-valid canonical example and every declared projection",
+ "legacyBehavioralCases": [],
+ "deterministicCases": [
+ "protocol-action:set_task_title"
+ ],
+ "legacyRequirementCases": [],
+ "deterministicOwners": [
+ "src/catalog/protocol-action-contracts.test.ts::set_task_title has a schema-valid canonical example and every declared projection",
+ "src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
+ ]
+ },
{
"id": "read_agent_instructions",
"ownership": "always_agent_tool",
diff --git a/packages/paperclip-runner/spec/operation-groups/source.json b/packages/paperclip-runner/spec/operation-groups/source.json
index 6b24551b0e4..f2f46af8229 100644
--- a/packages/paperclip-runner/spec/operation-groups/source.json
+++ b/packages/paperclip-runner/spec/operation-groups/source.json
@@ -48,7 +48,7 @@
{
"id": "discovery",
"description": "Company-visible task, agent, project, and goal discovery.",
- "operationIds": ["search_tasks", "list_agents", "get_agent", "list_projects", "list_goals"]
+ "operationIds": ["search_tasks", "list_agents", "get_agent", "list_projects", "list_goals", "set_task_title"]
},
{
"id": "projects",
@@ -120,7 +120,7 @@
"legacyGroup": 1,
"name": "Heartbeat",
"owner": "control plane + always tools",
- "operationIds": ["get_task_context"],
+ "operationIds": ["get_task_context", "set_task_title"],
"controlPlaneOperationIds": ["select_work", "enforce_budget"],
"realSurface": "agent identity, inbox-lite, heartbeat context, budget and active-issue services",
"mockStateDomains": ["company", "actor", "wake", "task", "budget", "run"],
diff --git a/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md b/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md
index afadc264d7d..5a60bb0684a 100644
--- a/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md
+++ b/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md
@@ -6,7 +6,7 @@ Status: canonical explanatory contract for the Paperclip runner V1 surface.
This document keeps three independent meanings of **group** separate. PRP families describe wire evidence and controller commands; capability placement decides who owns an operation; behavioral eval groups organize the 106 scenario corpus. None of the three axes can be used as a substitute for another.
-The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **52 reconciled semantic operations** (18 always, 34 optional), and **106 scenarios in 16 behavior groups**.
+The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **53 reconciled semantic operations** (18 always, 35 optional), and **106 scenarios in 16 behavior groups**.
## Axis 1: PRP v1 event and command families
@@ -87,13 +87,13 @@ Placement has exactly three outcomes:
`answer_status_question`, `block_task`, `finish_task`, `get_agent_instruction_history`, `get_task_context`, `get_task_history`, `inspect_operation_result`, `list_document_revisions`, `list_documents`, `read_agent_instructions`, `read_document`, `register_deliverable`, `report_progress`, `request_human_input`, `request_review`, `restore_agent_instructions`, `update_agent_instructions`, `write_document`.
-### Optional operations (34) and grant groups (13)
+### Optional operations (35) and grant groups (13)
Grant groups are documentation/exposure bundles, not additional authority. The operation descriptor's exact `requiredClaims` remains decisive.
| Grant group | Operations | Required claims represented | Purpose |
| --- | --- | --- | --- |
-| `discovery` | `search_tasks`
`list_agents`
`get_agent`
`list_projects`
`list_goals` | `discovery:agents:read`
`discovery:goals:read`
`discovery:projects:read`
`discovery:tasks:read` | Company-visible task, agent, project, and goal discovery. |
+| `discovery` | `search_tasks`
`list_agents`
`get_agent`
`list_projects`
`list_goals`
`set_task_title` | `discovery:agents:read`
`discovery:goals:read`
`discovery:projects:read`
`discovery:tasks:read` | Company-visible task, agent, project, and goal discovery. |
| `projects` | `create_project`
`list_project_repositories` | none | Project creation and authorized repository discovery through the live company/run authority. |
| `delegation_dependencies` | `create_task`
`reassign_task`
`set_dependencies`
`hire_agent` | `delegation:agents:create`
`delegation:tasks:assign`
`delegation:tasks:create`
`dependencies:write` | Create or reassign delegated work and maintain dependency edges. |
| `governance` | `list_approvals`
`get_approval`
`get_approval_context`
`request_approval`
`decide_approval`
`comment_on_approval` | `governance:approvals:comment`
`governance:approvals:decide`
`governance:approvals:read`
`governance:approvals:request` | Read, request, comment on, and decide approvals under governed-action checks. |
@@ -161,6 +161,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| `search_api` | `optional_agent_tool` | `api:discover` | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | inline/no mapping | `live`
`live_codex` | `PaperclipRunnerToolAuthority`
Authenticated PRP tool input/result and existing HTTP route authorization/activity records.
catalog PRP status: `bound` |
| `search_tasks` | `optional_agent_tool` | `discovery:tasks:read` | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | `snapshot_read:company_tasks` | `scenario` + `live`
`live_codex` | `unbound`
read projection surfaced via a tool-result item event; no control-plane state diff
catalog PRP status: `audit_pending` |
| `set_dependencies` | `optional_agent_tool` | `dependencies:write` | `standard`
`skill_test` | `company_write` | `required` | no | `semantic_command:set_dependencies` | `scenario` + `live`
`live_codex` | `issues.update.blockedByIssueIds`
semantic-operation item event plus company-entity state diff and audit record
catalog PRP status: `bound` |
+| `set_task_title` | `optional_agent_tool` | none | `standard`
`ask`
`planning`
`skill_test` | `task_write` | `required` | no | inline/no mapping | `live`
`live_codex` | `setIssueTitle`
Run-bound title update and transactional activity record.
catalog PRP status: `bound` |
| `sync_company_skills` | `optional_agent_tool` | `company_skills:write` | `standard`
`skill_test` | `admin` | `required` | no | `mock_extension:company_skills.sync` | `scenario`
`scenario_mock` | `unbound`
company admin/portability item event plus audit record
catalog PRP status: `audit_pending` |
| `update_agent_instructions` | `always_agent_tool` | none | `standard`
`planning` | `company_write` | `recommended` | no | inline/no mapping | `live`
`live_codex` | `agentInstructionRevisionService`
tool-result item event plus canonical revision receipt for writes; no scenario mock coverage
catalog PRP status: `bound` |
| `upsert_case` | `optional_agent_tool` | `cases:write` | `standard`
`skill_test` | `company_write` | `required` | no | `mock_extension:cases.upsert` | `scenario`
`scenario_mock` | `unbound`
semantic-operation item event plus company-entity state diff and audit record
catalog PRP status: `audit_pending` |
@@ -174,7 +175,7 @@ Behavior groups describe expected outcomes and trajectories. They do not grant t
| Group | Owner | Semantic operations | Control-plane operations | Real Paperclip surface | Mock state | Scenarios | PRP evidence | Gap / disposition |
| --- | --- | --- | --- | --- | --- | --- | ---: | --- | --- |
-| [`hb` — Heartbeat](#behavior-group-hb-heartbeat) | control plane + always tools | `get_task_context` | `select_work`
`enforce_budget` | agent identity, inbox-lite, heartbeat context, budget and active-issue services | `company`
`actor`
`wake`
`task`
`budget`
`run` | 5 | runner/session/run context plus bounded task-context tool results | Production semantic binding is unbound; identity and work selection remain injected/control-plane-owned. |
+| [`hb` — Heartbeat](#behavior-group-hb-heartbeat) | control plane + always tools | `get_task_context`
`set_task_title` | `select_work`
`enforce_budget` | agent identity, inbox-lite, heartbeat context, budget and active-issue services | `company`
`actor`
`wake`
`task`
`budget`
`run` | 5 | runner/session/run context plus bounded task-context tool results | Production semantic binding is unbound; identity and work selection remain injected/control-plane-owned. |
| [`co` — Checkout](#behavior-group-co-checkout) | control plane | none | `checkout_task` | POST /api/issues/:id/checkout and execution-lock services | `task`
`actor`
`run`
`idempotency`
`fault` | 6 | run preparation and issue-status decision evidence with checkout receipt | Intentionally no model tool; the production checkout receipt still needs the additive semantic-receipt envelope. |
| [`st` — Status](#behavior-group-st-status) | always tools + control-plane arbitration | `answer_status_question`
`finish_task`
`block_task`
`request_review` | `reconcile_run`
`append_audit_record` | issue PATCH, review/liveness policy, and native finalization arbitration | `task`
`comments`
`interactions`
`blockers`
`audit`
`run` | 8 | semantic operation receipt, work assessment, issue-status decision, and terminal causality | Production semantic binding and additive typed operation/conflict receipts remain unimplemented. |
| [`cm` — Comments](#behavior-group-cm-comments) | always tools | `get_task_history`
`report_progress` | `append_audit_record` | issue comment list/get/create routes | `task`
`comments`
`actor`
`idempotency`
`audit` | 6 | bounded read result or idempotent comment-write receipt plus audit reference | Active-task binding is unbound; cross-task comment mutation is deliberately outside V1. |
@@ -463,10 +464,10 @@ Current responsibility-based paths are normative. Numbered `phase-*` or mileston
### Catalog split and deliberate replacement
- Scenario/eval catalog: **39** operations.
-- Live dispatcher catalog: **40** operations.
-- Shared: **27**; union/canonical authority: **52**.
+- Live dispatcher catalog: **41** operations.
+- Shared: **27**; union/canonical authority: **53**.
- Scenario-only: `administer_company`, `export_company`, `inspect_operation_result`, `list_cases`, `list_company_skills`, `list_goals`, `list_routines`, `list_secret_metadata`, `manage_routine`, `read_secret_value`, `sync_company_skills`, `upsert_case`.
-- Live-only: `call_api`, `create_project`, `get_agent`, `get_agent_instruction_history`, `get_approval`, `get_approval_context`, `hire_agent`, `list_project_repositories`, `read_agent_instructions`, `restore_agent_instructions`, `schedule_wake`, `search_api`, `update_agent_instructions`.
+- Live-only: `call_api`, `create_project`, `get_agent`, `get_agent_instruction_history`, `get_approval`, `get_approval_context`, `hire_agent`, `list_project_repositories`, `read_agent_instructions`, `restore_agent_instructions`, `schedule_wake`, `search_api`, `set_task_title`, `update_agent_instructions`.
- The generated provider contract contains exactly the live catalog; the canonical union remains the migration authority until all scenario-only operations are either implemented, deferred, or removed by an explicit reconciliation decision.
- `generic_api_request` stays exported only for controlled tests and cannot be cited as real-surface, mock-parity, or PRP product coverage.
diff --git a/packages/paperclip-runner/src/catalog/reconciliation.test.ts b/packages/paperclip-runner/src/catalog/reconciliation.test.ts
index 0f69be379c1..5c6cd961393 100644
--- a/packages/paperclip-runner/src/catalog/reconciliation.test.ts
+++ b/packages/paperclip-runner/src/catalog/reconciliation.test.ts
@@ -20,9 +20,9 @@ describe("canonical semantic-catalog reconciliation authority", () => {
it("pins the reconciled op-set relationship between the two catalogs", () => {
const summary = capabilityCatalogReconciliation();
expect(summary.scenarioCount).toBe(39);
- expect(summary.liveCount).toBe(40);
+ expect(summary.liveCount).toBe(41);
expect(summary.sharedCount).toBe(27);
- expect(summary.unionCount).toBe(52);
+ expect(summary.unionCount).toBe(53);
// Any operation added to or removed from either catalog without a
// reconciliation decision changes these exact sets and fails the gate.
expect(summary.liveOnly).toEqual([
@@ -38,6 +38,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
"restore_agent_instructions",
"schedule_wake",
"search_api",
+ "set_task_title",
"update_agent_instructions",
]);
expect(summary.scenarioOnly).toEqual([
@@ -57,7 +58,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
});
it("is the single source both catalogs derive their operation set from", () => {
- expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(52);
+ expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(53);
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);
@@ -91,7 +92,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
});
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(52);
+ expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(53);
for (const operation of CAPABILITY_CANONICAL_CATALOG) {
expect(operation.placement).toMatch(/^(always|optional)_agent_tool$/);
expect(Array.isArray(operation.requiredClaims)).toBe(true);
@@ -117,7 +118,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
it("classifies real binding status so generic_api_request is never product coverage", () => {
const summary = capabilityCatalogReconciliation();
expect(summary.byRealBindingStatus).toEqual({
- live_codex: 39,
+ live_codex: 40,
scenario_mock: 12,
test_only: 1,
});
diff --git a/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts b/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts
index d1835af7bd0..a313a9ab10f 100644
--- a/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts
+++ b/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts
@@ -69,7 +69,7 @@ describe("semantic action catalog", () => {
(action) => action.operationId,
);
- expect(operationIds).toHaveLength(35);
+ expect(operationIds).toHaveLength(36);
expect(new Set(operationIds).size).toBe(operationIds.length);
expect(operationIds).not.toContain("generic_api_request");
expect(Object.isFrozen(PAPERCLIP_SEMANTIC_ACTION_CATALOG)).toBe(true);
diff --git a/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts b/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts
index 9fff6e589c5..68847c16dc2 100644
--- a/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts
+++ b/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts
@@ -1,3 +1,4 @@
+import { setTaskTitleAction } from "../protocol-actions/set-task-title.js";
import { reassignTaskAction } from "../protocol-actions/reassign-task.js";
import type {
PaperclipJsonSchema,
@@ -161,6 +162,15 @@ const descriptors: readonly PaperclipSemanticActionDescriptor[] = [
["key"],
),
}),
+ descriptor({
+ operationId: "set_task_title",
+ title: "Set task title",
+ description: setTaskTitleAction.documentation.description,
+ effect: "write",
+ placement: "optional",
+ inputSchema: setTaskTitleAction.live.descriptor.inputSchema,
+ outputSchema: openObject,
+ }),
descriptor({
operationId: "report_progress",
title: "Report durable progress",
diff --git a/packages/paperclip-runner/src/catalog/semantic-action-types.ts b/packages/paperclip-runner/src/catalog/semantic-action-types.ts
index 8a36868aa6d..0bc0d1d022f 100644
--- a/packages/paperclip-runner/src/catalog/semantic-action-types.ts
+++ b/packages/paperclip-runner/src/catalog/semantic-action-types.ts
@@ -1,6 +1,7 @@
export type PaperclipSemanticActionId =
| "search_api"
| "call_api"
+ | "set_task_title"
| "get_task_context"
| "get_task_history"
| "list_documents"
diff --git a/packages/paperclip-runner/src/drivers/acpx/permission-policy.ts b/packages/paperclip-runner/src/drivers/acpx/permission-policy.ts
index fcf5e0eb4e8..63076db5ea1 100644
--- a/packages/paperclip-runner/src/drivers/acpx/permission-policy.ts
+++ b/packages/paperclip-runner/src/drivers/acpx/permission-policy.ts
@@ -23,7 +23,7 @@ export class AcpxApprovalRequiredError extends Error {
const AUTOMATIC_PAPERCLIP_WORKFLOW_ACTIONS = new Set([
"report_progress", "answer_status_question", "write_document",
"request_human_input", "register_deliverable", "finish_task", "block_task",
- "request_review", "create_task", "reassign_task", "set_dependencies",
+ "set_task_title", "request_review", "create_task", "reassign_task", "set_dependencies",
"create_project", "request_approval",
]);
diff --git a/packages/paperclip-runner/src/eval/workflow-evals.test.ts b/packages/paperclip-runner/src/eval/workflow-evals.test.ts
index 14cbe7199c0..916a413f1a6 100644
--- a/packages/paperclip-runner/src/eval/workflow-evals.test.ts
+++ b/packages/paperclip-runner/src/eval/workflow-evals.test.ts
@@ -466,13 +466,13 @@ describe("workflow reports and stress traceability", () => {
candidateFailures: 36,
});
expect(report.coverage).toMatchObject({
- canonicalOperations: 52,
+ canonicalOperations: 53,
capabilityCases: 106,
workflows: 12,
stressFindings: 44,
stressExclusions: 1,
});
- expect(report.coverage.operations).toHaveLength(52);
+ expect(report.coverage.operations).toHaveLength(53);
expect(report.coverage.composedWorkflows).toHaveLength(12);
expect(
report.coverage.operations.find(
@@ -480,7 +480,7 @@ describe("workflow reports and stress traceability", () => {
)?.workflowIds.length,
).toBeGreaterThan(0);
expect(renderRunnerWorkflowMarkdown(report)).toContain(
- "52 operations · 106 capability cases · 12 workflows",
+ "53 operations · 106 capability cases · 12 workflows",
);
expect(renderRunnerWorkflowJUnit(report)).toContain(
'tests="36" failures="36" skipped="0"',
diff --git a/packages/paperclip-runner/src/protocol-actions/index.ts b/packages/paperclip-runner/src/protocol-actions/index.ts
index 4dcfcc66b0e..3cad6cc1bc5 100644
--- a/packages/paperclip-runner/src/protocol-actions/index.ts
+++ b/packages/paperclip-runner/src/protocol-actions/index.ts
@@ -1,3 +1,4 @@
+import { setTaskTitleAction } from "./set-task-title.js";
import { readAgentInstructionsAction } from "./read-agent-instructions.js";
import { updateAgentInstructionsAction } from "./update-agent-instructions.js";
import { getAgentInstructionHistoryAction } from "./get-agent-instruction-history.js";
@@ -53,6 +54,7 @@ import { writeDocumentAction } from "./write-document.js";
import { deepFreezeProtocolAction } from "./freeze.js";
export const PAPERCLIP_PROTOCOL_ACTIONS = deepFreezeProtocolAction([
+ setTaskTitleAction,
readAgentInstructionsAction,
updateAgentInstructionsAction,
getAgentInstructionHistoryAction,
diff --git a/packages/paperclip-runner/src/protocol-actions/set-task-title.ts b/packages/paperclip-runner/src/protocol-actions/set-task-title.ts
new file mode 100644
index 00000000000..300ecf20123
--- /dev/null
+++ b/packages/paperclip-runner/src/protocol-actions/set-task-title.ts
@@ -0,0 +1,41 @@
+/** Canonical title tool. The active task is resolved from the authenticated run. */
+const description = "Set a concise, descriptive title for the active task. When its titleNeedsGeneration is true, call this early with onlyIfProvisional: true to replace the initial prompt slice without overwriting a user's title. Use false only for an intentional rename. This changes no task status, ownership, or description.";
+const inputSchema = {
+ type: "object",
+ properties: {
+ idempotencyKey: { type: "string", minLength: 1, maxLength: 240, description: "Reuse this key on retries." },
+ title: { type: "string", minLength: 1, maxLength: 240, description: "Short title describing the requested outcome." },
+ onlyIfProvisional: { type: "boolean", description: "True for automatic initial naming; preserves any title already chosen by a user or agent." },
+ },
+ required: ["idempotencyKey", "title", "onlyIfProvisional"],
+ additionalProperties: false,
+} as const;
+
+export const setTaskTitleAction = {
+ id: "set_task_title",
+ canonical: {
+ operationId: "set_task_title", surfaces: ["live"], placement: "optional_agent_tool",
+ optionalGroup: "discovery", requiredClaims: [],
+ taskModes: ["standard", "ask", "planning", "skill_test"],
+ sideEffectClass: "task_write", idempotency: "required", disabledByDefault: false,
+ realBindingStatus: "live_codex", realServiceBinding: "setIssueTitle",
+ prpEvidence: "Run-bound title update and transactional activity record.",
+ prpBindingStatus: "bound", legacyAliases: [],
+ },
+ documentation: { title: "Set task title", description, note: null },
+ examples: {
+ call: { operationId: "set_task_title", input: { idempotencyKey: "initial-title", title: "Fix sign-in redirect", onlyIfProvisional: true } },
+ scenarioCall: null,
+ success: { ok: true, operationId: "set_task_title", result: { id: "task-1", title: "Fix sign-in redirect", titleNeedsGeneration: false, changed: true } },
+ },
+ live: {
+ order: 50,
+ descriptor: {
+ schema: "paperclip.semantic-tool.v1", operationId: "set_task_title", version: 1,
+ title: "Set task title", description, exposure: "optional", requiredClaims: [],
+ allowedModes: ["standard", "ask", "planning", "skill_test"], inputSchema,
+ outputSchema: { type: "object", additionalProperties: true },
+ },
+ },
+ scenario: null,
+} as const;
diff --git a/packages/paperclip-runner/src/semantic-tools/discovery.ts b/packages/paperclip-runner/src/semantic-tools/discovery.ts
index 7d6c28a0d2f..4b3631c8d63 100644
--- a/packages/paperclip-runner/src/semantic-tools/discovery.ts
+++ b/packages/paperclip-runner/src/semantic-tools/discovery.ts
@@ -20,6 +20,7 @@ export interface CapabilityDiscoveryResult {
const MAX_DISCOVERY_RESULTS = 10;
const NAMESPACE: Readonly> = Object.freeze({
+ set_task_title: "active_task",
search_api: "api_fallback",
call_api: "api_fallback",
get_task_context: "active_task", get_task_history: "active_task",
diff --git a/packages/paperclip-runner/src/semantic-tools/paperclip-discovery.ts b/packages/paperclip-runner/src/semantic-tools/paperclip-discovery.ts
index 4f7144ef3de..5e6a68a9195 100644
--- a/packages/paperclip-runner/src/semantic-tools/paperclip-discovery.ts
+++ b/packages/paperclip-runner/src/semantic-tools/paperclip-discovery.ts
@@ -12,6 +12,7 @@ import type {
const NAMESPACE: Readonly> =
Object.freeze({
+ set_task_title: "active_task",
search_api: "api_fallback",
call_api: "api_fallback",
get_task_context: "active_task",
diff --git a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts
index 1e6ebc1bb50..454074a4a47 100644
--- a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts
+++ b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts
@@ -94,7 +94,7 @@ describe("Capability semantic catalog and authorization", () => {
it("publishes a stable narrow catalog without credentials or control-plane-owned tools", () => {
const names = CAPABILITY_SEMANTIC_TOOL_CATALOG.map((tool) => tool.operationId);
expect(new Set(names).size).toBe(names.length);
- expect(names).toHaveLength(40);
+ expect(names).toHaveLength(41);
expect(names).toContain("get_task_context");
expect(names).toContain("finish_task");
expect(names).not.toContain("checkout_task");
diff --git a/packages/paperclip-runner/src/semantic-tools/types.ts b/packages/paperclip-runner/src/semantic-tools/types.ts
index 31178d2690a..2e95fe1cce8 100644
--- a/packages/paperclip-runner/src/semantic-tools/types.ts
+++ b/packages/paperclip-runner/src/semantic-tools/types.ts
@@ -18,6 +18,7 @@ export type CapabilitySemanticToolExposure = "always" | "optional";
export type CapabilitySemanticOperationId =
| "search_api"
| "call_api"
+ | "set_task_title"
| "get_task_context"
| "get_task_history"
| "list_documents"
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 2197f11128b..78ded88c4df 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -1945,6 +1945,8 @@ export {
type CompanySearchExtractQuery,
type CompanySearchQuery,
createIssueSchema,
+ setIssueTitleSchema,
+ type SetIssueTitle,
createIssueInputSchema,
createChildIssueSchema,
createAcceptedPlanDecompositionSchema,
diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts
index 197c3c883ac..22e5e0ae37a 100644
--- a/packages/shared/src/types/issue.ts
+++ b/packages/shared/src/types/issue.ts
@@ -774,6 +774,8 @@ export interface IssueChangeReceiptEntry {
export type IssueChanges = Record;
export interface Issue {
+ /** True only while the title is the provisional slice of the initial prompt. */
+ titleNeedsGeneration?: boolean;
conversationAgentId?: string | null;
conversationUserId?: string | null;
/** Server-owned Slack lifecycle projection; not writable through task updates. */
diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts
index 7bd58351e02..b6891b92176 100644
--- a/packages/shared/src/validators/index.ts
+++ b/packages/shared/src/validators/index.ts
@@ -417,6 +417,8 @@ export {
export {
createIssueSchema,
+ setIssueTitleSchema,
+ type SetIssueTitle,
createIssueInputSchema,
createChildIssueSchema,
createAcceptedPlanDecompositionSchema,
diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts
index 1eefdbb4bf7..c2a1795b3f1 100644
--- a/packages/shared/src/validators/issue.ts
+++ b/packages/shared/src/validators/issue.ts
@@ -784,17 +784,36 @@ const onboardingFirstTaskMarkerSchema = {
};
export const createIssueInputSchema = createIssueBaseSchema.extend({
+ title: z.string().optional(),
status: createIssueBaseSchema.shape.status.optional(),
...createIssueDuplicateGuardSchema,
...onboardingFirstTaskMarkerSchema,
});
+function requireTitleOrDescription(
+ value: { title?: string; description?: string | null },
+ ctx: z.RefinementCtx,
+) {
+ if (!value.title?.trim() && !value.description?.trim()) {
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["title"], message: "Provide a title or task description" });
+ }
+}
+
export const createIssueSchema = withCreateIssueStatusDefault(
createIssueBaseSchema.extend({
+ title: z.string().optional(),
...createIssueDuplicateGuardSchema,
...onboardingFirstTaskMarkerSchema,
}),
-).superRefine(requireBlockedStatusForUnblockDescriptor);
+).superRefine(requireBlockedStatusForUnblockDescriptor).superRefine(requireTitleOrDescription);
+
+export const setIssueTitleSchema = z.object({
+ title: z.string().trim().min(1).max(240),
+ onlyIfProvisional: z.boolean().optional().default(false),
+ idempotencyKey: z.string().trim().min(1).max(240).optional(),
+}).strict();
+
+export type SetIssueTitle = z.input;
export type CreateIssue = z.infer;
@@ -815,13 +834,14 @@ export const createChildIssueSchema = withCreateIssueStatusDefault(
watchdogDiscovery: true,
})
.extend({
+ title: z.string().optional(),
acceptanceCriteria: z
.array(z.string().trim().min(1).max(500))
.max(20)
.optional(),
blockParentUntilDone: z.boolean().optional().default(false),
}),
-).superRefine(requireBlockedStatusForUnblockDescriptor);
+).superRefine(requireBlockedStatusForUnblockDescriptor).superRefine(requireTitleOrDescription);
export type CreateChildIssue = z.infer;
diff --git a/server/src/__tests__/heartbeat-context-summary.test.ts b/server/src/__tests__/heartbeat-context-summary.test.ts
index cf40305af86..03e2f52b25d 100644
--- a/server/src/__tests__/heartbeat-context-summary.test.ts
+++ b/server/src/__tests__/heartbeat-context-summary.test.ts
@@ -8,6 +8,19 @@ import {
import { renderPaperclipWakePrompt } from "@paperclipai/adapter-utils/server-utils";
describe("buildPaperclipTaskMarkdown", () => {
+ it("asks for early naming only while an ordinary task has a provisional title", () => {
+ const issue = { id: "task-id", identifier: "PAP-1", title: "Please investigate", description: "Please investigate sign-in failures", titleNeedsGeneration: true };
+ const markdown = buildPaperclipTaskMarkdown({ issue });
+ expect(markdown).toContain("As one of your first tool calls");
+ expect(markdown).toContain("Check the title tool result");
+ expect(markdown).toContain("retry once with a shorter, plain-language title");
+ expect(markdown).toContain("new idempotency key for changed arguments");
+ expect(markdown).toContain("set_task_title");
+ expect(markdown).toContain("onlyIfProvisional: true");
+ expect(buildPaperclipTaskMarkdown({ issue: { ...issue, titleNeedsGeneration: false } })).not.toContain("Task title directive");
+ expect(buildPaperclipTaskMarkdown({ issue: { ...issue, conversationAgentId: "agent" } })).not.toContain("Task title directive");
+ });
+
it("carries current confirmation IDs and proposal data in every fresh or resumed chat assignment", () => {
const conversationConfirmations = { truncated: false, cards: [{
id: "existing-card", kind: "request_confirmation", status: "pending", title: "Proposal",
diff --git a/server/src/__tests__/task-title-routes.test.ts b/server/src/__tests__/task-title-routes.test.ts
new file mode 100644
index 00000000000..77ab74d5388
--- /dev/null
+++ b/server/src/__tests__/task-title-routes.test.ts
@@ -0,0 +1,197 @@
+import { randomUUID } from "node:crypto";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { and, eq } from "drizzle-orm";
+import { activityLog, externalObjectMentions, heartbeatRuns, issues } from "@paperclipai/db";
+import { createChildIssueSchema, createIssueSchema, setIssueTitleSchema } from "@paperclipai/shared";
+import { createLocalAgentJwt } from "../agent-auth-jwt.js";
+import { issueService } from "../services/issues.js";
+import { externalObjectService } from "../services/external-objects.js";
+import { instanceSettingsService } from "../services/instance-settings.js";
+import { callProjectTool, projectToolDefinitions } from "../services/project-tools.js";
+import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
+
+describe("task titles", () => {
+ let server: Awaited>;
+ const originalSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
+ beforeAll(async () => {
+ process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID();
+ server = await startRunnerApiTestServer();
+ }, 60_000);
+ afterAll(async () => {
+ await server?.close();
+ if (originalSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
+ else process.env.PAPERCLIP_AGENT_JWT_SECRET = originalSecret;
+ });
+ type Fixture = Awaited>;
+ const token = (f: Fixture) => createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
+ const request = (f: Fixture, path: string, method: string, body: unknown) => fetch(`${server.apiUrl}/api${path}`, {
+ method, headers: { Authorization: `Bearer ${token(f)}`, "Content-Type": "application/json" }, body: JSON.stringify(body),
+ });
+ const rename = (f: Fixture, input: Record = {}) => f.authority.execute({
+ tool: "set_task_title", callId: randomUUID(),
+ arguments: { idempotencyKey: "initial-title", title: "Fix sign-in redirect", onlyIfProvisional: true, ...input },
+ });
+ async function provisional(f: Fixture) {
+ await server.db.update(issues).set({ title: "Please investigate", description: "Please investigate the sign-in redirect", titleNeedsGeneration: true }).where(eq(issues.id, f.issueId));
+ }
+
+ it("creates a provisional title from the prompt through the API and preserves the full description", async () => {
+ const f = await server.fixture();
+ const description = " Investigate\n the sign-in redirect. " + "Keep this detail. ".repeat(20);
+ const response = await request(f, `/companies/${f.companyId}/issues`, "POST", { description, status: "backlog" });
+ const result = await response.json();
+ expect(response.status, JSON.stringify(result)).toBe(201);
+ expect(result).toMatchObject({ title: description.trim().replace(/\s+/g, " ").slice(0, 120), description, titleNeedsGeneration: true });
+ expect(await issueService(server.db).create(f.companyId, { title: "Chosen title", description })).toMatchObject({ title: "Chosen title", titleNeedsGeneration: false });
+ expect(createIssueSchema.safeParse({ title: " ", description: "\n " }).success).toBe(false);
+ expect(createIssueSchema.safeParse({ description: "A request" }).success).toBe(true);
+ expect(setIssueTitleSchema.safeParse({ title: " " }).success).toBe(false);
+ expect(setIssueTitleSchema.safeParse({ title: "x".repeat(241) }).success).toBe(false);
+ });
+
+ it("creates prompt-only children and still accepts explicit child titles", async () => {
+ const f = await server.fixture();
+ const description = "Investigate the child sign-in redirect";
+ const response = await request(f, `/issues/${f.issueId}/children`, "POST", { description, status: "backlog" });
+ const result = await response.json();
+ expect(response.status, JSON.stringify(result)).toBe(201);
+ expect(result).toMatchObject({ parentId: f.issueId, title: description, description, titleNeedsGeneration: true });
+ const explicit = await request(f, `/issues/${f.issueId}/children`, "POST", { title: "Chosen child title", description, status: "backlog" });
+ expect(explicit.status).toBe(201);
+ expect(await explicit.json()).toMatchObject({ title: "Chosen child title", titleNeedsGeneration: false });
+ expect(createChildIssueSchema.safeParse({ title: " ", description: "\n " }).success).toBe(false);
+ });
+
+ it("bounds title receipts without evicting replay protection across native and HTTP calls", async () => {
+ const f = await server.fixture();
+ const input = { title: "Original agent title", onlyIfProvisional: false, idempotencyKey: "original" };
+ const original = await rename(f, input);
+ await issueService(server.db).update(f.issueId, { title: "Later user title" });
+ for (let i = 1; i < 64; i++) {
+ await rename(f, { idempotencyKey: `bounded-${i}` });
+ }
+ await expect(rename(f, { idempotencyKey: "overflow" })).rejects.toThrow(/limit/);
+ const overflow = await request(f, `/issues/${f.issueId}/title`, "PUT", { ...input, idempotencyKey: "overflow" });
+ expect(overflow.status).toBe(409);
+ expect(await rename(f, input)).toEqual(original);
+ const replay = await request(f, `/issues/${f.issueId}/title`, "PUT", input);
+ expect(replay.status).toBe(200);
+ expect(await replay.json()).toEqual(original);
+ const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
+ expect(Object.keys(run.resultJson?.taskTitleReceipts ?? {})).toHaveLength(64);
+ expect(await issueService(server.db).getById(f.issueId)).toMatchObject({ title: "Later user title" });
+ });
+
+ it("keeps distinct prompts with the same prefix and replays creation keys after naming", async () => {
+ const f = await server.fixture();
+ const prefix = "Please investigate ".repeat(10);
+ const svc = issueService(server.db);
+ const first = await svc.create(f.companyId, { description: prefix + "the login flow", allowDuplicate: false, idempotencyKey: "prompt-request" });
+ const second = await svc.create(f.companyId, { description: prefix + "the signup flow", allowDuplicate: false });
+ expect(first.title).toBe(second.title);
+ expect(first.id).not.toBe(second.id);
+ await svc.update(first.id, { title: "Fix login" });
+ expect((await svc.create(f.companyId, { description: prefix + "the login flow", idempotencyKey: "prompt-request" })).id).toBe(first.id);
+ });
+
+ it.each(["standard", "ask", "planning"] as const)("names the active task once in %s mode without changing execution", async mode => {
+ const f = await server.fixture({ mode });
+ await provisional(f);
+ expect(f.authority.definitions().some(tool => tool.name === "set_task_title")).toBe(true);
+ expect(projectToolDefinitions(mode, true).some(tool => tool.name === "set_task_title")).toBe(true);
+ const [before] = await server.db.select().from(issues).where(eq(issues.id, f.issueId));
+ const first = await rename(f);
+ expect(first).toMatchObject({ id: f.issueId, title: "Fix sign-in redirect", titleNeedsGeneration: false, changed: true });
+ expect(await rename(f)).toEqual(first);
+ const [after] = await server.db.select().from(issues).where(eq(issues.id, f.issueId));
+ expect(after).toMatchObject({ status: before.status, statusVersion: before.statusVersion, workMode: mode, executionRunId: before.executionRunId, assigneeAgentId: before.assigneeAgentId, description: before.description });
+ const audit = await server.db.select().from(activityLog).where(and(eq(activityLog.entityId, f.issueId), eq(activityLog.action, "issue.updated")));
+ expect(audit).toHaveLength(1);
+ expect(audit[0]).toMatchObject({ runId: f.runId, actorId: f.agentId, details: { title: "Fix sign-in redirect", previous: { title: "Please investigate" } } });
+ await expect(rename(f, { title: "Conflicting retry" })).rejects.toThrow(/idempotency/);
+ });
+
+ it("preserves a user edit and supports intentional renaming through the shared MCP/API path", async () => {
+ const f = await server.fixture({ mode: "ask" });
+ await provisional(f);
+ await issueService(server.db).update(f.issueId, { title: "My chosen title" });
+ expect(await rename(f)).toMatchObject({ title: "My chosen title", changed: false });
+ const result = await callProjectTool({ name: "set_task_title", arguments: { title: "Intentional new title", onlyIfProvisional: false, idempotencyKey: "rename" }, apiUrl: server.apiUrl, token: token(f), companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, conversation: false });
+ expect(result).toMatchObject({ title: "Intentional new title", changed: true });
+ const retry = await request(f, `/issues/${f.issueId}/title`, "PUT", { title: "Intentional new title" });
+ expect(await retry.json()).toMatchObject({ changed: false });
+ await issueService(server.db).update(f.issueId, { title: "User edit after the response" });
+ const replay = await request(f, `/issues/${f.issueId}/title`, "PUT", { title: "Intentional new title", onlyIfProvisional: false, idempotencyKey: "rename" });
+ expect(await replay.json()).toEqual(result);
+ expect(await issueService(server.db).getById(f.issueId)).toMatchObject({ title: "User edit after the response" });
+ expect((await request(f, `/issues/${f.issueId}/title`, "PUT", { title: "Conflicting retry", idempotencyKey: "rename" })).status).toBe(409);
+ });
+
+ it("fences simultaneous initial titles and later retries", async () => {
+ const f = await server.fixture();
+ await provisional(f);
+ const results = await Promise.all([
+ rename(f, { idempotencyKey: "a", title: "First title" }),
+ rename(f, { idempotencyKey: "b", title: "Second title" }),
+ ]) as Array<{ changed: boolean; title: string }>;
+ expect(results.filter(result => result.changed)).toHaveLength(1);
+ expect(new Set(results.map(result => result.title)).size).toBe(1);
+ });
+
+ it.each(["native", "http"] as const)("shares %s title receipts with the other surface without overwriting later user edits", async firstSurface => {
+ const f = await server.fixture();
+ const input = { title: "Intentional rename", onlyIfProvisional: false, idempotencyKey: "shared-title-retry" };
+ const httpRename = async () => {
+ const response = await request(f, `/issues/${f.issueId}/title`, "PUT", input);
+ expect(response.status).toBe(200);
+ return response.json();
+ };
+ const first = await (firstSurface === "native" ? rename(f, input) : httpRename());
+ await issueService(server.db).update(f.issueId, { title: "Keep this user edit" });
+ expect(await (firstSurface === "native" ? httpRename() : rename(f, input))).toEqual(first);
+ expect(await issueService(server.db).getById(f.issueId)).toMatchObject({ title: "Keep this user edit" });
+ expect((await request(f, `/issues/${f.issueId}/title`, "PUT", { ...input, title: "Conflicting retry" })).status).toBe(409);
+ await expect(rename(f, { ...input, title: "Conflicting retry" })).rejects.toThrow(/idempotency/);
+ });
+
+ it("refreshes external title links through native and REST renames while preserving description links", async () => {
+ const f = await server.fixture();
+ await instanceSettingsService(server.db).updateExperimental({ enableExternalObjects: true });
+ try {
+ await server.db.update(issues).set({
+ title: "Review https://github.com/acme/app/pull/42",
+ titleNeedsGeneration: true,
+ description: "Keep https://github.com/acme/app/issues/7",
+ }).where(eq(issues.id, f.issueId));
+ await externalObjectService(server.db).syncIssue(f.issueId);
+ const mentions = () => server.db.select().from(externalObjectMentions).where(eq(externalObjectMentions.sourceIssueId, f.issueId));
+ expect(await mentions()).toHaveLength(2);
+ await rename(f);
+ expect(await mentions()).toMatchObject([{ sourceKind: "description", sanitizedDisplayUrl: "https://github.com/acme/app/issues/7" }]);
+ const response = await request(f, `/issues/${f.issueId}/title`, "PUT", { title: "Review https://github.com/acme/app/pull/43" });
+ expect(response.status).toBe(200);
+ expect(await mentions()).toEqual(expect.arrayContaining([
+ expect.objectContaining({ sourceKind: "title", sanitizedDisplayUrl: "https://github.com/acme/app/pull/43" }),
+ expect.objectContaining({ sourceKind: "description", sanitizedDisplayUrl: "https://github.com/acme/app/issues/7" }),
+ ]));
+ expect(await mentions()).toHaveLength(2);
+ } finally {
+ await instanceSettingsService(server.db).updateExperimental({ enableExternalObjects: false });
+ }
+ });
+
+ it("rejects unrelated tasks, stale runs, and malformed title mutations", async () => {
+ const f = await server.fixture();
+ await provisional(f);
+ const other = await issueService(server.db).create(f.companyId, { title: "Unrelated task" });
+ expect((await request(f, `/issues/${other.id}/title`, "PUT", { title: "Wrong" })).status).toBe(403);
+ const foreign = await issueService(server.db).create(f.foreignCompanyId, { title: "Foreign task" });
+ expect([403, 404]).toContain((await request(f, `/issues/${foreign.id}/title`, "PUT", { title: "Wrong" })).status);
+ expect((await request(f, `/issues/${f.issueId}/title`, "PUT", { title: "Wrong", status: "done" })).status).toBe(400);
+ await expect(rename(f, { title: " " })).rejects.toThrow();
+ await server.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.runId));
+ await expect(rename(f)).rejects.toThrow(/authorized/);
+ expect((await request(f, `/issues/${f.issueId}/title`, "PUT", { title: "Stale" })).status).toBe(403);
+ expect(await issueService(server.db).getById(f.issueId)).toMatchObject({ title: "Please investigate", titleNeedsGeneration: true });
+ });
+});
diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts
index 6db0c7f0d74..2b00cc1b0d4 100644
--- a/server/src/routes/issues.ts
+++ b/server/src/routes/issues.ts
@@ -1,3 +1,5 @@
+import { setIssueTitle } from "../services/issue-title.js";
+import { setIssueTitleSchema } from "@paperclipai/shared";
import { resolveConfirmationFromComment } from "../services/confirmation-comment-resolution.js";
import { createIssueReadTiming } from "../services/issue-read-timing.js";
import { isNativeWorkspaceExportRepairCause } from "@paperclipai/shared";
@@ -8512,6 +8514,7 @@ export function issueRoutes(
id: issue.id,
identifier: issue.identifier,
title: issue.title,
+ titleNeedsGeneration: issue.titleNeedsGeneration,
description: issue.description,
status: issue.status,
workMode: issue.workMode,
@@ -12702,6 +12705,25 @@ export function issueRoutes(
},
);
+ router.put("/issues/:id/title", validateIssueMutationBody(setIssueTitleSchema), async (req, res) => {
+ const existing = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Issue not found");
+ if (!existing) return;
+ if (req.actor.type === "agent") {
+ const decision = await decideIssueAccess(req, existing, "issue:mutate");
+ if (!decision.allowed) {
+ await denyIssueWrite(req, res, existing, issueWriteDenialCodeForDecision(decision));
+ return;
+ }
+ }
+ const actor = getActorInfo(req);
+ const { result, publication } = await db.transaction(tx => setIssueTitle(
+ tx as unknown as Db, existing.companyId, existing.id, req.body, actor,
+ ));
+ if (publication) publishActivity(publication);
+ await externalObjectsSvc.syncIssueSafely(existing.id);
+ res.json(result);
+ });
+
router.patch(
"/issues/:id",
validateIssueMutationBody(updateIssueRouteSchema),
diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts
index b9e9870495d..829064f4468 100644
--- a/server/src/routes/openapi.ts
+++ b/server/src/routes/openapi.ts
@@ -49,6 +49,7 @@ import {
testAdapterEnvironmentSchema,
// Issue
createIssueSchema,
+ setIssueTitleSchema,
updateIssueSchema,
stalledReviewDecisionSchema,
createIssueLabelSchema,
@@ -3904,6 +3905,7 @@ registry.registerPath({
path: "/api/companies/{companyId}/issues",
tags: ["issues"],
summary: "Create an issue",
+ description: "Title is optional when description contains the task prompt. The server seeds a provisional title from the first 120 characters of the whitespace-normalized prompt and asks the assigned agent to refine it early. Explicit titles are preserved.",
request: {
params: z.object({ companyId: z.string() }),
body: jsonBody(createIssueSchema),
@@ -3925,6 +3927,16 @@ registry.registerPath({
responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound },
});
+registry.registerPath({
+ method: "put",
+ path: "/api/issues/{id}/title",
+ tags: ["issues"],
+ summary: "Set a task title",
+ description: "Changes only the title. onlyIfProvisional preserves an explicit title, including concurrent user edits. Agent callers must own the task's active run. Allowed in Ask and Plan modes.",
+ request: { params: z.object({ id: z.string() }), body: jsonBody(setIssueTitleSchema) },
+ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
+});
+
registry.registerPath({
method: "patch",
path: "/api/issues/{id}",
diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts
index 6f569def35c..0b77ae299c8 100644
--- a/server/src/services/heartbeat.ts
+++ b/server/src/services/heartbeat.ts
@@ -1,3 +1,4 @@
+import { externalObjectService } from "./external-objects.js";
import { isAiAuthenticationBlocked } from "./ai-auth-failure.js";
import { CHAT_COMPLETION_WAKE_REASON, prepareChatCompletionTurn, chatCompletionInstruction, isCompletedOnboardingHandoffWake } from "./chat-completion-delivery.js";
import { AgentDirectoryReuseInvalidatedError, isAgentDirectoryCopy } from "./agent-directory-working-copies.js";
@@ -8554,6 +8555,7 @@ export function buildPaperclipTaskMarkdown(input: {
id: string;
identifier: string | null;
title: string;
+ titleNeedsGeneration?: boolean;
workMode?: string | null;
conversationAgentId?: string | null;
description?: string | null;
@@ -8732,6 +8734,14 @@ export function buildPaperclipTaskMarkdown(input: {
`- Issue: ${quoteTaskScalar(issue.identifier || issue.id)}`,
`- Title: ${quoteTaskScalar(issue.title)}`,
);
+ if (issue.titleNeedsGeneration && !issue.conversationAgentId) {
+ lines.push(
+ "",
+ "Task title directive:",
+ "The current title is a provisional slice of the user's prompt. As one of your first tool calls, use set_task_title with a concise title describing the requested outcome and onlyIfProvisional: true. If that tool is unavailable, PUT /api/issues/" + issue.id + "/title with {title, onlyIfProvisional: true} using your normal Paperclip authentication. Do this in Ask and Plan modes too. Preserve the full task description and any title already chosen by the user; then continue the task.",
+ "Check the title tool result. If its wording is rejected as credential material, retry once with a shorter, plain-language title that keeps the task's meaning and contains no secret values. Use a new idempotency key for changed arguments. Do not treat a rejected call as a saved title.",
+ );
+ }
if (issue.conversationAgentId) {
lines.push("", "Chat mode directive:", AGENT_CHAT_DIRECTIVE, `Current composer mode: ${issue.workMode ?? "standard"}.`);
if (input.conversationConfirmations?.cards.length) {
@@ -10691,6 +10701,7 @@ export function heartbeatService(
id: issues.id,
identifier: issues.identifier,
title: issues.title,
+ titleNeedsGeneration: issues.titleNeedsGeneration,
description: issues.description,
status: issues.status,
workMode: issues.workMode,
@@ -20976,6 +20987,7 @@ export function heartbeatService(
id: issueRef.id,
identifier: issueRef.identifier,
title: issueRef.title,
+ titleNeedsGeneration: issueContext?.titleNeedsGeneration,
workMode: issueRef.workMode,
conversationAgentId: issueContext?.conversationAgentId,
description: issueRef.description,
@@ -24608,6 +24620,10 @@ export function heartbeatService(
}
},
enqueueWakeup,
+ syncIssueExternalObjects: externalObjectService(db, {
+ pluginWorkerManager: options.pluginWorkerManager,
+ enabled: async () => (await instanceSettings.getExperimental()).enableExternalObjects === true,
+ }).syncIssueSafely,
onSpawn: async (meta) => {
markDispatchStarted();
await persistRunProcessMetadata(run.id, meta);
diff --git a/server/src/services/issue-title.ts b/server/src/services/issue-title.ts
new file mode 100644
index 00000000000..5b5386b5636
--- /dev/null
+++ b/server/src/services/issue-title.ts
@@ -0,0 +1,80 @@
+import { and, eq } from "drizzle-orm";
+import { heartbeatRuns, issues, type Db } from "@paperclipai/db";
+import { setIssueTitleSchema, type SetIssueTitle } from "@paperclipai/shared";
+import { badRequest, conflict, forbidden, notFound } from "../errors.js";
+import { persistActivity, type ActivityPublication, type LogActivityInput } from "./activity-log.js";
+import { issueReferenceService } from "./issue-references.js";
+
+type TitleActor = Pick;
+type TitleResult = { id: string; title: string; titleNeedsGeneration: boolean; changed: boolean };
+type TitleReceipt = { issueId: string; title: string; onlyIfProvisional: boolean; result: TitleResult };
+
+const MAX_TASK_TITLE_RECEIPTS_PER_RUN = 64;
+
+/** Caller owns the transaction and publishes the activity only after commit. */
+export async function setIssueTitle(
+ tx: Db,
+ companyId: string,
+ issueId: string,
+ input: SetIssueTitle,
+ actor: TitleActor,
+): Promise<{ result: TitleResult; publication: ActivityPublication | null }> {
+ const { title, onlyIfProvisional, idempotencyKey } = setIssueTitleSchema.parse(input);
+ const predicate = and(eq(issues.id, issueId), eq(issues.companyId, companyId));
+ const [issue] = await tx.select().from(issues).where(predicate).for("update");
+ if (!issue) throw notFound("Task not found");
+ let authorizedRun: typeof heartbeatRuns.$inferSelect | undefined;
+ if (actor.actorType === "agent") {
+ if (issue.assigneeAgentId !== actor.actorId || !actor.runId) {
+ throw forbidden("Setting a task title requires its assigned agent's active run");
+ }
+ const [run] = await tx.select().from(heartbeatRuns).where(and(
+ eq(heartbeatRuns.id, actor.runId), eq(heartbeatRuns.companyId, companyId),
+ eq(heartbeatRuns.agentId, actor.actorId),
+ )).for("update");
+ const snapshot = run?.contextSnapshot ?? {};
+ if (!run || run.status !== "running"
+ || (run.nativeIssueId ?? snapshot.issueId ?? snapshot.taskId) !== issueId
+ || (issue.executionRunId && issue.executionRunId !== run.id)
+ || (issue.checkoutRunId && issue.checkoutRunId !== run.id)
+ || (issue.conversationAgentId && Number(snapshot.conversationSessionGeneration ?? 0) !== issue.conversationSessionGeneration)) {
+ throw forbidden("The active run no longer owns this task");
+ }
+ authorizedRun = run;
+ }
+ if (idempotencyKey && !authorizedRun) throw badRequest("Title retry keys require an authenticated agent run");
+ const receipts = (authorizedRun?.resultJson?.taskTitleReceipts ?? {}) as Record;
+ const receiptKey = idempotencyKey ? `title:${idempotencyKey}` : null;
+ const prior = receiptKey ? receipts[receiptKey] : undefined;
+ if (prior) {
+ if (prior.issueId !== issueId || prior.title !== title || prior.onlyIfProvisional !== onlyIfProvisional) {
+ throw conflict("Task title idempotency key was already used with different arguments");
+ }
+ return { result: prior.result, publication: null };
+ }
+ // Keep all accepted keys replayable, including after a user edit. Reject new
+ // keys at the limit instead of evicting receipts and allowing writes to replay.
+ if (receiptKey && Object.keys(receipts).length >= MAX_TASK_TITLE_RECEIPTS_PER_RUN) {
+ throw conflict("Task title retry-key limit reached for this run");
+ }
+ const saveReceipt = async (result: TitleResult) => {
+ if (receiptKey && authorizedRun) {
+ await tx.update(heartbeatRuns).set({
+ resultJson: { ...authorizedRun.resultJson, taskTitleReceipts: { ...receipts, [receiptKey]: { issueId, title, onlyIfProvisional, result } } },
+ updatedAt: new Date(),
+ }).where(eq(heartbeatRuns.id, authorizedRun.id));
+ }
+ return result;
+ };
+ if ((onlyIfProvisional && !issue.titleNeedsGeneration)
+ || (issue.title === title && !issue.titleNeedsGeneration)) {
+ return { result: await saveReceipt({ id: issue.id, title: issue.title, titleNeedsGeneration: issue.titleNeedsGeneration, changed: false }), publication: null };
+ }
+ await tx.update(issues).set({ title, titleNeedsGeneration: false, updatedAt: new Date() }).where(predicate);
+ await issueReferenceService(tx).syncIssue(issueId, tx);
+ const { publication } = await persistActivity(tx, {
+ ...actor, companyId, action: "issue.updated", entityType: "issue", entityId: issueId,
+ details: { title, previous: { title: issue.title }, titleNeedsGeneration: false },
+ });
+ return { result: await saveReceipt({ id: issueId, title, titleNeedsGeneration: false, changed: true }), publication };
+}
diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts
index d2087b3da06..18585836a40 100644
--- a/server/src/services/issues.ts
+++ b/server/src/services/issues.ts
@@ -1929,7 +1929,8 @@ async function assertExecutionTaskParent(db: Db, companyId: string, parentId?: s
}
type DbTransaction = Parameters[0]>[0];
-type IssueCreateInput = Omit & {
+type IssueCreateInput = Omit & {
+ title?: string;
initialPlan?: string | null;
labelIds?: string[];
blockedByIssueIds?: string[];
@@ -4851,6 +4852,7 @@ const issueListSelect = {
goalId: issues.goalId,
parentId: issues.parentId,
title: issues.title,
+ titleNeedsGeneration: issues.titleNeedsGeneration,
description: sql`
CASE
WHEN ${issues.description} IS NULL THEN NULL
@@ -9753,6 +9755,14 @@ export function issueService(db: Db) {
onDeduplicated,
...issueData
} = data;
+ const explicitTitle = issueData.title?.trim();
+ const provisionalTitle = issueData.description?.trim().replace(/\s+/g, " ").slice(0, 120);
+ const resolvedTitle = explicitTitle || provisionalTitle;
+ if (!resolvedTitle) throw unprocessable("Provide a title or task description");
+ const titleNeedsGeneration = !explicitTitle;
+ // A prompt prefix is not a task identity: distinct requests can share it.
+ const deduplicateByTitle = allowDuplicate === false && !titleNeedsGeneration;
+ issueData.title = resolvedTitle;
const isolatedWorkspacesEnabled = (
await instanceSettings.getExperimental()
).enableIsolatedWorkspaces;
@@ -9793,8 +9803,8 @@ export function issueService(db: Db) {
}
}
const idempotencyKey = rawIdempotencyKey?.trim() || null;
- const normalizedTitle = normalizeCreateIssueTitle(issueData.title);
- if (allowDuplicate === false) {
+ const normalizedTitle = normalizeCreateIssueTitle(resolvedTitle);
+ if (deduplicateByTitle) {
const titleGuardKey = `issue-create:title:${companyId}:${issueData.parentId ?? "root"}:${normalizedTitle}`;
await tx.execute(
sql`select pg_advisory_xact_lock(hashtextextended(${titleGuardKey}, 0))`,
@@ -9843,7 +9853,7 @@ export function issueService(db: Db) {
.then((rows) => rows.map((row) => row.issues));
if (existingIssue) deduplicationReason = "idempotency_key";
}
- if (!existingIssue && allowDuplicate === false) {
+ if (!existingIssue && deduplicateByTitle) {
[existingIssue] = await tx
.select()
.from(issues)
@@ -10125,6 +10135,7 @@ export function issueService(db: Db) {
const values = {
...issueData,
+ titleNeedsGeneration,
originRunId: issueData.originRunId ?? actorRunId ?? null,
responsibleUserId,
requestDepth: clampIssueRequestDepth(issueData.requestDepth),
@@ -10622,6 +10633,8 @@ export function issueService(db: Db) {
companyGuard,
...issueData
} = data;
+ // An explicit edit claims the title, even if it keeps the same text.
+ if (issueData.title !== undefined) issueData.titleNeedsGeneration = false;
if (
issueData.assigneeAgentId !== undefined &&
issueData.assigneeAgentId !== existing.assigneeAgentId
diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts
index ca6901a3868..4ea8aa1b99b 100644
--- a/server/src/services/native-runtime/native-session-executor.test.ts
+++ b/server/src/services/native-runtime/native-session-executor.test.ts
@@ -2694,6 +2694,9 @@ describe("retained native cleanup activation", () => {
"distinct_provider_account",
"wrong_provider_account",
"wrong_result_digest",
+ "digest_only_command",
+ "wrong_command_digest",
+ "conflicting_command_digest",
"wrong_semantic_input",
"wrong_contract",
"wrong_turn",
@@ -2927,7 +2930,9 @@ describe("retained native cleanup activation", () => {
payload: {
callId: semantic.callId,
operationId: semantic.operationId,
- input: semanticInput,
+ ...(mode === "digest_only_command" || mode === "wrong_command_digest"
+ ? { inputDigest: mode === "wrong_command_digest" ? "wrong" : nativeSha256(semanticInput) }
+ : { input: semanticInput, ...(mode === "conflicting_command_digest" ? { inputDigest: "wrong" } : {}) }),
correlation,
sourceEventId: rawInput.sourceEventId,
sourceEventType: rawInput.eventType,
@@ -3188,6 +3193,8 @@ describe("retained native cleanup activation", () => {
![
"foreign_event",
"wrong_result_digest",
+ "wrong_command_digest",
+ "conflicting_command_digest",
"wrong_semantic_input",
"wrong_contract",
"wrong_turn",
@@ -3793,6 +3800,8 @@ describe("retained native cleanup activation", () => {
"foreign_event",
"nonempty_root",
"wrong_result_digest",
+ "wrong_command_digest",
+ "conflicting_command_digest",
"wrong_semantic_input",
"wrong_contract",
"wrong_turn",
@@ -3802,6 +3811,7 @@ describe("retained native cleanup activation", () => {
"wrong_provider_account",
].includes(mode);
const succeeds = [
+ "digest_only_command",
"home_paginated",
"canonical_source",
"canonical_claim_commit_stalled",
diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts
index d815eab5b1d..5bd60887163 100644
--- a/server/src/services/native-runtime/native-session-executor.ts
+++ b/server/src/services/native-runtime/native-session-executor.ts
@@ -2193,7 +2193,13 @@ export function retainedNativeCleanupJournalMatches(input: {
payload.isError === false &&
payload.sourceEventId === event.sourceEventId &&
payload.sourceEventType === event.eventType &&
- canonicalJson(payload.input) === canonicalJson(semantic.input) &&
+ // Current receipts retain a digest; older journals retain the input.
+ // If both exist, both must bind to the accepted semantic input.
+ (payload.inputDigest === undefined
+ ? canonicalJson(payload.input) === canonicalJson(semantic.input)
+ : payload.inputDigest === nativeSha256(semantic.input) &&
+ (payload.input === undefined ||
+ canonicalJson(payload.input) === canonicalJson(semantic.input))) &&
canonicalJson(payload.correlation) ===
canonicalJson(semantic.correlation) &&
record(record(command.result).result).callId === semantic.callId
@@ -7268,6 +7274,7 @@ export async function executePaperclipNativeSession(input: {
runnerRemoteCodexNpmSpec?: string | null;
runnerRemoteProviderPackPath?: string | null;
stopTaskForReassignment?: (target: { companyId: string; issueId: string; agentId: string; runId: string | null }) => Promise;
+ syncIssueExternalObjects?: (issueId: string) => Promise;
enqueueWakeup?: (
agentId: string,
options: {
@@ -10641,6 +10648,7 @@ export async function createRunnerdBackend(input: {
toolTrace?: NativeToolTrace;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise;
stopTaskForReassignment?: (target: { companyId: string; issueId: string; agentId: string; runId: string | null }) => Promise;
+ syncIssueExternalObjects?: (issueId: string) => Promise;
enqueueWakeup?: (
agentId: string,
options: {
@@ -10783,6 +10791,7 @@ async function createRunnerdBackendWithinSessionClaim(
currentWakeComments: currentWakeComments ?? undefined,
chatAttachmentReadScope: input.chatAttachmentReadScope,
stopTaskForReassignment: input.stopTaskForReassignment,
+ syncIssueExternalObjects: input.syncIssueExternalObjects,
enqueueWakeup: input.enqueueWakeup,
});
const authorityEpoch = new SessionToolAuthorityEpoch(
diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts
index fd703e16780..2eb7cc722ee 100644
--- a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts
+++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts
@@ -99,7 +99,7 @@ describe("PaperclipRunnerToolAuthority", () => {
issueId,
runId,
});
- expect(authority.definitions()).toHaveLength(34);
+ expect(authority.definitions()).toHaveLength(35);
const questions = authority.definitions().find(tool => tool.name === "request_human_input")!;
expect(questions.description).toContain("ask only the next unanswered question");
expect(questions.description).toContain("Never fabricate answers");
diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts
index 6155700edfc..d36be834206 100644
--- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts
+++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts
@@ -1,3 +1,7 @@
+import { setIssueTitle } from "../issue-title.js";
+import { externalObjectService } from "../external-objects.js";
+import { instanceSettingsService } from "../instance-settings.js";
+import { setIssueTitleSchema } from "@paperclipai/shared";
import { authorizeInstructionCommit } from "../agent-instruction-authorization.js";
import { executeAgentInstructionTool } from "./agent-instruction-tools.js";
import { createReadStream } from "node:fs";
@@ -87,7 +91,7 @@ import {
const IMPLEMENTED_OPERATIONS = new Set([
"read_agent_instructions", "update_agent_instructions", "get_agent_instruction_history", "restore_agent_instructions",
"search_api", "call_api", "hire_agent",
- "get_task_context", "get_task_history", "search_tasks", "report_progress",
+ "get_task_context", "get_task_history", "search_tasks", "report_progress", "set_task_title",
"request_human_input",
"create_skill", "create_task", "reassign_task", "set_dependencies", "create_project", "list_project_repositories", "list_projects", "register_deliverable",
"list_documents", "read_document", "list_document_revisions", "write_document",
@@ -120,6 +124,7 @@ type Binding = {
readRemoteWorkspaceFile?: RemoteWorkspaceFileReader;
currentWakeComments?: CurrentWakeCommentsBinding;
chatAttachmentReadScope?: NativeChatAttachmentReadScope;
+ syncIssueExternalObjects?: (issueId: string) => Promise;
stopTaskForReassignment?: (target: { companyId: string; issueId: string; agentId: string; runId: string | null }) => Promise;
enqueueWakeup?: (agentId: string, options: {
source: "assignment";
@@ -547,6 +552,7 @@ export class PaperclipRunnerToolAuthority {
case "create_task": return this.#createTask(input,
(await captureRunIdentity(this.db, this.binding)).context?.id ?? null);
case "reassign_task": return this.#reassignTask(input);
+ case "set_task_title": return this.#setTaskTitle(input);
case "set_dependencies": return this.#setDependencies(input);
case "register_deliverable": return this.#registerDeliverable(input);
default: throw new Error("paperclip_runner_tool_not_bound");
@@ -1246,6 +1252,25 @@ export class PaperclipRunnerToolAuthority {
return result;
}
+ async #setTaskTitle(input: Record): Promise {
+ const titleInput = setIssueTitleSchema.parse(input);
+ requiredString(titleInput.idempotencyKey);
+ // Use the shared title receipt so native and HTTP retries have the same
+ // identity and cannot overwrite a later user edit when switching surfaces.
+ const { result, publication } = await this.db.transaction(async (tx) => {
+ await this.#lockAuthorizedMutationContext(tx as unknown as Db);
+ return setIssueTitle(tx as unknown as Db, this.binding.companyId, this.binding.issueId, titleInput, {
+ actorType: "agent", actorId: this.binding.agentId, agentId: this.binding.agentId, runId: this.binding.runId,
+ });
+ });
+ if (publication) publishActivity(publication);
+ const syncExternalObjects = this.binding.syncIssueExternalObjects ?? externalObjectService(this.db, {
+ enabled: async () => (await instanceSettingsService(this.db).getExperimental()).enableExternalObjects === true,
+ }).syncIssueSafely;
+ await syncExternalObjects(this.binding.issueId);
+ return result;
+ }
+
async #setDependencies(input: Record): Promise {
const idempotencyKey = requiredString(input.idempotencyKey);
if (!Array.isArray(input.blockedByTaskIds)) {
@@ -1926,6 +1951,7 @@ function redactedTask(task: typeof issues.$inferSelect) {
companyId: task.companyId,
identifier: task.identifier,
title: task.title,
+ titleNeedsGeneration: task.titleNeedsGeneration,
description: task.description,
status: task.status,
statusVersion: task.statusVersion,
diff --git a/server/src/services/native-runtime/runner-api-catalog.ts b/server/src/services/native-runtime/runner-api-catalog.ts
index da07f5714b8..0a6ed482f5f 100644
--- a/server/src/services/native-runtime/runner-api-catalog.ts
+++ b/server/src/services/native-runtime/runner-api-catalog.ts
@@ -42,6 +42,7 @@ function words(text: string): string[] {
}
function dedicatedTools(method: string, path: string): string[] {
+ if (/\/issues\/\{[^}]+\}\/title$/.test(path) && method === "PUT") return ["set_task_title"];
if (/\/agents\/\{[^}]+\}\/instructions-bundle\/file$/.test(path)) return method === "GET" ? ["read_agent_instructions"] : method === "PUT" ? ["update_agent_instructions"] : [];
if (/\/agents\/\{[^}]+\}\/instructions-bundle\/history$/.test(path) && method === "GET") return ["get_agent_instruction_history"];
if (/\/agents\/\{[^}]+\}\/instructions-bundle\/revision\/\{[^}]+\}$/.test(path) && method === "GET") return ["read_agent_instructions"];
@@ -83,6 +84,7 @@ export function buildRunnerApiCatalog(document: Json = buildOpenApiDocument()):
const restriction = runnerApiRestriction(method, path);
const conversationalConfirmation = method === "POST"
&& /^\/api\/issues\/\{[^}]+\}\/interactions\/\{[^}]+\}\/resolve-from-comment$/.test(path);
+ const taskTitleUpdate = method === "PUT" && /^\/api\/issues\/\{[^}]+\}\/title$/.test(path);
const skillReference = runnerApiReference[`${method} ${path.replace(/\{[^}]+\}/g, "{}")}`];
const protocol = !path.startsWith("/api/") || /\/(oauth|auth|runtime-tools|mcp|ws)(\/|$)/.test(path)
|| /\/(claude-login|login-sessions|start-authorization|finalize-oauth-access)(\/|$)/.test(path)
@@ -109,7 +111,7 @@ export function buildRunnerApiCatalog(document: Json = buildOpenApiDocument()):
dedicatedToolGuidance: dedicatedTools(method, path).includes("hire_agent")
? "Use hire_agent for native teammate identity and persona fields. It fixes the reportsTo and source task context and inherits the caller's native runtime; never use call_api to supply adapter, environment, or credential configuration."
: "Use an available dedicated tool for its supported fields. Inspect that tool's advertised schema; call_api may be used for additional API fields, subject to lifecycle restrictions.",
- allowedModes: method === "GET" || method === "HEAD" ? ["standard", "ask", "planning", "skill_test"]
+ allowedModes: method === "GET" || method === "HEAD" || taskTitleUpdate ? ["standard", "ask", "planning", "skill_test"]
: conversationalConfirmation ? ["standard", "planning", "skill_test"] : ["standard", "skill_test"],
...(skillReference ? { skillReference } : {}),
});
@@ -167,6 +169,6 @@ export function searchRunnerApi(value: unknown) {
return {
results, total: matches.length,
nextCursor: offset + limit < matches.length ? `${fingerprint}:${offset + limit}` : null,
- guidance: "Prefer an available dedicated tool when it supports the required operation and parameters. API permissions still apply. Protocol endpoints require their existing clients. Ask and Plan permit only reads through call_api.",
+ guidance: "Prefer an available dedicated tool when it supports the required operation and parameters. API permissions still apply. Protocol endpoints require their existing clients. Ask and Plan permit reads and active-task title updates; Plan also permits conversational confirmation recording. Check each operation's allowedModes.",
};
}
diff --git a/server/src/services/native-runtime/runner-api.test.ts b/server/src/services/native-runtime/runner-api.test.ts
index a4ca53f595e..608619120f8 100644
--- a/server/src/services/native-runtime/runner-api.test.ts
+++ b/server/src/services/native-runtime/runner-api.test.ts
@@ -58,6 +58,17 @@ describe("bounded response capture receipts", () => {
});
describe("runner API catalog", () => {
+ it.each(["standard", "ask", "planning", "skill_test"])("permits the title-only API in %s mode", workMode => {
+ const operationId = "PUT /api/issues/{id}/title";
+ const { operation } = validateRunnerApiCall({
+ operationId,
+ pathParams: { id: context.issueId },
+ body: { title: "Fix sign-in redirect", onlyIfProvisional: true },
+ }, { ...context, workMode });
+ expect(operation.allowedModes).toContain(workMode);
+ expect(operation.dedicatedTools).toEqual(["set_task_title"]);
+ expect(operation.requestBody?.content["application/json"].schema.additionalProperties).toBe(false);
+ });
it("advertises the conversational recording exception without general approval authority", () => {
const operationId = "POST /api/issues/{id}/interactions/{interactionId}/resolve-from-comment";
const operation = runnerApiOperation(operationId);
diff --git a/server/src/services/project-tools.ts b/server/src/services/project-tools.ts
index 427b665d8f1..c413e5d218a 100644
--- a/server/src/services/project-tools.ts
+++ b/server/src/services/project-tools.ts
@@ -1,4 +1,4 @@
-import { createProjectSchema, createIssueSchema } from "@paperclipai/shared";
+import { createProjectSchema, createIssueSchema, setIssueTitleSchema } from "@paperclipai/shared";
import { z } from "zod";
import { CAPABILITY_SEMANTIC_TOOL_CATALOG } from "../vendor/paperclip-runner/index.js";
import { badRequest } from "../errors.js";
@@ -6,7 +6,7 @@ import { badRequest } from "../errors.js";
export const PROJECT_TOOL_NAMES = ["create_project", "list_project_repositories", "list_projects"];
export function projectToolDefinitions(workMode: string, includeTask = false) {
return CAPABILITY_SEMANTIC_TOOL_CATALOG.filter(tool =>
- (PROJECT_TOOL_NAMES.includes(tool.operationId) || includeTask && tool.operationId === "create_task")
+ (PROJECT_TOOL_NAMES.includes(tool.operationId) || includeTask && ["create_task", "set_task_title"].includes(tool.operationId))
&& tool.allowedModes.includes(workMode as "standard"),
).map(tool => ({ name: tool.operationId, description: tool.description,
inputSchema: tool.operationId === "create_project"
@@ -23,7 +23,10 @@ export async function callProjectTool(input: {
const args = input.arguments;
let path = `/companies/${input.companyId}/projects`;
let body: unknown;
- if (input.name === "list_project_repositories") path = `/companies/${input.companyId}/project-repositories`;
+ if (input.name === "set_task_title") {
+ path = `/issues/${input.issueId}/title`;
+ body = setIssueTitleSchema.parse(args);
+ } else if (input.name === "list_project_repositories") path = `/companies/${input.companyId}/project-repositories`;
else if (input.name === "list_projects") { /* read projects */ }
else if (input.name === "create_project") {
body = createProjectSchema.extend({ idempotencyKey: z.string().min(1).max(255) }).parse(args);
@@ -41,7 +44,7 @@ export async function callProjectTool(input: {
});
} else throw badRequest("Unknown project tool");
const response = await fetch(`${input.apiUrl.replace(/\/+$/, "").replace(/\/api$/, "")}/api${path}`, {
- method: body ? "POST" : "GET",
+ method: input.name === "set_task_title" ? "PUT" : body ? "POST" : "GET",
headers: { Authorization: `Bearer ${input.token}`, "Content-Type": "application/json" },
...(body ? { body: JSON.stringify(body) } : {}),
signal: AbortSignal.timeout(60_000),
diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md
index 753cf6e002b..b1da87ce882 100644
--- a/skills/paperclip/SKILL.md
+++ b/skills/paperclip/SKILL.md
@@ -125,6 +125,8 @@ Headers: Authorization: Bearer $PAPERCLIP_API_KEY, X-Paperclip-Run-Id: $PAPERCLI
If already checked out by you, returns normally. If owned by another agent: `409 Conflict` — stop, pick a different task. **Never retry a 409.**
+**Name provisional tasks early.** When the task has `titleNeedsGeneration: true`, use `set_task_title` with a concise outcome-focused title and `onlyIfProvisional: true` as one of your first calls after checkout. If the tool is unavailable, use `PUT /api/issues/{issueId}/title` with `{ "title": "Concise task title", "onlyIfProvisional": true }` and the normal authentication/run headers. This metadata update is allowed in Ask and Plan modes. Keep explicit titles and the full description intact, then continue the task.
+
**Step 6 — Understand context.** Prefer `GET /api/issues/{issueId}/heartbeat-context` first. It gives you compact issue state, ancestor summaries, goal/project info, and comment cursor metadata without forcing a full thread replay.
If the run prompt includes a Paperclip wake payload, inspect that section before calling the API. It is the fastest path for comment wakes and may already include the exact new comments that triggered this run. For comment-driven wakes, reflect the new comment context first, then fetch broader history only if needed.
diff --git a/tests/e2e/workspace-bootstrap/recovery.spec.ts b/tests/e2e/workspace-bootstrap/recovery.spec.ts
index 90e78e4d014..f8ac233e81f 100644
--- a/tests/e2e/workspace-bootstrap/recovery.spec.ts
+++ b/tests/e2e/workspace-bootstrap/recovery.spec.ts
@@ -22,7 +22,7 @@ for (const persistent of [false, true]) {
if (await announcement.isVisible()) await announcement.click();
await page.getByRole("link", { name: "Tasks", exact: true }).click();
await page.getByRole("button", { name: "New Task", exact: true }).last().click();
- await page.getByRole("textbox", { name: "Task title", exact: true }).fill(title);
+ await page.getByPlaceholder("Task title (optional)", { exact: true }).fill(title);
await page.getByRole("button", { name: "Assignee", exact: true }).click();
await page.getByRole("button", { name: fixture.agentName, exact: true }).click();
// Let the closing popover unmount before clicking another popover trigger.
diff --git a/tests/runner-e2e/FIXTURES.md b/tests/runner-e2e/FIXTURES.md
index 19b46e0dbf4..f28d59b0f20 100644
--- a/tests/runner-e2e/FIXTURES.md
+++ b/tests/runner-e2e/FIXTURES.md
@@ -135,6 +135,26 @@ Adding a task expands its suite's matrix. Update the suite's intentional size,
the complete-catalog size, and credential-free unit tests in the same change.
Paid tests never silently skip a missing credential or unsupported artifact.
+## Prompt-only task title fixtures
+
+`task-titles.ts` defines a bounded ordinary writing request and an independent
+title oracle. Its `single_turn` cases leave the title field empty or supply an
+explicit control title. The harness captures the exact browser creation response
+instead of searching by a title that the agent may already have changed. It
+never patches the title itself. Normal production instructions own the early
+naming behavior; fixture prompts and agent instruction bundles contain no naming
+hints. Existing company/secret/environment/agent registry dependencies are reused,
+with 500-cent company and agent budgets and normal instance teardown.
+
+Keep the call input, successful result, execution receipt, saved task, and
+agent/run-attributed audit correlated. Missing evidence must fail. The first-five
+tool-call bound counts calls in the initial provider run, including discovery.
+The title must describe API-key rotation without requiring one exact wording.
+The control must retain its title throughout, not merely restore it at the end.
+The source digest versions the grader and request in catalog metadata. See
+[Automatic task titles](README.md#automatic-task-titles) for live selectors,
+coverage limits, evidence, and calibration.
+
## New Paperclip object fixtures
The explicit-only `lifecycle-baseline` suite reuses this registry and existing
diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md
index d5231937276..0ee35e5517d 100644
--- a/tests/runner-e2e/README.md
+++ b/tests/runner-e2e/README.md
@@ -878,6 +878,38 @@ environment, task, matcher, or future Paperclip object fixture.
See [SECURITY.md](./SECURITY.md) before enabling paid dispatch, the runner
group, or permanent public history in this public repository.
+## Automatic task titles
+
+The explicit-only `task-titles` suite creates tasks in Chromium with an empty
+title field. Its ordinary writing request contains no naming or tool directions:
+the production execution prompt must cause the agent to call `set_task_title`.
+Six local cells cover standard and Ask naming plus an explicit-title preservation
+control on native Codex and Codex Mini. Each expects one provider run, has a
+six-minute attempt deadline, and applies 500-cent company and agent budget caps.
+
+The oracle captures the browser's original POST and creation response, then
+requires a descriptive replacement title, cleared generation marker, unchanged
+description/assignee, a correlated successful title call within the first five
+tool calls of the initial run and before completion, and a matching agent/run
+audit entry. Reloading the task must show the saved title and requested answer.
+The control rejects even a temporary rewrite of a user-supplied title. A model's
+claim that it renamed the task cannot pass. These cells require the dedicated
+native tool; they do not qualify legacy/API-fallback naming or planning mode.
+
+```sh
+pnpm test:e2e:runner -- --list --suite task-titles
+pnpm test:e2e:runner -- --id task-titles.runner-codex-mini.local.prompt-title-standard --max-automatic-retries 0
+```
+
+Only `OPENAI_API_KEY` is required; no Docker artifact oracle or Daytona is used.
+Use one exact ID for a live smoke run. The existing harness owns fixture setup,
+cleanup, source/model provenance, billing, failure classification, screenshots,
+and reports. All runs in the isolated fixture are included in billing/cleanup.
+Private attempt evidence adds `snapshots/task-title-creation.json` and
+`snapshots/task-title.json`; public screenshots use the existing marked task
+route and final-state capture. `task-titles.test.ts` calibrates correct evidence
+against missing, fabricated, late, misattributed, and overwritten-title outcomes.
+
## Everyday user-story evals
See [EVERYDAY-WORKFLOWS.md](EVERYDAY-WORKFLOWS.md) for the explicit-only native-runner stories and their canonical Evalbook importer. These cells do not expand scheduled `--all` runs.
diff --git a/tests/runner-e2e/catalog.test.ts b/tests/runner-e2e/catalog.test.ts
index 46afe5b8d92..3bf098ded5b 100644
--- a/tests/runner-e2e/catalog.test.ts
+++ b/tests/runner-e2e/catalog.test.ts
@@ -136,10 +136,10 @@ describe("runner E2E catalog", () => {
expect(localIntegrityTasks).toHaveLength(2);
expect(openRouterBreadthTasks).toHaveLength(3);
expect(runnerSuites.map((suite) => suite.expectedMatrixSize)).toEqual([
- 6, 30, 3, 16, 16, 2, 8, 46, 23, 47, 20, 52, 28, 18, 6, 6, 12, 10, 48, 16, 10, 2, 1, 1,
+ 6, 30, 3, 16, 16, 2, 6, 8, 46, 23, 47, 20, 52, 28, 18, 6, 6, 12, 10, 48, 16, 10, 2, 1, 1,
]);
- expect(validateRunnerCatalog()).toHaveLength(427);
- expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(427);
+ expect(validateRunnerCatalog()).toHaveLength(433);
+ expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(433);
expect(
runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"),
).toHaveLength(48);
diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts
index 32e99babc6c..baf09395651 100644
--- a/tests/runner-e2e/catalog.ts
+++ b/tests/runner-e2e/catalog.ts
@@ -1,6 +1,7 @@
import { chatConfirmationTasks } from "./chat-cases.js";
import { instructionPersistenceTask } from "./instruction-persistence.js";
import { apiResponseReadingTask } from "./api-response-reading.js";
+import { taskTitleTasks, taskTitleDefinitionDigest, TASK_TITLE_BUDGET_CENTS } from "./task-titles.js";
import { blockerTasks, blockerProfile } from "./blocker-cases.js";
import { accountingTasks } from "./accounting-cases.js";
import { continuationTasks } from "./continuation-cases.js";
@@ -1138,6 +1139,17 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [
tasks: [apiResponseReadingTask], expectedMatrixSize: 2,
definitionMetadata: { version: 1, grading: "hidden-evidence-exact-copy-and-api-tool-events", scheduling: "explicit-only" },
},
+ {
+ id: "task-titles", label: "Automatic task titles", manualOnly: true,
+ description: "A real agent names a prompt-only task early using production guidance and preserves a supplied title.",
+ groups: ["native", "local"], environments: [localEnvironment],
+ profiles: everydayProfiles.filter(profile => profile.provider === "codex").map(profile => ({
+ ...profile,
+ buildAgent(input) { return { ...profile.buildAgent(input), budgetMonthlyCents: TASK_TITLE_BUDGET_CENTS }; },
+ })),
+ tasks: taskTitleTasks, expectedMatrixSize: 6,
+ definitionMetadata: { version: 1, gradingDigest: taskTitleDefinitionDigest, instructions: "production", grading: "initial-response-early-correlated-tool-and-agent-audit", providerRuns: 1, budgetMonthlyCents: TASK_TITLE_BUDGET_CENTS, scheduling: "explicit-only" },
+ },
{
id: "continuation-accounting", label: "Continuation accounting baseline", manualOnly: true,
description: "Structured productive steps, bounded repair, restart and late gates; comments cannot buy more attempts.",
@@ -1486,6 +1498,7 @@ export function validateRunnerCatalog(): MatrixExecution[] {
...lifecycleLiveTasks,
...continuationTasks,
...everydayTasks,
+ ...taskTitleTasks,
...runnerTasks,
...localIntegrityTasks,
...openRouterBreadthTasks,
diff --git a/tests/runner-e2e/live-fixtures.ts b/tests/runner-e2e/live-fixtures.ts
index f4cf45f9cef..d8a4cbeccbe 100644
--- a/tests/runner-e2e/live-fixtures.ts
+++ b/tests/runner-e2e/live-fixtures.ts
@@ -1,6 +1,7 @@
import path from "node:path";
import { isManagedHiringCase } from "./chat-cases.js";
import { FixtureRegistry } from "./fixture-registry.js";
+import { TASK_TITLE_BUDGET_CENTS } from "./task-titles.js";
import { stageGrokSubscriptionFixture } from "./grok-subscription-fixture.js";
import type { RunnerApi } from "./api.js";
import type {
@@ -134,7 +135,7 @@ export async function setupLiveFixtures(input: {
return api.post("/api/companies", {
name: `Runner E2E ${execution.id} ${input.executionNonce}`,
description: "Ephemeral paid full-stack runner acceptance fixture",
- budgetMonthlyCents: 0,
+ budgetMonthlyCents: execution.suite.id === "task-titles" ? TASK_TITLE_BUDGET_CENTS : 0,
});
},
async teardown() {
diff --git a/tests/runner-e2e/runner.spec.ts b/tests/runner-e2e/runner.spec.ts
index 747747d8eda..3833d3cef02 100644
--- a/tests/runner-e2e/runner.spec.ts
+++ b/tests/runner-e2e/runner.spec.ts
@@ -12,6 +12,7 @@ import type { Issue } from "../../packages/shared/src/types/issue.js";
import { lifecycleLiveCase, gradeLifecycleRepair } from "./lifecycle-live-cases.js";
import { runContinuationFlow } from "./continuation-flow.js";
import { runEverydayFlow } from "./everyday-flow.js";
+import { gradeTaskTitle } from "./task-titles.js";
import { runContextIntegrityFlow } from "./context-integrity-flow.js";
import { createTaskThroughUi, submitTaskReply } from "./user-actions.js";
@@ -549,11 +550,12 @@ for (const execution of executions) {
const title = execution.task.buildTitle(nonce);
let prompt = execution.task.buildPrompt(nonce);
let apiResponseSourceId: string | undefined;
+ let titleCreation: { issue: unknown; submitted: unknown } | undefined;
let lifecycleBlockerId: string | null = null;
const credentials = credentialValues();
const secrets = normalizedSecrets(Object.values(credentials));
const api = new RunnerApi(request);
- const companyRunFlow = ["blocker_guidance", "continuation_accounting", "continuation", "context_integrity", "agent_chat", "everyday_workflow", "first_task", "instruction_persistence"].includes(execution.task.flow);
+ const companyRunFlow = execution.suite.id === "task-titles" || ["blocker_guidance", "continuation_accounting", "continuation", "context_integrity", "agent_chat", "everyday_workflow", "first_task", "instruction_persistence"].includes(execution.task.flow);
const consoleDiagnostics: Array> = [];
const networkDiagnostics: Array> = [];
const pageLifecycleDiagnostics: Array> = [];
@@ -991,6 +993,14 @@ for (const execution of executions) {
if (execution.task.flow === "governed_tool_review") {
reviewProvider = await setupConnectionReview({ page, api, prefix: issuePrefix, companyId: fixtures.company.id, agentId: fixtures.agent.id, marker });
}
+ // Capture the actual creation response: a generated title can change before
+ // a later list query, and the initial provisional state is part of the oracle.
+ const titleCreationResponse = execution.suite.id === "task-titles"
+ ? page.waitForResponse(response => response.request().method() === "POST"
+ && new URL(response.url()).pathname === `/api/companies/${fixtures!.company.id}/issues`,
+ { timeout: Math.max(1, startedAtMs + deadlineMs - Date.now()) })
+ .catch((cause: unknown) => new Error("Could not capture task creation response", { cause }))
+ : null;
turnSubmissionTimesMs.push(
await createTaskThroughUi({
page,
@@ -1004,7 +1014,14 @@ for (const execution of executions) {
);
const deadlineAt = startedAtMs + deadlineMs;
- issue = await pollUntil({
+ if (titleCreationResponse) {
+ const response = await titleCreationResponse;
+ if (response instanceof Error) throw response;
+ expect(response.status()).toBe(201);
+ issue = await response.json() as IssueRecord;
+ titleCreation = { issue, submitted: response.request().postDataJSON() };
+ await writeSanitizedJson(snapshotsDir, "task-title-creation.json", titleCreation, secrets);
+ } else issue = await pollUntil({
label: `UI-created issue ${title}`,
deadlineAt,
load: async () => {
@@ -2016,6 +2033,17 @@ for (const execution of executions) {
await writeSanitizedJson(snapshotsDir, "api-response-pagination.json", paging, secrets);
if (!paging.passed) invariantFailures.push(`Bounded response paging was not proven: ${paging.failure}`);
}
+ if (execution.suite.id === "task-titles") {
+ const titleEvidence = gradeTaskTitle({
+ initial: titleCreation?.issue, submitted: titleCreation?.submitted,
+ final: issue, prompt, explicitTitle: title, agentId: fixtures.agent.id,
+ runId: selectedRuns[0]!.id,
+ events: runEventsByRun.find(captured => captured.runId === selectedRuns[0]!.id)?.events ?? [],
+ activity: await api.get(`/api/issues/${issue.id}/activity`),
+ });
+ await writeSanitizedJson(snapshotsDir, "task-title.json", titleEvidence, secrets);
+ invariantFailures.push(...titleEvidence.checks.filter(check => !check.passed).map(check => `${check.id}: ${check.detail}`));
+ }
const context = record(run.contextSnapshot);
const environmentContext = record(context.paperclipEnvironment);
@@ -2535,12 +2563,17 @@ for (const execution of executions) {
const visibleAgentReplies = page
.getByTestId("task-chat-thread")
.getByTestId("task-chat-agent-bubble");
+ if (execution.suite.id === "task-titles") {
+ await expect(page.getByRole("heading", { name: issue.title.trim(), exact: true })).toBeVisible();
+ }
if (execution.suite.id === "api-response-reading") {
// File delivery renders a card instead of an exact summary bubble.
// Prove the visible link points to the same independently checked bytes.
const proofLink = page.getByRole("link", { name: "Open api-response-proof.txt", exact: true }).first();
await expect(proofLink).toBeVisible({ timeout: 30_000 });
await expect(proofLink).toHaveAttribute("href", `/api/attachments/${downloadedResponseProof!.attachmentId}/content`);
+ } else if (execution.suite.id === "task-titles") {
+ await expect(visibleAgentReplies.filter({ hasText: marker }).last()).toBeVisible({ timeout: 30_000 });
} else if (execution.task.flow === "warm_three_turn") {
// Prove the persisted user-facing response is visible, independently
// of the byte-for-byte workspace checks and lease continuity checks.
diff --git a/tests/runner-e2e/task-titles.test.ts b/tests/runner-e2e/task-titles.test.ts
new file mode 100644
index 00000000000..6adedf4a57c
--- /dev/null
+++ b/tests/runner-e2e/task-titles.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from "vitest";
+import { runnerMatrix, runnerSuites } from "./catalog.js";
+import { parseRunnerSelectors, selectRunnerExecutions } from "./selectors.js";
+import { discoverReportCatalog } from "./report-catalog.js";
+import { gradeTaskTitle, taskTitlePrompt, taskTitleTasks, TASK_TITLE_EARLY_CALL_LIMIT, type TaskTitleEvidenceInput } from "./task-titles.js";
+
+const prompt = taskTitlePrompt("fixture");
+const provisional = prompt.replace(/\s+/g, " ").slice(0, 120);
+const title = "API key rotation checklist";
+const event = (eventType: string, payload: unknown) => ({ eventType, payload: { prpEvent: { payload } } });
+const start = (id: string, name: string, input: unknown = {}) => event("item.started", { item: { id, name, type: "tool_use", input } });
+function evidence(): TaskTitleEvidenceInput {
+ return {
+ prompt, explicitTitle: "", agentId: "agent", runId: "run",
+ submitted: { description: prompt },
+ initial: { id: "task", title: provisional, titleNeedsGeneration: true, description: prompt },
+ final: { id: "task", title, titleNeedsGeneration: false, description: prompt, assigneeAgentId: "agent" },
+ events: [
+ start("context", "get_task_context"),
+ start("naming", "set_task_title", { title, onlyIfProvisional: true, idempotencyKey: "initial-title" }),
+ event("item.completed", { item: { id: "naming", tool_use_id: "naming", type: "tool_result", result: { id: "task", title, changed: true, titleNeedsGeneration: false } } }),
+ event("tool.execution.completed", { name: "set_task_title", executionId: "naming", status: "completed" }),
+ start("finish", "paperclip_finish"),
+ ],
+ activity: [{ entityId: "task", entityType: "issue", action: "issue.updated", actorType: "agent", actorId: "agent", runId: "run", details: { title, previous: { title: provisional } } }],
+ };
+}
+
+describe("task title Product E2E", () => {
+ it("registers six explicit-only local cells, with production instructions and no naming hints in the request", () => {
+ const suite = runnerSuites.find(entry => entry.id === "task-titles")!;
+ const selected = selectRunnerExecutions(parseRunnerSelectors(["--suite", suite.id]));
+ expect(selected).toHaveLength(6);
+ expect(selected.every(entry => entry.environment.id === "local" && entry.profile.provider === "codex")).toBe(true);
+ expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(entry => entry.suite.id === suite.id)).toBe(false);
+ expect(suite.definitionMetadata).toMatchObject({ instructions: "production", providerRuns: 1, budgetMonthlyCents: 500 });
+ for (const task of taskTitleTasks) {
+ expect(task).toMatchObject({ expectedRunCount: 1, flow: "single_turn" });
+ expect(task.buildPrompt("fixture")).not.toMatch(/title|set_task|\/api\/|finish_task|paperclip_finish/i);
+ }
+ const discovered = discoverReportCatalog({ catalog: runnerMatrix, expected: selected.map(entry => entry.id), results: [] });
+ expect(discovered.filter(entry => entry.suite.id === suite.id)).toHaveLength(6);
+ });
+
+ it("accepts early, correlated tool evidence plus the same agent/run's persisted title mutation", () => {
+ expect(gradeTaskTitle(evidence())).toMatchObject({ passed: true, initialTitle: provisional, finalTitle: title });
+ });
+
+ it.each([
+ ["missing evidence", (input: TaskTitleEvidenceInput) => { input.events = []; input.activity = []; }],
+ ["a missing creation request", (input: TaskTitleEvidenceInput) => { input.submitted = undefined; }],
+ ["a narrated call", (input: TaskTitleEvidenceInput) => { input.events = [event("message", { text: `set_task_title(${title}) succeeded` })]; }],
+ ["a stale prompt slice", (input: TaskTitleEvidenceInput) => { (input.final as Record).title = provisional; }],
+ ["a generic new title", (input: TaskTitleEvidenceInput) => { (input.final as Record).title = "Complete the requested task"; }],
+ ["a title supplied by the fixture", (input: TaskTitleEvidenceInput) => { input.submitted = { title: provisional, description: prompt }; }],
+ ["missing provisional marker", (input: TaskTitleEvidenceInput) => { (input.initial as Record).titleNeedsGeneration = false; }],
+ ["a late title call", (input: TaskTitleEvidenceInput) => { input.events = [...Array.from({ length: TASK_TITLE_EARLY_CALL_LIMIT }, (_, i) => start(`read-${i}`, "get_task_context")), ...input.events]; }],
+ ["naming after five shell tools", (input: TaskTitleEvidenceInput) => { input.events = [...Array.from({ length: TASK_TITLE_EARLY_CALL_LIMIT }, (_, i) => event("tool.execution.started", { executionId: `shell-${i}`, name: "shell" })), ...input.events]; }],
+ ["naming after completion", (input: TaskTitleEvidenceInput) => { input.events = [start("already-finished", "paperclip_finish"), ...input.events]; }],
+ ["an unmatched result", (input: TaskTitleEvidenceInput) => { input.events = input.events.filter(row => row.eventType !== "item.completed"); }],
+ ["no successful execution receipt", (input: TaskTitleEvidenceInput) => { input.events = input.events.filter(row => row.eventType !== "tool.execution.completed"); }],
+ ["another run's mutation", (input: TaskTitleEvidenceInput) => { (input.activity[0] as Record).runId = "other-run"; }],
+ ["a board-authored mutation", (input: TaskTitleEvidenceInput) => { (input.activity[0] as Record).actorType = "user"; }],
+ ["a different task", (input: TaskTitleEvidenceInput) => { (input.final as Record).id = "other-task"; }],
+ ["a truncated prompt", (input: TaskTitleEvidenceInput) => { (input.final as Record).description = provisional; }],
+ ] as const)("rejects %s", (_label, corrupt) => {
+ const input = evidence(); corrupt(input);
+ expect(gradeTaskTitle(input).passed).toBe(false);
+ });
+
+ it("preserves an explicit title and rejects a rename even if the original is later restored", () => {
+ const input = evidence();
+ input.explicitTitle = "My chosen title";
+ input.submitted = { title: input.explicitTitle, description: prompt };
+ input.initial = { ...(input.initial as object), title: input.explicitTitle, titleNeedsGeneration: false };
+ input.final = { ...(input.final as object), title: input.explicitTitle };
+ input.events = [start("context", "get_task_context"), start("finish", "paperclip_finish")];
+ const forbiddenWrite = input.activity[0]; input.activity = [];
+ expect(gradeTaskTitle(input).passed).toBe(true);
+ const calls = input.events; input.events = [];
+ expect(gradeTaskTitle(input).passed).toBe(false);
+ input.events = calls;
+ input.activity = [forbiddenWrite];
+ expect(gradeTaskTitle(input).passed).toBe(false);
+ });
+});
diff --git a/tests/runner-e2e/task-titles.ts b/tests/runner-e2e/task-titles.ts
new file mode 100644
index 00000000000..55edb40cb2d
--- /dev/null
+++ b/tests/runner-e2e/task-titles.ts
@@ -0,0 +1,126 @@
+import { createHash } from "node:crypto";
+import { readFileSync } from "node:fs";
+import type { RunnerTaskFixture } from "./types.js";
+
+export const TASK_TITLE_BUDGET_CENTS = 500;
+export const TASK_TITLE_EARLY_CALL_LIMIT = 5;
+export const taskTitleDefinitionDigest = createHash("sha256").update(readFileSync(new URL(import.meta.url))).digest("hex");
+
+// Deliberately no title/tool/API instructions: production guidance must cause naming.
+export function taskTitlePrompt(nonce: string) {
+ return [
+ "I am preparing a short internal handover for a teammate who is taking over a routine maintenance job tomorrow. Keep the answer practical and brief.",
+ "Give me a three-item checklist for safely rotating an expired API key: create its replacement, update the consuming service, and verify access before revoking the old key.",
+ `Include receipt CHECKLIST-${nonce} in your answer. This is writing only; do not access a real account, change credentials, or create files.`,
+ ].join("\n\n");
+}
+
+export const taskTitleTasks: readonly RunnerTaskFixture[] = ([
+ ["prompt-title-standard", "Name a prompt-only task", "standard"],
+ ["prompt-title-ask", "Name a prompt-only Ask task", "ask"],
+ ["preserve-explicit-title", "Preserve the user's title", "standard"],
+] as const).map(([id, label, workMode]) => ({
+ id, label, groups: [], workMode,
+ flow: "single_turn", expectedRunCount: 1,
+ attemptTimeoutMs: { local: 6 * 60_000, daytona: 6 * 60_000 },
+ expectedTerminalState: { issue: "done", run: "succeeded" },
+ buildTitle: nonce => id === "preserve-explicit-title" ? `Teammate handover ${nonce}` : "",
+ buildPrompt: taskTitlePrompt,
+ buildVisibleMarker: nonce => `CHECKLIST-${nonce}`,
+ buildMatchers: (nonce, execution) => [
+ { kind: "message_contains", expected: `CHECKLIST-${nonce}` },
+ { kind: "issue_status", expected: "done" },
+ { kind: "run_status", expected: "succeeded" },
+ { kind: "runtime_mode", expected: execution.profile.expectedRuntimeMode },
+ { kind: "environment", expected: execution.environment.id },
+ ],
+}));
+
+const record = (value: unknown): Record =>
+ value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {};
+
+export interface TaskTitleEvidenceInput {
+ initial: unknown;
+ final: unknown;
+ submitted: unknown;
+ prompt: string;
+ explicitTitle: string;
+ agentId: string;
+ runId: string;
+ events: readonly { eventType?: string; payload?: unknown }[];
+ activity: readonly unknown[];
+}
+
+/** Grade real call/result/receipt evidence and persisted state, never model prose. */
+export function gradeTaskTitle(input: TaskTitleEvidenceInput) {
+ const initial = record(input.initial), final = record(input.final), submitted = record(input.submitted);
+ const provisional = input.prompt.trim().replace(/\s+/g, " ").slice(0, 120);
+ const checks: Array<{ id: string; passed: boolean; detail: string }> = [];
+ const check = (id: string, passed: boolean, detail: string) => checks.push({ id, passed, detail });
+ const starts = new Map; position: number; index: number }>();
+ const results = new Map>();
+ const completions = new Map();
+ let position = 0;
+ input.events.forEach((event, index) => {
+ const payload = record(record(record(event.payload).prpEvent).payload);
+ const item = record(payload.item);
+ if (event.eventType === "tool.execution.started" && typeof payload.executionId === "string" && !starts.has(payload.executionId)) {
+ starts.set(payload.executionId, { name: String(payload.name ?? ""), input: {}, position: ++position, index });
+ }
+ if (event.eventType === "item.started" && item.type === "tool_use" && typeof item.id === "string" && typeof item.name === "string") {
+ const prior = starts.get(item.id);
+ starts.set(item.id, { name: item.name, input: record(item.input), position: prior?.position ?? ++position, index: prior?.index ?? index });
+ }
+ if (event.eventType === "item.completed" && item.type === "tool_result" && typeof item.id === "string"
+ && (item.tool_use_id === undefined || item.tool_use_id === item.id)) results.set(item.id, record(item.result));
+ if (event.eventType === "tool.execution.completed" && payload.name === "set_task_title" && payload.status === "completed" && typeof payload.executionId === "string") {
+ completions.set(payload.executionId, index);
+ }
+ });
+ const calls = [...starts].filter(([, call]) => call.name === "set_task_title");
+ const titleWrites = input.activity.map(record).filter(row => row.action === "issue.updated"
+ && row.entityType === "issue" && row.entityId === initial.id && typeof record(row.details).title === "string");
+ check("creation-request", submitted.description === input.prompt
+ && (input.explicitTitle ? submitted.title === input.explicitTitle : !Object.hasOwn(submitted, "title")), "Browser submitted the full prompt with the requested title, or omitted the title entirely.");
+ check("provider-tool-evidence", starts.size > 0 && Boolean(input.runId), "Provider tool evidence from the initial run is present; narration alone is insufficient.");
+ check("initial-title", initial.title === (input.explicitTitle || provisional)
+ && initial.titleNeedsGeneration === !Boolean(input.explicitTitle), "Creation response proves the initial title and server-owned generation marker before the agent runs.");
+ check("same-task-and-description", typeof initial.id === "string" && final.id === initial.id
+ && initial.description === input.prompt && final.description === input.prompt
+ && final.assigneeAgentId === input.agentId, "The original task, assignment, and complete prompt survive naming.");
+ check("generation-settled", final.titleNeedsGeneration === false, "The stored task no longer needs a generated title.");
+ if (input.explicitTitle) {
+ check("explicit-title-preserved", final.title === input.explicitTitle && titleWrites.length === 0
+ && calls.every(([id]) => results.get(id)?.changed === false), "No title-changing audit or tool result may overwrite a supplied title, even temporarily.");
+ } else {
+ const title = typeof final.title === "string" ? final.title : "";
+ check("descriptive-title", title.trim() === title && title.length >= 8 && title.length <= 120
+ && title !== provisional && !input.prompt.startsWith(title) && !title.includes("CHECKLIST-")
+ && /(rotat|replac|renew|expir)/i.test(title) && /\b(api|keys?|credentials?)\b/i.test(title), "A concise title describes the key-rotation request instead of copying the prompt prefix or answer receipt.");
+ const terminalIndex = input.events.findIndex(event => event.eventType === "run.terminal");
+ const finishIndex = Math.min(
+ [...starts.values()].find(call => ["paperclip_finish", "finish_task"].includes(call.name))?.index ?? input.events.length,
+ terminalIndex < 0 ? input.events.length : terminalIndex,
+ );
+ const proven = calls.filter(([id, call]) => {
+ const result = results.get(id);
+ const completedAt = completions.get(id);
+ return result !== undefined && call.position <= TASK_TITLE_EARLY_CALL_LIMIT && call.input.onlyIfProvisional === true
+ && typeof call.input.idempotencyKey === "string" && call.input.idempotencyKey.trim().length > 0
+ && call.input.title === title && result.id === initial.id && result.title === title
+ && result.changed === true && result.titleNeedsGeneration === false
+ && completedAt !== undefined && completedAt > call.index && completedAt < finishIndex;
+ });
+ check("early-title-tool", proven.length > 0, `A correlated set_task_title call must succeed among the first ${TASK_TITLE_EARLY_CALL_LIMIT} tools of the initial run, before completion.`);
+ check("agent-title-audit", titleWrites.length === 1 && titleWrites[0]?.actorType === "agent"
+ && titleWrites[0]?.actorId === input.agentId && titleWrites[0]?.runId === input.runId
+ && record(titleWrites[0]?.details).title === title
+ && record(record(titleWrites[0]?.details).previous).title === provisional, "The same assigned agent and initial run must own the single persisted title mutation.");
+ }
+ return {
+ schema: "paperclip.task-title-eval.v1", passed: checks.every(entry => entry.passed), checks,
+ initialTitle: initial.title, finalTitle: final.title,
+ titleCalls: calls.map(([id, call]) => ({ id, position: call.position, completed: completions.has(id), result: results.get(id) ?? null })),
+ titleWrites,
+ };
+}
diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts
index b4f0671acc8..129961dbb5b 100644
--- a/ui/src/api/issues.ts
+++ b/ui/src/api/issues.ts
@@ -1,3 +1,4 @@
+import type { SetIssueTitle } from "@paperclipai/shared";
import type { ExecutionReconciliation } from "@paperclipai/shared";
import type {
AcceptedPlanDecompositionSummary,
@@ -256,6 +257,8 @@ export const issuesApi = {
api.delete<{ id: string; archivedAt: Date } | { ok: true }>(
`/issues/${id}/inbox-archive`,
),
+ setTitle: (id: string, data: SetIssueTitle) =>
+ api.put<{ id: string; title: string; titleNeedsGeneration: boolean; changed: boolean }>(`/issues/${id}/title`, data),
create: (companyId: string, data: Record) =>
api.post(`/companies/${companyId}/issues`, data),
update: (id: string, data: Record) => {
diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx
index 9eb155cf017..83c37195be3 100644
--- a/ui/src/components/NewIssueDialog.test.tsx
+++ b/ui/src/components/NewIssueDialog.test.tsx
@@ -731,7 +731,7 @@ describe("NewIssueDialog", () => {
await flush();
expect(container.textContent).not.toContain("Execution workspace");
expect(container.querySelector('option[value="isolated_workspace"]')).toBeNull();
- await typeTextareaValue(container.querySelector('textarea[placeholder="Task title"]')!, "Managed task");
+ await typeTextareaValue(container.querySelector('textarea[placeholder="Task title (optional)"]')!, "Managed task");
const create = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Create Task"));
act(() => create!.click());
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled());
@@ -751,7 +751,7 @@ describe("NewIssueDialog", () => {
const { root } = renderDialog(container, ["workspaces.isolation"]);
await flush();
expect(container.querySelector('option[value="isolated_workspace"]')).toBeNull();
- await typeTextareaValue(container.querySelector('textarea[placeholder="Task title"]')!, "Context task");
+ await typeTextareaValue(container.querySelector('textarea[placeholder="Task title (optional)"]')!, "Context task");
const create = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes(subtask ? "Create Sub-Task" : "Create Task"));
act(() => create!.click());
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled());
@@ -893,6 +893,36 @@ describe("NewIssueDialog", () => {
act(() => root.unmount());
});
+ it("restores a description-only draft", async () => {
+ localStorage.setItem("paperclip:issue-draft", JSON.stringify({
+ title: "", description: "Keep the request without a title", status: "todo", priority: "medium",
+ assigneeValue: "", reviewerValue: "", approverValue: "", projectId: "",
+ assigneeModelOverride: "", assigneeThinkingEffort: "", assigneeChrome: false,
+ }));
+ const { root } = renderDialog(container);
+ await flush();
+ await waitForAssertion(() => {
+ expect((container.querySelector('textarea[aria-label="Add description..."]') as HTMLTextAreaElement).value).toBe("Keep the request without a title");
+ });
+ const submit = Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Create Task"))!;
+ expect(submit.hasAttribute("disabled")).toBe(false);
+ await act(async () => root.unmount());
+ });
+
+ it("creates a task from its description without requiring a title", async () => {
+ const { root } = renderDialog(container);
+ await flush();
+ const submit = Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Create Task"))!;
+ expect(submit.hasAttribute("disabled")).toBe(true);
+ await typeTextareaValue(container.querySelector('textarea[aria-label="Add description..."]')!, "Investigate the sign-in redirect and fix it");
+ await vi.waitFor(() => expect(submit.hasAttribute("disabled")).toBe(false));
+ await act(async () => { submit.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
+ await flush();
+ expect(mockIssuesApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ description: "Investigate the sign-in redirect and fix it" }));
+ expect(mockIssuesApi.create.mock.calls[0][1]).not.toHaveProperty("title");
+ await act(async () => root.unmount());
+ });
+
it("submits the latest locally typed title and description", async () => {
let resolveProjects: (projects: Array<{
id: string;
@@ -908,7 +938,7 @@ describe("NewIssueDialog", () => {
const { root } = renderDialog(container);
await flush();
- const titleInput = container.querySelector('textarea[placeholder="Task title"]') as HTMLTextAreaElement | null;
+ const titleInput = container.querySelector('textarea[placeholder="Task title (optional)"]') as HTMLTextAreaElement | null;
const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]') as HTMLTextAreaElement | null;
expect(titleInput).not.toBeNull();
expect(descriptionInput).not.toBeNull();
@@ -988,7 +1018,7 @@ describe("NewIssueDialog", () => {
const { root } = renderDialog(container);
await flush();
- const titleInput = container.querySelector('textarea[placeholder="Task title"]') as HTMLTextAreaElement | null;
+ const titleInput = container.querySelector('textarea[placeholder="Task title (optional)"]') as HTMLTextAreaElement | null;
const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]') as HTMLTextAreaElement | null;
expect(titleInput).not.toBeNull();
expect(descriptionInput).not.toBeNull();
@@ -1024,7 +1054,7 @@ describe("NewIssueDialog", () => {
const { root } = renderDialog(container);
await flush();
- const titleInput = container.querySelector('textarea[placeholder="Task title"]') as HTMLTextAreaElement | null;
+ const titleInput = container.querySelector('textarea[placeholder="Task title (optional)"]') as HTMLTextAreaElement | null;
expect(titleInput).not.toBeNull();
await typeTextareaValue(titleInput!, "Plan this first");
@@ -1062,7 +1092,7 @@ describe("NewIssueDialog", () => {
const { root } = renderDialog(container);
await flush();
- const titleInput = container.querySelector('textarea[placeholder="Task title"]') as HTMLTextAreaElement | null;
+ const titleInput = container.querySelector('textarea[placeholder="Task title (optional)"]') as HTMLTextAreaElement | null;
expect(titleInput).not.toBeNull();
await typeTextareaValue(titleInput!, "Answer this first");
@@ -1256,7 +1286,7 @@ describe("NewIssueDialog", () => {
expect(dialogContent?.className).toContain("h-(--new-issue-dialog-height)");
expect(dialogContent?.className).toContain("overflow-hidden");
- const titleInput = container.querySelector('textarea[placeholder="Task title"]');
+ const titleInput = container.querySelector('textarea[placeholder="Task title (optional)"]');
const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]');
const bodyScrollRegion = Array.from(container.querySelectorAll("div")).find((element) =>
typeof element.className === "string" && element.className.includes("overscroll-contain"),
diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx
index 858b86695e1..6ffaf746925 100644
--- a/ui/src/components/NewIssueDialog.tsx
+++ b/ui/src/components/NewIssueDialog.tsx
@@ -400,7 +400,7 @@ const IssueTitleTextarea = memo(function IssueTitleTextarea({
return (