feat: let agents name prompt-only tasks (#14761)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Users create tasks with a title and a description.
> - A required title adds work when the prompt already explains the
request.
> - An agent can name the task once it reads that request.
> - This pull request accepts prompt-only tasks and starts them with a
short prompt slice.
> - A scoped title tool lets the assigned agent replace that slice early
without changing execution state.
> - A live browser eval checks the real agent call, saved title, audit
entry, and preservation of user titles.

## Linked Issues or Issue Description

**Subsystem affected**

Cross-cutting: task creation, shared contracts, database, server, runner
tools, and board UI.

**Problem or motivation**

Users must currently write a title before they can submit a detailed
task prompt. The agent has enough context to write a useful title
itself.

**Proposed solution**

Make the title optional when a description is present. Save the first
120 characters of the normalized prompt as a provisional title. Ask the
assigned agent to call `set_task_title` early. Use an atomic
provisional-title guard to preserve titles supplied or edited by users.
Keep explicit titles supported.

Related: #14543 and #14556 concern empty-title submission. This change
intentionally enables that submission when a prompt is present, instead
of requiring a title.

## What Changed

- Add the `titleNeedsGeneration` field with an idempotent migration.
Keep existing titles unchanged.
- Add `PUT /api/issues/:id/title` and the native and legacy
`set_task_title` tool. Enforce company access, active-run ownership,
shared, bounded retry receipts across native/HTTP calls, and
transactional audit logging. Refresh external-object links after commit,
with the same feature gate and plugin detectors as ordinary title edits.
- Add early naming guidance in Standard, Ask, and Plan task context.
Preserve the description, status, and assignment.
- Allow prompt-only root and child task creation, plus draft restoration
in the New Task dialog. Keep user titles supported.
- Add an opt-in Product E2E suite for prompt-only Standard and Ask
tasks, plus an explicit-title control. It checks actual provider calls
within the first five tools, persisted state, audit attribution, and the
reloaded UI.
- Preserve a closed vocabulary of API key maintenance phrases in
declared prose while rejecting opaque credential suffixes. Add one
bounded naming retry after wording is rejected, without treating the
rejected call as a saved title.
- Repair the native cleanup receipt check exposed during full
verification: accept matching input digests, retain legacy input checks,
and reject conflicting receipts.

## Verification

- Live Product E2E on `f43478473800e3a46b85c5ee79677efdb15108e7`: **3/3
passed** with native Codex `gpt-5.4-mini`, first attempts only,
automatic retries disabled. Standard and Ask each saved “Rotate expired
API key” on their first tool call, with matching persisted state and a
single same-run audit entry. The explicit-title control retained its
user title with zero title writes. All three verified the reloaded
browser UI.
- Campaign: `local-2026-09-30T21-30-11-021Z`. Earlier failed campaigns
are retained separately; they exposed credential-prose handling and
prompted the naming recovery fix. No failed result was regraded or
deleted.
- Reproduce with `pnpm test:e2e:runner -- --id
task-titles.runner-codex-mini.local.prompt-title-standard --id
task-titles.runner-codex-mini.local.prompt-title-ask --id
task-titles.runner-codex-mini.local.preserve-explicit-title
--max-automatic-retries 0` and an authorized provider key.
- Full `pnpm -r typecheck` and `pnpm build` passed on the latest commit.
The runner build used the configured external eval source tree.
- Product E2E unit suite: **61 files, 818 tests passed**; E2E typecheck
and UI token gates passed.
- Title API/native regressions cover prompt-only and explicit child
creation, user edits, ownership/company isolation, external reference
refresh, cross-surface retry replay, and the 64-key limit without
receipt eviction. All passed. Prompt-context coverage: **44 tests
passed**.
- Rust credential regressions: **35 tests passed**, including benign
maintenance qualifiers and opaque credential rejection in every declared
prose field. Catalog/report reconciliation: **28 tests passed**. Native
recovery: **560 tests passed**.
- Broad local `pnpm test:run`: **14,555 tests passed** in the general
server group; two suites failed to initialize embedded PostgreSQL and
the existing 40,000-file Git streaming stress test exceeded its
300-second macOS timeout. All three suites then passed in isolation (**5
tests passed**) without code or timeout changes. The original full local
command exited nonzero and is not being represented as a clean full run.
- Latest-head GitHub checks are green: **53 passed, 4 skipped, zero
failed or pending**, including all test shards and the canary packaging
dry run. Greptile reviewed the same commit at **5/5**, with zero
unresolved review threads.

## Risks

- The additive database field must reach the server and UI together. The
migration uses `IF NOT EXISTS` and defaults existing tasks to a final
title.
- Title generation depends on the assigned agent running. Tasks without
a run keep their provisional title.
- Live qualification covers the native Codex path in Standard and Ask
modes. API/legacy and Plan behavior have deterministic coverage.
- The credential-prose exception validates the entire suffix against a
closed maintenance vocabulary. Unknown suffixes, assignments, quoted
values, credential prefixes, and diagnostics retain strict checks.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, tool use, and code
execution. The exact deployment ID and context window are not exposed in
this session. The live eval uses the native Codex `gpt-5.4-mini`
profile.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-30 16:48:24 -05:00
1 parent d6d67b00d3
commit 018993140f
63 files changed
+2279 -226

No files matched your search

+11
View File
@@ -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
+4
View File
@@ -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
+6
View File
@@ -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,
@@ -0,0 +1 @@
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "title_needs_generation" boolean DEFAULT false NOT NULL;
+7
View File
@@ -2038,6 +2038,13 @@
"when": 1790776905226,
"tag": "0292_powerful_devos",
"breakpoints": true
},
{
"idx": 293,
"version": "7",
"when": 1790796608171,
"tag": "0293_broad_rattler",
"breakpoints": true
}
]
}
+2
View File
@@ -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),
@@ -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 |
@@ -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",
@@ -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.
@@ -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","requiredClaimLine truncated
[{"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","requiredClaimLine truncated
@@ -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",
@@ -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"
@@ -160,7 +160,7 @@
},
{
"path": "fixtures/evals/native-execution-seeded.json",
"sha256": "076427778f1ecced927c0680ab3d59e52e06d3e680a610bee3af6fd0795b6a8c",
"sha256": "a0f8a3804b062746cdd893d5713fb714ef137f6ef30e37102a4b1bcb7cf66ec5",
"expectation": "accept",
"compatibilityCase": "canonical"
},
@@ -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 [
@@ -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"
]
},
{
@@ -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",
@@ -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"],
@@ -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`<br>`list_agents`<br>`get_agent`<br>`list_projects`<br>`list_goals` | `discovery:agents:read`<br>`discovery:goals:read`<br>`discovery:projects:read`<br>`discovery:tasks:read` | Company-visible task, agent, project, and goal discovery. |
| `discovery` | `search_tasks`<br>`list_agents`<br>`get_agent`<br>`list_projects`<br>`list_goals`<br>`set_task_title` | `discovery:agents:read`<br>`discovery:goals:read`<br>`discovery:projects:read`<br>`discovery:tasks:read` | Company-visible task, agent, project, and goal discovery. |
| `projects` | `create_project`<br>`list_project_repositories` | none | Project creation and authorized repository discovery through the live company/run authority. |
| `delegation_dependencies` | `create_task`<br>`reassign_task`<br>`set_dependencies`<br>`hire_agent` | `delegation:agents:create`<br>`delegation:tasks:assign`<br>`delegation:tasks:create`<br>`dependencies:write` | Create or reassign delegated work and maintain dependency edges. |
| `governance` | `list_approvals`<br>`get_approval`<br>`get_approval_context`<br>`request_approval`<br>`decide_approval`<br>`comment_on_approval` | `governance:approvals:comment`<br>`governance:approvals:decide`<br>`governance:approvals:read`<br>`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`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated PRP tool input/result and existing HTTP route authorization/activity records.<br>catalog PRP status: `bound` |
| `search_tasks` | `optional_agent_tool` | `discovery:tasks:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:company_tasks` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `set_dependencies` | `optional_agent_tool` | `dependencies:write` | `standard`<br>`skill_test` | `company_write` | `required` | no | `semantic_command:set_dependencies` | `scenario` + `live`<br>`live_codex` | `issues.update.blockedByIssueIds`<br>semantic-operation item event plus company-entity state diff and audit record<br>catalog PRP status: `bound` |
| `set_task_title` | `optional_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `task_write` | `required` | no | inline/no mapping | `live`<br>`live_codex` | `setIssueTitle`<br>Run-bound title update and transactional activity record.<br>catalog PRP status: `bound` |
| `sync_company_skills` | `optional_agent_tool` | `company_skills:write` | `standard`<br>`skill_test` | `admin` | `required` | no | `mock_extension:company_skills.sync` | `scenario`<br>`scenario_mock` | `unbound`<br>company admin/portability item event plus audit record<br>catalog PRP status: `audit_pending` |
| `update_agent_instructions` | `always_agent_tool` | none | `standard`<br>`planning` | `company_write` | `recommended` | no | inline/no mapping | `live`<br>`live_codex` | `agentInstructionRevisionService`<br>tool-result item event plus canonical revision receipt for writes; no scenario mock coverage<br>catalog PRP status: `bound` |
| `upsert_case` | `optional_agent_tool` | `cases:write` | `standard`<br>`skill_test` | `company_write` | `required` | no | `mock_extension:cases.upsert` | `scenario`<br>`scenario_mock` | `unbound`<br>semantic-operation item event plus company-entity state diff and audit record<br>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`<br>`enforce_budget` | agent identity, inbox-lite, heartbeat context, budget and active-issue services | `company`<br>`actor`<br>`wake`<br>`task`<br>`budget`<br>`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`<br>`set_task_title` | `select_work`<br>`enforce_budget` | agent identity, inbox-lite, heartbeat context, budget and active-issue services | `company`<br>`actor`<br>`wake`<br>`task`<br>`budget`<br>`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`<br>`actor`<br>`run`<br>`idempotency`<br>`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`<br>`finish_task`<br>`block_task`<br>`request_review` | `reconcile_run`<br>`append_audit_record` | issue PATCH, review/liveness policy, and native finalization arbitration | `task`<br>`comments`<br>`interactions`<br>`blockers`<br>`audit`<br>`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`<br>`report_progress` | `append_audit_record` | issue comment list/get/create routes | `task`<br>`comments`<br>`actor`<br>`idempotency`<br>`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.
@@ -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,
});
@@ -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);
@@ -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",
@@ -1,6 +1,7 @@
export type PaperclipSemanticActionId =
| "search_api"
| "call_api"
| "set_task_title"
| "get_task_context"
| "get_task_history"
| "list_documents"
@@ -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",
]);
@@ -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"',
@@ -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,
@@ -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;
@@ -20,6 +20,7 @@ export interface CapabilityDiscoveryResult {
const MAX_DISCOVERY_RESULTS = 10;
const NAMESPACE: Readonly<Record<CapabilitySemanticOperationId, string>> = 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",
@@ -12,6 +12,7 @@ import type {
const NAMESPACE: Readonly<Record<PaperclipSemanticActionId, string>> =
Object.freeze({
set_task_title: "active_task",
search_api: "api_fallback",
call_api: "api_fallback",
get_task_context: "active_task",
@@ -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");
@@ -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"
+2
View File
@@ -1945,6 +1945,8 @@ export {
type CompanySearchExtractQuery,
type CompanySearchQuery,
createIssueSchema,
setIssueTitleSchema,
type SetIssueTitle,
createIssueInputSchema,
createChildIssueSchema,
createAcceptedPlanDecompositionSchema,
+2
View File
@@ -774,6 +774,8 @@ export interface IssueChangeReceiptEntry {
export type IssueChanges = Record<string, IssueChangeReceiptEntry>;
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. */
+2
View File
@@ -417,6 +417,8 @@ export {
export {
createIssueSchema,
setIssueTitleSchema,
type SetIssueTitle,
createIssueInputSchema,
createChildIssueSchema,
createAcceptedPlanDecompositionSchema,
+22 -2
View File
@@ -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<typeof setIssueTitleSchema>;
export type CreateIssue = z.infer<typeof createIssueSchema>;
@@ -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<typeof createChildIssueSchema>;
@@ -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",
@@ -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<ReturnType<typeof startRunnerApiTestServer>>;
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<ReturnType<typeof server.fixture>>;
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<string, unknown> = {}) => 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 });
});
});
+22
View File
@@ -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),
+12
View File
@@ -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}",
+16
View File
@@ -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);
+80
View File
@@ -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<LogActivityInput, "actorType" | "actorId" | "agentId" | "runId" | "agentApiKeyId">;
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<string, TitleReceipt>;
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 };
}
+17 -4
View File
@@ -1929,7 +1929,8 @@ async function assertExecutionTaskParent(db: Db, companyId: string, parentId?: s
}
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId"> & {
type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId" | "title" | "titleNeedsGeneration"> & {
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<string | null>`
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
@@ -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",
@@ -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<void>;
syncIssueExternalObjects?: (issueId: string) => Promise<void>;
enqueueWakeup?: (
agentId: string,
options: {
@@ -10641,6 +10648,7 @@ export async function createRunnerdBackend(input: {
toolTrace?: NativeToolTrace;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
stopTaskForReassignment?: (target: { companyId: string; issueId: string; agentId: string; runId: string | null }) => Promise<void>;
syncIssueExternalObjects?: (issueId: string) => Promise<void>;
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(
@@ -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");
@@ -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<void>;
stopTaskForReassignment?: (target: { companyId: string; issueId: string; agentId: string; runId: string | null }) => Promise<void>;
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<string, unknown>): Promise<unknown> {
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<string, unknown>): Promise<unknown> {
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,
@@ -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.",
};
}
@@ -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);
+7 -4
View File
@@ -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),
+2
View File
@@ -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.
@@ -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.
+20
View File
@@ -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
+32
View File
@@ -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.
+3 -3
View File
@@ -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);
+13
View File
@@ -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,
+2 -1
View File
@@ -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<CompanyRecord>("/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() {
+35 -2
View File
@@ -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<Record<string, unknown>> = [];
const networkDiagnostics: Array<Record<string, unknown>> = [];
const pageLifecycleDiagnostics: Array<Record<string, unknown>> = [];
@@ -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<unknown[]>(`/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.
+86
View File
@@ -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<string, unknown>).title = provisional; }],
["a generic new title", (input: TaskTitleEvidenceInput) => { (input.final as Record<string, unknown>).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<string, unknown>).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<string, unknown>).runId = "other-run"; }],
["a board-authored mutation", (input: TaskTitleEvidenceInput) => { (input.activity[0] as Record<string, unknown>).actorType = "user"; }],
["a different task", (input: TaskTitleEvidenceInput) => { (input.final as Record<string, unknown>).id = "other-task"; }],
["a truncated prompt", (input: TaskTitleEvidenceInput) => { (input.final as Record<string, unknown>).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);
});
});
+126
View File
@@ -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<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
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<string, { name: string; input: Record<string, unknown>; position: number; index: number }>();
const results = new Map<string, Record<string, unknown>>();
const completions = new Map<string, number>();
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,
};
}
+3
View File
@@ -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<string, unknown>) =>
api.post<Issue>(`/companies/${companyId}/issues`, data),
update: (id: string, data: Record<string, unknown>) => {
+37 -7
View File
@@ -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"),
+8 -11
View File
@@ -400,7 +400,7 @@ const IssueTitleTextarea = memo(function IssueTitleTextarea({
return (
<textarea
className="w-full text-lg font-semibold bg-transparent outline-none resize-none overflow-hidden placeholder:text-muted-foreground/50"
placeholder="Task title"
placeholder="Task title (optional)"
rows={1}
value={draftValue}
onChange={(e) => {
@@ -492,7 +492,6 @@ export function NewIssueDialog() {
const [description, setDescription] = useState("");
const titleRef = useRef("");
const descriptionRef = useRef("");
const [titleHasText, setTitleHasText] = useState(false);
const [draftHasText, setDraftHasText] = useState(false);
const [status, setStatus] = useState("todo");
const [priority, setPriority] = useState("");
@@ -704,7 +703,7 @@ export function NewIssueDialog() {
(draft: IssueDraft) => {
if (draftTimer.current) clearTimeout(draftTimer.current);
draftTimer.current = setTimeout(() => {
if (draft.title.trim()) saveDraft(draft);
if (draft.title.trim() || draft.description.trim()) saveDraft(draft);
}, DEBOUNCE_MS);
},
[],
@@ -715,7 +714,6 @@ export function NewIssueDialog() {
descriptionRef.current = nextDescription;
setTitle(nextTitle);
setDescription(nextDescription);
setTitleHasText(nextTitle.trim().length > 0);
setDraftHasText(nextTitle.trim().length > 0 || nextDescription.trim().length > 0);
}, []);
@@ -767,7 +765,6 @@ export function NewIssueDialog() {
titleRef.current = nextTitle;
const nextTitleHasText = nextTitle.trim().length > 0;
const nextDraftHasText = nextTitleHasText || descriptionRef.current.trim().length > 0;
setTitleHasText((current) => current === nextTitleHasText ? current : nextTitleHasText);
setDraftHasText((current) => current === nextDraftHasText ? current : nextDraftHasText);
queueDraftSave({ title: nextTitle });
}, [queueDraftSave]);
@@ -841,9 +838,9 @@ export function NewIssueDialog() {
executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || defaultProject
? defaultProjectId || null
: null;
} else if (newIssueDefaults.title) {
} else if (newIssueDefaults.title || newIssueDefaults.description) {
const nextWorkMode = isIssueWorkMode(newIssueDefaults.workMode) ? newIssueDefaults.workMode : "standard";
setIssueText(newIssueDefaults.title, newIssueDefaults.description ?? "");
setIssueText(newIssueDefaults.title ?? "", newIssueDefaults.description ?? "");
setStatus(newIssueDefaults.status ?? "todo");
setPriority(newIssueDefaults.priority ?? "");
const defaultProjectId = newIssueDefaults.projectId ?? "";
@@ -868,7 +865,7 @@ export function NewIssueDialog() {
executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || newIssueDefaults.executionWorkspaceId || defaultProject
? defaultProjectId || null
: null;
} else if (draft && draft.title.trim()) {
} else if (draft && (draft.title.trim() || draft.description.trim())) {
const nextWorkMode = isIssueWorkMode(draft.workMode) ? draft.workMode : "standard";
const restoredProjectId = newIssueDefaults.projectId ?? draft.projectId;
const restoredProject = orderedProjects.find((project) => project.id === restoredProjectId);
@@ -1041,7 +1038,7 @@ export function NewIssueDialog() {
function handleSubmit() {
const currentTitle = titleRef.current.trim();
const currentDescription = descriptionRef.current.trim();
if (!effectiveCompanyId || !currentTitle || createIssue.isPending) return;
if (!effectiveCompanyId || (!currentTitle && !currentDescription) || createIssue.isPending) return;
const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({
adapterType: assigneeAdapterType,
lane: assigneeModelLane,
@@ -1078,7 +1075,7 @@ export function NewIssueDialog() {
createIssue.mutate({
companyId: effectiveCompanyId,
stagedFiles,
title: currentTitle,
...(currentTitle ? { title: currentTitle } : {}),
description: currentDescription || undefined,
status,
priority: priority || "medium",
@@ -2362,7 +2359,7 @@ export function NewIssueDialog() {
<Button
size="sm"
className="min-w-(--sz-8_5rem) disabled:opacity-100"
disabled={!titleHasText || createIssue.isPending}
disabled={!draftHasText || createIssue.isPending}
onClick={handleSubmit}
aria-busy={createIssue.isPending}
>
@@ -220,6 +220,7 @@ const EXACT_ACTIONS: Record<string, ExactAction> = {
get_workspace_runtime: { action: "read", running: "Reading workspace status", completed: "Read workspace status" },
control_workspace_service: { action: "run", running: "Controlling a workspace service", completed: "Controlled a workspace service" },
set_dependencies: { action: "update", running: "Updating task dependencies", completed: "Updated task dependencies" },
set_task_title: { action: "update", running: "Naming the task", completed: "Named the task" },
create_task: { action: "create", running: "Creating a task", completed: "Created a task" },
request_approval: { action: "request", running: "Requesting approval", completed: "Requested approval" },
decide_approval: { action: "update", running: "Deciding an approval", completed: "Decided an approval" },