feat: add experimental persistent agent chat (#13284)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Conversations must use the same tasks, controls, and execution history. > - Users need an ongoing chat with an agent without managing task properties. > - Agents should clarify and plan work, then hand execution to assigned project tasks. > - This pull request combines the reviewed Agent Chat stack for one squash merge. > - The benefit is persistent conversation with normal task governance and shared UI. ## Linked Issues or Issue Description **Subsystem affected** Task lifecycle, agent runtime tools, shared task UI, and browser/paid runner tests. **Problem or motivation** Users need one persistent conversation with each agent. A separate chat store or renderer would duplicate task behavior and bypass existing controls. **Proposed solution** Use a task-backed chat per company, user, and agent. Reuse the task composer and transcript. Clarify and plan in chat, then create assigned project tasks with the relevant plan. Keep Agent Chat behind its own disabled-by-default experimental setting. **Roadmap alignment** This implements the task-backed direction in [CEO Chat](https://github.com/paperclipai/paperclip/blob/master/ROADMAP.md#-ceo-chat). Related proposals: #2504 and #9693. Related request: #7981. The maintainer requested one squash merge of the complete stack. Consolidates the reviewed runtime [#13281](https://github.com/paperclipai/paperclip/pull/13281), backend [#13282](https://github.com/paperclipai/paperclip/pull/13282), and UI [#13283](https://github.com/paperclipai/paperclip/pull/13283) layers with this PR's E2E coverage. All four layers passed CI and received Greptile 5/5 before consolidation. This PR targets master and includes the complete feature. ## What Changed - Add personal canonical chat tasks with ordinary company visibility, immutable identity, idempotent first sends, and an idle waiting state. - Process `/new` in queue order. Preserve history, release a chat pause, and fence old provider context and delayed writes. - Keep chat lifecycle rules across recovery, finalization, assignment, task lists, and rollups. - Support research and plan revision in chat. Hand plans to ordinary assigned project tasks before execution starts. Reject new chat subtasks. - Add repository-aware project creation and discovery tools, including multiple repository IDs and GitHub URLs, authorization, idempotency, and durable project-created cards. - Reuse task UI components for chat, with starred/recent agent navigation and a separate `enableAgentChat` experimental flag. - Add deterministic browser tests and 24 paid chat cells across four Codex/Claude profiles, with validated reports and screenshots. - Integrate current master recovery, controller lease, queued-message, and task UI changes. Gate chat interruption and deferred promotion on ownership/feature policy. Guarantee lease renewal and active controls are stopped even if teardown fails. - Preserve master's migration 0273 and generate chat migration 0274 with idempotent replay for development databases. ## Verification - Prior exact heads of all four PRs passed Linux CI, including build, typecheck, general/serialized tests, and browser E2E. Each had Greptile 5/5 and no unresolved findings. - Integrated local verification passed: full repository typecheck and production build, Storybook build, token gates, 340 focused UI tests, all 20 deterministic chat browser tests, two migration replay tests, 88 focused chat/queue/native/controller tests, and provider/session regressions including real lease expiry. These include the three lifecycle regressions for the final admission/teardown fixes; server typecheck also passes. Current head `1268eda16cc2af892055917e7292f068820be135` has Greptile 5/5 with no unresolved findings and passing security scans. All final-head CI gates passed: build, full Runner verification, typecheck/release registry, canary, all general/serialized test shards, and all browser E2E shards ([CI run](https://github.com/paperclipai/paperclip/actions/runs/34696739927)). Local PostgreSQL startup contention required serialized retries; skipped fixtures do not count as passing coverage. - The earlier paid campaign passed all 24 chat cells and retained 32 screenshots: [report](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-34648511170-1/index.html?report=agent-chat#suite-agent-chat). It tested `abacbdfd2f660709ec37312cdb758284c8399d04`; it is prior evidence, not a paid run of this integrated head. - Manual check: enable Agent Chat in Experimental settings, open an agent, clarify and revise a plan, then hand off to an assigned project task. Stop a reply, send `/new`, and verify fresh context with retained history. Disable the setting and verify agent shortcuts/new chat turns are blocked. ## Risks - Queue/session integration can affect retries and delayed writes. Tests cover ownership, cancellation, reset boundaries, idle recovery, and ordinary task behavior. - Migration 0274 adds conversation fields and constraints. Replay is idempotent and preserves existing development chat history. - This combines the previously reviewed stack at the maintainer's request. Agent Chat remains off by default and is separate from Conference Room. ## Model Used OpenAI Codex, GPT-6 Astra (`gpt-6-astra`), with reasoning, code execution, browser tools, and parallel review. The exact context-window size is not exposed in this session. Codex and Claude also ran as test subjects in the linked paid campaign. ## 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:
parent
e830180139
commit
ab15aff390
|
|
@ -968,6 +968,40 @@ jobs:
|
|||
sleep "$((attempt * 10))"
|
||||
done
|
||||
|
||||
# This definition executes only from the authorized default-branch workflow.
|
||||
# Provision host policy before credentials reach target-controlled tests.
|
||||
- name: Provision Codex sandbox on the disposable trusted runner
|
||||
if: matrix.environmentId == 'local' && matrix.profileId == 'runner-codex'
|
||||
run: |
|
||||
node --input-type=module <<'NODE'
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, realpathSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
if (process.platform !== "linux") process.exit(0);
|
||||
let restricted = "0";
|
||||
try { restricted = readFileSync("/proc/sys/kernel/apparmor_restrict_unprivileged_userns", "utf8").trim(); } catch {}
|
||||
if (restricted !== "1") process.exit(0);
|
||||
const root = realpathSync(process.env.GITHUB_WORKSPACE);
|
||||
const runnerRequire = createRequire(path.join(root, "packages/paperclip-runner/package.json"));
|
||||
const acpRequire = createRequire(runnerRequire.resolve("@agentclientprotocol/codex-acp/package.json"));
|
||||
const codexRequire = createRequire(acpRequire.resolve("@openai/codex/package.json"));
|
||||
const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : null;
|
||||
if (!arch) throw new Error("Unsupported Codex CI architecture");
|
||||
const platformPackage = codexRequire.resolve(`@openai/codex-linux-${arch}/package.json`);
|
||||
const triple = arch === "x64" ? "x86_64-unknown-linux-musl" : "aarch64-unknown-linux-musl";
|
||||
const suffix = `/vendor/${triple}/bin/codex`;
|
||||
const binary = realpathSync(path.join(path.dirname(platformPackage), suffix));
|
||||
if (!binary.startsWith(root + "/node_modules/.pnpm/") || !binary.endsWith(suffix) || !/^[/A-Za-z0-9_.@+\-]+$/.test(binary)) {
|
||||
throw new Error("Codex executable is outside the resolved dependency tree");
|
||||
}
|
||||
const name = `paperclip-e2e-codex-${createHash("sha256").update(binary).digest("hex").slice(0,16)}`;
|
||||
const profilePath = path.join(process.env.RUNNER_TEMP, "paperclip-codex-userns.apparmor");
|
||||
writeFileSync(profilePath, `abi <abi/4.0>,\ninclude <tunables/global>\nprofile ${name} "${binary}" flags=(unconfined) {\n userns,\n}\n`, {mode:0o600, flag:"wx"});
|
||||
execFileSync("sudo", ["-n", "apparmor_parser", "-r", profilePath], {timeout:15000, stdio:"pipe"});
|
||||
NODE
|
||||
|
||||
- name: Run paid cell
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ matrix.credentialName == 'OPENAI_API_KEY' && secrets.OPENAI_API_KEY || '' }}
|
||||
|
|
|
|||
|
|
@ -165,15 +165,12 @@ async function seedValidWorktreeSource(
|
|||
principalId: userId,
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Representative seed issue",
|
||||
status: "backlog",
|
||||
priority: "medium",
|
||||
issueNumber: 1,
|
||||
identifier: "SEED-1",
|
||||
});
|
||||
// This helper also seeds an intentionally older schema. Current Drizzle
|
||||
// insert builders include defaults for newly added columns absent there.
|
||||
await db.$client`
|
||||
insert into issues (id, company_id, title, status, priority, issue_number, identifier)
|
||||
values (${issueId}, ${companyId}, 'Representative seed issue', 'backlog', 'medium', 1, 'SEED-1')
|
||||
`;
|
||||
await db.$client.end({ timeout: 5 });
|
||||
return { companyId, issueId };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -387,6 +387,10 @@ pnpm secrets:migrate-inline-env --apply
|
|||
|
||||
Hosted AWS provider notes live in [SECRETS-AWS-PROVIDER.md](./SECRETS-AWS-PROVIDER.md).
|
||||
|
||||
### Persistent agent conversations
|
||||
|
||||
Migration `0274_agent_chat.sql` adds conversation identity/state and session generation/boundary columns to `issues`, plus idempotent client request IDs and processed session-boundary generations to `issue_comments`. The company/agent/user unique index resolves concurrent first writes to one issue. A check constraint preserves the assigned-agent identity and prevents terminal conversation status. Comment request IDs are unique per issue and user. There is no separate chat/message store. Provider sessions continue to use `agent_task_sessions`; `/new` removes only the matching conversation session, and session writers fence stale generations against the issue row.
|
||||
|
||||
## Legacy controller ownership
|
||||
|
||||
Legacy run claims atomically record `controller_boot_id`, a database-clock
|
||||
|
|
|
|||
|
|
@ -160,3 +160,17 @@ Paperclip’s core identity is a **control plane for autonomous AI companies**,
|
|||
|
||||
9. **Thin core, rich edges**
|
||||
Put optional chat, knowledge, and special surfaces into plugins/extensions rather than bloating the control plane.
|
||||
|
||||
### Experimental persistent agent conversations
|
||||
|
||||
Agent Chat is an opt-in core task presentation (`enableAgentChat`, off by default). Each person has one persistent task-backed conversation per agent and company, with ordinary company task visibility. The shared task composer, transcript, tools, files, and document panel remain the interaction surface. Agents clarify goals and hand substantial execution to linked, assigned tasks; a reply ends a turn without completing the conversation. `/new` starts fresh provider context in the same conversation while preserving visible history and artifacts. Healthy idle conversations wait for a message and do not count as unfinished execution work. See `doc/plans/2026-09-10-agent-chat.md` for the implementation contract.
|
||||
|
||||
### Agent chat project handoff (2026-09-11)
|
||||
|
||||
Chat supports research and full plan drafting/revision in its existing plan document. On handoff, each ordinary assigned task receives the relevant plan in its own `plan` document, committed with task creation before execution is scheduled. The source plan remains in the conversation. Plan acceptance hands off execution; it never switches the conversation into implementation.
|
||||
|
||||
Chat instructions require selecting a suitable project, reusing an existing one where appropriate. The project requirement is prompt-only; ordinary projectless tasks remain supported. New parent relationships beneath conversation tasks are rejected by task services, including direct API creation and reparenting. Existing children remain readable/editable and can be moved elsewhere. The Subtasks panel is unchanged.
|
||||
|
||||
The `create_project` runtime tool uses the normal project API with durable idempotency. `list_projects` and `list_project_repositories` support selection. Multiple `repositoryIds` select authorized catalog entries; multiple HTTPS GitHub `repositoryUrls` register existing repositories absent from the catalog. IDs and URLs may be combined, but cannot accompany an explicit `workspace`. URLs do not create repositories on GitHub or grant credentials. Execution uses normal repository access rules. Repository IDs are revalidated against the authenticated run's responsible user and connection grants. Agents should consider proper available repositories, clarify material ambiguity, and use repository-free projects when appropriate for non-code work.
|
||||
|
||||
Confirmed project creation appears as a durable card in the shared task transcript, including selected repository links. Tasks are linked inline. Failed creation never produces a success card. Tool evals cover planning/handoff, project/repository selection, retries, permission and mode denials, and ordinary delegation regressions using the production chat directive.
|
||||
|
|
|
|||
|
|
@ -1573,6 +1573,28 @@ Export/import behavior in V1:
|
|||
- import preview reports skill-policy and legacy-grant mappings before apply and rejects unknown policy schema versions
|
||||
- GitHub imports warn on unpinned refs instead of blocking
|
||||
|
||||
### Experimental task-backed agent chat (2026-09-10)
|
||||
|
||||
`enableAgentChat` is an instance experimental flag, default false. Conversation containers remain issues, unique by `(company_id, conversation_agent_id, conversation_user_id)`. The authenticated board actor supplies ownership; local trusted mode uses `local-board`. Ordinary company task access applies. A conversation's agent assignment and identity are immutable through ordinary updates; terminal status mutations are rejected.
|
||||
|
||||
`GET /api/companies/:companyId/chats/:agentRef` reads an existing conversation or null. `POST` atomically resolves its issue on first send/upload. Existing issue comment, attachment, document, interaction, and run APIs apply thereafter. User chat comments require an idempotent UUID `clientRequestId`. Conversation delivery preserves comment order through the existing issue execution queue; the durable comment outbox repairs the commit-to-enqueue crash window.
|
||||
|
||||
The server owns conversation state: `waiting` plus `in_review` denotes a healthy idle conversation, and `active` denotes an unanswered or executing turn. Successful replies settle a turn; they do not finish the issue. Idle containers are excluded from execution-work counts, ordinary task lists, timer work, and recovery invocations. Failed/unanswered turns retain normal handling. Child completion never wakes or completes the conversation. Search and direct task access preserve history.
|
||||
|
||||
Standalone `/new` is an ordered queue command with no model response. It advances a durable session generation and boundary comment, resets only this issue's provider context, and preserves the issue ID and history. Generation checks reject stale context writes and replies. Fresh replay excludes earlier messages and summaries. The shared transcript renders a session divider.
|
||||
|
||||
Chat prompts retain agent instructions and tools while directing clarification and task creation. Substantial execution belongs to linked, assigned ordinary issues. Ask mode remains non-mutating. Feature disablement prevents new turns and resets while retaining data and lifecycle protection; already-running turns may settle normally.
|
||||
|
||||
### Agent chat project handoff (2026-09-11)
|
||||
|
||||
Chat supports research and full plan drafting/revision in its existing plan document. On handoff, each ordinary assigned task receives the relevant plan in its own `plan` document, committed with task creation before execution is scheduled. The source plan remains in the conversation. Plan acceptance hands off execution; it never switches the conversation into implementation.
|
||||
|
||||
Chat instructions require selecting a suitable project, reusing an existing one where appropriate. The project requirement is prompt-only; ordinary projectless tasks remain supported. New parent relationships beneath conversation tasks are rejected by task services, including direct API creation and reparenting. Existing children remain readable/editable and can be moved elsewhere. The Subtasks panel is unchanged.
|
||||
|
||||
The `create_project` runtime tool uses the normal project API with durable idempotency. `list_projects` and `list_project_repositories` support selection. Multiple `repositoryIds` select authorized catalog entries; multiple HTTPS GitHub `repositoryUrls` register existing repositories absent from the catalog. IDs and URLs may be combined, but cannot accompany an explicit `workspace`. URLs do not create repositories on GitHub or grant credentials. Execution uses normal repository access rules. Repository IDs are revalidated against the authenticated run's responsible user and connection grants. Agents should consider proper available repositories, clarify material ambiguity, and use repository-free projects when appropriate for non-code work.
|
||||
|
||||
Confirmed project creation appears as a durable card in the shared task transcript, including selected repository links. Tasks are linked inline. Failed creation never produces a success card. Tool evals cover planning/handoff, project/repository selection, retries, permission and mode denials, and ordinary delegation regressions using the production chat directive.
|
||||
|
||||
### User continuation after execution recovery stops
|
||||
|
||||
An authenticated user message or an exact failed-run Retry can start a fresh
|
||||
|
|
|
|||
12
doc/SPEC.md
12
doc/SPEC.md
|
|
@ -277,6 +277,8 @@ All agent communication flows through the **task system**.
|
|||
|
||||
There is no separate messaging or chat system. Tasks are the communication channel. This keeps all context attached to the work it relates to and creates a natural audit trail.
|
||||
|
||||
Experimental Agent Chat presents one persistent task per person and agent as a simplified conversation. It retains the task composer, transcript, tools, attachments, documents, and existing Subtasks panel, with ordinary company visibility. New execution tasks are ordinary project tasks, not children of the conversation. Idle conversations wait for a message without entering execution-task work queues. Agents clarify goals here and create assigned tasks for substantial execution. `/new` resets provider context at an ordered session boundary within the same task while preserving visible history. `enableAgentChat` is disabled by default; the V1 lifecycle and rollout contract is specified in `SPEC-implementation.md`.
|
||||
|
||||
### Implications
|
||||
|
||||
- An agent's "inbox" is: tasks assigned to them + comments on tasks they're involved in
|
||||
|
|
@ -549,6 +551,16 @@ Things Paperclip explicitly does **not** do:
|
|||
8. **Progressive deployment.** Trivial to start local, straightforward to scale to hosted.
|
||||
9. **Extensible core.** Clean boundaries so plugins can add capabilities (Adapters, knowledge base, revenue tracking) without modifying core.
|
||||
|
||||
### Agent chat project handoff (2026-09-11)
|
||||
|
||||
Chat supports research and full plan drafting/revision in its existing plan document. On handoff, each ordinary assigned task receives the relevant plan in its own `plan` document, committed with task creation before execution is scheduled. The source plan remains in the conversation. Plan acceptance hands off execution; it never switches the conversation into implementation.
|
||||
|
||||
Chat instructions require selecting a suitable project, reusing an existing one where appropriate. The project requirement is prompt-only; ordinary projectless tasks remain supported. New parent relationships beneath conversation tasks are rejected by task services, including direct API creation and reparenting. Existing children remain readable/editable and can be moved elsewhere. The Subtasks panel is unchanged.
|
||||
|
||||
The `create_project` runtime tool uses the normal project API with durable idempotency. `list_projects` and `list_project_repositories` support selection. Multiple `repositoryIds` select authorized catalog entries; multiple HTTPS GitHub `repositoryUrls` register existing repositories absent from the catalog. IDs and URLs may be combined, but cannot accompany an explicit `workspace`. URLs do not create repositories on GitHub or grant credentials. Execution uses normal repository access rules. Repository IDs are revalidated against the authenticated run's responsible user and connection grants. Agents should consider proper available repositories, clarify material ambiguity, and use repository-free projects when appropriate for non-code work.
|
||||
|
||||
Confirmed project creation appears as a durable card in the shared task transcript, including selected repository links. Tasks are linked inline. Failed creation never produces a success card. Tool evals cover planning/handoff, project/repository selection, retries, permission and mode denials, and ordinary delegation regressions using the production chat directive.
|
||||
|
||||
### Paused task messages
|
||||
|
||||
A paused task takes over the composer with an amber notice and a Resume action.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,296 @@
|
|||
# Persistent agent chat, backed by tasks
|
||||
|
||||
Date: 2026-09-10
|
||||
Status: Implemented behind `enableAgentChat`; verification recorded in the implementation handoff.
|
||||
|
||||
## Contract
|
||||
|
||||
Each person has one persistent conversation with each agent in a company. A conversation is an ordinary issue with fixed `conversationAgentId` and `conversationUserId`, a matching agent assignee, and a unique company/agent/user identity. The authenticated board actor supplies the user identity (`local-board` in local trusted mode). Company task authorization still applies: these are separate conversations, not private messages.
|
||||
|
||||
Opening an unused conversation performs a read. First send or upload resolves its backing issue atomically through `POST /api/companies/:companyId/chats/:agentRef`. `GET` on the same path returns the existing issue or null. Comments, documents, files, interactions, runs, and subscriptions use the existing task APIs. User chat comments require a stable `clientRequestId`; retries return the same comment. Comment rows also provide a durable delivery outbox, serialized across servers before admission to the normal execution queue.
|
||||
|
||||
Idle conversations are `in_review` with server-owned `conversationState: waiting`. A new message makes the conversation active. A successful run parks it only after a durable agent response, with no later pending message. Failed or unanswered turns retain ordinary error handling. Finalizers, assignment recovery, liveness classification, timer eligibility, and work counts distinguish conversations from execution tasks. Completion or reassignment cannot terminate or transfer the container. Child completion does not wake the conversation.
|
||||
|
||||
## Session boundaries
|
||||
|
||||
Standalone `/new` is an ordinary queued user comment. The shared composer offers it as a slash command. It executes at a turn boundary without invoking the provider, increments `conversationSessionGeneration`, and records `conversationBoundaryCommentId` plus the generation on the command comment. Processing a retried command is idempotent. Only the matching agent/task provider session is deleted; agent-wide state and other task sessions are untouched.
|
||||
|
||||
A board-authored `/new` also releases pause holds rooted at the chat without waking the stopped turn. Dispatch admits the verified reset even when the previous turn has a no-replay recovery disposition; after the boundary, that old disposition remains auditable but does not block a fresh session. Pending clarification questions from the old session expire, including questions configured to survive ordinary comments. The reset run adds no empty-response notice.
|
||||
|
||||
Provider-session writes and run-authored replies check the generation. Cancelled conversation runs cannot issue mutating API calls, post late replies, or restore provider sessions. Fresh prompt replay is bounded to nondeleted comments after the boundary and before the current wake comment, with source-trust sanitization. Automatic task continuation summaries are omitted for conversations. History, artifacts, plans, and linked tasks retain their IDs and remain available for explicit inspection. The shared transcript renders processed command comments as session dividers.
|
||||
|
||||
## Agent policy
|
||||
|
||||
`server/src/services/agent-conversations.ts` owns the chat directive. The task prompt includes it on initial turns, retries, resumed turns, and fresh sessions. It asks the agent to clarify material gaps, then create and assign ordinary project tasks with outcomes, context, copied plans, and acceptance criteria before claiming they exist. It explicitly overrides ordinary completion and accepted-plan execution instructions for the container. Ask mode stays non-mutating; plan mode supports clarification and planning. Normal tools, approvals, budgets, assignment, and execution policies continue to apply.
|
||||
|
||||
## Shared production composition
|
||||
|
||||
`TaskDetailSurface` in `IssueDetail.tsx` is the shared controller and surface. `AgentChat.tsx` resolves the canonical task and provides an ephemeral view model before the first write. It does not implement a second transcript, composer, file panel, or run controller. The chat presentation hides task metadata and the seeded description bubble, uses task breadcrumb typography with agent avatar/name and a configuration-page gear, and defaults the existing task side panel to artifacts/plans rather than Properties.
|
||||
|
||||
Company-prefixed `chats/:agentRef` routes open the current person's conversation. Direct task URLs remain supported. The existing Agents roster provides a Chat action. The shared sidebar lists starred agents alphabetically, then four recent unstarred conversations, without a divider. Stars appear on hover or keyboard focus. Existing resource memberships store stars; company/user-scoped recent-navigation storage records conversation visits only. The gear goes to agent runtime configuration; See all agents goes to `/agents/all`.
|
||||
|
||||
## Rollout
|
||||
|
||||
`enableAgentChat` defaults to false in the shared feature catalog, validator, server settings, and Experimental settings UI. Navigation, resolution, new messages, and reset commands are gated. Turning the flag off preserves data, allows already-running turns to settle, and prevents new chat execution. Lifecycle protection is independent of flag state; task links remain readable under normal authorization.
|
||||
|
||||
## Verification
|
||||
|
||||
Database and route tests cover concurrent canonical creation, independent users with ordinary company visibility, client retry identity, cross-company denial, local identity, ordered concurrent delivery, separate queued resets, generation fences, replay boundaries, idle recovery classification, disabled admission, and child wake suppression. Shared composer/sidebar/settings tests and Storybook fixtures cover the production composition. Storybook scenarios include first conversation, returning, working, paused, failed send, long history, session boundary, disabled feature, light theme, and ordinary task comparison.
|
||||
|
||||
Required handoff checks: targeted tests; token gates; Storybook build; repository typecheck, tests, and build; browser checks of first send, session divider, stars, switching, drafts, configuration, roster, and disabled states. Fixture navigation is not a claim of a live provider evaluation: task creation quality remains prompt-guided and should be observed during the experimental rollout.
|
||||
|
||||
### Implementation verification — 2026-09-10
|
||||
|
||||
- Repository typecheck (`pnpm -r typecheck`), production build (`pnpm build`), token gates, and Storybook build passed.
|
||||
- Final UI suite: 563 files, 5,622 tests passed. The shared controller/live-update regression pass covers first send and upload, preservation of agent routes, read-only unused conversations, personal live-update resolution, and durable session-divider refresh.
|
||||
- General server lane: 8,345 passed and 38 skipped initially; the four failures (a stale module loaded during editing and three socket disconnects) passed in a fresh 68-test rerun. The conversation suite also executes a real process adapter: two ordinary turns invoke it twice, `/new` invokes it zero times, and each answered turn returns to idle.
|
||||
- All 144 serialized route suites were exercised. The skill-route socket failure and queued-comment fixture cleanup failures passed in a 70-test rerun. Queue test cleanup now clears its full company-scoped foreign-key closure rather than ignoring failed deletes.
|
||||
- Shared, database, CLI, adapter, skills-catalog, and plugin project suites passed. The CLI migration test exposed and verified the cloned-database constraint upgrade fix. Adapter suites that exceeded the default five-second timeout passed with capped workers and a 30-second test timeout. Tests ran against isolated temporary homes and databases; repository-standard unsupported integration cases remained skipped.
|
||||
- Browser checks used the production composition with fixture APIs: first send, retry and draft retention, agent switching, stars, configuration/roster navigation, linked subtasks, paused-agent controls, disabled navigation, long history, and `/new` preserving earlier messages and plans. Live-update unit tests cover the socket/cache behavior independently of Storybook fixtures. No live-model task-handoff quality evaluation was performed.
|
||||
|
||||
The broad `pnpm test:run` attempt was followed by isolated group/file reruns for the failures above; this is not a claim that the initial monolithic command exited successfully. The experiment remains off by default.
|
||||
|
||||
## September 11 reset regression verification
|
||||
|
||||
The initial live demo checked an idle reset but missed Stop followed by `/new`. In the reported Claude run, dispatch cancelled both the reset and follow-up before reset processing; the provider generation stayed at zero, and the cancelled old turn posted a late reply. Regression coverage now includes pause plus a prior no-replay recovery disposition, reset and immediate follow-up queue order, cancelled-run write rejection, expiring persistent clarification questions, and suppressing empty reset-run transcript notices.
|
||||
|
||||
Live Codex and Claude checks confirmed fresh context after pause → `/new` → follow-up. An additional Claude check stopped an actively streaming turn containing a unique code word, reset, and asked for that word without history inspection. Claude reported it was absent; the chat returned to waiting.
|
||||
|
||||
### Agent chat project handoff (2026-09-11)
|
||||
|
||||
Chat supports research and full plan drafting/revision in its existing plan document. On handoff, each ordinary assigned task receives the relevant plan in its own `plan` document, committed with task creation before execution is scheduled. The source plan remains in the conversation. Plan acceptance hands off execution; it never switches the conversation into implementation.
|
||||
|
||||
Chat instructions require selecting a suitable project, reusing an existing one where appropriate. The project requirement is prompt-only; ordinary projectless tasks remain supported. New parent relationships beneath conversation tasks are rejected by task services, including direct API creation and reparenting. Existing children remain readable/editable and can be moved elsewhere. The Subtasks panel is unchanged.
|
||||
|
||||
The `create_project` runtime tool uses the normal project API with durable idempotency. `list_projects` and `list_project_repositories` support selection. Multiple `repositoryIds` select authorized catalog entries; multiple HTTPS GitHub `repositoryUrls` register existing repositories absent from the catalog. IDs and URLs may be combined, but cannot accompany an explicit `workspace`. URLs do not create repositories on GitHub or grant credentials. Execution uses normal repository access rules. Repository IDs are revalidated against the authenticated run's responsible user and connection grants. Agents should consider proper available repositories, clarify material ambiguity, and use repository-free projects when appropriate for non-code work.
|
||||
|
||||
Confirmed project creation appears as a durable card in the shared task transcript, including selected repository links. Tasks are linked inline. Failed creation never produces a success card. Tool evals cover planning/handoff, project/repository selection, retries, permission and mode denials, and ordinary delegation regressions using the production chat directive.
|
||||
|
||||
|
||||
### Project handoff verification (September 11)
|
||||
|
||||
The real-server tool tests cover concurrent project retries, task/plan atomic creation, ordinary child delegation, import/reparenting rejection under conversations, mode restrictions, cancellation, repository URL normalization, and committed project cards. The ordinary task review-path guard now exempts conversations; the server owns their waiting state after a successful reply. The chat directive explicitly tells agents to reply and end their turn without inventing a reviewer or changing status.
|
||||
|
||||
Five focused Codex live evals passed: existing-project reuse, new project/task handoff, multiple repository URLs, plan-only drafting, and authorized repository discovery. Four provider-free contract evals passed for retries, missing access, Ask-mode denial, and persisted handoff plans. The companion harness has 30 passing tests. The qualified Claude eval profile could not start on macOS without its explicit eval credential (it additionally requires Linux x64); it was not bypassed.
|
||||
|
||||
A separate local Claude agent drafted and revised a chat plan, created Garden Club Demo through the dedicated project tool, and created normal assigned task AGE-7 with its initial plan. The plan was persisted at 16:47:51.426 UTC before execution started at 16:47:51.492 UTC; the task completed with an output document and the original chat plan remained. Local Codex created Repository URL Demo with two URLs absent from its catalog; both appeared on the inline card and project configuration. The card persisted across reload and `/new`. Light and dark production-composition stories were inspected in the browser.
|
||||
|
||||
The broad general-server run reported 8,362 passing tests and two failures from pre-fix modules cached before the review guard and explicit-workspace card changes. The fresh current-source API run passed all 21 tests across four files, including both regressions. Remaining repository groups are verified separately so the initial monolithic exit is not represented as a clean pass.
|
||||
|
||||
The full UI lane passed 5,626 tests and the CLI passed 484. The shared and skills-catalog projects passed. The remaining database/adapter/plugin group passed 2,365 tests; a migration startup failure passed alone (1 test), after reducing workers to avoid embedded-Postgres contention. Existing unsupported integration tests remained skipped.
|
||||
|
||||
Both serialized server shards are now verified: all 144 suites passed across their final runs/resumed segments. An outdated project-route mock and the new MCP transport's missing OpenAPI inventory entry were corrected; embedded-Postgres startup failures passed in isolated retries. The API catalog now includes the task-run-only MCP transport and points project discovery/creation to their dedicated tools; its focused suite passed 824 tests. The catalog census has 792 operations (555 authored REST contract cases).
|
||||
|
||||
Repository-wide typecheck and build, Storybook build, and token gates passed. The final API metadata change also passed server typecheck/build. Two follow-up Codex live cases passed with the final directive, and all 11 retained deterministic/live artifacts passed the stronger persisted-state scoring, including detection of unintended tasks created through API fallback. These results do not turn the earlier failed monolithic test command into a clean run.
|
||||
|
||||
|
||||
### 2026-09-11: E2E regression coverage
|
||||
|
||||
Persistent conversations now have dedicated `tests/e2e/agent-chat.spec.ts`
|
||||
coverage using a deterministic process adapter against a disposable real server.
|
||||
The authenticated suite additionally checks separate canonical chats, personal
|
||||
stars/recency, shared company visibility, and cross-company denial for two people.
|
||||
The runner catalog registers `agent-chat`: six scenarios on four local
|
||||
Codex/Claude profiles (24 paid cells). See `tests/runner-e2e/README.md` for launch
|
||||
commands, credential preflight, evidence, and reset/child-run accounting.
|
||||
|
||||
Browser testing identified a company-cache shape mismatch in chat live updates
|
||||
and a dropped reset marker in compact run summaries. Preserve the shared cache
|
||||
contract and `conversationReset` summary field so messages refresh live and reset
|
||||
boundaries do not render empty model-completion notices.
|
||||
|
||||
Local acceptance on 2026-09-11: all 20 deterministic chat scenarios and two
|
||||
existing repository browser scenarios passed against a fresh test instance.
|
||||
The new authenticated two-person scenario passed independently. Runner fixture
|
||||
checks passed (121 tests), and the live-update/run-summary regression checks
|
||||
passed (46 tests). Repository typecheck, build, Storybook build, and token gates
|
||||
passed. Paid Codex and Claude smoke attempts failed credential preflight because
|
||||
`OPENAI_API_KEY` and `ANTHROPIC_API_KEY` were unavailable; the 24-cell matrix is
|
||||
registered but has no claimed paid passing coverage from this run.
|
||||
|
||||
|
||||
### 2026-09-11: Paid runner regression fixes
|
||||
|
||||
The first GitHub campaign exercised all 24 cells and exposed provider-session,
|
||||
queue/lifecycle, plan-review, and shared-feed issues. Follow-up work uses focused
|
||||
provider-free regressions first, then individual paid cells on disposable
|
||||
instances; the running demo remains untouched.
|
||||
|
||||
Claude session serialization now retains its MCP server identity. Conversation
|
||||
containers ignore dependency and child-completion wakes, while pending questions
|
||||
and plan reviews count as durable replies and settle the conversation to waiting.
|
||||
Rejected plan feedback is included in both full and resumed prompt assembly;
|
||||
acceptance resolves the implicit current-task target and hands off the selected
|
||||
plan revision before execution begins.
|
||||
|
||||
Native provider handling preserves FIFO events and terminal schema, projects
|
||||
committed normal replies into chat, and verifies ownership when a restored ACPX
|
||||
session lazily launches its provider during model selection. Linux Codex preflight
|
||||
uses an exact executable AppArmor profile and a provider-free sandbox probe. The
|
||||
focused GitHub campaign `34638268637` passed native Codex continuity/restart and
|
||||
fresh-session reset on both selected cells.
|
||||
|
||||
The shared project card hydrates repository links from the authorized project
|
||||
record while retaining its original durable creation receipt. Regression coverage
|
||||
checks a second repository arriving after creation, reload, and `/new`. Handoff
|
||||
fixtures check committed repository workspaces and actual output documents rather
|
||||
than assuming URL registration adds an entry to the external connection catalog
|
||||
or requiring an unspecified output document key. Failure classification avoids
|
||||
paid retries for explicit non-retryable provider-session failures.
|
||||
|
||||
All 20 deterministic chat browser scenarios and Storybook build passed after
|
||||
these fixes. Focused live checks additionally passed legacy Codex project reuse
|
||||
and repository handoff, legacy Claude plan revision/acceptance/handoff, and native
|
||||
Claude planning, Stop/reset/resume, fresh sessions, and multiple repositories.
|
||||
Final campaign results and broad verification are recorded below when complete.
|
||||
|
||||
The next full campaign (`34640536416`) reached 18/24 passing cells and identified
|
||||
three additional issues. Execution prompts now include the task's persisted plan
|
||||
and selected revision on both fresh and resumed runs; a plan handed off without a
|
||||
description therefore still reaches its executor. Native durable redaction keeps
|
||||
explicit literal/exact acceptance identifiers while continuing to redact actual
|
||||
credential-shaped values. Recovery for an older conversation generation or an
|
||||
already answered turn cannot block a reset or healthy idle chat. Regression tests
|
||||
also preserve recovery for current unanswered turns and unprepared failures.
|
||||
|
||||
Fixture assertions now accept concrete clarification requests without requiring a
|
||||
question mark. They check the approved revision and final execution output rather
|
||||
than rejecting an old draft quoted in plan revision history. Restart verification
|
||||
opens the canonical chat route after reconnecting, preserving the continuity and
|
||||
no-unsolicited-run checks. Stable inconsistent idle states fail promptly instead
|
||||
of waiting through a long timeout and hiding a product race behind a paid retry.
|
||||
Focused native Claude project reuse and multiple-repository handoffs, and legacy
|
||||
Claude multiple-repository handoff, passed on their first attempts with these fixes.
|
||||
|
||||
The focused legacy Claude Stop/reset/resume regression also passed on its first
|
||||
attempt. Latest repository-wide typecheck, build, and token gates passed. Final
|
||||
runner fixture checks passed 151 tests; fresh chat/prompt/recovery checks passed
|
||||
209 tests, and the native session executor file passed 207 tests. Broad local
|
||||
verification is recorded as resumed groups rather than a clean monolithic run:
|
||||
the original command encountered source edits during execution, generated-evidence
|
||||
scanner input, and cold-import/process-startup timeouts under concurrent load.
|
||||
The guidance scanner now excludes only generated runner evidence and has a
|
||||
regression proving authored runner guidance remains scanned. Focused UI, database,
|
||||
publication, and canonical-path CLI reruns passed without product changes.
|
||||
|
||||
Broader adapter verification exposed OpenCode test fixtures reading the developer's
|
||||
real configuration directory. Those fixtures now allocate and restore isolated
|
||||
XDG configuration directories; all 44 source tests and package typecheck pass.
|
||||
The remaining workspace projects were run even after earlier groups stopped at a
|
||||
failure, and the original failure logs remain available alongside focused reruns.
|
||||
|
||||
Campaign `34642700703` passed 19/24 cells. Its remaining failures were traced to
|
||||
one clarification-oracle phrasing, revision-write guidance, runner teardown after
|
||||
a successful restart, and native mutation content passing through diagnostic
|
||||
redaction. The clarification fixture now also recognizes substantive requests
|
||||
for a brief or details. Revision instructions and HTTP conflict errors explicitly
|
||||
map the GET `latestRevisionId` to PUT `baseRevisionId`; a live Codex
|
||||
plan/revise/accept/handoff run passed on its first attempt with that fix.
|
||||
|
||||
Playwright now gives the restart supervisor a bounded SIGTERM shutdown so it can
|
||||
reap children and close log streams. A real zero-provider Playwright regression
|
||||
verifies restart, child process exit, and port closure; cleanup failure still
|
||||
fails the campaign. Native schema-declared task/project/document prose retains
|
||||
its complete contents while credentials and diagnostic data remain scrubbed.
|
||||
Regression coverage includes long plans beyond the diagnostic preview limit.
|
||||
The macOS fake Anthropic service now clears inherited nonblocking socket mode
|
||||
before its bounded request read; all 271 Rust library tests passed afterward.
|
||||
|
||||
All 144 serialized server suites have passing coverage across the resumed shards
|
||||
and focused reruns. Three route fixtures moved cold module imports into bounded
|
||||
setup hooks, preserving their HTTP assertion timeouts; the final affected files
|
||||
passed 119 tests. The completed workspace groups likewise have passing focused
|
||||
reruns for every observed failure. These results are recorded alongside, rather
|
||||
than replacing, the earlier failed monolithic invocation.
|
||||
|
||||
The ACPX sidecar decoder was an additional execution boundary: it applied generic
|
||||
diagnostic redaction before the native semantic-input stage. It now uses the same
|
||||
schema-declared prose policy at decode. The regression feeds a real
|
||||
`runtime.tool_called` event through decoding, pending-call state, and semantic
|
||||
projection, checking complete long-plan contents, protected credentials, unknown
|
||||
operation handling, and matching content digests. The decoder/state checks passed
|
||||
22 tests and durable-state checks passed 30 tests before the next paid campaign.
|
||||
|
||||
The final local native Claude repository handoff preserved the exact previously
|
||||
corrupted task description, plan, and execution output. Its product assertions
|
||||
passed on the first attempt; post-run secret scanning then exposed PostgreSQL
|
||||
removing `instances/<id>/db/postmaster.pid` after directory enumeration. Only
|
||||
ENOENT for that exact transient path is now tolerated. Existing PID contents,
|
||||
other scan errors, mandatory evidence, and process/lease cleanup remain enforced.
|
||||
All 157 runner fixture tests and final repository typecheck/build passed.
|
||||
Campaign `34645293835` tests the complete set of fixes.
|
||||
|
||||
Campaign `34645293835` passed 20/24 cells. Two failures exposed narrow lifecycle
|
||||
races: a successful native chat turn could be mistaken for productive unfinished
|
||||
work before response publication, and an agent comment deferred behind an active
|
||||
execution could wake its assignee after that execution completed. Recovery now
|
||||
leaves the first case to the conversation finalizer; queue promotion cancels the
|
||||
stale terminal-task continuation while retaining human reopening and notifications
|
||||
to other agents. The recovery regression fails with the guard removed and passes
|
||||
with it restored; all 20 comment-wake batching tests and server typecheck pass.
|
||||
|
||||
The other failures distinguish requested approval from ordinary draft planning,
|
||||
and a persisted Paperclip document from a workspace file. The chat directive now
|
||||
explains how to create a revision-bound approval card when explicitly requested,
|
||||
including after a revision. Paid fixtures name the requested Paperclip document
|
||||
explicitly while retaining strict checks of approvals, transferred plans, and
|
||||
persisted execution output.
|
||||
|
||||
Native reconciliation also preserves assessment lineage within its owning run
|
||||
when the task's previous status decision belongs to a different run. Decision
|
||||
lineage still spans runs; the database ownership constraint remains unchanged.
|
||||
The regression reproduces the original foreign-key failure without the fix and
|
||||
passes for absent, same-run, and different-run predecessors with it. The next
|
||||
24-cell campaign is `34646672139`, pinned to `3556fa25f`.
|
||||
|
||||
Campaign `34646672139` passed 23/24 cells: all native cases and all legacy Claude
|
||||
cases passed. The remaining legacy Codex plan-revision failure exposed an adapter
|
||||
prompt omission. Its resume delta discarded the server's task-context Markdown,
|
||||
including both the chat directive and document-concurrency guidance. Codex now
|
||||
selects the same full/compact task-context Markdown as Claude on initial and
|
||||
resumed sessions. Both adapters suppress generic task-completion and child-task
|
||||
planning directives in chat, leaving the central chat policy authoritative.
|
||||
The approval and output assertions remain unchanged.
|
||||
|
||||
Fresh chat prompts use a small conversation-safe default template that retains
|
||||
connection guidance, permissions, budgets, cancellation, and mutation honesty.
|
||||
Explicit custom agent templates remain intact. Native execution and continuation
|
||||
prompts also carry the conversation flag so shared wake rendering cannot reinsert
|
||||
ordinary completion/subtask instructions. The integration regression inspects
|
||||
both fake-CLI stdin and the recorded adapter invocation with the production chat
|
||||
directive; removing the task-context section reproduces the failure. Shared prompt
|
||||
checks (102), actual Codex prompt cases (3), native resume checks (11), affected
|
||||
package typechecks, and server typecheck pass. Campaign `34648511170` tests all
|
||||
24 cells on `abacbdfd2`.
|
||||
|
||||
The complete Codex/Claude execution regression files passed 46 tests. Final
|
||||
repository-wide typecheck and build also passed on `abacbdfd2`, after all prompt
|
||||
changes.
|
||||
|
||||
Final paid verification: campaign `34648511170` passed **24/24** chat cases on
|
||||
`abacbdfd2`: legacy Codex 6/6, legacy Claude 6/6, native Codex 6/6, and native
|
||||
ACPX Claude 6/6. All cells completed by 21:34 UTC on September 11, within the
|
||||
requested three-hour repair window. No acceptance assertions were disabled.
|
||||
|
||||
- [Exact campaign results](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-34648511170-1/summary.md)
|
||||
- [GitHub run and retained evidence](https://github.com/paperclipai/paperclip/actions/runs/34648511170)
|
||||
|
||||
The [HTML dashboard](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-34648511170-1/index.html?report=agent-chat#suite-agent-chat)
|
||||
was repaired from retained evidence after its older trusted catalog omitted the
|
||||
branch-only suite. It now includes the chat suite and 32 screenshots, including
|
||||
eight draft/revised plan captures recovered from their original Playwright
|
||||
attachments. No paid cells were rerun; result records, tested SHA, timestamps,
|
||||
usage, billing, attempts, and cleanup outcomes remain unchanged.
|
||||
|
||||
Reporting now discovers validated display-only entries for unknown selected
|
||||
execution IDs, and publication rejects missing declared screenshots. The exact
|
||||
chat plan filenames are included in packaged evidence. All 165 runner unit tests
|
||||
and runner TypeScript checks passed. Browser verification covered suite
|
||||
filtering, restored plan images, and gallery navigation. This explicitly
|
||||
authorized repair replaces only this campaign's report objects; normal
|
||||
immutable-publication protections remain unchanged.
|
||||
|
||||
The published summary and normalized results were verified after publication:
|
||||
exactly 24 unique expected cells, all passed on attempt 1, all cleanup checks
|
||||
passed, all evidence valid with no evidence errors, and every result bound to
|
||||
`abacbdfd2f660709ec37312cdb758284c8399d04`. The public report returned HTTP 200.
|
||||
|
|
@ -161,6 +161,7 @@ async function runExecutor(
|
|||
config: Record<string, unknown>,
|
||||
options: {
|
||||
context?: Record<string, unknown>;
|
||||
runtime?: Record<string, unknown>;
|
||||
executionTransport?: Record<string, unknown>;
|
||||
authToken?: string;
|
||||
executionTarget?: Record<string, unknown>;
|
||||
|
|
@ -194,7 +195,7 @@ async function runExecutor(
|
|||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
},
|
||||
runtime: {},
|
||||
runtime: options.runtime ?? {},
|
||||
config,
|
||||
context: options.context ?? {},
|
||||
executionTransport: options.executionTransport,
|
||||
|
|
@ -592,6 +593,52 @@ describe("shared ACPX engine runtime behavior", () => {
|
|||
expect(promptMetrics?.runtimeNoteChars).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["claude", false], ["codex", false], ["claude", true], ["codex", true],
|
||||
] as const)("keeps %s ACP conversation policy on fresh, resumed, and reset turns (custom=%s)", async (agent, custom) => {
|
||||
const root = await makeTempRoot();
|
||||
const config = { agent, cwd: root, stateDir: path.join(root, "state"), mode: "persistent",
|
||||
...(custom ? { promptTemplate: "Custom agent instructions." } : {}),
|
||||
};
|
||||
const chatDirective = "Chat mode: clarify goals and hand accepted plans off to ordinary project tasks.";
|
||||
const context = {
|
||||
conversationMode: true,
|
||||
taskId: "chat-1",
|
||||
paperclipTaskMarkdown: chatDirective,
|
||||
paperclipTaskMarkdownCompact: chatDirective,
|
||||
paperclipWake: {
|
||||
reason: "issue_commented",
|
||||
issue: { id: "chat-1", workMode: "planning", status: "in_progress" },
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "accepted",
|
||||
comments: [],
|
||||
commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 },
|
||||
fallbackFetchNeeded: false,
|
||||
},
|
||||
};
|
||||
const fresh = await runExecutor(config, { context });
|
||||
const resumed = await runExecutor(config, {
|
||||
context,
|
||||
runtime: { sessionParams: fresh.result.sessionParams },
|
||||
});
|
||||
expect(resumed.sessionInputs[0]?.resumeSessionId).toBe(fresh.result.sessionId);
|
||||
const reset = await runExecutor(config, { context });
|
||||
expect(reset.sessionInputs[0]?.resumeSessionId).toBeUndefined();
|
||||
for (const { meta } of [fresh, resumed, reset]) {
|
||||
const prompt = String(meta[0]?.prompt ?? "");
|
||||
expect(prompt).toContain(chatDirective);
|
||||
expect(prompt).not.toContain("Execution contract:");
|
||||
expect(prompt).not.toContain("clear final disposition");
|
||||
expect(prompt).not.toContain("Create child issues");
|
||||
expect(prompt).not.toContain("Use child issues");
|
||||
}
|
||||
expect(String(fresh.meta[0]?.prompt)).toContain(custom ? "Custom agent instructions." : "Continue your Paperclip conversation");
|
||||
expect(String(reset.meta[0]?.prompt)).toContain(custom ? "Custom agent instructions." : "Continue your Paperclip conversation");
|
||||
const ordinary = await runExecutor({ ...config, promptTemplate: "" }, { context: { ...context, conversationMode: false } });
|
||||
expect(String(ordinary.meta[0]?.prompt)).toContain("Execution contract:");
|
||||
expect(String(ordinary.meta[0]?.prompt)).toContain("Create child issues from the approved plan");
|
||||
});
|
||||
|
||||
it("uses only the guarded external-chat contract for a default ACPX prompt", async () => {
|
||||
const { meta } = await runExecutor(
|
||||
{ agent: "custom", agentCommand: "node ./fake-acp.js" },
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import {
|
|||
} from "../workspace-restore-merge.js";
|
||||
import {
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
applyPaperclipWorkspaceEnv,
|
||||
asNumber,
|
||||
asString,
|
||||
|
|
@ -2928,7 +2929,9 @@ async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean
|
|||
const hasCustomPromptTemplate = configuredPromptTemplate.trim().length > 0;
|
||||
const promptTemplate = hasCustomPromptTemplate
|
||||
? configuredPromptTemplate
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE;
|
||||
: context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE;
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||
let instructionsPrefix = "";
|
||||
|
|
@ -2972,6 +2975,7 @@ async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean
|
|||
const externalChatTurn = isPaperclipExternalChatTurn(context.paperclipWake);
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
resumedSession,
|
||||
conversationMode: context.conversationMode === true,
|
||||
// The task-context markdown is the authoritative brief on this lane; keep
|
||||
// the wake prompt's description copy out so the prompt carries it once.
|
||||
suppressIssueDescription: taskContextNote.length > 0,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
buildPaperclipEnv,
|
||||
buildRuntimeToolsEnv,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
isPaperclipExternalChatContractTurn,
|
||||
isPaperclipExternalChatQuestionResponseTurn,
|
||||
isPaperclipExternalChatTurn,
|
||||
|
|
@ -86,6 +87,9 @@ describe("runtime connection tool delivery", () => {
|
|||
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain(
|
||||
CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
);
|
||||
expect(DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE).toContain(CONNECTION_INTENT_AGENT_GUIDANCE);
|
||||
expect(DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE).not.toContain("Execution contract:");
|
||||
expect(DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE).not.toContain("child issues");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -908,6 +912,30 @@ describe("runChildProcess", () => {
|
|||
});
|
||||
|
||||
describe("renderPaperclipWakePrompt", () => {
|
||||
it("leaves conversation disposition and accepted-plan handoff to the injected chat policy", () => {
|
||||
const payload = {
|
||||
reason: "issue_commented",
|
||||
issue: { id: "chat", workMode: "planning", status: "in_progress" },
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "accepted",
|
||||
comments: [],
|
||||
commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 },
|
||||
fallbackFetchNeeded: false,
|
||||
};
|
||||
const ordinary = renderPaperclipWakePrompt(payload, { resumedSession: true });
|
||||
expect(ordinary).toContain("Execution contract:");
|
||||
expect(ordinary).toContain("Create child issues from the approved plan");
|
||||
for (const resumedSession of [false, true]) {
|
||||
const chat = renderPaperclipWakePrompt(payload, {
|
||||
resumedSession, conversationMode: true, includeExecutionContract: true,
|
||||
});
|
||||
expect(chat).not.toContain("Execution contract:");
|
||||
expect(chat).not.toContain("clear final disposition");
|
||||
expect(chat).not.toContain("Create child issues");
|
||||
expect(chat).not.toContain("you may create child implementation issues");
|
||||
}
|
||||
});
|
||||
|
||||
const ordinaryExternalChatWake = {
|
||||
reason: "External chat message received",
|
||||
externalChatProvider: " GitHub ",
|
||||
|
|
|
|||
|
|
@ -229,6 +229,18 @@ export const DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [
|
|||
CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
].join("\n");
|
||||
|
||||
// Chat behavior is supplied centrally by the server's task-context markdown.
|
||||
// Keep the ordinary task's completion/delegation contract out of this template.
|
||||
export const DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE = [
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip conversation using the supplied chat mode directive.",
|
||||
"Use available tools and assigned skills as needed; respect budget, pause/cancel, approval gates, and company boundaries.",
|
||||
"Prefer the smallest verification that proves the action. Use PAPERCLIP_SCRATCH_DIR / PAPERCLIP_RUN_SCRATCH_DIR for temporary scratch files.",
|
||||
"After 2 consecutive failures of the same control-plane write, stop retrying that write for the rest of the turn. Report the failure honestly; never claim an unconfirmed mutation succeeded.",
|
||||
"Never create probe or throwaway issue-thread interactions. Every interaction must carry a real, answerable prompt; withdraw one you no longer need.",
|
||||
"",
|
||||
CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
].join("\n");
|
||||
|
||||
export const WATCHDOG_DEFAULT_MANDATE = [
|
||||
"You are running as a task watchdog, not as the original deliverable worker.",
|
||||
"Your mission is to keep the watched issue tree moving by verifying stopped work, not by trusting agent claims.",
|
||||
|
|
@ -2180,6 +2192,9 @@ function renderPaperclipWakePromptBody(
|
|||
options: {
|
||||
resumedSession?: boolean;
|
||||
includeExecutionContract?: boolean;
|
||||
// Conversation policy arrives in the server-owned task markdown. Generic
|
||||
// task disposition and child-delegation instructions conflict with it.
|
||||
conversationMode?: boolean;
|
||||
nativeWakeReaderAvailable?: boolean;
|
||||
// Set by adapters whose prompt already carries the task-context markdown
|
||||
// (the authoritative, uncapped brief) so the description is not delivered
|
||||
|
|
@ -2203,8 +2218,8 @@ function renderPaperclipWakePromptBody(
|
|||
// The heartbeat prompt template already carries the execution contract on
|
||||
// fresh sessions; only resume deltas (which replace the template) and
|
||||
// template-less adapters need the wake-payload copy.
|
||||
const includeExecutionContract =
|
||||
resumedSession || options.includeExecutionContract === true;
|
||||
const includeExecutionContract = options.conversationMode !== true &&
|
||||
(resumedSession || options.includeExecutionContract === true);
|
||||
const hasWakeCommentBatch =
|
||||
normalized.comments.length > 0 ||
|
||||
normalized.includedCount > 0 ||
|
||||
|
|
@ -2499,7 +2514,7 @@ function renderPaperclipWakePromptBody(
|
|||
lines.push(`- checkbox selection ids: ${selectedOptionIds}`);
|
||||
lines.push(`- checkbox selection options: ${selectedOptions}`);
|
||||
}
|
||||
if (normalized.issue?.workMode === "planning" && !normalized.taskWatchdog) {
|
||||
if (normalized.issue?.workMode === "planning" && !normalized.taskWatchdog && options.conversationMode !== true) {
|
||||
const hasWakeComments = normalized.comments.length > 0;
|
||||
const acceptedPlanContinuation =
|
||||
!hasWakeComments &&
|
||||
|
|
@ -2646,7 +2661,7 @@ function renderPaperclipWakePromptBody(
|
|||
"",
|
||||
"Open plan comments to incorporate:",
|
||||
"These open plan annotations are user feedback. Resolved annotations were intentionally omitted.",
|
||||
"Read this before revising the plan or creating child issues from an accepted plan.",
|
||||
"Read this before revising the plan or acting on an accepted plan.",
|
||||
);
|
||||
if (context.latestRevisionNumber || context.latestRevisionId) {
|
||||
lines.push(
|
||||
|
|
@ -2654,9 +2669,10 @@ function renderPaperclipWakePromptBody(
|
|||
);
|
||||
}
|
||||
if (context.interaction) {
|
||||
lines.push(
|
||||
`- interaction: ${context.interaction.kind ?? "unknown"} ${context.interaction.status ?? "unknown"}`,
|
||||
);
|
||||
lines.push(`- interaction: ${context.interaction.kind ?? "unknown"} ${context.interaction.status ?? "unknown"}`);
|
||||
if (context.interaction.status === "rejected") {
|
||||
lines.push("The user requested changes to this plan. Revise it using the feedback below; this is not approval to implement or hand off execution tasks. In Ask mode, discuss the requested changes without mutating documents or tasks.");
|
||||
}
|
||||
if (context.interaction.result) {
|
||||
const result = context.interaction.result;
|
||||
lines.push(
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
shapePaperclipWorkspaceEnvForExecution,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { buildSkillLibraryManifestMarkdown } from "@paperclipai/adapter-utils/skill-library-manifest";
|
||||
import {
|
||||
|
|
@ -428,7 +429,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const effort = asString(config.effort, "");
|
||||
const chrome = asBoolean(config.chrome, false);
|
||||
|
|
@ -845,6 +848,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
const taskContextNote = selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) });
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
resumedSession: Boolean(sessionId),
|
||||
conversationMode: context.conversationMode === true,
|
||||
// The task-context markdown is the authoritative brief on this lane; keep
|
||||
// the wake prompt's description copy out so the prompt carries it once.
|
||||
suppressIssueDescription: taskContextNote.length > 0,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ export const sessionCodec: AdapterSessionCodec = {
|
|||
const promptBundleKey =
|
||||
readNonEmptyString(record.promptBundleKey) ??
|
||||
readNonEmptyString(record.prompt_bundle_key);
|
||||
const mcpServerIdentity = readNonEmptyString(record.mcpServerIdentity);
|
||||
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||
|
|
@ -89,6 +90,7 @@ export const sessionCodec: AdapterSessionCodec = {
|
|||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(promptBundleKey ? { promptBundleKey } : {}),
|
||||
...(mcpServerIdentity ? { mcpServerIdentity } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
|
|
@ -105,6 +107,7 @@ export const sessionCodec: AdapterSessionCodec = {
|
|||
const promptBundleKey =
|
||||
readNonEmptyString(params.promptBundleKey) ??
|
||||
readNonEmptyString(params.prompt_bundle_key);
|
||||
const mcpServerIdentity = readNonEmptyString(params.mcpServerIdentity);
|
||||
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
||||
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
||||
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
||||
|
|
@ -112,6 +115,7 @@ export const sessionCodec: AdapterSessionCodec = {
|
|||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(promptBundleKey ? { promptBundleKey } : {}),
|
||||
...(mcpServerIdentity ? { mcpServerIdentity } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
|
|
|
|||
|
|
@ -45,9 +45,11 @@ import {
|
|||
readPaperclipIssueWorkModeFromContext,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
selectPaperclipTaskMarkdown,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
joinPromptSections,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import {
|
||||
|
|
@ -587,7 +589,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const command = asString(config.command, "codex");
|
||||
const model = asString(config.model, "");
|
||||
|
|
@ -1119,7 +1123,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
||||
const taskContextNote = selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) });
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
resumedSession: Boolean(sessionId),
|
||||
conversationMode: context.conversationMode === true,
|
||||
suppressIssueDescription: taskContextNote.length > 0,
|
||||
});
|
||||
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
||||
const promptInstructionsPrefix = shouldUseResumeDeltaPrompt ? "" : instructionsPrefix;
|
||||
instructionsChars = promptInstructionsPrefix.length;
|
||||
|
|
@ -1202,6 +1211,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
wakePrompt,
|
||||
codexFallbackHandoffNote,
|
||||
sessionHandoffNote,
|
||||
taskContextNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
const promptMetrics = {
|
||||
|
|
@ -1210,6 +1220,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
taskContextChars: taskContextNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ function createMockSdkAgent(options: MockAgentOptions = {}) {
|
|||
const sendRun = options.sendRun ?? createMockRun();
|
||||
return {
|
||||
agentId: options.agentId ?? sendRun.agentId,
|
||||
send: vi.fn(async () => sendRun),
|
||||
send: vi.fn(async (_prompt: string, _options?: Record<string, unknown>) => sendRun),
|
||||
[Symbol.asyncDispose]: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -142,6 +142,32 @@ describe("cursor_cloud execute", () => {
|
|||
getRunMock.mockReset();
|
||||
});
|
||||
|
||||
it.each([false, true])("sends the central chat directive to Cursor Cloud (custom=%s)", async (custom) => {
|
||||
const sdkAgent = createMockSdkAgent();
|
||||
createMock.mockResolvedValue(sdkAgent);
|
||||
const ctx = createContext();
|
||||
if (!custom) delete ctx.config.promptTemplate;
|
||||
const directive = "Chat directive: clarify goals and hand plans off to project tasks.";
|
||||
ctx.context = {
|
||||
...ctx.context,
|
||||
conversationMode: true,
|
||||
paperclipTaskMarkdown: directive,
|
||||
paperclipWake: {
|
||||
reason: "issue_commented",
|
||||
issue: { id: "issue-1", workMode: "planning", status: "in_progress" },
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "accepted",
|
||||
},
|
||||
};
|
||||
const result = await execute(ctx);
|
||||
expect(result.exitCode).toBe(0);
|
||||
const prompt = String(sdkAgent.send.mock.calls[0]?.[0]);
|
||||
expect(prompt).toContain(directive);
|
||||
expect(prompt).toContain(custom ? "Do the work for" : "Continue your Paperclip conversation");
|
||||
expect(prompt).not.toContain("Execution contract:");
|
||||
expect(prompt).not.toContain("Create child issues");
|
||||
});
|
||||
|
||||
it("creates a fresh Cursor agent and injects Paperclip env without CURSOR_API_KEY", async () => {
|
||||
const run = createMockRun({
|
||||
agentId: "agent-fresh",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import type { AdapterExecutionContext, AdapterExecutionResult, AdapterInvocationMeta } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
asBoolean,
|
||||
asString,
|
||||
buildPaperclipEnv,
|
||||
|
|
@ -20,6 +21,7 @@ import {
|
|||
parseObject,
|
||||
readPaperclipIssueWorkModeFromContext,
|
||||
renderPaperclipWakePrompt,
|
||||
selectPaperclipTaskMarkdown,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
renderTemplate,
|
||||
stringifyPaperclipWakePayload,
|
||||
|
|
@ -400,7 +402,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
}
|
||||
: null);
|
||||
const canReuseSession = sessionMatches(session, envType, envName, repos);
|
||||
const promptTemplate = asString(config.promptTemplate, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE);
|
||||
const promptTemplate = asString(config.promptTemplate, context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE);
|
||||
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||
const templateData = {
|
||||
agentId: agent.id,
|
||||
|
|
@ -412,7 +416,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
context,
|
||||
};
|
||||
const instructions = await buildInstructionsPrefix(config, onLog);
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: canReuseSession });
|
||||
const taskContextNote = context.conversationMode === true
|
||||
? selectPaperclipTaskMarkdown(context, { resumedSession: canReuseSession })
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
conversationMode: context.conversationMode === true,
|
||||
resumedSession: canReuseSession,
|
||||
suppressIssueDescription: taskContextNote.length > 0,
|
||||
});
|
||||
const renderedBootstrapPrompt =
|
||||
!canReuseSession && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
|
|
@ -426,6 +437,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
instructions.prefix,
|
||||
renderedBootstrapPrompt,
|
||||
wakePrompt,
|
||||
taskContextNote,
|
||||
paperclipEnvNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
|
|
@ -465,6 +477,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
instructionsChars: instructions.chars,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
taskContextChars: taskContextNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
},
|
||||
context: {
|
||||
|
|
|
|||
|
|
@ -44,9 +44,11 @@ import {
|
|||
removeMaintainerOnlySkillSymlinks,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
selectPaperclipTaskMarkdown,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
joinPromptSections,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js";
|
||||
|
|
@ -206,7 +208,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
let command = asString(config.command, "agent");
|
||||
const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim();
|
||||
|
|
@ -566,7 +570,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
||||
const taskContextNote = context.conversationMode === true
|
||||
? selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) })
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
conversationMode: context.conversationMode === true,
|
||||
resumedSession: Boolean(sessionId),
|
||||
suppressIssueDescription: taskContextNote.length > 0,
|
||||
});
|
||||
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
||||
const renderedPrompt = shouldUseResumeDeltaPrompt || isPaperclipRecoveryWakePayload(context.paperclipWake)
|
||||
? ""
|
||||
|
|
@ -577,6 +588,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
instructionsPrefix,
|
||||
renderedBootstrapPrompt,
|
||||
wakePrompt,
|
||||
taskContextNote,
|
||||
sessionHandoffNote,
|
||||
paperclipEnvNote,
|
||||
renderedPrompt,
|
||||
|
|
@ -586,6 +598,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
instructionsChars,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
taskContextChars: taskContextNote.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
runtimeNoteChars: paperclipEnvNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
|
|
|
|||
|
|
@ -47,9 +47,11 @@ import {
|
|||
parseObject,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
selectPaperclipTaskMarkdown,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js";
|
||||
|
|
@ -230,7 +232,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const command = asString(config.command, "gemini");
|
||||
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
|
||||
|
|
@ -555,7 +559,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
||||
const taskContextNote = context.conversationMode === true
|
||||
? selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) })
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
conversationMode: context.conversationMode === true,
|
||||
resumedSession: Boolean(sessionId),
|
||||
suppressIssueDescription: taskContextNote.length > 0,
|
||||
});
|
||||
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
||||
const renderedPrompt = shouldUseResumeDeltaPrompt || isPaperclipRecoveryWakePayload(context.paperclipWake)
|
||||
? ""
|
||||
|
|
@ -567,6 +578,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
instructionsPrefix,
|
||||
renderedBootstrapPrompt,
|
||||
wakePrompt,
|
||||
taskContextNote,
|
||||
sessionHandoffNote,
|
||||
paperclipEnvNote,
|
||||
apiAccessNote,
|
||||
|
|
@ -577,6 +589,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
instructionsChars: instructionsPrefix.length,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
taskContextChars: taskContextNote.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
|
|
|
|||
|
|
@ -34,11 +34,13 @@ import {
|
|||
readPaperclipRuntimeSkillEntries,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
selectPaperclipTaskMarkdown,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
resolveLegacyPaperclipDesiredSkillNames,
|
||||
stringifyPaperclipWakePayload,
|
||||
refreshPaperclipWorkspaceEnvForExecution,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js";
|
||||
import { copyBackGrokAuth } from "./grok-auth-copyback.js";
|
||||
|
|
@ -202,7 +204,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const command = asString(config.command, "grok");
|
||||
const model = asString(config.model, DEFAULT_GROK_LOCAL_MODEL).trim();
|
||||
|
|
@ -474,7 +478,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
};
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
||||
const taskContextNote = context.conversationMode === true
|
||||
? selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) })
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
conversationMode: context.conversationMode === true,
|
||||
resumedSession: Boolean(sessionId),
|
||||
suppressIssueDescription: taskContextNote.length > 0,
|
||||
});
|
||||
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
||||
const renderedPrompt = shouldUseResumeDeltaPrompt || isPaperclipRecoveryWakePayload(context.paperclipWake)
|
||||
? ""
|
||||
|
|
@ -484,6 +495,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
const apiAccessNote = renderApiAccessNote(env);
|
||||
const prompt = joinPromptSections([
|
||||
wakePrompt,
|
||||
taskContextNote,
|
||||
sessionHandoffNote,
|
||||
paperclipEnvNote,
|
||||
apiAccessNote,
|
||||
|
|
@ -492,6 +504,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
const promptMetrics = {
|
||||
promptChars: prompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
taskContextChars: taskContextNote.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
|
|
|
|||
|
|
@ -174,6 +174,40 @@ describe("execute", () => {
|
|||
expect(body.session_id).toBe("paperclip:company:company-1:agent:agent-1:issue:issue-1");
|
||||
});
|
||||
|
||||
it.each([false, true])("preserves chat handoff policy on gateway turns (resumed=%s)", async (resumed) => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => new Response(JSON.stringify(
|
||||
String(input).endsWith("/v1/runs")
|
||||
? { run_id: "run-hermes-1", status: "started" }
|
||||
: { status: "completed", output: "done" },
|
||||
), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const ctx = makeCtx({ apiBaseUrl: "http://127.0.0.1:8642", apiKey: "secret-key", timeoutSec: 5 });
|
||||
ctx.config.payloadTemplate = { input: "Custom gateway instruction." };
|
||||
const directive = "Chat directive: clarify goals and hand plans off to project tasks.";
|
||||
ctx.context = {
|
||||
conversationMode: true,
|
||||
issueId: "issue-1",
|
||||
paperclipTaskMarkdown: directive,
|
||||
paperclipTaskMarkdownCompact: directive,
|
||||
paperclipWake: {
|
||||
reason: "issue_commented",
|
||||
issue: { id: "issue-1", workMode: "planning", status: "in_progress" },
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "accepted",
|
||||
},
|
||||
};
|
||||
if (resumed) ctx.runtime.sessionId = "prior-session";
|
||||
await execute(ctx);
|
||||
const calls = fetchMock.mock.calls as Array<[RequestInfo | URL, RequestInit?]>;
|
||||
const call = calls.find(([input]) => String(input).endsWith("/v1/runs"));
|
||||
const prompt = JSON.parse(String(call?.[1]?.body)).input as string;
|
||||
expect(prompt).toContain("Custom gateway instruction.");
|
||||
expect(prompt).toContain(directive);
|
||||
expect(prompt).not.toContain("Execution contract:");
|
||||
expect(prompt).not.toContain("clear final disposition");
|
||||
expect(prompt).not.toContain("Create child issues");
|
||||
});
|
||||
|
||||
it("sends the task brief once on fresh runs and compacts it on stable-session resumes", async () => {
|
||||
const description = "Update launch-card.svg and change the CTA to Try Team free.";
|
||||
const fullTaskMarkdown = [
|
||||
|
|
|
|||
|
|
@ -274,6 +274,7 @@ function buildInput(ctx: AdapterExecutionContext, paperclipApiUrl: string | null
|
|||
Boolean(nonEmpty(ctx.runtime?.sessionId));
|
||||
const taskMarkdown = nonEmpty(selectPaperclipTaskMarkdown(ctx.context, { resumedSession }));
|
||||
const wakePrompt = renderPaperclipWakePrompt(ctx.context.paperclipWake, {
|
||||
conversationMode: ctx.context.conversationMode === true,
|
||||
// The task-context markdown is the authoritative brief on this lane; keep
|
||||
// the wake prompt's description copy out so the prompt carries it once.
|
||||
suppressIssueDescription: Boolean(taskMarkdown),
|
||||
|
|
@ -293,7 +294,7 @@ function buildInput(ctx: AdapterExecutionContext, paperclipApiUrl: string | null
|
|||
...(paperclipApiUrl ? [`- Paperclip API URL: ${paperclipApiUrl}`] : []),
|
||||
...(issueWorkMode ? [`- Issue work mode: ${issueWorkMode}`] : []),
|
||||
"",
|
||||
...(isPaperclipRecoveryWakePayload(ctx.context.paperclipWake)
|
||||
...(ctx.context.conversationMode === true || isPaperclipRecoveryWakePayload(ctx.context.paperclipWake)
|
||||
? []
|
||||
: [
|
||||
"Execution contract:",
|
||||
|
|
@ -322,7 +323,10 @@ function buildInput(ctx: AdapterExecutionContext, paperclipApiUrl: string | null
|
|||
function buildRunBody(ctx: AdapterExecutionContext, sessionKey: string | null): Record<string, unknown> {
|
||||
const paperclipApiUrl = nonEmpty(ctx.config.paperclipApiUrl);
|
||||
const payloadTemplate = parseObject(ctx.config.payloadTemplate);
|
||||
const input = nonEmpty(payloadTemplate.input) ?? buildInput(ctx, paperclipApiUrl);
|
||||
const configuredInput = nonEmpty(payloadTemplate.input);
|
||||
const input = configuredInput && ctx.context.conversationMode === true
|
||||
? `${configuredInput}\n\n${buildInput(ctx, paperclipApiUrl)}`
|
||||
: configuredInput ?? buildInput(ctx, paperclipApiUrl);
|
||||
const instructions =
|
||||
nonEmpty(ctx.config.instructions) ??
|
||||
nonEmpty(payloadTemplate.instructions) ??
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
renderTemplate,
|
||||
ensureAbsoluteDirectory,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
joinPromptSections,
|
||||
renderPaperclipWakePrompt,
|
||||
selectPaperclipTaskMarkdown,
|
||||
|
|
@ -140,9 +141,10 @@ export function buildPrompt(
|
|||
config: Record<string, unknown>,
|
||||
options: { resumedSession?: boolean } = {},
|
||||
): string {
|
||||
const template = cfgString(config.promptTemplate) || HERMES_DEFAULT_PROMPT_TEMPLATE;
|
||||
|
||||
const context = (ctx as any).context || {};
|
||||
const template = cfgString(config.promptTemplate) || (context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: HERMES_DEFAULT_PROMPT_TEMPLATE);
|
||||
const taskId = cfgString(context.taskId) || cfgString(context.issueId) || cfgString(ctx.config?.taskId);
|
||||
const taskTitle = cfgString(context.taskTitle) || cfgString(ctx.config?.taskTitle) || "";
|
||||
const taskBody = cfgString(context.taskBody) || cfgString(ctx.config?.taskBody) || "";
|
||||
|
|
@ -166,6 +168,7 @@ export function buildPrompt(
|
|||
resumedSession: options.resumedSession === true,
|
||||
});
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
conversationMode: context.conversationMode === true,
|
||||
resumedSession: options.resumedSession === true,
|
||||
// The task-context markdown is the authoritative brief on this lane; keep
|
||||
// the wake prompt's description copy out so the prompt carries it once.
|
||||
|
|
|
|||
|
|
@ -246,3 +246,23 @@ test("preserves custom prompt templates while exposing runtime and wake variable
|
|||
expect(prompt).toContain("Issue description:\n```text\nUse the wake payload as runtime authority.\n```");
|
||||
expect(prompt).not.toContain("Paperclip runtime identity:");
|
||||
});
|
||||
|
||||
|
||||
test.each([false, true])("conversation prompts preserve the handoff policy (resumed=%s)", (resumedSession) => {
|
||||
const directive = "Chat directive: clarify goals and hand the plan off to project tasks.";
|
||||
const ctx = baseContext({
|
||||
conversationMode: true,
|
||||
paperclipTaskMarkdown: directive,
|
||||
paperclipTaskMarkdownCompact: directive,
|
||||
});
|
||||
ctx.context.paperclipWake.interactionKind = "request_confirmation";
|
||||
ctx.context.paperclipWake.interactionStatus = "accepted";
|
||||
for (const config of [{}, { promptTemplate: "Custom agent instruction." }]) {
|
||||
const prompt = buildPrompt(ctx, config, { resumedSession });
|
||||
expect(prompt).toContain(directive);
|
||||
expect(prompt).not.toContain("Execution contract:");
|
||||
expect(prompt).not.toContain("clear final disposition");
|
||||
expect(prompt).not.toContain("Create child issues");
|
||||
expect(prompt).not.toContain("--arg status done");
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -39,9 +39,11 @@ import {
|
|||
parseObject,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
selectPaperclipTaskMarkdown,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import {
|
||||
SANDBOX_INSTALL_COMMAND,
|
||||
|
|
@ -210,7 +212,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const command = asString(config.command, "kimi");
|
||||
const model = asString(config.model, "").trim();
|
||||
|
|
@ -512,7 +516,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
||||
const taskContextNote = context.conversationMode === true
|
||||
? selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) })
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
conversationMode: context.conversationMode === true,
|
||||
resumedSession: Boolean(sessionId),
|
||||
suppressIssueDescription: taskContextNote.length > 0,
|
||||
});
|
||||
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
||||
const renderedPrompt = shouldUseResumeDeltaPrompt || isPaperclipRecoveryWakePayload(context.paperclipWake)
|
||||
? ""
|
||||
|
|
@ -524,6 +535,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
instructionsPrefix,
|
||||
renderedBootstrapPrompt,
|
||||
wakePrompt,
|
||||
taskContextNote,
|
||||
sessionHandoffNote,
|
||||
paperclipEnvNote,
|
||||
apiAccessNote,
|
||||
|
|
@ -534,6 +546,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
instructionsChars: instructionsPrefix.length,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
taskContextChars: taskContextNote.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const websocketState = vi.hoisted(() => ({
|
|||
failConnectAttempts: 0,
|
||||
failAgentRequests: 0,
|
||||
events: [] as string[],
|
||||
messages: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("ws", async () => {
|
||||
|
|
@ -35,7 +36,8 @@ vi.mock("ws", async () => {
|
|||
}
|
||||
|
||||
send(payload: string) {
|
||||
const request = JSON.parse(payload) as { id: string; method: string };
|
||||
const request = JSON.parse(payload) as { id: string; method: string; params?: { message?: string } };
|
||||
if (request.method === "agent") websocketState.messages.push(request.params?.message ?? "");
|
||||
websocketState.events.push(`send:${request.method}`);
|
||||
if (request.method === "agent" && websocketState.failAgentRequests > 0) {
|
||||
websocketState.failAgentRequests--;
|
||||
|
|
@ -105,12 +107,41 @@ describe("openclaw_gateway execute dispatch boundary", () => {
|
|||
websocketState.failConnectAttempts = 0;
|
||||
websocketState.failAgentRequests = 0;
|
||||
websocketState.events = [];
|
||||
websocketState.messages = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([false, true])("sends conversation policy without the issue-completion workflow (resumed=%s)", async (resumed) => {
|
||||
const ctx = createContext();
|
||||
const directive = "Chat directive: clarify goals and hand plans off to project tasks.";
|
||||
ctx.context = {
|
||||
...ctx.context,
|
||||
conversationMode: true,
|
||||
paperclipTaskMarkdown: directive,
|
||||
paperclipTaskMarkdownCompact: directive,
|
||||
paperclipWake: {
|
||||
reason: "issue_commented",
|
||||
issue: { id: "issue-1", workMode: "planning", status: "in_progress" },
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "accepted",
|
||||
},
|
||||
};
|
||||
if (resumed) ctx.runtime.sessionId = "prior-session";
|
||||
const result = await execute(ctx);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(websocketState.messages).toHaveLength(1);
|
||||
const prompt = websocketState.messages[0]!;
|
||||
expect(prompt).toContain(directive);
|
||||
expect(prompt).toContain("X-Paperclip-Run-Id");
|
||||
expect(prompt).not.toContain("Execution contract:");
|
||||
expect(prompt).not.toContain("Create child issues");
|
||||
expect(prompt).not.toContain('"status":"done"');
|
||||
expect(prompt).not.toContain("GET /api/issues/{issueId}/comments");
|
||||
});
|
||||
|
||||
it("reports dispatch after transport setup and before the remote agent request", async () => {
|
||||
const onDispatch = vi.fn(() => {
|
||||
websocketState.events.push("dispatch");
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
parseObject,
|
||||
readPaperclipIssueWorkModeFromContext,
|
||||
renderPaperclipWakePrompt,
|
||||
selectPaperclipTaskMarkdown,
|
||||
stringifyPaperclipWakePayload,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import crypto, { randomUUID } from "node:crypto";
|
||||
|
|
@ -372,6 +373,7 @@ function buildWakeText(
|
|||
paperclipEnv: Record<string, string>,
|
||||
structuredWakePrompt: string,
|
||||
claimedApiKeyPath: string,
|
||||
conversationTaskMarkdown?: string,
|
||||
): string {
|
||||
const orderedKeys = [
|
||||
"PAPERCLIP_RUN_ID",
|
||||
|
|
@ -396,6 +398,19 @@ function buildWakeText(
|
|||
const issueIdHint = payload.taskId ?? payload.issueId ?? "";
|
||||
const apiBaseHint = paperclipEnv.PAPERCLIP_API_URL ?? "<set PAPERCLIP_API_URL>";
|
||||
|
||||
if (conversationTaskMarkdown !== undefined) {
|
||||
return [
|
||||
"Paperclip conversation turn for a cloud adapter.",
|
||||
"Set these values in your run context:",
|
||||
...envLines,
|
||||
`Load PAPERCLIP_API_KEY from ${claimedApiKeyPath} (the token saved after claim-api-key).`,
|
||||
"Use Authorization: Bearer $PAPERCLIP_API_KEY on every API call and X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on every mutation.",
|
||||
"Follow the supplied chat mode directive. Keep this conversation available for the next message.",
|
||||
structuredWakePrompt,
|
||||
conversationTaskMarkdown,
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
const lines = [
|
||||
"Paperclip wake event for a cloud adapter.",
|
||||
"",
|
||||
|
|
@ -1091,6 +1106,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
// must carry the execution contract itself.
|
||||
const structuredWakePrompt = renderPaperclipWakePrompt(ctx.context.paperclipWake, {
|
||||
includeExecutionContract: true,
|
||||
conversationMode: ctx.context.conversationMode === true,
|
||||
});
|
||||
const structuredWakeJson = stringifyPaperclipWakePayload(ctx.context.paperclipWake);
|
||||
const wakeText = buildWakeText(
|
||||
|
|
@ -1100,6 +1116,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
? joinWakePayloadSections(structuredWakePrompt, structuredWakeJson)
|
||||
: structuredWakePrompt,
|
||||
resolveClaimedApiKeyPath(ctx.config.claimedApiKeyPath),
|
||||
ctx.context.conversationMode === true
|
||||
? selectPaperclipTaskMarkdown(ctx.context, { resumedSession: Boolean(ctx.runtime?.sessionId) })
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const sessionKeyStrategy = normalizeSessionKeyStrategy(ctx.config.sessionKeyStrategy);
|
||||
|
|
|
|||
|
|
@ -104,12 +104,16 @@ describe("opencode remote execution", () => {
|
|||
const cleanupDirs: string[] = [];
|
||||
const originalOpenCodeAllowAllModels = process.env.OPENCODE_ALLOW_ALL_MODELS;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
const configHome = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-test-config-"));
|
||||
cleanupDirs.push(configHome);
|
||||
vi.stubEnv("XDG_CONFIG_HOME", configHome);
|
||||
delete process.env.OPENCODE_ALLOW_ALL_MODELS;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
if (originalOpenCodeAllowAllModels === undefined) {
|
||||
delete process.env.OPENCODE_ALLOW_ALL_MODELS;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,53 @@ function probeResult(overrides: Record<string, unknown>) {
|
|||
}
|
||||
|
||||
describe("OpenCode local skill injection", () => {
|
||||
let configHome: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
configHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-test-config-"));
|
||||
vi.stubEnv("XDG_CONFIG_HOME", configHome);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await fs.rm(configHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps chat policy with a legacy OpenCode prompt (custom=%s)", async (custom) => {
|
||||
const commandPath = path.join(configHome, "fake-opencode");
|
||||
await fs.writeFile(commandPath, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
||||
runProcessMock.mockReset();
|
||||
runProcessMock.mockResolvedValue(probeResult({ stdout: JSON.stringify({
|
||||
type: "text", sessionID: "chat-session", part: { text: "Reply" },
|
||||
}) }));
|
||||
const directive = "Chat directive: clarify goals and hand plans off to project tasks.";
|
||||
let prompt = "";
|
||||
const result = await execute({
|
||||
runId: "chat-run",
|
||||
agent: { id: "agent-1", companyId: "company-1", name: "OpenCode", adapterType: "opencode_local", adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: {
|
||||
command: commandPath, cwd: configHome, model: "openai/gpt-5", env: { OPENCODE_ALLOW_ALL_MODELS: "1" },
|
||||
...(custom ? { promptTemplate: "Custom agent instruction." } : {}),
|
||||
},
|
||||
context: {
|
||||
conversationMode: true,
|
||||
paperclipTaskMarkdown: directive,
|
||||
paperclipWake: {
|
||||
reason: "issue_commented", issue: { id: "chat-1", status: "in_progress", workMode: "planning" },
|
||||
interactionKind: "request_confirmation", interactionStatus: "accepted",
|
||||
},
|
||||
},
|
||||
onLog: async () => {},
|
||||
onMeta: async (meta) => { prompt = String(meta.prompt ?? ""); },
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(prompt).toContain(directive);
|
||||
expect(prompt).toContain(custom ? "Custom agent instruction." : "Continue your Paperclip conversation");
|
||||
expect(prompt).not.toContain("Execution contract:");
|
||||
expect(prompt).not.toContain("Create child issues");
|
||||
});
|
||||
|
||||
it("injects runtime skills into the configured child HOME", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-configured-home-"));
|
||||
const processHome = path.join(root, "process-home");
|
||||
|
|
|
|||
|
|
@ -40,9 +40,11 @@ import {
|
|||
refreshPaperclipWorkspaceEnvForExecution,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
selectPaperclipTaskMarkdown,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
runChildProcess,
|
||||
isPaperclipSkillSourceMissing,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
|
|
@ -229,7 +231,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const command = asString(config.command, "opencode");
|
||||
const model = asString(config.model, "").trim();
|
||||
|
|
@ -560,7 +564,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
||||
const taskContextNote = context.conversationMode === true
|
||||
? selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) })
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
conversationMode: context.conversationMode === true,
|
||||
resumedSession: Boolean(sessionId),
|
||||
suppressIssueDescription: taskContextNote.length > 0,
|
||||
});
|
||||
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
||||
const renderedPrompt = shouldUseResumeDeltaPrompt || isPaperclipRecoveryWakePayload(context.paperclipWake)
|
||||
? ""
|
||||
|
|
@ -570,6 +581,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
instructionsPrefix,
|
||||
renderedBootstrapPrompt,
|
||||
wakePrompt,
|
||||
taskContextNote,
|
||||
sessionHandoffNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
|
|
@ -578,6 +590,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
instructionsChars: instructionsPrefix.length,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
taskContextChars: taskContextNote.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
|
||||
const {
|
||||
|
|
@ -71,8 +74,17 @@ vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
|
|||
import { testEnvironment } from "./test.js";
|
||||
|
||||
describe("opencode remote environment diagnostics", () => {
|
||||
afterEach(() => {
|
||||
let configHome: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
configHome = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-test-config-"));
|
||||
vi.stubEnv("XDG_CONFIG_HOME", configHome);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
await rm(configHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("stages remote runtime config assets for sandbox hello probes", async () => {
|
||||
|
|
|
|||
|
|
@ -45,9 +45,11 @@ import {
|
|||
removeMaintainerOnlySkillSymlinks,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
selectPaperclipTaskMarkdown,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { shellQuote } from "@paperclipai/adapter-utils/ssh";
|
||||
|
|
@ -228,7 +230,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const command = asString(config.command, "pi");
|
||||
const model = asString(config.model, "").trim();
|
||||
|
|
@ -583,7 +587,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
`${instructionsContents}\n\n` +
|
||||
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsFileDir}.\n\n` +
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE;
|
||||
(context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE);
|
||||
} catch (err) {
|
||||
instructionsReadFailed = true;
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
|
|
@ -613,7 +619,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
!canResumeSession && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: canResumeSession });
|
||||
const taskContextNote = context.conversationMode === true
|
||||
? selectPaperclipTaskMarkdown(context, { resumedSession: canResumeSession })
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
conversationMode: context.conversationMode === true,
|
||||
resumedSession: canResumeSession,
|
||||
suppressIssueDescription: taskContextNote.length > 0,
|
||||
});
|
||||
const shouldUseResumeDeltaPrompt = canResumeSession && wakePrompt.length > 0;
|
||||
const renderedHeartbeatPrompt = shouldUseResumeDeltaPrompt || isPaperclipRecoveryWakePayload(context.paperclipWake)
|
||||
? ""
|
||||
|
|
@ -622,6 +635,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
const userPrompt = joinPromptSections([
|
||||
renderedBootstrapPrompt,
|
||||
wakePrompt,
|
||||
taskContextNote,
|
||||
sessionHandoffNote,
|
||||
renderedHeartbeatPrompt,
|
||||
]);
|
||||
|
|
@ -630,6 +644,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
promptChars: userPrompt.length,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
taskContextChars: taskContextNote.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
heartbeatPromptChars: renderedHeartbeatPrompt.length,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readFileSync, realpathSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||
import { createDb } from "../src/client.js";
|
||||
|
|
@ -31,13 +31,28 @@ async function main() {
|
|||
database?: {
|
||||
mode?: string;
|
||||
embeddedPostgresPort?: number;
|
||||
embeddedPostgresDataDir?: string;
|
||||
connectionString?: string;
|
||||
};
|
||||
};
|
||||
// The server can select another port when the configured one is occupied.
|
||||
// Bind bootstrap to this data directory's running process, never another instance.
|
||||
let embeddedPort: number | undefined;
|
||||
if (config.database?.mode !== "postgres") {
|
||||
const dataDir = config.database?.embeddedPostgresDataDir;
|
||||
if (!dataDir) throw new Error("Embedded bootstrap requires its configured data directory");
|
||||
const pidLines = readFileSync(path.join(dataDir, "postmaster.pid"), "utf8").split(/\r?\n/);
|
||||
if (realpathSync(pidLines[1] ?? "") !== realpathSync(dataDir)) throw new Error("Embedded bootstrap data directory does not match the running postmaster");
|
||||
const postmasterPid = Number(pidLines[0]);
|
||||
if (!Number.isInteger(postmasterPid) || postmasterPid <= 1) throw new Error("Invalid embedded postmaster PID");
|
||||
process.kill(postmasterPid, 0);
|
||||
embeddedPort = Number(pidLines[3]);
|
||||
if (!Number.isInteger(embeddedPort) || embeddedPort < 1 || embeddedPort > 65535) throw new Error("Invalid running embedded database port");
|
||||
}
|
||||
const dbUrl =
|
||||
config.database?.mode === "postgres"
|
||||
? config.database.connectionString
|
||||
: `postgres://paperclip:paperclip@127.0.0.1:${config.database?.embeddedPostgresPort ?? 54329}/paperclip`;
|
||||
: `postgres://paperclip:paperclip@127.0.0.1:${embeddedPort}/paperclip`;
|
||||
if (!dbUrl) {
|
||||
throw new Error(`Could not resolve database connection from ${configPath}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import postgres from "postgres";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { applyPendingMigrations, inspectMigrations } from "./client.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
|
||||
|
||||
const migrationFile = "0274_agent_chat.sql";
|
||||
const migrationSql = await readFile(new URL(`./migrations/${migrationFile}`, import.meta.url), "utf8");
|
||||
const migrationHash = createHash("sha256").update(migrationSql).digest("hex");
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describePostgres = support.supported ? describe : describe.skip;
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanups.length) await cleanups.pop()?.();
|
||||
});
|
||||
|
||||
async function seed(sql: postgres.Sql) {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const commentId = randomUUID();
|
||||
const userId = `chat-user-${randomUUID()}`;
|
||||
await sql`INSERT INTO companies (id, name, issue_prefix) VALUES (${companyId}, 'Chat migration', 'CHM')`;
|
||||
await sql`INSERT INTO agents (id, company_id, name, role, adapter_type) VALUES (${agentId}, ${companyId}, 'Chat agent', 'engineer', 'process')`;
|
||||
await sql`INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at)
|
||||
VALUES (${userId}, 'Chat user', ${`${userId}@example.test`}, true, now(), now())`;
|
||||
await sql`INSERT INTO issues (id, company_id, title, assignee_agent_id, status,
|
||||
conversation_agent_id, conversation_user_id, conversation_state, conversation_session_generation, conversation_boundary_comment_id)
|
||||
VALUES (${issueId}, ${companyId}, 'Preserved chat', ${agentId}, 'in_review',
|
||||
${agentId}, ${userId}, 'waiting', 7, ${commentId})`;
|
||||
await sql`INSERT INTO issue_comments (id, company_id, issue_id, author_user_id, body, client_request_id, conversation_session_generation)
|
||||
VALUES (${commentId}, ${companyId}, ${issueId}, ${userId}, 'Preserved conversation history', 'first-message', 7)`;
|
||||
return { companyId, agentId, issueId, commentId, userId };
|
||||
}
|
||||
|
||||
async function assertConstraints(sql: postgres.Sql, row: Awaited<ReturnType<typeof seed>>) {
|
||||
for (const update of [
|
||||
{ conversation_state: null },
|
||||
{ status: "done" },
|
||||
{ status: "cancelled" },
|
||||
{ assignee_agent_id: null },
|
||||
{ conversation_user_id: null },
|
||||
]) {
|
||||
await expect(sql`UPDATE issues SET ${sql(update)} WHERE id = ${row.issueId}`)
|
||||
.rejects.toMatchObject({ code: "23514", constraint_name: "issues_conversation_identity_check" });
|
||||
}
|
||||
await expect(sql`INSERT INTO issues (company_id, title, assignee_agent_id, status, conversation_agent_id, conversation_user_id, conversation_state)
|
||||
VALUES (${row.companyId}, 'Duplicate conversation', ${row.agentId}, 'in_review', ${row.agentId}, ${row.userId}, 'waiting')`)
|
||||
.rejects.toMatchObject({ code: "23505", constraint_name: "issues_conversation_identity_idx" });
|
||||
await expect(sql`INSERT INTO issue_comments (company_id, issue_id, author_user_id, body, client_request_id)
|
||||
VALUES (${row.companyId}, ${row.issueId}, ${row.userId}, 'Duplicate message', 'first-message')`)
|
||||
.rejects.toMatchObject({ code: "23505", constraint_name: "issue_comments_client_request_uq" });
|
||||
await sql`INSERT INTO issues (company_id, title, assignee_agent_id, status, conversation_agent_id, conversation_user_id, conversation_state)
|
||||
VALUES (${row.companyId}, 'Other person conversation', ${row.agentId}, 'in_review', ${row.agentId}, 'other-person', 'waiting')`;
|
||||
await sql`INSERT INTO issues (company_id, title, status) VALUES (${row.companyId}, 'Ordinary completed task', 'done')`;
|
||||
}
|
||||
|
||||
describePostgres("persistent agent chat migration", () => {
|
||||
it("applies to a fresh database and enforces conversation identity and message retry uniqueness", async () => {
|
||||
const database = await startEmbeddedPostgresTestDatabase("paperclip-chat-migration-fresh-");
|
||||
cleanups.push(database.cleanup);
|
||||
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
await assertConstraints(sql, await seed(sql));
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("replays over pre-release columns and constraints without losing history or weakening the state guard", async () => {
|
||||
const database = await startEmbeddedPostgresTestDatabase("paperclip-chat-migration-replay-");
|
||||
cleanups.push(database.cleanup);
|
||||
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
const row = await seed(sql);
|
||||
const beforeIssue = await sql`SELECT * FROM issues WHERE id = ${row.issueId}`;
|
||||
const beforeComment = await sql`SELECT * FROM issue_comments WHERE id = ${row.commentId}`;
|
||||
// The original pre-release guard omitted the explicit state null check.
|
||||
// Keep every column, index and FK to model an already-upgraded development DB.
|
||||
await sql`ALTER TABLE issues DROP CONSTRAINT issues_conversation_identity_check`;
|
||||
const legacyGuard = migrationSql.slice(migrationSql.lastIndexOf('ALTER TABLE "issues" ADD CONSTRAINT'))
|
||||
.replace(' and "issues"."conversation_state" is not null', "");
|
||||
await sql.unsafe(legacyGuard);
|
||||
const legacyNullIds = [randomUUID(), randomUUID()];
|
||||
for (const [index, status] of ["in_review", "in_progress"].entries()) {
|
||||
await sql`INSERT INTO issues (id, company_id, title, assignee_agent_id, status, conversation_agent_id, conversation_user_id, conversation_state)
|
||||
VALUES (${legacyNullIds[index]!}, ${row.companyId}, 'Legacy null state', ${row.agentId}, ${status}, ${row.agentId}, ${`legacy-null-${index}`}, NULL)`;
|
||||
}
|
||||
|
||||
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${migrationHash}`;
|
||||
expect(await inspectMigrations(database.connectionString)).toMatchObject({
|
||||
status: "needsMigrations", pendingMigrations: [migrationFile],
|
||||
});
|
||||
await applyPendingMigrations(database.connectionString);
|
||||
// Exercise the SQL itself a second time, even with every new object present.
|
||||
await sql.begin(async (tx) => {
|
||||
for (const statement of migrationSql.split("--> statement-breakpoint")) {
|
||||
if (statement.trim()) await tx.unsafe(statement);
|
||||
}
|
||||
});
|
||||
expect(await sql`SELECT * FROM issues WHERE id = ${row.issueId}`).toEqual(beforeIssue);
|
||||
expect(await sql`SELECT * FROM issue_comments WHERE id = ${row.commentId}`).toEqual(beforeComment);
|
||||
const repaired = await sql`SELECT id, conversation_state FROM issues WHERE id IN ${sql(legacyNullIds)}`;
|
||||
expect(repaired.find((item) => item.id === legacyNullIds[0])?.conversation_state).toBe("waiting");
|
||||
expect(repaired.find((item) => item.id === legacyNullIds[1])?.conversation_state).toBe("active");
|
||||
await assertConstraints(sql, row);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
-- Idempotent for development instances that applied the pre-release chat migrations.
|
||||
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "client_request_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "conversation_session_generation" integer;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "conversation_agent_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "conversation_user_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "conversation_state" text;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "conversation_session_generation" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "conversation_boundary_comment_id" uuid;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issues_conversation_agent_id_agents_id_fk' AND conrelid = 'issues'::regclass) THEN
|
||||
ALTER TABLE "issues" ADD CONSTRAINT "issues_conversation_agent_id_agents_id_fk" FOREIGN KEY ("conversation_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "issues_conversation_identity_idx" ON "issues" USING btree ("company_id","conversation_agent_id","conversation_user_id");--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_comments_client_request_uq' AND conrelid = 'issue_comments'::regclass) THEN
|
||||
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_client_request_uq" UNIQUE("issue_id","author_user_id","client_request_id");
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
-- The first development guard allowed NULL through SQL three-valued logic.
|
||||
-- Recover the server-owned idle/active state before enforcing the stronger guard.
|
||||
UPDATE "issues" SET "conversation_state" = CASE WHEN "status" = 'in_review' THEN 'waiting' ELSE 'active' END
|
||||
WHERE "conversation_agent_id" IS NOT NULL AND "conversation_state" IS NULL;--> statement-breakpoint
|
||||
ALTER TABLE "issues" DROP CONSTRAINT IF EXISTS "issues_conversation_identity_check";--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD CONSTRAINT "issues_conversation_identity_check" CHECK ((
|
||||
"issues"."conversation_agent_id" is null and "issues"."conversation_user_id" is null and "issues"."conversation_state" is null
|
||||
) or (
|
||||
"issues"."conversation_agent_id" is not null and "issues"."conversation_user_id" is not null
|
||||
and "issues"."assignee_agent_id" = "issues"."conversation_agent_id" and "issues"."assignee_agent_id" is not null
|
||||
and "issues"."assignee_user_id" is null and "issues"."conversation_state" is not null
|
||||
and "issues"."conversation_state" in ('active', 'waiting')
|
||||
and "issues"."status" not in ('done', 'cancelled')
|
||||
));
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1905,6 +1905,13 @@
|
|||
"when": 1789164595203,
|
||||
"tag": "0273_aromatic_moondragon",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 274,
|
||||
"version": "7",
|
||||
"when": 1789219070888,
|
||||
"tag": "0274_agent_chat",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type {
|
|||
IssueCommentPresentation,
|
||||
SourceTrustMetadata,
|
||||
} from "@paperclipai/shared";
|
||||
import { pgTable, uuid, text, timestamp, index, jsonb, unique } from "drizzle-orm/pg-core";
|
||||
import { pgTable, uuid, text, timestamp, index, jsonb, unique, integer } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { agents } from "./agents.js";
|
||||
|
|
@ -30,6 +30,8 @@ export const issueComments = pgTable(
|
|||
derivedAuthorAgentId: uuid("derived_author_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
derivedCreatedByRunId: uuid("derived_created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
||||
derivedAuthorSource: text("derived_author_source").$type<IssueCommentDerivedAuthorSource>(),
|
||||
clientRequestId: text("client_request_id"),
|
||||
conversationSessionGeneration: integer("conversation_session_generation"),
|
||||
body: text("body").notNull(),
|
||||
presentation: jsonb("presentation").$type<IssueCommentPresentation | null>(),
|
||||
metadata: jsonb("metadata").$type<IssueCommentMetadata | null>(),
|
||||
|
|
@ -43,6 +45,7 @@ export const issueComments = pgTable(
|
|||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
clientRequestUq: unique("issue_comments_client_request_uq").on(table.issueId, table.authorUserId, table.clientRequestId),
|
||||
companyIdUq: unique("issue_comments_company_id_uq").on(table.companyId, table.id),
|
||||
issueIdx: index("issue_comments_issue_idx").on(table.issueId),
|
||||
companyIdx: index("issue_comments_company_idx").on(table.companyId),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
uniqueIndex,
|
||||
unique,
|
||||
bigint,
|
||||
check,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { agents } from "./agents.js";
|
||||
import { projects } from "./projects.js";
|
||||
|
|
@ -26,6 +27,12 @@ export const issues = pgTable(
|
|||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
// Conversation identity and session boundaries are owned by the server.
|
||||
conversationAgentId: uuid("conversation_agent_id").references(() => agents.id),
|
||||
conversationUserId: text("conversation_user_id"),
|
||||
conversationState: text("conversation_state").$type<"active" | "waiting">(),
|
||||
conversationSessionGeneration: integer("conversation_session_generation").notNull().default(0),
|
||||
conversationBoundaryCommentId: uuid("conversation_boundary_comment_id"),
|
||||
projectId: uuid("project_id").references(() => projects.id),
|
||||
projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }),
|
||||
goalId: uuid("goal_id").references(() => goals.id),
|
||||
|
|
@ -83,6 +90,16 @@ export const issues = pgTable(
|
|||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
conversationIdentityIdx: uniqueIndex("issues_conversation_identity_idx").on(table.companyId, table.conversationAgentId, table.conversationUserId),
|
||||
conversationIdentityCheck: check("issues_conversation_identity_check", sql`(
|
||||
${table.conversationAgentId} is null and ${table.conversationUserId} is null and ${table.conversationState} is null
|
||||
) or (
|
||||
${table.conversationAgentId} is not null and ${table.conversationUserId} is not null
|
||||
and ${table.assigneeAgentId} = ${table.conversationAgentId} and ${table.assigneeAgentId} is not null
|
||||
and ${table.assigneeUserId} is null and ${table.conversationState} is not null
|
||||
and ${table.conversationState} in ('active', 'waiting')
|
||||
and ${table.status} not in ('done', 'cancelled')
|
||||
)`),
|
||||
companyIdUq: unique("issues_company_id_uq").on(table.companyId, table.id),
|
||||
companyStatusIdx: index("issues_company_status_idx").on(table.companyId, table.status),
|
||||
companyHarnessKindIdx: index("issues_company_harness_kind_idx").on(table.companyId, table.harnessKind),
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ The skill/reference inventory and eval cases are the only normative behavior sou
|
|||
|
||||
## Baseline Counts
|
||||
|
||||
- Skill/reference headings: 153
|
||||
- Skill/reference headings: 154
|
||||
- Eval cases: 106 across 16 groups
|
||||
- Total normative rows: 259
|
||||
- Total normative rows: 260
|
||||
- Legacy MCP aliases folded into normative rows: 42
|
||||
|
||||
| Eval group | Cases |
|
||||
|
|
@ -44,32 +44,33 @@ The skill/reference inventory and eval cases are the only normative behavior sou
|
|||
| skill:skills/paperclip/SKILL.md:paperclip-skill:10 | optional_agent_tool | skills/paperclip/SKILL.md:10 |
|
||||
| skill:skills/paperclip/SKILL.md:terminology:14 | optional_agent_tool | skills/paperclip/SKILL.md:14 |
|
||||
| skill:skills/paperclip/SKILL.md:authentication:18 | control_plane_owned | skills/paperclip/SKILL.md:18 |
|
||||
| skill:skills/paperclip/SKILL.md:server-verified-external-chat-turns:30 | control_plane_owned | skills/paperclip/SKILL.md:30 |
|
||||
| skill:skills/paperclip/SKILL.md:the-heartbeat-procedure:70 | optional_agent_tool | skills/paperclip/SKILL.md:70 |
|
||||
| skill:skills/paperclip/SKILL.md:generated-artifacts-and-work-products:142 | always_agent_tool | skills/paperclip/SKILL.md:142 |
|
||||
| skill:skills/paperclip/SKILL.md:status-quick-guide:190 | control_plane_owned | skills/paperclip/SKILL.md:190 |
|
||||
| skill:skills/paperclip/SKILL.md:monitors-and-watchers-say-only-what-you-actually-scheduled:200 | optional_agent_tool | skills/paperclip/SKILL.md:200 |
|
||||
| skill:skills/paperclip/SKILL.md:delegating-review-tasks:213 | always_agent_tool | skills/paperclip/SKILL.md:213 |
|
||||
| skill:skills/paperclip/SKILL.md:managing-a-user-s-inbox:224 | control_plane_owned | skills/paperclip/SKILL.md:224 |
|
||||
| skill:skills/paperclip/SKILL.md:issue-dependencies-blockers:232 | control_plane_owned | skills/paperclip/SKILL.md:232 |
|
||||
| skill:skills/paperclip/SKILL.md:requesting-board-approval:257 | optional_agent_tool | skills/paperclip/SKILL.md:257 |
|
||||
| skill:skills/paperclip/SKILL.md:issue-thread-interactions:278 | optional_agent_tool | skills/paperclip/SKILL.md:278 |
|
||||
| skill:skills/paperclip/SKILL.md:standalone-decisions:307 | optional_agent_tool | skills/paperclip/SKILL.md:307 |
|
||||
| skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:411 | optional_agent_tool | skills/paperclip/SKILL.md:411 |
|
||||
| skill:skills/paperclip/SKILL.md:niche-workflow-pointers:453 | optional_agent_tool | skills/paperclip/SKILL.md:453 |
|
||||
| skill:skills/paperclip/SKILL.md:cases:463 | optional_agent_tool | skills/paperclip/SKILL.md:463 |
|
||||
| skill:skills/paperclip/SKILL.md:company-skills-workflow:468 | optional_agent_tool | skills/paperclip/SKILL.md:468 |
|
||||
| skill:skills/paperclip/SKILL.md:routines:479 | optional_agent_tool | skills/paperclip/SKILL.md:479 |
|
||||
| skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:490 | optional_agent_tool | skills/paperclip/SKILL.md:490 |
|
||||
| skill:skills/paperclip/SKILL.md:proposing-credentials-safely:497 | optional_agent_tool | skills/paperclip/SKILL.md:497 |
|
||||
| skill:skills/paperclip/SKILL.md:reading-granted-secrets:504 | optional_agent_tool | skills/paperclip/SKILL.md:504 |
|
||||
| skill:skills/paperclip/SKILL.md:critical-rules:530 | optional_agent_tool | skills/paperclip/SKILL.md:530 |
|
||||
| skill:skills/paperclip/SKILL.md:comment-style-required:554 | always_agent_tool | skills/paperclip/SKILL.md:554 |
|
||||
| skill:skills/paperclip/SKILL.md:update:586 | optional_agent_tool | skills/paperclip/SKILL.md:586 |
|
||||
| skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:596 | optional_agent_tool | skills/paperclip/SKILL.md:596 |
|
||||
| skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:629 | optional_agent_tool | skills/paperclip/SKILL.md:629 |
|
||||
| skill:skills/paperclip/SKILL.md:searching-issues:658 | optional_agent_tool | skills/paperclip/SKILL.md:658 |
|
||||
| skill:skills/paperclip/SKILL.md:full-reference:668 | optional_agent_tool | skills/paperclip/SKILL.md:668 |
|
||||
| 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:159 | always_agent_tool | skills/paperclip/SKILL.md:159 |
|
||||
| skill:skills/paperclip/SKILL.md:status-quick-guide:207 | control_plane_owned | skills/paperclip/SKILL.md:207 |
|
||||
| skill:skills/paperclip/SKILL.md:monitors-and-watchers-say-only-what-you-actually-scheduled:217 | optional_agent_tool | skills/paperclip/SKILL.md:217 |
|
||||
| skill:skills/paperclip/SKILL.md:delegating-review-tasks:230 | always_agent_tool | skills/paperclip/SKILL.md:230 |
|
||||
| skill:skills/paperclip/SKILL.md:managing-a-user-s-inbox:241 | control_plane_owned | skills/paperclip/SKILL.md:241 |
|
||||
| skill:skills/paperclip/SKILL.md:issue-dependencies-blockers:249 | control_plane_owned | skills/paperclip/SKILL.md:249 |
|
||||
| skill:skills/paperclip/SKILL.md:requesting-board-approval:274 | optional_agent_tool | skills/paperclip/SKILL.md:274 |
|
||||
| skill:skills/paperclip/SKILL.md:issue-thread-interactions:295 | optional_agent_tool | skills/paperclip/SKILL.md:295 |
|
||||
| skill:skills/paperclip/SKILL.md:standalone-decisions:324 | optional_agent_tool | skills/paperclip/SKILL.md:324 |
|
||||
| skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:428 | optional_agent_tool | skills/paperclip/SKILL.md:428 |
|
||||
| skill:skills/paperclip/SKILL.md:niche-workflow-pointers:470 | optional_agent_tool | skills/paperclip/SKILL.md:470 |
|
||||
| skill:skills/paperclip/SKILL.md:cases:480 | optional_agent_tool | skills/paperclip/SKILL.md:480 |
|
||||
| skill:skills/paperclip/SKILL.md:company-skills-workflow:485 | optional_agent_tool | skills/paperclip/SKILL.md:485 |
|
||||
| skill:skills/paperclip/SKILL.md:routines:496 | optional_agent_tool | skills/paperclip/SKILL.md:496 |
|
||||
| skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:507 | optional_agent_tool | skills/paperclip/SKILL.md:507 |
|
||||
| skill:skills/paperclip/SKILL.md:proposing-credentials-safely:514 | optional_agent_tool | skills/paperclip/SKILL.md:514 |
|
||||
| skill:skills/paperclip/SKILL.md:reading-granted-secrets:521 | optional_agent_tool | skills/paperclip/SKILL.md:521 |
|
||||
| skill:skills/paperclip/SKILL.md:critical-rules:547 | optional_agent_tool | skills/paperclip/SKILL.md:547 |
|
||||
| skill:skills/paperclip/SKILL.md:comment-style-required:571 | always_agent_tool | skills/paperclip/SKILL.md:571 |
|
||||
| skill:skills/paperclip/SKILL.md:update:603 | optional_agent_tool | skills/paperclip/SKILL.md:603 |
|
||||
| skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:613 | optional_agent_tool | skills/paperclip/SKILL.md:613 |
|
||||
| skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:646 | optional_agent_tool | skills/paperclip/SKILL.md:646 |
|
||||
| skill:skills/paperclip/SKILL.md:searching-issues:675 | optional_agent_tool | skills/paperclip/SKILL.md:675 |
|
||||
| skill:skills/paperclip/SKILL.md:full-reference:685 | optional_agent_tool | skills/paperclip/SKILL.md:685 |
|
||||
| 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 |
|
||||
|
|
@ -172,28 +173,28 @@ The skill/reference inventory and eval cases are the only normative behavior sou
|
|||
| skill:skills/paperclip/references/api-reference.md:openclaw-invite-prompt-ceo:731 | optional_agent_tool | skills/paperclip/references/api-reference.md:731 |
|
||||
| skill:skills/paperclip/references/api-reference.md:setting-agent-instructions-path:750 | optional_agent_tool | skills/paperclip/references/api-reference.md:750 |
|
||||
| skill:skills/paperclip/references/api-reference.md:project-setup-create-workspace:783 | optional_agent_tool | skills/paperclip/references/api-reference.md:783 |
|
||||
| skill:skills/paperclip/references/api-reference.md:option-a-one-call-create-with-workspace:787 | optional_agent_tool | skills/paperclip/references/api-reference.md:787 |
|
||||
| skill:skills/paperclip/references/api-reference.md:option-b-two-calls-project-first-then-workspace:806 | optional_agent_tool | skills/paperclip/references/api-reference.md:806 |
|
||||
| skill:skills/paperclip/references/api-reference.md:governance-and-approvals:835 | optional_agent_tool | skills/paperclip/references/api-reference.md:835 |
|
||||
| skill:skills/paperclip/references/api-reference.md:requesting-a-hire-management-only:839 | optional_agent_tool | skills/paperclip/references/api-reference.md:839 |
|
||||
| skill:skills/paperclip/references/api-reference.md:ceo-strategy-approval:859 | optional_agent_tool | skills/paperclip/references/api-reference.md:859 |
|
||||
| skill:skills/paperclip/references/api-reference.md:issue-thread-confirmations:868 | always_agent_tool | skills/paperclip/references/api-reference.md:868 |
|
||||
| skill:skills/paperclip/references/api-reference.md:checkbox-confirmations:926 | always_agent_tool | skills/paperclip/references/api-reference.md:926 |
|
||||
| skill:skills/paperclip/references/api-reference.md:item-verdict-requests:1041 | optional_agent_tool | skills/paperclip/references/api-reference.md:1041 |
|
||||
| skill:skills/paperclip/references/api-reference.md:checking-approval-status:1151 | optional_agent_tool | skills/paperclip/references/api-reference.md:1151 |
|
||||
| skill:skills/paperclip/references/api-reference.md:approval-follow-up-requesting-agent:1157 | always_agent_tool | skills/paperclip/references/api-reference.md:1157 |
|
||||
| skill:skills/paperclip/references/api-reference.md:issue-lifecycle:1175 | always_agent_tool | skills/paperclip/references/api-reference.md:1175 |
|
||||
| skill:skills/paperclip/references/api-reference.md:error-handling:1205 | control_plane_owned | skills/paperclip/references/api-reference.md:1205 |
|
||||
| skill:skills/paperclip/references/api-reference.md:full-api-reference:1219 | optional_agent_tool | skills/paperclip/references/api-reference.md:1219 |
|
||||
| skill:skills/paperclip/references/api-reference.md:agents:1221 | optional_agent_tool | skills/paperclip/references/api-reference.md:1221 |
|
||||
| skill:skills/paperclip/references/api-reference.md:issues-tasks:1242 | optional_agent_tool | skills/paperclip/references/api-reference.md:1242 |
|
||||
| skill:skills/paperclip/references/api-reference.md:companies-projects-goals:1282 | optional_agent_tool | skills/paperclip/references/api-reference.md:1282 |
|
||||
| skill:skills/paperclip/references/api-reference.md:routines:1306 | optional_agent_tool | skills/paperclip/references/api-reference.md:1306 |
|
||||
| skill:skills/paperclip/references/api-reference.md:approvals-costs-activity-dashboard:1322 | optional_agent_tool | skills/paperclip/references/api-reference.md:1322 |
|
||||
| skill:skills/paperclip/references/api-reference.md:secrets:1344 | optional_agent_tool | skills/paperclip/references/api-reference.md:1344 |
|
||||
| skill:skills/paperclip/references/api-reference.md:agent-secret-proposals:1357 | optional_agent_tool | skills/paperclip/references/api-reference.md:1357 |
|
||||
| skill:skills/paperclip/references/api-reference.md:agent-secret-access:1457 | optional_agent_tool | skills/paperclip/references/api-reference.md:1457 |
|
||||
| skill:skills/paperclip/references/api-reference.md:common-mistakes:1497 | optional_agent_tool | skills/paperclip/references/api-reference.md:1497 |
|
||||
| skill:skills/paperclip/references/api-reference.md:option-a-one-call-create-with-workspace:807 | optional_agent_tool | skills/paperclip/references/api-reference.md:807 |
|
||||
| skill:skills/paperclip/references/api-reference.md:option-b-two-calls-project-first-then-workspace:826 | optional_agent_tool | skills/paperclip/references/api-reference.md:826 |
|
||||
| skill:skills/paperclip/references/api-reference.md:governance-and-approvals:855 | optional_agent_tool | skills/paperclip/references/api-reference.md:855 |
|
||||
| skill:skills/paperclip/references/api-reference.md:requesting-a-hire-management-only:859 | optional_agent_tool | skills/paperclip/references/api-reference.md:859 |
|
||||
| skill:skills/paperclip/references/api-reference.md:ceo-strategy-approval:879 | optional_agent_tool | skills/paperclip/references/api-reference.md:879 |
|
||||
| skill:skills/paperclip/references/api-reference.md:issue-thread-confirmations:888 | always_agent_tool | skills/paperclip/references/api-reference.md:888 |
|
||||
| skill:skills/paperclip/references/api-reference.md:checkbox-confirmations:946 | always_agent_tool | skills/paperclip/references/api-reference.md:946 |
|
||||
| skill:skills/paperclip/references/api-reference.md:item-verdict-requests:1061 | optional_agent_tool | skills/paperclip/references/api-reference.md:1061 |
|
||||
| skill:skills/paperclip/references/api-reference.md:checking-approval-status:1171 | optional_agent_tool | skills/paperclip/references/api-reference.md:1171 |
|
||||
| skill:skills/paperclip/references/api-reference.md:approval-follow-up-requesting-agent:1177 | always_agent_tool | skills/paperclip/references/api-reference.md:1177 |
|
||||
| skill:skills/paperclip/references/api-reference.md:issue-lifecycle:1195 | always_agent_tool | skills/paperclip/references/api-reference.md:1195 |
|
||||
| skill:skills/paperclip/references/api-reference.md:error-handling:1225 | control_plane_owned | skills/paperclip/references/api-reference.md:1225 |
|
||||
| skill:skills/paperclip/references/api-reference.md:full-api-reference:1239 | optional_agent_tool | skills/paperclip/references/api-reference.md:1239 |
|
||||
| skill:skills/paperclip/references/api-reference.md:agents:1241 | optional_agent_tool | skills/paperclip/references/api-reference.md:1241 |
|
||||
| skill:skills/paperclip/references/api-reference.md:issues-tasks:1262 | optional_agent_tool | skills/paperclip/references/api-reference.md:1262 |
|
||||
| skill:skills/paperclip/references/api-reference.md:companies-projects-goals:1302 | optional_agent_tool | skills/paperclip/references/api-reference.md:1302 |
|
||||
| skill:skills/paperclip/references/api-reference.md:routines:1326 | optional_agent_tool | skills/paperclip/references/api-reference.md:1326 |
|
||||
| skill:skills/paperclip/references/api-reference.md:approvals-costs-activity-dashboard:1342 | optional_agent_tool | skills/paperclip/references/api-reference.md:1342 |
|
||||
| skill:skills/paperclip/references/api-reference.md:secrets:1364 | optional_agent_tool | skills/paperclip/references/api-reference.md:1364 |
|
||||
| skill:skills/paperclip/references/api-reference.md:agent-secret-proposals:1377 | optional_agent_tool | skills/paperclip/references/api-reference.md:1377 |
|
||||
| skill:skills/paperclip/references/api-reference.md:agent-secret-access:1477 | optional_agent_tool | skills/paperclip/references/api-reference.md:1477 |
|
||||
| skill:skills/paperclip/references/api-reference.md:common-mistakes:1517 | optional_agent_tool | skills/paperclip/references/api-reference.md:1517 |
|
||||
|
||||
## Legacy MCP Alias Index
|
||||
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ the authoritative rows.
|
|||
Only two sources are normative:
|
||||
|
||||
1. The Paperclip skill and its seven references (`SKILL.md` plus
|
||||
`references/*.md`), contributing **152 headings**.
|
||||
`references/*.md`), contributing **153 headings**.
|
||||
2. The Paperclip Evals corpus, contributing **106 cases across 16 groups**.
|
||||
|
||||
Together these produce **258 normative rows**. The legacy Paperclip MCP tool
|
||||
Together these produce **259 normative rows**. The legacy Paperclip MCP tool
|
||||
surface (**41 tools**) is not a production capability surface; each MCP name is
|
||||
folded one-to-one into a normative eval row as a traceability alias and inherits
|
||||
that row's disposition. The contract prints the alias index only so the
|
||||
|
|
|
|||
|
|
@ -31,232 +31,241 @@
|
|||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:30",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L30:server-verified-external-chat-turns",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L30:conversation-tasks",
|
||||
"heading": "Conversation tasks",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_reconciliation",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:47",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L47:server-verified-external-chat-turns",
|
||||
"heading": "Server-Verified External Chat Turns",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_reconciliation",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:70",
|
||||
"id": "skill:skills/paperclip/SKILL.md:87",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L70:the-heartbeat-procedure",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L87:the-heartbeat-procedure",
|
||||
"heading": "The Heartbeat Procedure",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_reconciliation",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:142",
|
||||
"id": "skill:skills/paperclip/SKILL.md:159",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L142:generated-artifacts-and-work-products",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L159: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:190",
|
||||
"id": "skill:skills/paperclip/SKILL.md:207",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L190:status-quick-guide",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L207:status-quick-guide",
|
||||
"heading": "Status Quick Guide",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_reconciliation",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:200",
|
||||
"id": "skill:skills/paperclip/SKILL.md:217",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L200:monitors-and-watchers-say-only-what-you-actually-scheduled",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L217: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:213",
|
||||
"id": "skill:skills/paperclip/SKILL.md:230",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L213:delegating-review-tasks",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L230:delegating-review-tasks",
|
||||
"heading": "Delegating review tasks",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_reconciliation",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:224",
|
||||
"id": "skill:skills/paperclip/SKILL.md:241",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L224:managing-a-user-s-inbox",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L241: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:232",
|
||||
"id": "skill:skills/paperclip/SKILL.md:249",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L232:issue-dependencies-blockers",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L249:issue-dependencies-blockers",
|
||||
"heading": "Issue Dependencies (Blockers)",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_reconciliation",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:257",
|
||||
"id": "skill:skills/paperclip/SKILL.md:274",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L257:requesting-board-approval",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L274:requesting-board-approval",
|
||||
"heading": "Requesting Board Approval",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:278",
|
||||
"id": "skill:skills/paperclip/SKILL.md:295",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L278:issue-thread-interactions",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L295:issue-thread-interactions",
|
||||
"heading": "Issue-Thread Interactions",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
"semanticOperation": "request_human_input",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:307",
|
||||
"id": "skill:skills/paperclip/SKILL.md:324",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L307:standalone-decisions",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L324:standalone-decisions",
|
||||
"heading": "Standalone Decisions",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_reconciliation",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:411",
|
||||
"id": "skill:skills/paperclip/SKILL.md:428",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L411:mcp-tool-approval-gates",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L428: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:453",
|
||||
"id": "skill:skills/paperclip/SKILL.md:470",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L453:niche-workflow-pointers",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L470:niche-workflow-pointers",
|
||||
"heading": "Niche Workflow Pointers",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_reconciliation",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:463",
|
||||
"id": "skill:skills/paperclip/SKILL.md:480",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L463:cases",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L480:cases",
|
||||
"heading": "Cases",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:468",
|
||||
"id": "skill:skills/paperclip/SKILL.md:485",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L468:company-skills-workflow",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L485:company-skills-workflow",
|
||||
"heading": "Company Skills Workflow",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:479",
|
||||
"id": "skill:skills/paperclip/SKILL.md:496",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L479:routines",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L496:routines",
|
||||
"heading": "Routines",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:490",
|
||||
"id": "skill:skills/paperclip/SKILL.md:507",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L490:issue-workspace-runtime-controls",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L507: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:497",
|
||||
"id": "skill:skills/paperclip/SKILL.md:514",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L497:proposing-credentials-safely",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L514:proposing-credentials-safely",
|
||||
"heading": "Proposing Credentials Safely",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_reconciliation",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:504",
|
||||
"id": "skill:skills/paperclip/SKILL.md:521",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L504:reading-granted-secrets",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L521:reading-granted-secrets",
|
||||
"heading": "Reading Granted Secrets",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:530",
|
||||
"id": "skill:skills/paperclip/SKILL.md:547",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L530:critical-rules",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L547:critical-rules",
|
||||
"heading": "Critical Rules",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_reconciliation",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:554",
|
||||
"id": "skill:skills/paperclip/SKILL.md:571",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L554:comment-style-required",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L571:comment-style-required",
|
||||
"heading": "Comment Style (Required)",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
"semanticOperation": "report_progress",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:586",
|
||||
"id": "skill:skills/paperclip/SKILL.md:603",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L586:update",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L603:update",
|
||||
"heading": "Update",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_reconciliation",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:596",
|
||||
"id": "skill:skills/paperclip/SKILL.md:613",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L596:planning-required-when-planning-requested",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L613: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:629",
|
||||
"id": "skill:skills/paperclip/SKILL.md:646",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L629:key-endpoints-hot-routes",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L646: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:658",
|
||||
"id": "skill:skills/paperclip/SKILL.md:675",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L658:searching-issues",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L675:searching-issues",
|
||||
"heading": "Searching Issues",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_reconciliation",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:668",
|
||||
"id": "skill:skills/paperclip/SKILL.md:685",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L668:full-reference",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md#L685:full-reference",
|
||||
"heading": "Full Reference",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_reconciliation",
|
||||
|
|
@ -677,207 +686,207 @@
|
|||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:787",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:807",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L787:option-a-one-call-create-with-workspace",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L807:option-a-one-call-create-with-workspace",
|
||||
"heading": "Option A: One-call create with workspace",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:806",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:826",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L806:option-b-two-calls-project-first-then-workspace",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L826:option-b-two-calls-project-first-then-workspace",
|
||||
"heading": "Option B: Two calls (project first, then workspace)",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:835",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:855",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L835:governance-and-approvals",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L855:governance-and-approvals",
|
||||
"heading": "Governance and Approvals",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:839",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L839:requesting-a-hire-management-only",
|
||||
"heading": "Requesting a hire (management only)",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:859",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L859:ceo-strategy-approval",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L859:requesting-a-hire-management-only",
|
||||
"heading": "Requesting a hire (management only)",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:879",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L879:ceo-strategy-approval",
|
||||
"heading": "CEO strategy approval",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:868",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:888",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L868:issue-thread-confirmations",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L888:issue-thread-confirmations",
|
||||
"heading": "Issue-thread confirmations",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:926",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:946",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L926:checkbox-confirmations",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L946:checkbox-confirmations",
|
||||
"heading": "Checkbox confirmations",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1041",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1061",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1041:item-verdict-requests",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1061:item-verdict-requests",
|
||||
"heading": "Item verdict requests",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1151",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1171",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1151:checking-approval-status",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1171:checking-approval-status",
|
||||
"heading": "Checking approval status",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1157",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1177",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1157:approval-follow-up-requesting-agent",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1177:approval-follow-up-requesting-agent",
|
||||
"heading": "Approval follow-up (requesting agent)",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_reconciliation",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1175",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1195",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1175:issue-lifecycle",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1195:issue-lifecycle",
|
||||
"heading": "Issue Lifecycle",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1205",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1225",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1205:error-handling",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1225:error-handling",
|
||||
"heading": "Error Handling",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_reconciliation",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1219",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1239",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1219:full-api-reference",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1239:full-api-reference",
|
||||
"heading": "Full API Reference",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1221",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1241",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1221:agents",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1241:agents",
|
||||
"heading": "Agents",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1242",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1262",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1242:issues-tasks",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1262:issues-tasks",
|
||||
"heading": "Issues (Tasks)",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1282",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1302",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1282:companies-projects-goals",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1302:companies-projects-goals",
|
||||
"heading": "Companies, Projects, Goals",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1306",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1326",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1306:routines",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1326:routines",
|
||||
"heading": "Routines",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1322",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1342",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1322:approvals-costs-activity-dashboard",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1342:approvals-costs-activity-dashboard",
|
||||
"heading": "Approvals, Costs, Activity, Dashboard",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"semanticOperation": "runtime_reconciliation",
|
||||
"expectedMockState": "runtime_decision_record"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1344",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1364",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1344:secrets",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1364:secrets",
|
||||
"heading": "Secrets",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1357",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1377",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1357:agent-secret-proposals",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1377:agent-secret-proposals",
|
||||
"heading": "Agent secret proposals",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1410",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1430",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1410:re-bind-an-existing-secret-under-a-new-path-no-secret-id",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1430:re-bind-an-existing-secret-under-a-new-path-no-secret-id",
|
||||
"heading": "Re-bind an existing secret under a new path (no secret ID)",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1457",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1477",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1457:agent-secret-access",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1477:agent-secret-access",
|
||||
"heading": "Agent secret access",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
"expectedMockState": "operation_result"
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1497",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:1517",
|
||||
"kind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1497:common-mistakes",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1517:common-mistakes",
|
||||
"heading": "Common Mistakes",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"semanticOperation": "scoped_discovery",
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
Generated by `scripts/generate-capability-contract.mjs`; do not edit generated files.
|
||||
|
||||
- Skill/reference headings: 154
|
||||
- Skill/reference headings: 155
|
||||
- Legacy MCP tools: 42
|
||||
- Eval cases: 106 across 16 groups
|
||||
- Deterministic content SHA-256: `7d89b580b41830403a625dc44644e5faf9b5eb83a27706bc2d624d9da464d331`
|
||||
- Deterministic content SHA-256: `f83b043e95387a56679241e89e330600e198b890e83e1b21012cf2f4b9210a00`
|
||||
|
||||
Every row has exactly one primary disposition, a source anchor, a semantic operation, and a mock-state expectation.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1556,9 +1556,211 @@
|
|||
{
|
||||
"allowedModes": [
|
||||
"standard",
|
||||
"ask",
|
||||
"planning",
|
||||
"skill_test"
|
||||
],
|
||||
"description": "Create one child task under the active task.",
|
||||
"description": "Inspect available company projects before selecting a project for new work.",
|
||||
"effect": "read",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {},
|
||||
"required": [],
|
||||
"type": "object"
|
||||
},
|
||||
"operationId": "list_projects",
|
||||
"outputSchema": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"placement": "optional",
|
||||
"requiredClaims": [
|
||||
"discovery:projects:read"
|
||||
],
|
||||
"schema": "paperclip.semantic-action.v1",
|
||||
"title": "List projects",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"allowedModes": [
|
||||
"standard",
|
||||
"ask",
|
||||
"planning",
|
||||
"skill_test"
|
||||
],
|
||||
"description": "List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.",
|
||||
"effect": "read",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {},
|
||||
"required": [],
|
||||
"type": "object"
|
||||
},
|
||||
"operationId": "list_project_repositories",
|
||||
"outputSchema": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"placement": "optional",
|
||||
"requiredClaims": [],
|
||||
"schema": "paperclip.semantic-action.v1",
|
||||
"title": "List available repositories",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"allowedModes": [
|
||||
"standard",
|
||||
"skill_test"
|
||||
],
|
||||
"description": "Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.",
|
||||
"effect": "write",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"archivedAt": {
|
||||
"description": "Archive timestamp.",
|
||||
"maxLength": 20000,
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"color": {
|
||||
"description": "Project color.",
|
||||
"maxLength": 20000,
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"description": "Project outcome and context.",
|
||||
"maxLength": 20000,
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"env": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"executionWorkspacePolicy": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"goalId": {
|
||||
"description": "Goal ID.",
|
||||
"maxLength": 20000,
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"goalIds": {
|
||||
"description": "Goal IDs.",
|
||||
"items": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"maxItems": 200,
|
||||
"type": "array",
|
||||
"uniqueItems": true
|
||||
},
|
||||
"icon": {
|
||||
"description": "Project icon.",
|
||||
"maxLength": 20000,
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"idempotencyKey": {
|
||||
"description": "Caller-stable retry key.",
|
||||
"maxLength": 240,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"leadAgentId": {
|
||||
"description": "Lead agent ID.",
|
||||
"maxLength": 20000,
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"description": "Project name.",
|
||||
"maxLength": 500,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"repositoryIds": {
|
||||
"description": "Authorized repository IDs from list_project_repositories; may contain multiple repositories.",
|
||||
"items": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"maxItems": 200,
|
||||
"type": "array",
|
||||
"uniqueItems": true
|
||||
},
|
||||
"repositoryUrls": {
|
||||
"description": "Existing HTTPS GitHub repository URLs, including repos absent from the catalog.",
|
||||
"items": {
|
||||
"maxLength": 2000,
|
||||
"pattern": "^https://github\\.com/(?!\\.{1,2}/)[A-Za-z0-9_.-]+/(?!\\.{1,2}/?$)[A-Za-z0-9_.-]+/?$",
|
||||
"type": "string"
|
||||
},
|
||||
"maxItems": 100,
|
||||
"type": "array",
|
||||
"uniqueItems": true
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"backlog",
|
||||
"planned",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"cancelled"
|
||||
]
|
||||
},
|
||||
"targetDate": {
|
||||
"description": "Target date.",
|
||||
"maxLength": 20000,
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"idempotencyKey",
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"operationId": "create_project",
|
||||
"outputSchema": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"placement": "optional",
|
||||
"requiredClaims": [],
|
||||
"schema": "paperclip.semantic-action.v1",
|
||||
"title": "Create project",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"allowedModes": [
|
||||
"standard",
|
||||
"skill_test"
|
||||
],
|
||||
"description": "Create an assigned task. In a conversation, create a project task with no parent; otherwise create a child of the active task. Include initialPlan to persist its plan before execution.",
|
||||
"effect": "write",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
|
|
@ -1595,6 +1797,14 @@
|
|||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"initialPlan": {
|
||||
"description": "Relevant markdown plan to persist on the new task before it starts.",
|
||||
"maxLength": 20000,
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"priority": {
|
||||
"enum": [
|
||||
"critical",
|
||||
|
|
@ -1603,8 +1813,16 @@
|
|||
"low"
|
||||
]
|
||||
},
|
||||
"projectId": {
|
||||
"description": "Project identifier for the new task.",
|
||||
"maxLength": 20000,
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"description": "Child task title.",
|
||||
"description": "Task title.",
|
||||
"maxLength": 500,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
|
|
@ -1671,7 +1889,7 @@
|
|||
"delegation:tasks:create"
|
||||
],
|
||||
"schema": "paperclip.semantic-action.v1",
|
||||
"title": "Create child task",
|
||||
"title": "Create task",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
"prpVersion": 1,
|
||||
"nativeExecutionVersion": 1,
|
||||
"catalogVersion": 1,
|
||||
"catalogSha256": "sha256:842a1515a5b549fcc5df7675f3a96471b2f1ca33f4699cc5dd2ecf6c4235f2ec",
|
||||
"catalogSha256": "sha256:155849f666fffed8133d497c4323d42639eae7696699f9df049649f836e2edbc",
|
||||
"driverContractVersion": 1,
|
||||
"driverKind": "paperclip-deterministic",
|
||||
"driverVersion": "1.0.0"
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@
|
|||
},
|
||||
{
|
||||
"path": "fixtures/evals/native-execution-seeded.json",
|
||||
"sha256": "89641b73df452a5d03502bc151a81a68387ece129c8826e0572800c3b1c5265c",
|
||||
"sha256": "43bda8e713605d690a5e755f2d47eaea012d28fef81bd7dc787a5f9cacc507a7",
|
||||
"expectation": "accept",
|
||||
"compatibilityCase": "canonical"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use serde_json::Value;
|
|||
|
||||
use crate::acpx_event_scope::AcpxEventScope;
|
||||
use crate::acpx_sidecar_transport::AcpxSidecarEvent;
|
||||
use crate::durable::{redact_text, sanitize_value};
|
||||
use crate::durable::{redact_text, sanitize_semantic_tool_input, sanitize_value};
|
||||
use crate::generated_acpx_sidecar_contract::{
|
||||
classify_generated_acpx_tool_operation, GeneratedAcpxSidecarEventType,
|
||||
};
|
||||
|
|
@ -133,11 +133,19 @@ pub fn decode_acpx_event(
|
|||
"ACPX tool call input must be an object",
|
||||
));
|
||||
}
|
||||
let operation_id = required_id(&event.payload, "operationId", "tool operation")?;
|
||||
// This input is dispatched as a mutation, not merely displayed in
|
||||
// the event feed. Use the same declared-prose policy as native
|
||||
// semantic_tool.input before any generic diagnostic scrub can
|
||||
// irreversibly change the task's requirements.
|
||||
let safe_input = sanitize_semantic_tool_input(&operation_id, &input)
|
||||
.map_err(|error| LocalRunnerError::invalid(error.to_string()))?;
|
||||
Ok(AcpxEventPayload::ToolCalled {
|
||||
call_id: required_id(&event.payload, "callId", "tool call")?,
|
||||
operation_id: required_id(&event.payload, "operationId", "tool operation")?,
|
||||
operation_id,
|
||||
// Keep the original digest for the sidecar's result binding.
|
||||
input_digest: semantic_value_digest(&input),
|
||||
input: sanitize_value(&input),
|
||||
input: safe_input,
|
||||
})
|
||||
}
|
||||
GeneratedAcpxSidecarEventType::RuntimeTurnTerminal => {
|
||||
|
|
|
|||
|
|
@ -632,7 +632,9 @@ impl AcpxCommandExecutor {
|
|||
event_type: "run.terminal".to_owned(),
|
||||
priority: EventPriority::P0,
|
||||
payload: json!({
|
||||
"schema": "paperclip.prp.terminal.v1",
|
||||
"status": "failed",
|
||||
"turnTerminalState": "failed",
|
||||
"runTerminalState": "failed",
|
||||
"reportedWorkDisposition": "unknown",
|
||||
"provider": "acpx",
|
||||
|
|
@ -1393,11 +1395,11 @@ impl AcpxCommandExecutor {
|
|||
{
|
||||
continue;
|
||||
}
|
||||
let status = match event_type.as_str() {
|
||||
"turn.completed" => "succeeded",
|
||||
"turn.cancelled" => "cancelled",
|
||||
"turn.interrupted" => "interrupted",
|
||||
_ => "failed",
|
||||
let (turn_terminal_state, status) = match event_type.as_str() {
|
||||
"turn.completed" => ("completed", "succeeded"),
|
||||
"turn.cancelled" => ("cancelled", "cancelled"),
|
||||
"turn.interrupted" => ("interrupted", "cancelled"),
|
||||
_ => ("failed", "failed"),
|
||||
};
|
||||
let disposition = goal_terminal_disposition(
|
||||
state
|
||||
|
|
@ -1415,7 +1417,9 @@ impl AcpxCommandExecutor {
|
|||
event_type: "run.terminal".to_owned(),
|
||||
priority: EventPriority::P0,
|
||||
payload: json!({
|
||||
"schema": "paperclip.prp.terminal.v1",
|
||||
"status": status,
|
||||
"turnTerminalState": turn_terminal_state,
|
||||
"runTerminalState": status,
|
||||
"reportedWorkDisposition": disposition,
|
||||
"provider": "acpx",
|
||||
|
|
@ -1527,6 +1531,13 @@ impl CommandExecutor for AcpxCommandExecutor {
|
|||
return Ok(Vec::new());
|
||||
}
|
||||
self.poll_provider()?;
|
||||
self.retained_events()
|
||||
}
|
||||
|
||||
fn retained_events(&mut self) -> Result<Vec<PolledEvent>, DurableRunnerError> {
|
||||
// Explicit drain runs while control traffic suppresses provider polling.
|
||||
// Expose the already-retained suffix so runnerd can commit and ACK it
|
||||
// before suspension, without restoring or advancing the provider.
|
||||
Ok(self
|
||||
.state
|
||||
.as_ref()
|
||||
|
|
@ -1805,6 +1816,57 @@ mod tests {
|
|||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_events_exposes_terminal_suffix_without_restoring_provider() {
|
||||
let directory = temporary_directory("retained-terminal-suffix");
|
||||
let config = test_config(&directory, None);
|
||||
let mut executor = AcpxCommandExecutor::with_runner_config(&directory, &config);
|
||||
// Invalid on-disk state would fail restoration. Retained-only reads
|
||||
// must neither restore a provider nor inspect a different state owner.
|
||||
fs::write(executor.state_path(), b"not provider state").unwrap();
|
||||
assert!(executor.retained_events().unwrap().is_empty());
|
||||
|
||||
let operations = Vec::new();
|
||||
let tool_set = AuthorizedToolSet {
|
||||
schema: TOOL_SET_SCHEMA.to_owned(),
|
||||
schema_version: 1,
|
||||
catalog_digest: authorized_tool_catalog_digest(&operations).unwrap(),
|
||||
operations,
|
||||
};
|
||||
let mut state = AcpxDurableState::new(
|
||||
serde_json::from_value(descriptor("claude")).unwrap(),
|
||||
tool_set,
|
||||
"retained-only-test".to_owned(),
|
||||
);
|
||||
state.lifecycle = "session_open".to_owned();
|
||||
for event_type in ["turn.completed", "run.usage", "run.completed"] {
|
||||
state
|
||||
.push(NormalizedProviderEvent {
|
||||
event_type: event_type.to_owned(),
|
||||
priority: EventPriority::P0,
|
||||
payload: json!({}),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
executor.state = Some(state);
|
||||
let suffix = executor.retained_events().unwrap();
|
||||
assert_eq!(
|
||||
suffix
|
||||
.iter()
|
||||
.map(|event| event.event_type.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["turn.completed", "run.usage", "run.completed"],
|
||||
);
|
||||
// Reading is not acknowledgement: a retry sees the exact same FIFO.
|
||||
assert_eq!(executor.retained_events().unwrap(), suffix);
|
||||
assert!(executor.session.is_none());
|
||||
assert_eq!(
|
||||
fs::read(executor.state_path()).unwrap(),
|
||||
b"not provider state"
|
||||
);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admits_only_exact_qualified_claude_and_codex_descriptors() {
|
||||
for agent in ["claude", "codex"] {
|
||||
|
|
@ -2169,6 +2231,8 @@ mod tests {
|
|||
assert_eq!(events[0].event_type, "turn.failed");
|
||||
assert_eq!(events[0].payload["providerShutdownFailed"], true);
|
||||
assert_eq!(events[1].event_type, "run.terminal");
|
||||
assert_eq!(events[1].payload["schema"], "paperclip.prp.terminal.v1");
|
||||
assert_eq!(events[1].payload["turnTerminalState"], "failed");
|
||||
let cleanup_error = recovered
|
||||
.shutdown()
|
||||
.expect_err("cleanup must not succeed while the original lifetime remains active");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use std::collections::VecDeque;
|
||||
use std::collections::{BTreeSet, VecDeque};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::RecvTimeoutError;
|
||||
use std::time::{Duration, Instant};
|
||||
|
|
@ -82,6 +82,7 @@ pub struct AcpxSidecarTransport {
|
|||
last_event_sequence: u64,
|
||||
buffered_events: VecDeque<AcpxSidecarEvent>,
|
||||
stderr_tail: BoundedLogBuffer,
|
||||
stderr_categories: BTreeSet<&'static str>,
|
||||
poisoned: bool,
|
||||
}
|
||||
|
||||
|
|
@ -154,6 +155,7 @@ impl AcpxSidecarTransport {
|
|||
last_event_sequence: 0,
|
||||
buffered_events: VecDeque::new(),
|
||||
stderr_tail: BoundedLogBuffer::new(32, 8 * 1024),
|
||||
stderr_categories: BTreeSet::new(),
|
||||
poisoned: false,
|
||||
})
|
||||
}
|
||||
|
|
@ -323,7 +325,7 @@ impl AcpxSidecarTransport {
|
|||
match self.process.recv_timeout(remaining) {
|
||||
Ok(ProcessOutput::Stdout(line)) => return Ok(Some(line)),
|
||||
Ok(ProcessOutput::Stderr(line)) => {
|
||||
self.stderr_tail.push(redact_diagnostic(&line));
|
||||
self.record_stderr(&line);
|
||||
}
|
||||
Ok(ProcessOutput::StdoutError(message)) => {
|
||||
return Err(LocalRunnerError::invalid(format!(
|
||||
|
|
@ -412,7 +414,7 @@ impl AcpxSidecarTransport {
|
|||
};
|
||||
match output {
|
||||
Some(ProcessOutput::Stderr(line)) => {
|
||||
self.stderr_tail.push(redact_diagnostic(&line));
|
||||
self.record_stderr(&line);
|
||||
}
|
||||
Some(ProcessOutput::StderrClosed) | None => break,
|
||||
Some(ProcessOutput::Stdout(_))
|
||||
|
|
@ -424,13 +426,33 @@ impl AcpxSidecarTransport {
|
|||
|
||||
fn diagnostic_suffix(&self) -> String {
|
||||
let diagnostics = self.stderr_tail.snapshot().lines.join("\n");
|
||||
if diagnostics.is_empty() {
|
||||
let categories = if self.stderr_categories.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" stderrTail={diagnostics:?}")
|
||||
format!(
|
||||
" stderrCategories={}",
|
||||
self.stderr_categories
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
)
|
||||
};
|
||||
if diagnostics.is_empty() {
|
||||
categories
|
||||
} else {
|
||||
format!("{categories} stderrTail={diagnostics:?}")
|
||||
}
|
||||
}
|
||||
|
||||
fn record_stderr(&mut self, line: &str) {
|
||||
// Only fixed categories cross this boundary. Raw errors, stack paths,
|
||||
// identifiers, and credential-bearing strings remain fully redacted.
|
||||
self.stderr_categories
|
||||
.extend(stderr_diagnostic_categories(line));
|
||||
self.stderr_tail.push(redact_diagnostic(line));
|
||||
}
|
||||
|
||||
fn poison(&mut self) {
|
||||
if self.poisoned {
|
||||
return;
|
||||
|
|
@ -598,6 +620,53 @@ fn redact_diagnostic(value: &str) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
fn stderr_diagnostic_categories(value: &str) -> BTreeSet<&'static str> {
|
||||
const CATEGORIES: &[(&str, &str)] = &[
|
||||
("TypeError", "javascript_type_error"),
|
||||
("ReferenceError", "javascript_reference_error"),
|
||||
("SyntaxError", "javascript_syntax_error"),
|
||||
("RangeError", "javascript_range_error"),
|
||||
("AssertionError", "javascript_assertion_error"),
|
||||
("UnhandledPromiseRejection", "unhandled_rejection"),
|
||||
("ERR_UNHANDLED_REJECTION", "unhandled_rejection"),
|
||||
("ERR_UNHANDLED_ERROR", "unhandled_event_error"),
|
||||
("ERR_INVALID_ARG_TYPE", "invalid_argument_type"),
|
||||
("ERR_INVALID_ARG_VALUE", "invalid_argument_value"),
|
||||
("ERR_STREAM_WRITE_AFTER_END", "stream_write_after_end"),
|
||||
("ERR_STREAM_DESTROYED", "stream_destroyed"),
|
||||
("ERR_IPC_CHANNEL_CLOSED", "ipc_channel_closed"),
|
||||
("ERR_SOCKET_CLOSED", "socket_closed"),
|
||||
("ERR_MODULE_NOT_FOUND", "module_not_found"),
|
||||
("MODULE_NOT_FOUND", "module_not_found"),
|
||||
("EPIPE", "broken_pipe"),
|
||||
("ECONNRESET", "connection_reset"),
|
||||
("EADDRINUSE", "address_in_use"),
|
||||
("ENOENT", "file_not_found"),
|
||||
("EACCES", "permission_denied"),
|
||||
("EPERM", "permission_denied"),
|
||||
(
|
||||
"ACPX_PERSISTED_SESSION_IDENTITY_MISMATCH",
|
||||
"persisted_session_identity_mismatch",
|
||||
),
|
||||
("SESSION_RESUME_REQUIRED", "session_resume_required"),
|
||||
];
|
||||
let mut categories: BTreeSet<&'static str> = value
|
||||
.split(|character: char| !character.is_ascii_alphanumeric() && character != '_')
|
||||
.filter_map(|token| {
|
||||
CATEGORIES
|
||||
.iter()
|
||||
.find_map(|(known, category)| (token == *known).then_some(*category))
|
||||
})
|
||||
.collect();
|
||||
if value.contains("triggerUncaughtException") && value.contains("fromPromise") {
|
||||
categories.insert("unhandled_rejection");
|
||||
}
|
||||
if value.contains("ACPX provider spawned after ownership admission was sealed") {
|
||||
categories.insert("provider_spawn_after_ownership_seal");
|
||||
}
|
||||
categories
|
||||
}
|
||||
|
||||
fn response_error_classification(error: &ResponseError) -> &'static str {
|
||||
match error.code.as_str() {
|
||||
"ACP_MODEL_UNSUPPORTED" => return "requested_model_unsupported",
|
||||
|
|
@ -642,6 +711,17 @@ fn response_error_classification(error: &ResponseError) -> &'static str {
|
|||
_ => {}
|
||||
}
|
||||
match error.message.as_str() {
|
||||
"ACPX provider spawned after ownership admission was sealed" => {
|
||||
"provider_spawn_after_ownership_seal"
|
||||
}
|
||||
"ACPX recovery identity conflicts with the immutable session configuration" => {
|
||||
"recovery_configuration_mismatch"
|
||||
}
|
||||
"ACPX recovery identity does not match the persisted runtime record" => {
|
||||
"recovery_identity_mismatch"
|
||||
}
|
||||
"ACPX provider lifetime lease is unavailable" => "provider_lifetime_unavailable",
|
||||
"Managed Codex credential home already has an active lease" => "provider_lifetime_owned",
|
||||
"ACPX session handshake exceeded its admission deadline" => "session_handshake_timeout",
|
||||
"ACPX provider lifetime guardian exited before ownership transfer" => {
|
||||
"provider_guardian_exit"
|
||||
|
|
@ -734,6 +814,39 @@ mod tests {
|
|||
)),
|
||||
"session_handshake_timeout"
|
||||
);
|
||||
for (message, classification) in [
|
||||
(
|
||||
"ACPX recovery identity conflicts with the immutable session configuration",
|
||||
"recovery_configuration_mismatch",
|
||||
),
|
||||
(
|
||||
"ACPX recovery identity does not match the persisted runtime record",
|
||||
"recovery_identity_mismatch",
|
||||
),
|
||||
(
|
||||
"ACPX provider lifetime lease is unavailable",
|
||||
"provider_lifetime_unavailable",
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
response_error_classification(&error("acpx_sidecar_command_failed", message)),
|
||||
classification
|
||||
);
|
||||
assert_eq!(
|
||||
response_error_classification(&error(
|
||||
"acpx_sidecar_command_failed",
|
||||
&format!("{message}: private-provider-detail")
|
||||
)),
|
||||
"unclassified"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
response_error_classification(&error(
|
||||
"acpx_sidecar_command_failed",
|
||||
"Managed Codex credential home already has an active lease"
|
||||
)),
|
||||
"provider_lifetime_owned"
|
||||
);
|
||||
let admission_failures = [
|
||||
(
|
||||
"ACPX_RUNTIME_ADMISSION_VERIFICATION_TIMEOUT",
|
||||
|
|
|
|||
|
|
@ -2670,6 +2670,9 @@ mod tests {
|
|||
}
|
||||
|
||||
fn read_http_request(socket: &mut TcpStream) -> Result<CapturedRequest, std::io::Error> {
|
||||
// Darwin inherits the listener's nonblocking flag on accept. Wait for
|
||||
// request bytes within the timeout instead of dropping an early accept.
|
||||
socket.set_nonblocking(false)?;
|
||||
socket.set_read_timeout(Some(Duration::from_secs(2)))?;
|
||||
let mut bytes = Vec::new();
|
||||
let mut buffer = [0_u8; 4096];
|
||||
|
|
@ -2722,6 +2725,34 @@ mod tests {
|
|||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fake_service_waits_for_request_bytes_on_an_accepted_nonblocking_socket() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let mut client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
|
||||
let (mut accepted, _) = listener.accept().unwrap();
|
||||
// Reproduce Darwin's inherited listener flag on every test platform.
|
||||
accepted.set_nonblocking(true).unwrap();
|
||||
let (result_tx, result_rx) = mpsc::channel();
|
||||
let reader = thread::spawn(move || {
|
||||
result_tx.send(read_http_request(&mut accepted)).unwrap();
|
||||
});
|
||||
assert!(matches!(
|
||||
result_rx.recv_timeout(Duration::from_millis(25)),
|
||||
Err(mpsc::RecvTimeoutError::Timeout)
|
||||
));
|
||||
client
|
||||
.write_all(b"POST /delayed HTTP/1.1\r\nContent-Length: 2\r\n\r\n{}")
|
||||
.unwrap();
|
||||
let request = result_rx
|
||||
.recv_timeout(Duration::from_secs(3))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
reader.join().unwrap();
|
||||
assert_eq!(request.method, "POST");
|
||||
assert_eq!(request.path, "/delayed");
|
||||
assert_eq!(request.body, "{}");
|
||||
}
|
||||
|
||||
fn send_json_response(socket: &mut TcpStream, status: &str, value: &Value) {
|
||||
let body = serde_json::to_string(value).unwrap();
|
||||
let _ = write!(socket, "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
|
||||
|
|
|
|||
|
|
@ -1597,6 +1597,28 @@ pub(crate) fn sanitize_semantic_tool_input(
|
|||
input: &Value,
|
||||
) -> Result<Value, DurableRunnerError> {
|
||||
let mut sanitized = sanitize_value(input);
|
||||
// Mutation prose is the user's intended work, not a diagnostic. Preserve
|
||||
// ordinary references to a token in these declared text fields; credential
|
||||
// syntax and high-confidence secret values are still scrubbed. All other
|
||||
// fields and operations retain the strict diagnostic policy.
|
||||
let prose_fields: &[&str] = match operation_id {
|
||||
"create_task" => &["title", "description", "initialPlan"],
|
||||
"create_project" => &["name", "description"],
|
||||
"write_document" => &["title", "body", "changeSummary"],
|
||||
_ => &[],
|
||||
};
|
||||
if let Some(sanitized_input) = sanitized.as_object_mut() {
|
||||
for field in prose_fields {
|
||||
if let Some(text) = input.get(*field).and_then(Value::as_str) {
|
||||
sanitized_input.insert(
|
||||
(*field).to_owned(),
|
||||
// The tool/API schema bounds business content. A diagnostic
|
||||
// preview limit must never truncate a plan or document.
|
||||
Value::String(redact_sensitive_text_values_with_context(text, true)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matches!(operation_id, "paperclip_finish" | "paperclip_block") {
|
||||
return Ok(sanitized);
|
||||
}
|
||||
|
|
@ -1720,6 +1742,10 @@ pub(crate) fn redact_text(input: &str) -> String {
|
|||
}
|
||||
|
||||
fn redact_sensitive_text_values(input: &str) -> String {
|
||||
redact_sensitive_text_values_with_context(input, false)
|
||||
}
|
||||
|
||||
fn redact_sensitive_text_values_with_context(input: &str, semantic_prose: bool) -> String {
|
||||
let normalized = input.to_ascii_lowercase();
|
||||
let bytes = normalized.as_bytes();
|
||||
let mut ranges: Vec<(usize, usize)> = Vec::new();
|
||||
|
|
@ -1893,6 +1919,7 @@ fn redact_sensitive_text_values(input: &str) -> String {
|
|||
("ghu_", 20),
|
||||
("ghs_", 20),
|
||||
("ghr_", 20),
|
||||
("github_pat_", 20),
|
||||
] {
|
||||
for (start, _) in normalized.match_indices(prefix) {
|
||||
if start > 0 && is_name_byte(bytes[start - 1]) {
|
||||
|
|
@ -2088,6 +2115,35 @@ fn redact_sensitive_text_values(input: &str) -> String {
|
|||
.any(|delimiter| before.ends_with(delimiter))
|
||||
};
|
||||
let has_hyphenated_count_lead = token_phrase_has_lead("one-");
|
||||
// A bare token reference in declared mutation prose can be an output
|
||||
// requirement. Auth/access/session context, explicit assignment,
|
||||
// quoted credentials and CLI/compound names remain credential pairs.
|
||||
// Known key/JWT/Bearer values are independently scrubbed above.
|
||||
let is_semantic_token_reference = semantic_prose
|
||||
&& key == "token"
|
||||
&& !key_is_compound
|
||||
&& whitespace_start == start + key.len()
|
||||
&& separator > whitespace_start
|
||||
&& !has_assignment_separator
|
||||
&& bytes[whitespace_start..separator]
|
||||
.iter()
|
||||
.all(|value| matches!(value, b' ' | b'\t'))
|
||||
&& quoted_value_start(separator).1.is_none()
|
||||
&& ![
|
||||
"auth ",
|
||||
"authentication ",
|
||||
"authorization ",
|
||||
"access ",
|
||||
"refresh ",
|
||||
"session ",
|
||||
"api ",
|
||||
"security ",
|
||||
"secret ",
|
||||
"credential ",
|
||||
"bearer ",
|
||||
]
|
||||
.iter()
|
||||
.any(|lead| token_phrase_has_lead(lead));
|
||||
let is_benign_token_noun_phrase = key == "token"
|
||||
&& (!key_is_compound || has_hyphenated_count_lead)
|
||||
&& whitespace_start == start + key.len()
|
||||
|
|
@ -2150,7 +2206,8 @@ fn redact_sensitive_text_values(input: &str) -> String {
|
|||
|| (token_phrase_has_tail("can equal") && token_phrase_has_lead("one ")));
|
||||
let has_whitespace_separator = separator > whitespace_start
|
||||
&& (key != "authorization" || key_is_compound || has_authorization_scheme)
|
||||
&& !is_benign_token_noun_phrase;
|
||||
&& !is_benign_token_noun_phrase
|
||||
&& !is_semantic_token_reference;
|
||||
if !has_assignment_separator && !has_whitespace_separator {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -3159,6 +3216,148 @@ mod tests {
|
|||
assert_eq!(sanitized["accessToken"], json!("[REDACTED]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_handoff_preserves_acceptance_identifiers_in_declared_prose() {
|
||||
let description =
|
||||
"The document body must contain the token CHAT250ed7e4dc071. No code changes needed.";
|
||||
let plan = format!(
|
||||
"## Plan\n{}\n- The token CHAT250ed7e4dc071 included somewhere in the body.\n- Save the output document.",
|
||||
"Relevant task context. ".repeat(300),
|
||||
);
|
||||
assert!(plan.len() > 4096);
|
||||
let input = json!({
|
||||
"title": "Write project description",
|
||||
"description": description,
|
||||
"initialPlan": plan,
|
||||
"idempotencyKey": "write-description-1",
|
||||
});
|
||||
assert_eq!(
|
||||
sanitize_semantic_tool_input("create_task", &input).unwrap(),
|
||||
input
|
||||
);
|
||||
for text in [
|
||||
description,
|
||||
plan.as_str(),
|
||||
"Must include the literal token `CHAT66e7813a4f9d1` somewhere in the text.",
|
||||
"Include the exact token ACCEPTANCE-42 in the final output.",
|
||||
] {
|
||||
assert_eq!(
|
||||
sanitize_semantic_tool_input("write_document", &json!({"body": text})).unwrap(),
|
||||
json!({"body": text})
|
||||
);
|
||||
assert_ne!(
|
||||
redact_text(text),
|
||||
text,
|
||||
"diagnostics keep their strict policy"
|
||||
);
|
||||
}
|
||||
let config = config(PathBuf::from("unused"));
|
||||
let mut state = DurableState::new(&config);
|
||||
state
|
||||
.enqueue_executor_event(
|
||||
&config,
|
||||
"provider-create-task".to_owned(),
|
||||
"semantic_tool.input".to_owned(),
|
||||
EventPriority::P0,
|
||||
json!({"semantic_tool": {
|
||||
"schema": "paperclip.prp.semantic_tool.v1",
|
||||
"schemaVersion": 1,
|
||||
"phase": "input",
|
||||
"operationId": "create_task",
|
||||
"content": {"digest": semantic_value_digest(&input)},
|
||||
"input": input,
|
||||
}}),
|
||||
)
|
||||
.unwrap();
|
||||
let transmitted = state.outbox[0]
|
||||
.envelope
|
||||
.pointer("/payload/payload/semantic_tool/input")
|
||||
.unwrap();
|
||||
assert_eq!(transmitted, &input);
|
||||
assert_eq!(
|
||||
state.outbox[0]
|
||||
.envelope
|
||||
.pointer("/payload/payload/semantic_tool/content/digest"),
|
||||
Some(&json!(semantic_value_digest(transmitted))),
|
||||
);
|
||||
let document = format!(
|
||||
"{}\nAuthorization: Bearer late-credential\nFINAL-ACCEPTANCE-42",
|
||||
"Document content. ".repeat(400)
|
||||
);
|
||||
let safe =
|
||||
sanitize_semantic_tool_input("write_document", &json!({"body": document})).unwrap();
|
||||
let body = safe["body"].as_str().unwrap();
|
||||
assert!(body.len() > 4096);
|
||||
assert!(body.ends_with("FINAL-ACCEPTANCE-42"));
|
||||
assert!(!body.contains("late-credential"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_prose_does_not_exempt_credential_syntax_or_shapes() {
|
||||
for text in [
|
||||
"auth token opaque-credential",
|
||||
"access token opaque-credential",
|
||||
"session token opaque-credential",
|
||||
"refresh token opaque-credential",
|
||||
"authentication token opaque-credential",
|
||||
"literal token=opaque-credential",
|
||||
"literal token:opaque-credential",
|
||||
"literal --token opaque-credential",
|
||||
"literal access_token opaque-credential",
|
||||
"literal \"token\" opaque-credential",
|
||||
"literal token \"opaque-credential\"",
|
||||
] {
|
||||
assert!(!redact_text(text).contains("opaque-credential"), "{text}");
|
||||
let input = json!({"description": text, "initialPlan": text});
|
||||
assert!(
|
||||
!sanitize_semantic_tool_input("create_task", &input)
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains("opaque-credential"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
for secret in [
|
||||
"sk-proj-secretvalue123456",
|
||||
"ghp_secretvalue12345678901234567890",
|
||||
"github_pat_secretvalue12345678901234567890",
|
||||
"eyJhbGciOiJIUzI1NiJ9.c2VjcmV0LWNsYWlt.signaturesecret",
|
||||
] {
|
||||
let text = format!("Include the literal token {secret} in the document.");
|
||||
assert!(!redact_text(&text).contains(secret), "{text}");
|
||||
assert!(
|
||||
!sanitize_semantic_tool_input("write_document", &json!({"body": text}))
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains(secret)
|
||||
);
|
||||
}
|
||||
let input = json!({
|
||||
"description": "Include the literal token ACCEPTANCE-42. Authorization: Bearer opaque-credential",
|
||||
"token": "opaque-credential",
|
||||
});
|
||||
let safe = sanitize_semantic_tool_input("create_task", &input).unwrap();
|
||||
assert!(safe["description"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("ACCEPTANCE-42"));
|
||||
assert!(!safe.to_string().contains("opaque-credential"));
|
||||
let diagnostic = json!({"description": "the token opaque-credential"});
|
||||
assert!(
|
||||
!sanitize_semantic_tool_input("get_task_context", &diagnostic)
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains("opaque-credential")
|
||||
);
|
||||
assert!(!sanitize_semantic_tool_input(
|
||||
"create_task",
|
||||
&json!({"diagnostic": "token opaque-credential"})
|
||||
)
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains("opaque-credential"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_redaction_preserves_benign_token_system_prose() {
|
||||
let prose = "Offer a simple token system so guests can exchange items even when their contributions differ in quantity.";
|
||||
|
|
|
|||
|
|
@ -495,3 +495,116 @@ fn terminal_events_clear_pending_requests_and_reject_late_turn_events() {
|
|||
))
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutation_prose_survives_sidecar_decode_pending_state_and_semantic_projection() {
|
||||
let mut state = AcpxProviderState::new("run-1").unwrap();
|
||||
state.begin_turn("turn-1").unwrap();
|
||||
let plan = format!(
|
||||
"{}\nThe token CHAT8322bda781b81 must be included in the document.",
|
||||
"Relevant context. ".repeat(400)
|
||||
);
|
||||
let input = json!({
|
||||
"title": "Write project description",
|
||||
"description": "The document must contain the token CHAT8322bda781b81.",
|
||||
"initialPlan": plan,
|
||||
"idempotencyKey": "CHAT8322bda781b81-task",
|
||||
"apiToken": "actual-credential",
|
||||
});
|
||||
let mut expected = input.clone();
|
||||
expected["apiToken"] = json!("[REDACTED]");
|
||||
let emitted = state
|
||||
.accept_event(&event(
|
||||
1,
|
||||
GeneratedAcpxSidecarEventType::RuntimeToolCalled,
|
||||
Some("turn-1"),
|
||||
json!({"callId": "call-1", "operationId": "create_task", "input": input}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(state.pending_tool("call-1").unwrap().input, expected);
|
||||
let projected = project_acpx_state_event(
|
||||
&AcpxEventProjectionContext {
|
||||
run_id: "run-1".to_owned(),
|
||||
normalized_session_id: "session-1".to_owned(),
|
||||
turn_id: "turn-1".to_owned(),
|
||||
provider_turn_id: None,
|
||||
item_id: "call-1".to_owned(),
|
||||
},
|
||||
&emitted[0],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(projected[0].event_type, "semantic_tool.input");
|
||||
assert_eq!(projected[0].payload["semantic_tool"]["input"], expected);
|
||||
assert_eq!(
|
||||
projected[0].payload["semantic_tool"]["content"]["digest"],
|
||||
json!(paperclip_runner_core::provider_bridge::semantic_value_digest(&expected))
|
||||
);
|
||||
|
||||
for (operation, field, prose, preserved) in [
|
||||
(
|
||||
"write_document",
|
||||
"body",
|
||||
"Include the token CHAT8322bda781b81.",
|
||||
true,
|
||||
),
|
||||
(
|
||||
"create_project",
|
||||
"description",
|
||||
"Include the token CHAT8322bda781b81.",
|
||||
true,
|
||||
),
|
||||
(
|
||||
"get_task_context",
|
||||
"description",
|
||||
"Include the token CHAT8322bda781b81.",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"mcp__untrusted__create_task",
|
||||
"description",
|
||||
"Include the token CHAT8322bda781b81.",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"create_task",
|
||||
"description",
|
||||
"Authorization: Bearer actual-credential",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"create_task",
|
||||
"initialPlan",
|
||||
"access token actual-credential",
|
||||
false,
|
||||
),
|
||||
] {
|
||||
state
|
||||
.complete_tool(
|
||||
"call-1",
|
||||
state
|
||||
.pending_tool("call-1")
|
||||
.unwrap()
|
||||
.operation_id
|
||||
.clone()
|
||||
.as_str(),
|
||||
)
|
||||
.unwrap();
|
||||
let emitted = state
|
||||
.accept_event(&event(
|
||||
2,
|
||||
GeneratedAcpxSidecarEventType::RuntimeToolCalled,
|
||||
Some("turn-1"),
|
||||
json!({"callId": "call-1", "operationId": operation, "input": {field: prose}}),
|
||||
))
|
||||
.unwrap();
|
||||
let AcpxProviderStateEvent::ToolCall { input, .. } = &emitted[0] else {
|
||||
panic!("expected tool call");
|
||||
};
|
||||
assert_eq!(
|
||||
input[field] == json!(prose),
|
||||
preserved,
|
||||
"{operation}: {prose}"
|
||||
);
|
||||
assert!(!input.to_string().contains("actual-credential"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -180,3 +180,37 @@ fn redacts_sidecar_stderr_when_the_process_exits() {
|
|||
assert!(message.contains("[REDACTED]"));
|
||||
assert!(!message.contains("amber-signal-7305"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn preserves_only_allowlisted_stderr_categories_when_the_process_exits() {
|
||||
let mut transport = AcpxSidecarTransport::start(&AcpxSidecarTransportConfig {
|
||||
command: PathBuf::from("/bin/sh"),
|
||||
args: vec![
|
||||
"-c".to_owned(),
|
||||
"printf '%s\n' 'TypeError [ERR_INVALID_ARG_TYPE]: token=amber-signal-7305' ' at /private/secret-project/session-123.js:42' 'triggerUncaughtException(err, true /* fromPromise */);' 'Error: ACPX provider spawned after ownership admission was sealed' 'code: EPIPE' 'UnknownProviderError: private-value' 'prefixECONNRESETsuffix' >&2; exit 1".to_owned(),
|
||||
],
|
||||
verified_launch: None,
|
||||
request_timeout: Duration::from_secs(1),
|
||||
shutdown_grace: Duration::from_millis(50),
|
||||
})
|
||||
.expect("diagnostic fixture should start");
|
||||
let error = transport
|
||||
.poll_event(Duration::from_secs(1))
|
||||
.expect_err("exited sidecar must fail");
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("stderrCategories=broken_pipe,invalid_argument_type,javascript_type_error,provider_spawn_after_ownership_seal,unhandled_rejection"));
|
||||
assert!(message.contains("stderrTail="));
|
||||
assert!(message.contains("[REDACTED]"));
|
||||
for sensitive in [
|
||||
"amber-signal-7305",
|
||||
"secret-project",
|
||||
"session-123",
|
||||
"private-value",
|
||||
"UnknownProviderError",
|
||||
"connection_reset",
|
||||
"TypeError",
|
||||
] {
|
||||
assert!(!message.contains(sensitive));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,30 @@ fn opencode_call_count(state_dir: &Path, method: &str) -> usize {
|
|||
.count()
|
||||
}
|
||||
|
||||
fn assert_valid_terminal(payload: &Value) {
|
||||
let schema: Value = serde_json::from_str(include_str!(
|
||||
"../../../../protocol/schemas/terminal.schema.json"
|
||||
))
|
||||
.unwrap();
|
||||
let stop_reason: Value = serde_json::from_str(include_str!(
|
||||
"../../../../protocol/schemas/stop-reason.schema.json"
|
||||
))
|
||||
.unwrap();
|
||||
let registry = jsonschema::Registry::new()
|
||||
.add(
|
||||
"https://paperclip.dev/schemas/prp/v1/stop-reason.schema.json",
|
||||
stop_reason,
|
||||
)
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.unwrap();
|
||||
let validator = jsonschema::options()
|
||||
.with_registry(®istry)
|
||||
.build(&schema)
|
||||
.unwrap();
|
||||
validator.validate(payload).unwrap();
|
||||
}
|
||||
|
||||
fn command(sequence: u64, command_type: &str, payload: Value) -> Command {
|
||||
Command {
|
||||
schema: "paperclip.prp.command.v1".to_owned(),
|
||||
|
|
@ -216,6 +240,7 @@ fn preserves_acpx_semantic_disposition_in_the_run_terminal() {
|
|||
.iter()
|
||||
.find(|event| event.event_type == "run.terminal")
|
||||
.expect("ACPX blocked result must become terminal");
|
||||
assert_valid_terminal(&terminal.payload);
|
||||
assert_eq!(terminal.payload["runTerminalState"], "succeeded");
|
||||
assert_eq!(terminal.payload["reportedWorkDisposition"], "blocked");
|
||||
|
||||
|
|
@ -360,7 +385,19 @@ fn executes_a_qualified_acpx_profile_through_the_native_selector() {
|
|||
assert!(events
|
||||
.iter()
|
||||
.any(|event| event.event_type == "run.terminal"));
|
||||
assert_valid_terminal(
|
||||
&events
|
||||
.iter()
|
||||
.find(|event| event.event_type == "run.terminal")
|
||||
.unwrap()
|
||||
.payload,
|
||||
);
|
||||
// runner.drain must see this exact terminal suffix without polling the
|
||||
// provider again. An empty default implementation strands the suffix and
|
||||
// makes shared native transport closure fail after a successful reply.
|
||||
assert_eq!(executor.retained_events().unwrap(), events);
|
||||
executor.acknowledge_events(events.len()).unwrap();
|
||||
assert!(executor.retained_events().unwrap().is_empty());
|
||||
executor
|
||||
.execute(&command(4, "session.close", json!({})))
|
||||
.unwrap();
|
||||
|
|
@ -368,6 +405,57 @@ fn executes_a_qualified_acpx_profile_through_the_native_selector() {
|
|||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumes_an_idle_acpx_session_in_a_cold_replacement_runner() {
|
||||
let directory = temporary_directory("acpx-cold-idle-recovery");
|
||||
let config = acpx_config(&directory, "turns-reserved-result-terminal");
|
||||
let mut executor = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
executor
|
||||
.execute(&command(
|
||||
1,
|
||||
"run.prepare",
|
||||
prepare_payload(&directory, "codex"),
|
||||
))
|
||||
.unwrap();
|
||||
let original = executor
|
||||
.execute(&command(2, "session.open", json!({})))
|
||||
.unwrap();
|
||||
executor
|
||||
.execute(&command(
|
||||
3,
|
||||
"turn.start",
|
||||
json!({"text":"Acknowledge.", "turnId":"provider-turn-first"}),
|
||||
))
|
||||
.unwrap();
|
||||
let events = executor.poll_events().unwrap();
|
||||
executor.acknowledge_events(events.len()).unwrap();
|
||||
executor
|
||||
.execute(&command(4, "runner.suspend", json!({})))
|
||||
.unwrap();
|
||||
executor.shutdown().unwrap();
|
||||
drop(executor);
|
||||
|
||||
let mut replacement_config = config.clone();
|
||||
replacement_config.run_id = "run-2".to_owned();
|
||||
replacement_config.turn_id = "turn-2".to_owned();
|
||||
let mut replacement =
|
||||
NativeProviderCommandExecutor::with_runner_config(&directory, &replacement_config);
|
||||
let mut payload = prepare_payload(&directory, "codex");
|
||||
payload["provider"]["runId"] = json!("run-2");
|
||||
let resumed = replacement
|
||||
.execute(&command(1, "run.attach", payload))
|
||||
.unwrap();
|
||||
assert_eq!(resumed.result["status"], "resumed");
|
||||
assert_eq!(
|
||||
resumed.result["providerSessionId"],
|
||||
original.result["providerSessionId"]
|
||||
);
|
||||
// Admission itself must preserve the provider identity before any new
|
||||
// model turn. The fixture's scripted terminal events belong to run-1.
|
||||
replacement.shutdown().unwrap();
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_native_acpx_semantic_events_on_the_durable_controller_turn() {
|
||||
let directory = temporary_directory("acpx-durable-turn-correlation");
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ function validInventories() {
|
|||
schemaVersion: 2,
|
||||
inventoryRole: "normative",
|
||||
generatedFrom: ["skills/paperclip/SKILL.md"],
|
||||
rows: Array.from({ length: 153 }, (_, index) => row(`capability-${index}`)),
|
||||
rows: Array.from({ length: 154 }, (_, index) => row(`capability-${index}`)),
|
||||
},
|
||||
evaluations: {
|
||||
schemaVersion: 2,
|
||||
|
|
|
|||
|
|
@ -2,10 +2,18 @@ import { readFile, writeFile } from "node:fs/promises";
|
|||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { serializeCapabilityGeneratedSemanticContracts } from "../dist/semantic-tools/provider-neutral.js";
|
||||
import { PAPERCLIP_RUNNER_BUILD_METADATA } from "../dist/evals/build-metadata.js";
|
||||
import { buildProtocolManifest } from "./generate-protocol-manifest.mjs";
|
||||
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const outputPath = resolve(packageRoot, "generated/capability/semantic-tool-contracts.json");
|
||||
const generated = serializeCapabilityGeneratedSemanticContracts();
|
||||
const manifestPath = resolve(packageRoot, "protocol/manifest.json");
|
||||
// This is an explicitly seeded schema fixture, not retained live evidence.
|
||||
// Keep its advertised catalog identity synchronized with the shipped contracts.
|
||||
const fixturePath = resolve(packageRoot, "protocol/fixtures/evals/native-execution-seeded.json");
|
||||
const fixture = JSON.parse(await readFile(fixturePath, "utf8"));
|
||||
const fixtureCurrent = fixture.runner.catalogSha256 === PAPERCLIP_RUNNER_BUILD_METADATA.semanticCatalog.sha256;
|
||||
|
||||
if (process.argv.includes("--check")) {
|
||||
const current = await readFile(outputPath, "utf8").catch(() => "");
|
||||
|
|
@ -13,7 +21,22 @@ if (process.argv.includes("--check")) {
|
|||
process.stderr.write("semantic-tool-contracts.json is stale; run generate:semantic-contracts\n");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
if (!fixtureCurrent) {
|
||||
process.stderr.write("native-execution-seeded.json catalog is stale; run generate:semantic-contracts\n");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
const manifest = `${JSON.stringify(await buildProtocolManifest(), null, 2)}\n`;
|
||||
if (await readFile(manifestPath, "utf8").catch(() => "") !== manifest) {
|
||||
process.stderr.write("protocol/manifest.json is stale; run generate:semantic-contracts\n");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} else {
|
||||
await writeFile(outputPath, generated);
|
||||
process.stdout.write(`wrote ${outputPath}\n`);
|
||||
if (!fixtureCurrent) {
|
||||
fixture.runner.catalogSha256 = PAPERCLIP_RUNNER_BUILD_METADATA.semanticCatalog.sha256;
|
||||
await writeFile(fixturePath, `${JSON.stringify(fixture, null, 2)}\n`);
|
||||
}
|
||||
// The manifest hashes fixture bytes, so refresh it after the seeded catalog.
|
||||
await writeFile(manifestPath, `${JSON.stringify(await buildProtocolManifest(), null, 2)}\n`);
|
||||
process.stdout.write(`wrote ${outputPath} and ${manifestPath}\n`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@ export async function buildMcpInventory(repoRoot) {
|
|||
|
||||
export function validateInventories(inventories) {
|
||||
const errors = [];
|
||||
const expectedCounts = { capabilities: 153, evaluations: 106, legacyMcpAliases: 42 };
|
||||
const expectedCounts = { capabilities: 154, evaluations: 106, legacyMcpAliases: 42 };
|
||||
const normativeNames = ["capabilities", "evaluations"];
|
||||
const normativeRows = new Map();
|
||||
const globalNormativeIds = new Set();
|
||||
|
|
|
|||
|
|
@ -59,12 +59,12 @@
|
|||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:server-verified-external-chat-turns:30",
|
||||
"id": "skill:skills/paperclip/SKILL.md:conversation-tasks:30",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:30",
|
||||
"title": "Server-Verified External Chat Turns",
|
||||
"expectedSemantics": "Skill guidance headed “Server-Verified External Chat Turns”.",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"title": "Conversation tasks",
|
||||
"expectedSemantics": "Skill guidance headed “Conversation tasks”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"requiredGrants": [],
|
||||
"assertionClasses": [
|
||||
"control_plane_invariant"
|
||||
|
|
@ -74,9 +74,24 @@
|
|||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:the-heartbeat-procedure:70",
|
||||
"id": "skill:skills/paperclip/SKILL.md:server-verified-external-chat-turns:47",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:70",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:47",
|
||||
"title": "Server-Verified External Chat Turns",
|
||||
"expectedSemantics": "Skill guidance headed “Server-Verified External Chat Turns”.",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
"requiredGrants": [],
|
||||
"assertionClasses": [
|
||||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:47"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:the-heartbeat-procedure:87",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:87",
|
||||
"title": "The Heartbeat Procedure",
|
||||
"expectedSemantics": "Skill guidance headed “The Heartbeat Procedure”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -85,13 +100,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:70"
|
||||
"skill:skills/paperclip/SKILL.md:87"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:generated-artifacts-and-work-products:142",
|
||||
"id": "skill:skills/paperclip/SKILL.md:generated-artifacts-and-work-products:159",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:142",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:159",
|
||||
"title": "Generated Artifacts and Work Products",
|
||||
"expectedSemantics": "Skill guidance headed “Generated Artifacts and Work Products”.",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
|
|
@ -100,13 +115,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:142"
|
||||
"skill:skills/paperclip/SKILL.md:159"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:status-quick-guide:190",
|
||||
"id": "skill:skills/paperclip/SKILL.md:status-quick-guide:207",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:190",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:207",
|
||||
"title": "Status Quick Guide",
|
||||
"expectedSemantics": "Skill guidance headed “Status Quick Guide”.",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
|
|
@ -115,13 +130,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:190"
|
||||
"skill:skills/paperclip/SKILL.md:207"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:monitors-and-watchers-say-only-what-you-actually-scheduled:200",
|
||||
"id": "skill:skills/paperclip/SKILL.md:monitors-and-watchers-say-only-what-you-actually-scheduled:217",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:200",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:217",
|
||||
"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",
|
||||
|
|
@ -130,13 +145,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:200"
|
||||
"skill:skills/paperclip/SKILL.md:217"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:delegating-review-tasks:213",
|
||||
"id": "skill:skills/paperclip/SKILL.md:delegating-review-tasks:230",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:213",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:230",
|
||||
"title": "Delegating review tasks",
|
||||
"expectedSemantics": "Skill guidance headed “Delegating review tasks”.",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
|
|
@ -145,13 +160,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:213"
|
||||
"skill:skills/paperclip/SKILL.md:230"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:managing-a-user-s-inbox:224",
|
||||
"id": "skill:skills/paperclip/SKILL.md:managing-a-user-s-inbox:241",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:224",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:241",
|
||||
"title": "Managing A User's Inbox",
|
||||
"expectedSemantics": "Skill guidance headed “Managing A User's Inbox”.",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
|
|
@ -160,13 +175,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:224"
|
||||
"skill:skills/paperclip/SKILL.md:241"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:issue-dependencies-blockers:232",
|
||||
"id": "skill:skills/paperclip/SKILL.md:issue-dependencies-blockers:249",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:232",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:249",
|
||||
"title": "Issue Dependencies (Blockers)",
|
||||
"expectedSemantics": "Skill guidance headed “Issue Dependencies (Blockers)”.",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
|
|
@ -175,13 +190,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:232"
|
||||
"skill:skills/paperclip/SKILL.md:249"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:requesting-board-approval:257",
|
||||
"id": "skill:skills/paperclip/SKILL.md:requesting-board-approval:274",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:257",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:274",
|
||||
"title": "Requesting Board Approval",
|
||||
"expectedSemantics": "Skill guidance headed “Requesting Board Approval”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -190,13 +205,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:257"
|
||||
"skill:skills/paperclip/SKILL.md:274"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:issue-thread-interactions:278",
|
||||
"id": "skill:skills/paperclip/SKILL.md:issue-thread-interactions:295",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:278",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:295",
|
||||
"title": "Issue-Thread Interactions",
|
||||
"expectedSemantics": "Skill guidance headed “Issue-Thread Interactions”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -205,13 +220,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:278"
|
||||
"skill:skills/paperclip/SKILL.md:295"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:standalone-decisions:307",
|
||||
"id": "skill:skills/paperclip/SKILL.md:standalone-decisions:324",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:307",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:324",
|
||||
"title": "Standalone Decisions",
|
||||
"expectedSemantics": "Skill guidance headed “Standalone Decisions”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -220,13 +235,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:307"
|
||||
"skill:skills/paperclip/SKILL.md:324"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:411",
|
||||
"id": "skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:428",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:411",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:428",
|
||||
"title": "MCP Tool Approval Gates",
|
||||
"expectedSemantics": "Skill guidance headed “MCP Tool Approval Gates”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -235,13 +250,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:411"
|
||||
"skill:skills/paperclip/SKILL.md:428"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:niche-workflow-pointers:453",
|
||||
"id": "skill:skills/paperclip/SKILL.md:niche-workflow-pointers:470",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:453",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:470",
|
||||
"title": "Niche Workflow Pointers",
|
||||
"expectedSemantics": "Skill guidance headed “Niche Workflow Pointers”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -250,13 +265,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:453"
|
||||
"skill:skills/paperclip/SKILL.md:470"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:cases:463",
|
||||
"id": "skill:skills/paperclip/SKILL.md:cases:480",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:463",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:480",
|
||||
"title": "Cases",
|
||||
"expectedSemantics": "Skill guidance headed “Cases”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -265,13 +280,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:463"
|
||||
"skill:skills/paperclip/SKILL.md:480"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:company-skills-workflow:468",
|
||||
"id": "skill:skills/paperclip/SKILL.md:company-skills-workflow:485",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:468",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:485",
|
||||
"title": "Company Skills Workflow",
|
||||
"expectedSemantics": "Skill guidance headed “Company Skills Workflow”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -280,13 +295,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:468"
|
||||
"skill:skills/paperclip/SKILL.md:485"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:routines:479",
|
||||
"id": "skill:skills/paperclip/SKILL.md:routines:496",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:479",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:496",
|
||||
"title": "Routines",
|
||||
"expectedSemantics": "Skill guidance headed “Routines”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -295,13 +310,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:479"
|
||||
"skill:skills/paperclip/SKILL.md:496"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:490",
|
||||
"id": "skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:507",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:490",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:507",
|
||||
"title": "Issue Workspace Runtime Controls",
|
||||
"expectedSemantics": "Skill guidance headed “Issue Workspace Runtime Controls”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -310,13 +325,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:490"
|
||||
"skill:skills/paperclip/SKILL.md:507"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:proposing-credentials-safely:497",
|
||||
"id": "skill:skills/paperclip/SKILL.md:proposing-credentials-safely:514",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:497",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:514",
|
||||
"title": "Proposing Credentials Safely",
|
||||
"expectedSemantics": "Skill guidance headed “Proposing Credentials Safely”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -325,13 +340,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:497"
|
||||
"skill:skills/paperclip/SKILL.md:514"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:reading-granted-secrets:504",
|
||||
"id": "skill:skills/paperclip/SKILL.md:reading-granted-secrets:521",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:504",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:521",
|
||||
"title": "Reading Granted Secrets",
|
||||
"expectedSemantics": "Skill guidance headed “Reading Granted Secrets”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -340,13 +355,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:504"
|
||||
"skill:skills/paperclip/SKILL.md:521"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:critical-rules:530",
|
||||
"id": "skill:skills/paperclip/SKILL.md:critical-rules:547",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:530",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:547",
|
||||
"title": "Critical Rules",
|
||||
"expectedSemantics": "Skill guidance headed “Critical Rules”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -355,13 +370,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:530"
|
||||
"skill:skills/paperclip/SKILL.md:547"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:comment-style-required:554",
|
||||
"id": "skill:skills/paperclip/SKILL.md:comment-style-required:571",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:554",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:571",
|
||||
"title": "Comment Style (Required)",
|
||||
"expectedSemantics": "Skill guidance headed “Comment Style (Required)”.",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
|
|
@ -370,13 +385,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:554"
|
||||
"skill:skills/paperclip/SKILL.md:571"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:update:586",
|
||||
"id": "skill:skills/paperclip/SKILL.md:update:603",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:586",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:603",
|
||||
"title": "Update",
|
||||
"expectedSemantics": "Skill guidance headed “Update”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -385,13 +400,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:586"
|
||||
"skill:skills/paperclip/SKILL.md:603"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:596",
|
||||
"id": "skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:613",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:596",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:613",
|
||||
"title": "Planning (Required when planning requested)",
|
||||
"expectedSemantics": "Skill guidance headed “Planning (Required when planning requested)”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -400,13 +415,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:596"
|
||||
"skill:skills/paperclip/SKILL.md:613"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:629",
|
||||
"id": "skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:646",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:629",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:646",
|
||||
"title": "Key Endpoints (Hot Routes)",
|
||||
"expectedSemantics": "Skill guidance headed “Key Endpoints (Hot Routes)”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -415,13 +430,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:629"
|
||||
"skill:skills/paperclip/SKILL.md:646"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:searching-issues:658",
|
||||
"id": "skill:skills/paperclip/SKILL.md:searching-issues:675",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:658",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:675",
|
||||
"title": "Searching Issues",
|
||||
"expectedSemantics": "Skill guidance headed “Searching Issues”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -430,13 +445,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:658"
|
||||
"skill:skills/paperclip/SKILL.md:675"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/SKILL.md:full-reference:668",
|
||||
"id": "skill:skills/paperclip/SKILL.md:full-reference:685",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:668",
|
||||
"sourceAnchor": "skills/paperclip/SKILL.md:685",
|
||||
"title": "Full Reference",
|
||||
"expectedSemantics": "Skill guidance headed “Full Reference”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -445,7 +460,7 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/SKILL.md:668"
|
||||
"skill:skills/paperclip/SKILL.md:685"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -1979,9 +1994,9 @@
|
|||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:option-a-one-call-create-with-workspace:787",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:option-a-one-call-create-with-workspace:807",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:787",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:807",
|
||||
"title": "Option A: One-call create with workspace",
|
||||
"expectedSemantics": "Skill guidance headed “Option A: One-call create with workspace”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -1990,13 +2005,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/references/api-reference.md:787"
|
||||
"skill:skills/paperclip/references/api-reference.md:807"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:option-b-two-calls-project-first-then-workspace:806",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:option-b-two-calls-project-first-then-workspace:826",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:806",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:826",
|
||||
"title": "Option B: Two calls (project first, then workspace)",
|
||||
"expectedSemantics": "Skill guidance headed “Option B: Two calls (project first, then workspace)”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -2005,13 +2020,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/references/api-reference.md:806"
|
||||
"skill:skills/paperclip/references/api-reference.md:826"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:governance-and-approvals:835",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:governance-and-approvals:855",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:835",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:855",
|
||||
"title": "Governance and Approvals",
|
||||
"expectedSemantics": "Skill guidance headed “Governance and Approvals”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -2020,30 +2035,15 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/references/api-reference.md:835"
|
||||
"skill:skills/paperclip/references/api-reference.md:855"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:requesting-a-hire-management-only:839",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:839",
|
||||
"title": "Requesting a hire (management only)",
|
||||
"expectedSemantics": "Skill guidance headed “Requesting a hire (management only)”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"requiredGrants": [],
|
||||
"assertionClasses": [
|
||||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/references/api-reference.md:839"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:ceo-strategy-approval:859",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:requesting-a-hire-management-only:859",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:859",
|
||||
"title": "CEO strategy approval",
|
||||
"expectedSemantics": "Skill guidance headed “CEO strategy approval”.",
|
||||
"title": "Requesting a hire (management only)",
|
||||
"expectedSemantics": "Skill guidance headed “Requesting a hire (management only)”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"requiredGrants": [],
|
||||
"assertionClasses": [
|
||||
|
|
@ -2054,9 +2054,24 @@
|
|||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:issue-thread-confirmations:868",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:ceo-strategy-approval:879",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:868",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:879",
|
||||
"title": "CEO strategy approval",
|
||||
"expectedSemantics": "Skill guidance headed “CEO strategy approval”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
"requiredGrants": [],
|
||||
"assertionClasses": [
|
||||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/references/api-reference.md:879"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:issue-thread-confirmations:888",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:888",
|
||||
"title": "Issue-thread confirmations",
|
||||
"expectedSemantics": "Skill guidance headed “Issue-thread confirmations”.",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
|
|
@ -2065,13 +2080,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/references/api-reference.md:868"
|
||||
"skill:skills/paperclip/references/api-reference.md:888"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:checkbox-confirmations:926",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:checkbox-confirmations:946",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:926",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:946",
|
||||
"title": "Checkbox confirmations",
|
||||
"expectedSemantics": "Skill guidance headed “Checkbox confirmations”.",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
|
|
@ -2080,13 +2095,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/references/api-reference.md:926"
|
||||
"skill:skills/paperclip/references/api-reference.md:946"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:item-verdict-requests:1041",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:item-verdict-requests:1061",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1041",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1061",
|
||||
"title": "Item verdict requests",
|
||||
"expectedSemantics": "Skill guidance headed “Item verdict requests”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -2095,13 +2110,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/references/api-reference.md:1041"
|
||||
"skill:skills/paperclip/references/api-reference.md:1061"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:checking-approval-status:1151",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:checking-approval-status:1171",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1151",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1171",
|
||||
"title": "Checking approval status",
|
||||
"expectedSemantics": "Skill guidance headed “Checking approval status”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -2110,13 +2125,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/references/api-reference.md:1151"
|
||||
"skill:skills/paperclip/references/api-reference.md:1171"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:approval-follow-up-requesting-agent:1157",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:approval-follow-up-requesting-agent:1177",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1157",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1177",
|
||||
"title": "Approval follow-up (requesting agent)",
|
||||
"expectedSemantics": "Skill guidance headed “Approval follow-up (requesting agent)”.",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
|
|
@ -2125,13 +2140,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/references/api-reference.md:1157"
|
||||
"skill:skills/paperclip/references/api-reference.md:1177"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:issue-lifecycle:1175",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:issue-lifecycle:1195",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1175",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1195",
|
||||
"title": "Issue Lifecycle",
|
||||
"expectedSemantics": "Skill guidance headed “Issue Lifecycle”.",
|
||||
"primaryDisposition": "always_agent_tool",
|
||||
|
|
@ -2140,13 +2155,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/references/api-reference.md:1175"
|
||||
"skill:skills/paperclip/references/api-reference.md:1195"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:error-handling:1205",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:error-handling:1225",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1205",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1225",
|
||||
"title": "Error Handling",
|
||||
"expectedSemantics": "Skill guidance headed “Error Handling”.",
|
||||
"primaryDisposition": "control_plane_owned",
|
||||
|
|
@ -2155,13 +2170,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/references/api-reference.md:1205"
|
||||
"skill:skills/paperclip/references/api-reference.md:1225"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:full-api-reference:1219",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:full-api-reference:1239",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1219",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1239",
|
||||
"title": "Full API Reference",
|
||||
"expectedSemantics": "Skill guidance headed “Full API Reference”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -2170,13 +2185,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/references/api-reference.md:1219"
|
||||
"skill:skills/paperclip/references/api-reference.md:1239"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:agents:1221",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:agents:1241",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1221",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1241",
|
||||
"title": "Agents",
|
||||
"expectedSemantics": "Skill guidance headed “Agents”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -2185,13 +2200,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/references/api-reference.md:1221"
|
||||
"skill:skills/paperclip/references/api-reference.md:1241"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:issues-tasks:1242",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:issues-tasks:1262",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1242",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1262",
|
||||
"title": "Issues (Tasks)",
|
||||
"expectedSemantics": "Skill guidance headed “Issues (Tasks)”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -2200,13 +2215,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/references/api-reference.md:1242"
|
||||
"skill:skills/paperclip/references/api-reference.md:1262"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:companies-projects-goals:1282",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:companies-projects-goals:1302",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1282",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1302",
|
||||
"title": "Companies, Projects, Goals",
|
||||
"expectedSemantics": "Skill guidance headed “Companies, Projects, Goals”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -2215,13 +2230,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/references/api-reference.md:1282"
|
||||
"skill:skills/paperclip/references/api-reference.md:1302"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:routines:1306",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:routines:1326",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1306",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1326",
|
||||
"title": "Routines",
|
||||
"expectedSemantics": "Skill guidance headed “Routines”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -2230,13 +2245,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/references/api-reference.md:1306"
|
||||
"skill:skills/paperclip/references/api-reference.md:1326"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:approvals-costs-activity-dashboard:1322",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:approvals-costs-activity-dashboard:1342",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1322",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1342",
|
||||
"title": "Approvals, Costs, Activity, Dashboard",
|
||||
"expectedSemantics": "Skill guidance headed “Approvals, Costs, Activity, Dashboard”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -2245,13 +2260,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/references/api-reference.md:1322"
|
||||
"skill:skills/paperclip/references/api-reference.md:1342"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:secrets:1344",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:secrets:1364",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1344",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1364",
|
||||
"title": "Secrets",
|
||||
"expectedSemantics": "Skill guidance headed “Secrets”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -2260,13 +2275,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/references/api-reference.md:1344"
|
||||
"skill:skills/paperclip/references/api-reference.md:1364"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:agent-secret-proposals:1357",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:agent-secret-proposals:1377",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1357",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1377",
|
||||
"title": "Agent secret proposals",
|
||||
"expectedSemantics": "Skill guidance headed “Agent secret proposals”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -2275,13 +2290,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/references/api-reference.md:1357"
|
||||
"skill:skills/paperclip/references/api-reference.md:1377"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:agent-secret-access:1457",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:agent-secret-access:1477",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1457",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1477",
|
||||
"title": "Agent secret access",
|
||||
"expectedSemantics": "Skill guidance headed “Agent secret access”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -2290,13 +2305,13 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/references/api-reference.md:1457"
|
||||
"skill:skills/paperclip/references/api-reference.md:1477"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:common-mistakes:1497",
|
||||
"id": "skill:skills/paperclip/references/api-reference.md:common-mistakes:1517",
|
||||
"sourceKind": "skill_heading",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1497",
|
||||
"sourceAnchor": "skills/paperclip/references/api-reference.md:1517",
|
||||
"title": "Common Mistakes",
|
||||
"expectedSemantics": "Skill guidance headed “Common Mistakes”.",
|
||||
"primaryDisposition": "optional_agent_tool",
|
||||
|
|
@ -2305,7 +2320,7 @@
|
|||
"control_plane_invariant"
|
||||
],
|
||||
"evidenceIds": [
|
||||
"skill:skills/paperclip/references/api-reference.md:1497"
|
||||
"skill:skills/paperclip/references/api-reference.md:1517"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -7,10 +7,48 @@
|
|||
"src/scenarios/scenario-plan.ts"
|
||||
],
|
||||
"counts": {
|
||||
"actions": 43,
|
||||
"actions": 45,
|
||||
"legacyRequirements": 106
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"id": "create_project",
|
||||
"ownership": "optional_agent_tool",
|
||||
"surfaces": [
|
||||
"live"
|
||||
],
|
||||
"legacyAliases": [],
|
||||
"contractCase": "protocol-action:create_project",
|
||||
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::create_project has a schema-valid canonical example and every declared projection",
|
||||
"legacyBehavioralCases": [],
|
||||
"deterministicCases": [
|
||||
"protocol-action:create_project"
|
||||
],
|
||||
"legacyRequirementCases": [],
|
||||
"deterministicOwners": [
|
||||
"src/catalog/protocol-action-contracts.test.ts::create_project 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": "list_project_repositories",
|
||||
"ownership": "optional_agent_tool",
|
||||
"surfaces": [
|
||||
"live"
|
||||
],
|
||||
"legacyAliases": [],
|
||||
"contractCase": "protocol-action:list_project_repositories",
|
||||
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::list_project_repositories has a schema-valid canonical example and every declared projection",
|
||||
"legacyBehavioralCases": [],
|
||||
"deterministicCases": [
|
||||
"protocol-action:list_project_repositories"
|
||||
],
|
||||
"legacyRequirementCases": [],
|
||||
"deterministicOwners": [
|
||||
"src/catalog/protocol-action-contracts.test.ts::list_project_repositories 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": "search_api",
|
||||
"ownership": "optional_agent_tool",
|
||||
|
|
@ -913,7 +951,8 @@
|
|||
"id": "list_projects",
|
||||
"ownership": "optional_agent_tool",
|
||||
"surfaces": [
|
||||
"scenario"
|
||||
"scenario",
|
||||
"live"
|
||||
],
|
||||
"legacyAliases": [],
|
||||
"contractCase": "protocol-action:list_projects",
|
||||
|
|
|
|||
|
|
@ -50,6 +50,11 @@
|
|||
"description": "Company-visible task, agent, project, and goal discovery.",
|
||||
"operationIds": ["search_tasks", "list_agents", "get_agent", "list_projects", "list_goals"]
|
||||
},
|
||||
{
|
||||
"id": "projects",
|
||||
"description": "Project creation and authorized repository discovery through the live company/run authority.",
|
||||
"operationIds": ["create_project", "list_project_repositories"]
|
||||
},
|
||||
{
|
||||
"id": "delegation_dependencies",
|
||||
"description": "Create delegated work and maintain dependency edges.",
|
||||
|
|
@ -163,12 +168,12 @@
|
|||
"legacyGroup": 5,
|
||||
"name": "Search",
|
||||
"owner": "optional discovery tools",
|
||||
"operationIds": ["search_tasks", "list_agents", "get_agent", "list_projects", "list_goals"],
|
||||
"operationIds": ["search_tasks", "list_agents", "get_agent", "list_projects", "list_goals", "list_project_repositories"],
|
||||
"controlPlaneOperationIds": [],
|
||||
"realSurface": "company issue search and agent/project/goal list/get routes",
|
||||
"mockStateDomains": ["company", "task", "actor", "project", "goal"],
|
||||
"prpEvidence": "bounded redacted read projections through tool-result item events",
|
||||
"gap": "Project and goal operations are scenario-only; every real service binding is unbound."
|
||||
"gap": "Goal operations remain scenario-only. Project and repository discovery contracts are delivered by the live server authority; they are not implemented by the mock command dispatcher."
|
||||
},
|
||||
{
|
||||
"id": "su",
|
||||
|
|
@ -259,12 +264,12 @@
|
|||
"legacyGroup": 13,
|
||||
"name": "Reference files",
|
||||
"owner": "optional domain tools + test-only escape hatch",
|
||||
"operationIds": ["list_cases", "upsert_case", "list_routines", "manage_routine", "list_company_skills", "sync_company_skills", "list_secret_metadata", "read_secret_value", "export_company", "administer_company", "generic_api_request", "search_api", "call_api"],
|
||||
"operationIds": ["list_cases", "upsert_case", "list_routines", "manage_routine", "list_company_skills", "sync_company_skills", "list_secret_metadata", "read_secret_value", "export_company", "administer_company", "generic_api_request", "search_api", "call_api", "create_project"],
|
||||
"controlPlaneOperationIds": ["append_audit_record"],
|
||||
"realSurface": "case, routine, company-skill, secret, portability, and administration services",
|
||||
"realSurface": "project, case, routine, company-skill, secret, portability, and administration services",
|
||||
"mockStateDomains": ["company", "cases", "routines", "skills", "secrets", "audit", "fault"],
|
||||
"prpEvidence": "bounded domain projections, redacted broker receipts, company diffs, and audit references",
|
||||
"gap": "These operations are scenario-only except generic_api_request, which is test-only; broad administer_company is deferred and cannot claim product coverage. Production escape-hatch and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage."
|
||||
"gap": "Project creation and API tools require the live server authority; generic_api_request is test-only and the remaining domain operations are scenario-only. Broad administer_company is deferred and cannot claim product coverage. Production API and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage."
|
||||
},
|
||||
{
|
||||
"id": "mh",
|
||||
|
|
|
|||
|
|
@ -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**, **43 reconciled semantic operations** (14 always, 29 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**, **45 reconciled semantic operations** (14 always, 31 optional), and **106 scenarios in 16 behavior groups**.
|
||||
|
||||
## Axis 1: PRP v1 event and command families
|
||||
|
||||
|
|
@ -87,13 +87,14 @@ Placement has exactly three outcomes:
|
|||
|
||||
`answer_status_question`, `block_task`, `finish_task`, `get_task_context`, `get_task_history`, `inspect_operation_result`, `list_document_revisions`, `list_documents`, `read_document`, `register_deliverable`, `report_progress`, `request_human_input`, `request_review`, `write_document`.
|
||||
|
||||
### Optional operations (29) and grant groups (12)
|
||||
### Optional operations (31) 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. |
|
||||
| `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>`set_dependencies` | `delegation:tasks:create`<br>`dependencies:write` | Create 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. |
|
||||
| `cases` | `list_cases`<br>`upsert_case` | `cases:read`<br>`cases:write` | Read and update case summaries without reusing issue-document authority. |
|
||||
|
|
@ -118,7 +119,8 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
|
|||
| `call_api` | `optional_agent_tool` | `api:call` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `company_write` | `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` |
|
||||
| `comment_on_approval` | `optional_agent_tool` | `governance:approvals:comment` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `governance` | `required` | no | `semantic_command:comment_on_approval` | `scenario` + `live`<br>`live_codex` | `unbound`<br>approval lifecycle plus governed-wait continuation and audit events<br>catalog PRP status: `audit_pending` |
|
||||
| `control_workspace_service` | `optional_agent_tool` | `workspace:control` | `standard`<br>`skill_test` | `workspace_control` | `required` | no | `semantic_command:control_workspace_service` | `scenario` + `live`<br>`live_codex` | `unbound`<br>workspace service lifecycle event<br>catalog PRP status: `audit_pending` |
|
||||
| `create_task` | `optional_agent_tool` | `delegation:tasks:create` | `standard`<br>`skill_test` | `company_write` | `required` | no | `semantic_command:create_task` | `scenario` + `live`<br>`live_codex` | `issues.createChild`<br>semantic-operation item event plus company-entity state diff and audit record<br>catalog PRP status: `bound` |
|
||||
| `create_project` | `optional_agent_tool` | none | `standard`<br>`skill_test` | `company_write` | `required` | no | inline/no mapping | `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated project tools, persisted projects and repository workspaces, and run-bound activity.<br>catalog PRP status: `bound` |
|
||||
| `create_task` | `optional_agent_tool` | `delegation:tasks:create` | `standard`<br>`skill_test` | `company_write` | `required` | no | `semantic_command:create_task` | `scenario` + `live`<br>`live_codex` | `issues.create / issues.createChild`<br>semantic-operation item event plus company-entity state diff and audit record<br>catalog PRP status: `bound` |
|
||||
| `decide_approval` | `optional_agent_tool` | `governance:approvals:decide` | `standard`<br>`skill_test`<br>roles: `board`<br>`approver`<br>`security` | `governance` | `required` | no | `semantic_command:decide_approval` | `scenario` + `live`<br>`live_codex` | `unbound`<br>approval lifecycle plus governed-wait continuation and audit events<br>catalog PRP status: `audit_pending` |
|
||||
| `export_company` | `optional_agent_tool` | `portability:export` | `standard`<br>`skill_test` | `admin` | `required` | no | `mock_extension:portability.export` | `scenario`<br>`scenario_mock` | `unbound`<br>company admin/portability item event plus audit record<br>catalog PRP status: `audit_pending` |
|
||||
| `finish_task` | `always_agent_tool` | none | `standard`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:finish_task` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
|
||||
|
|
@ -137,7 +139,8 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
|
|||
| `list_document_revisions` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_document_revisions` | `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` |
|
||||
| `list_documents` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_documents` | `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` |
|
||||
| `list_goals` | `optional_agent_tool` | `discovery:goals:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:discovery.goals` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `list_projects` | `optional_agent_tool` | `discovery:projects:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:discovery.projects` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `list_project_repositories` | `optional_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated project tools, persisted projects and repository workspaces, and run-bound activity.<br>catalog PRP status: `bound` |
|
||||
| `list_projects` | `optional_agent_tool` | `discovery:projects:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `mock_extension:discovery.projects` | `scenario` + `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated project tools, persisted projects and repository workspaces, and run-bound activity.<br>catalog PRP status: `bound` |
|
||||
| `list_routines` | `optional_agent_tool` | `routines:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:routines.list` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `list_secret_metadata` | `optional_agent_tool` | `secrets:metadata:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:secrets.metadata` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `manage_routine` | `optional_agent_tool` | `routines:write` | `standard`<br>`skill_test` | `admin` | `required` | no | `mock_extension:routines.manage` | `scenario`<br>`scenario_mock` | `unbound`<br>company admin/portability item event plus audit record<br>catalog PRP status: `audit_pending` |
|
||||
|
|
@ -168,7 +171,7 @@ Behavior groups describe expected outcomes and trajectories. They do not grant t
|
|||
| [`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. |
|
||||
| [`se` — Search](#behavior-group-se-search) | optional discovery tools | `search_tasks`<br>`list_agents`<br>`get_agent`<br>`list_projects`<br>`list_goals` | none | company issue search and agent/project/goal list/get routes | `company`<br>`task`<br>`actor`<br>`project`<br>`goal` | 4 | bounded redacted read projections through tool-result item events | Project and goal operations are scenario-only; every real service binding is unbound. |
|
||||
| [`se` — Search](#behavior-group-se-search) | optional discovery tools | `search_tasks`<br>`list_agents`<br>`get_agent`<br>`list_projects`<br>`list_goals`<br>`list_project_repositories` | none | company issue search and agent/project/goal list/get routes | `company`<br>`task`<br>`actor`<br>`project`<br>`goal` | 4 | bounded redacted read projections through tool-result item events | Goal operations remain scenario-only. Project and repository discovery contracts are delivered by the live server authority; they are not implemented by the mock command dispatcher. |
|
||||
| [`su` — Subtasks](#behavior-group-su-subtasks) | optional delegation tools | `create_task` | `route_wake` | company issue create, child issue, assignment, and wake services | `company`<br>`task`<br>`actor`<br>`blockers`<br>`wake`<br>`audit` | 4 | company/task state diff, audit reference, and continuation wake evidence | create_task is production-bound to ordinary active-issue child creation with assignment, dependency-ready wake, company checks, child limits, and durable source-scoped idempotency. |
|
||||
| [`bl` — Blockers](#behavior-group-bl-blockers) | always/optional tools + control plane | `block_task`<br>`set_dependencies` | `schedule_blocker_wake`<br>`route_wake` | issue relations, blocker projection, liveness validation, and blocker wake services | `task`<br>`blockers`<br>`wake`<br>`actor`<br>`audit`<br>`fault` | 5 | dependency diff, block receipt, attention routing, and issue-status decision | set_dependencies is production-bound for the active issue; block_task remains unbound, and cancelled-blocker receipts still need typed additive evidence. |
|
||||
| [`dp` — Documents and plans](#behavior-group-dp-documents-and-plans) | always tools; restore optional; destructive lifecycle control-plane-only | `list_documents`<br>`read_document`<br>`list_document_revisions`<br>`write_document` | `append_audit_record` | issue document list/read/upsert/revision/restore/lock/unlock/delete routes | `task`<br>`documents`<br>`interactions`<br>`idempotency`<br>`audit`<br>`fault` | 3 | bounded reads and revision-safe write/conflict/denial receipts with revision lineage | restore_document_revision is an approved optional-tool gap; lock/unlock/delete are intentionally control-plane-only. |
|
||||
|
|
@ -176,7 +179,7 @@ Behavior groups describe expected outcomes and trajectories. They do not grant t
|
|||
| [`ap` — Approvals](#behavior-group-ap-approvals) | optional governance tools + governed approver | `list_approvals`<br>`get_approval`<br>`get_approval_context`<br>`request_approval`<br>`decide_approval`<br>`comment_on_approval` | `route_wake`<br>`append_audit_record` | company approval, decision, issue-link, comment, and governed-action services | `company`<br>`task`<br>`approvals`<br>`actor`<br>`wake`<br>`audit`<br>`idempotency` | 6 | governed semantic receipts, audit references, and attention/continuation linkage | Production binding and additive governed-action receipts are unbound; board-only authority stays outside grants. |
|
||||
| [`ar` — Artifacts](#behavior-group-ar-artifacts) | always tools + artifact/work-product services | `register_deliverable` | `append_audit_record` | attachment upload and issue work-product routes | `task`<br>`artifacts`<br>`workProducts`<br>`workspace`<br>`audit`<br>`idempotency` | 4 | artifact/work-product reference and durable inspectability receipt; never binary bytes | Production upload/register composite and additive durable-reference receipt are unbound. |
|
||||
| [`er` — Errors and critical rules](#behavior-group-er-errors-and-critical-rules) | runner/control plane + optional workspace/wake tools | `get_workspace_runtime`<br>`control_workspace_service`<br>`schedule_wake`<br>`inspect_operation_result` | `release_task`<br>`enforce_budget`<br>`persist_run`<br>`replay_run`<br>`reconcile_run` | workspace runtime, monitor/recovery, budget, run persistence/replay, release, and terminal services | `workspace`<br>`budget`<br>`run`<br>`wake`<br>`audit`<br>`idempotency`<br>`fault` | 9 | runtime/workspace/attention/run lifecycle, typed denials, replay facts, and terminal causality | Budget stop reasons and semantic denial/conflict receipts require additive v1 envelopes; inspect_operation_result remains scenario-only. |
|
||||
| [`rf` — Reference files](#behavior-group-rf-reference-files) | optional domain tools + test-only escape hatch | `list_cases`<br>`upsert_case`<br>`list_routines`<br>`manage_routine`<br>`list_company_skills`<br>`sync_company_skills`<br>`list_secret_metadata`<br>`read_secret_value`<br>`export_company`<br>`administer_company`<br>`generic_api_request`<br>`search_api`<br>`call_api` | `append_audit_record` | case, routine, company-skill, secret, portability, and administration services | `company`<br>`cases`<br>`routines`<br>`skills`<br>`secrets`<br>`audit`<br>`fault` | 22 | bounded domain projections, redacted broker receipts, company diffs, and audit references | These operations are scenario-only except generic_api_request, which is test-only; broad administer_company is deferred and cannot claim product coverage. Production escape-hatch and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage. |
|
||||
| [`rf` — Reference files](#behavior-group-rf-reference-files) | optional domain tools + test-only escape hatch | `list_cases`<br>`upsert_case`<br>`list_routines`<br>`manage_routine`<br>`list_company_skills`<br>`sync_company_skills`<br>`list_secret_metadata`<br>`read_secret_value`<br>`export_company`<br>`administer_company`<br>`generic_api_request`<br>`search_api`<br>`call_api`<br>`create_project` | `append_audit_record` | project, case, routine, company-skill, secret, portability, and administration services | `company`<br>`cases`<br>`routines`<br>`skills`<br>`secrets`<br>`audit`<br>`fault` | 22 | bounded domain projections, redacted broker receipts, company diffs, and audit references | Project creation and API tools require the live server authority; generic_api_request is test-only and the remaining domain operations are scenario-only. Broad administer_company is deferred and cannot claim product coverage. Production API and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage. |
|
||||
| [`mh` — Multi-hop](#behavior-group-mh-multi-hop) | composed semantic operations + control-plane continuation | `create_task`<br>`set_dependencies`<br>`request_human_input`<br>`request_approval`<br>`register_deliverable` | `route_wake`<br>`reconcile_run` | delegation, dependency, interaction, approval, artifact, and terminal orchestration services | `task`<br>`blockers`<br>`interactions`<br>`approvals`<br>`artifacts`<br>`wake`<br>`run`<br>`audit` | 4 | correlated operation receipts, state diffs, attention hops, work assessment, status decision, and terminal outcome | No generic transaction tool is allowed; shared mock/real conformance must prove each composed effect. |
|
||||
| [`rs` — Restraint and no-call](#behavior-group-rs-restraint-and-no-call) | policy/exposure layer | `answer_status_question`<br>`read_secret_value`<br>`generic_api_request` | `enforce_budget` | task-mode, secret-broker, test-scope, pause, and budget policy checks | `actor`<br>`task`<br>`budget`<br>`secrets`<br>`audit`<br>`fault` | 3 | absence of forbidden effects plus typed policy denial/redaction receipts when a call is attempted | Typed redaction/authorization receipts need additive v1 evidence; generic_api_request is never a product fallback. |
|
||||
| [`wk` — Wake situations](#behavior-group-wk-wake-situations) | control plane + always context/history tools | `get_task_context`<br>`get_task_history`<br>`schedule_wake` | `select_work`<br>`route_wake` | wakeup requests, heartbeat context, comment/interaction/approval/blocker wake routing, and scheduled wake services | `wake`<br>`task`<br>`comments`<br>`interactions`<br>`approvals`<br>`blockers`<br>`run` | 8 | attention request routing/resolution plus resumed session/run causality | Production scheduling binding is unbound; control-plane routing remains non-callable. |
|
||||
|
|
@ -453,10 +456,10 @@ Current responsibility-based paths are normative. Numbered `phase-*` or mileston
|
|||
### Catalog split and deliberate replacement
|
||||
|
||||
- Scenario/eval catalog: **37** operations.
|
||||
- Live dispatcher catalog: **30** operations.
|
||||
- Shared: **24**; union/canonical authority: **43**.
|
||||
- Scenario-only: `administer_company`, `export_company`, `inspect_operation_result`, `list_cases`, `list_company_skills`, `list_goals`, `list_projects`, `list_routines`, `list_secret_metadata`, `manage_routine`, `read_secret_value`, `sync_company_skills`, `upsert_case`.
|
||||
- Live-only: `call_api`, `get_agent`, `get_approval`, `get_approval_context`, `schedule_wake`, `search_api`.
|
||||
- Live dispatcher catalog: **33** operations.
|
||||
- Shared: **25**; union/canonical authority: **45**.
|
||||
- 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_approval`, `get_approval_context`, `list_project_repositories`, `schedule_wake`, `search_api`.
|
||||
- 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export const CAPABILITY_CANONICAL_OPERATIONS: readonly CapabilityCanonicalOperat
|
|||
.sort((left, right) => left.operationId.localeCompare(right.operationId)),
|
||||
);
|
||||
const byId = new Map(CAPABILITY_CANONICAL_OPERATIONS.map((operation) => [operation.operationId, operation]));
|
||||
if (byId.size !== 43) throw new Error(`expected 43 canonical semantic operations, found ${byId.size}`);
|
||||
if (byId.size !== PAPERCLIP_PROTOCOL_ACTIONS.length) throw new Error("Duplicate canonical semantic operation ID");
|
||||
export function capabilityCanonicalOperation(operationId: string): CapabilityCanonicalOperation | undefined { return byId.get(operationId); }
|
||||
export function capabilityCanonicalOperationsForSurface(surface: CapabilityCatalogSurface): readonly CapabilityCanonicalOperation[] { return CAPABILITY_CANONICAL_OPERATIONS.filter((operation) => operation.surfaces.includes(surface)); }
|
||||
export function capabilityCanonicalOperationIds(): readonly string[] { return CAPABILITY_CANONICAL_OPERATIONS.map((operation) => operation.operationId); }
|
||||
|
|
|
|||
|
|
@ -20,16 +20,18 @@ describe("canonical semantic-catalog reconciliation authority", () => {
|
|||
it("pins the reconciled op-set relationship between the two catalogs", () => {
|
||||
const summary = capabilityCatalogReconciliation();
|
||||
expect(summary.scenarioCount).toBe(37);
|
||||
expect(summary.liveCount).toBe(30);
|
||||
expect(summary.sharedCount).toBe(24);
|
||||
expect(summary.unionCount).toBe(43);
|
||||
expect(summary.liveCount).toBe(33);
|
||||
expect(summary.sharedCount).toBe(25);
|
||||
expect(summary.unionCount).toBe(45);
|
||||
// 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([
|
||||
"call_api",
|
||||
"create_project",
|
||||
"get_agent",
|
||||
"get_approval",
|
||||
"get_approval_context",
|
||||
"list_project_repositories",
|
||||
"schedule_wake",
|
||||
"search_api",
|
||||
]);
|
||||
|
|
@ -40,7 +42,6 @@ describe("canonical semantic-catalog reconciliation authority", () => {
|
|||
"list_cases",
|
||||
"list_company_skills",
|
||||
"list_goals",
|
||||
"list_projects",
|
||||
"list_routines",
|
||||
"list_secret_metadata",
|
||||
"manage_routine",
|
||||
|
|
@ -51,7 +52,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
|
|||
});
|
||||
|
||||
it("is the single source both catalogs derive their operation set from", () => {
|
||||
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(43);
|
||||
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(45);
|
||||
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);
|
||||
|
|
@ -85,7 +86,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(43);
|
||||
expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(45);
|
||||
for (const operation of CAPABILITY_CANONICAL_CATALOG) {
|
||||
expect(operation.placement).toMatch(/^(always|optional)_agent_tool$/);
|
||||
expect(Array.isArray(operation.requiredClaims)).toBe(true);
|
||||
|
|
@ -111,8 +112,8 @@ 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: 29,
|
||||
scenario_mock: 13,
|
||||
live_codex: 32,
|
||||
scenario_mock: 12,
|
||||
test_only: 1,
|
||||
});
|
||||
expect(capabilityCanonicalOperation("generic_api_request")?.realBindingStatus).toBe("test_only");
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { fileURLToPath } from "node:url";
|
|||
|
||||
import Ajv2020 from "ajv/dist/2020.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createTaskAction } from "../protocol-actions/create-task.js";
|
||||
import { createProjectAction } from "../protocol-actions/create-project.js";
|
||||
|
||||
import {
|
||||
PAPERCLIP_SEMANTIC_ACTION_CATALOG,
|
||||
|
|
@ -18,12 +20,44 @@ const packageRoot = resolve(
|
|||
);
|
||||
|
||||
describe("semantic action catalog", () => {
|
||||
it("limits project repository URLs to HTTPS GitHub repository paths on both tool surfaces", () => {
|
||||
const ajv = new Ajv2020({ allErrors: true, allowUnionTypes: true, strict: true });
|
||||
for (const schema of [createProjectAction.live.descriptor.inputSchema, paperclipSemanticAction("create_project")!.inputSchema]) {
|
||||
const validate = ajv.compile(schema);
|
||||
const input = { name: "Project", idempotencyKey: "create-project-1" };
|
||||
expect(validate({ ...input, repositoryUrls: ["https://github.com/org/repo", "https://github.com/org/other.git/"] })).toBe(true);
|
||||
for (const url of [
|
||||
"http://github.com/org/repo", "file:///etc/passwd", "data:text/plain,repo",
|
||||
"https://localhost/org/repo", "https://127.0.0.1/org/repo", "https://10.0.0.1/org/repo",
|
||||
"https://github.com.evil.test/org/repo", "https://token@github.com/org/repo",
|
||||
"https://github.com:8443/org/repo", "https://github.com/org/repo?token=secret",
|
||||
"https://github.com/org/repo#fragment", "https://github.com/org/repo/tree/main",
|
||||
"https://github.com/../repo", "https://github.com/org/..",
|
||||
]) expect(validate({ ...input, repositoryUrls: [url] }), url).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts project handoff receipts and preserves ordinary child task receipts", () => {
|
||||
const ajv = new Ajv2020({ allErrors: true, allowUnionTypes: true, strict: true });
|
||||
const validate = ajv.compile(createTaskAction.live.descriptor.outputSchema);
|
||||
const receipt = {
|
||||
commandId: "create-task-1", disposition: "applied", stateRevision: 1,
|
||||
entityRefs: ["task-1"], scheduledWakeIds: ["wake-1"],
|
||||
task: { id: "task-1", identifier: "CHAT-1", parentId: null, projectId: "project-1", status: "todo", assigneeActorId: "agent-1" },
|
||||
};
|
||||
expect(validate(receipt), JSON.stringify(validate.errors)).toBe(true);
|
||||
const { projectId: _projectId, ...childTask } = receipt.task;
|
||||
expect(validate({ ...receipt, task: { ...childTask, parentId: "parent-1" } })).toBe(true);
|
||||
expect(validate({ ...receipt, task: { ...receipt.task, projectId: 42 } })).toBe(false);
|
||||
expect(validate({ ...receipt, task: { ...receipt.task, parentId: "" } })).toBe(false);
|
||||
});
|
||||
|
||||
it("defines one immutable v1 declaration for each Codex-spine action", () => {
|
||||
const operationIds = PAPERCLIP_SEMANTIC_ACTION_CATALOG.map(
|
||||
(action) => action.operationId,
|
||||
);
|
||||
|
||||
expect(operationIds).toHaveLength(29);
|
||||
expect(operationIds).toHaveLength(32);
|
||||
expect(new Set(operationIds).size).toBe(operationIds.length);
|
||||
expect(operationIds).not.toContain("generic_api_request");
|
||||
expect(Object.isFrozen(PAPERCLIP_SEMANTIC_ACTION_CATALOG)).toBe(true);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
} from "./semantic-action-types.js";
|
||||
import { searchApiAction } from "../protocol-actions/search-api.js";
|
||||
import { callApiAction } from "../protocol-actions/call-api.js";
|
||||
import { projectRepositoryUrlSchema } from "../protocol-actions/create-project.js";
|
||||
|
||||
const ALL_MODES = ["standard", "ask", "planning", "skill_test"] as const;
|
||||
const WORK_MODES = ["standard", "planning", "skill_test"] as const;
|
||||
|
|
@ -428,10 +429,44 @@ const descriptors: readonly PaperclipSemanticActionDescriptor[] = [
|
|||
),
|
||||
outputSchema: operationReceipt,
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "list_projects",
|
||||
title: "List projects",
|
||||
requiredClaims: ["discovery:projects:read"],
|
||||
description: "Inspect available company projects before selecting a project for new work.",
|
||||
placement: "optional",
|
||||
inputSchema: object({}),
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "list_project_repositories",
|
||||
title: "List available repositories",
|
||||
description: "List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.",
|
||||
placement: "optional",
|
||||
inputSchema: object({}),
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "create_project",
|
||||
title: "Create project",
|
||||
description: "Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.",
|
||||
placement: "optional", effect: "write", allowedModes: STANDARD_MODE,
|
||||
inputSchema: object({
|
||||
...idempotency, name: text("Project name.", 500), description: nullableText("Project outcome and context."),
|
||||
repositoryIds: stringArray("Authorized repository IDs from list_project_repositories; may contain multiple repositories."),
|
||||
repositoryUrls: {
|
||||
type: "array", items: projectRepositoryUrlSchema, maxItems: 100, uniqueItems: true,
|
||||
description: "Existing HTTPS GitHub repository URLs, including repos absent from the catalog.",
|
||||
},
|
||||
workspace: openObject, status: { enum: ["backlog", "planned", "in_progress", "completed", "cancelled"] },
|
||||
goalId: nullableText("Goal ID."), goalIds: stringArray("Goal IDs."), leadAgentId: nullableText("Lead agent ID."),
|
||||
targetDate: nullableText("Target date."), color: nullableText("Project color."), icon: nullableText("Project icon."),
|
||||
env: openObject, executionWorkspacePolicy: openObject, archivedAt: nullableText("Archive timestamp."),
|
||||
}, ["idempotencyKey", "name"]),
|
||||
outputSchema: openObject,
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "create_task",
|
||||
title: "Create child task",
|
||||
description: "Create one child task under the active task.",
|
||||
title: "Create task",
|
||||
description: "Create an assigned task. In a conversation, create a project task with no parent; otherwise create a child of the active task. Include initialPlan to persist its plan before execution.",
|
||||
placement: "optional",
|
||||
effect: "write",
|
||||
requiredClaims: ["delegation:tasks:create"],
|
||||
|
|
@ -439,7 +474,9 @@ const descriptors: readonly PaperclipSemanticActionDescriptor[] = [
|
|||
inputSchema: object(
|
||||
{
|
||||
...idempotency,
|
||||
title: text("Child task title.", 500),
|
||||
title: text("Task title.", 500),
|
||||
projectId: nullableText("Project identifier for the new task."),
|
||||
initialPlan: nullableText("Relevant markdown plan to persist on the new task before it starts."),
|
||||
description: nullableText("Child task description."),
|
||||
assigneeActorId: nullableText("Optional actor assignee.", 200),
|
||||
priority: { enum: ["critical", "high", "medium", "low"] },
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ export type PaperclipSemanticActionId =
|
|||
| "get_workspace_runtime"
|
||||
| "control_workspace_service"
|
||||
| "set_dependencies"
|
||||
| "create_project"
|
||||
| "list_project_repositories"
|
||||
| "list_projects"
|
||||
| "create_task"
|
||||
| "request_approval"
|
||||
| "decide_approval"
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ it("renews one authenticated connection for three weeks without replacing its au
|
|||
await core.stop();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it.each(["expired", "revoked", "wrong-run", "wrong-connection", "wrong-epoch", "future-expiry"])(
|
||||
"cannot renew a lease with %s authority",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||
|
||||
import type { VerifiedAcpxCommandLease } from "./installation-integrity.js";
|
||||
import { openCodexAcpxRuntime } from "./codex-runtime-adapter.js";
|
||||
import { createAcpxCommandLeaseOwner } from "./command-lease-owner.js";
|
||||
import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js";
|
||||
import type { AcpxRuntimePortOpenOptions } from "./runtime-host.js";
|
||||
|
||||
|
|
@ -1311,6 +1312,116 @@ describe("Codex ACPX runtime adapter", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("observes prompt admission rejection when the sidecar consumes only events and the result", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const failure = new Error("Recovered provider could not start the prompt");
|
||||
vi.mocked(runtime.startTurn).mockImplementation(() => ({
|
||||
requestId: "turn-recovered-failure",
|
||||
promptStarted: Promise.reject(failure),
|
||||
events: { async *[Symbol.asyncIterator]() { throw failure; } },
|
||||
result: Promise.reject(failure),
|
||||
cancel: vi.fn(),
|
||||
closeStream: vi.fn(),
|
||||
}));
|
||||
const port = await openCodexAcpxRuntime(openOptions(fakeCommand()), {
|
||||
createRegistry: () => registry(), createStore: () => store(), createRuntime: () => runtime,
|
||||
});
|
||||
const turn = port.startTurn({ text: "Resume", requestId: "turn-recovered-failure" });
|
||||
const eventDrain = (async () => { for await (const _event of turn.events) { /* drain */ } })();
|
||||
await expect(eventDrain).rejects.toBe(failure);
|
||||
// The sidecar does not await promptStarted. Leave it unconsumed across a
|
||||
// full event-loop turn so an unobserved derived rejection fails this test.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
// Observing internally must not replace failure with successful admission.
|
||||
await expect(turn.result).rejects.toBe(failure);
|
||||
await expect(turn.promptStarted).rejects.toBe(failure);
|
||||
await port.close({ reason: "test complete" });
|
||||
});
|
||||
|
||||
it("verifies a lazy recovered provider spawned by model selection before returning", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const command = fakeCommand();
|
||||
vi.mocked(command.spawn).mockReturnValue(fakeChild());
|
||||
let runtimeOptions: AcpRuntimeOptions | undefined;
|
||||
let acknowledgeOwnership!: () => void;
|
||||
const ownership = new Promise<void>((resolve) => { acknowledgeOwnership = resolve; });
|
||||
vi.mocked(runtime.setConfigOption!).mockImplementation(async () => {
|
||||
await Promise.resolve();
|
||||
runtimeOptions?.spawnAgent?.({ command: "ignored", args: ["--stdio"], options: {} });
|
||||
});
|
||||
const port = await openCodexAcpxRuntime(openOptions(command), {
|
||||
createRegistry: () => registry(), createStore: () => store(),
|
||||
awaitProviderOwnership: () => ownership,
|
||||
awaitProviderExit: providerOwnershipEstablished,
|
||||
createRuntime: (options) => { runtimeOptions = options; return runtime; },
|
||||
});
|
||||
let admitted = false;
|
||||
const selection = port.setModel!("gpt-5.6-sol").then(() => { admitted = true; });
|
||||
void selection.catch(() => undefined);
|
||||
await vi.waitFor(() => expect(command.spawn).toHaveBeenCalledOnce());
|
||||
expect(admitted).toBe(false);
|
||||
acknowledgeOwnership();
|
||||
await selection;
|
||||
expect(admitted).toBe(true);
|
||||
expect(() => runtimeOptions?.spawnAgent?.({ command: "ignored", args: [], options: {} }))
|
||||
.toThrow("provider spawned after ownership admission was sealed");
|
||||
await port.close({ reason: "test complete" });
|
||||
});
|
||||
|
||||
it("uses a fresh single-use command after a cold model control consumes its launch", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const freshCommand = () => {
|
||||
const command = fakeCommand();
|
||||
vi.mocked(command.spawn).mockReturnValueOnce(fakeChild()).mockImplementation(() => {
|
||||
throw new Error("Verified ACPX command lease is closed");
|
||||
});
|
||||
return command;
|
||||
};
|
||||
const first = freshCommand();
|
||||
const second = freshCommand();
|
||||
const openCommand = vi.fn(async () => second);
|
||||
const owner = createAcpxCommandLeaseOwner(first, openCommand);
|
||||
let runtimeOptions: AcpRuntimeOptions;
|
||||
vi.mocked(runtime.setConfigOption!).mockImplementation(async () => {
|
||||
runtimeOptions.spawnAgent!({ command: "ignored", args: [], options: {} });
|
||||
});
|
||||
vi.mocked(runtime.startTurn).mockImplementation(() => {
|
||||
runtimeOptions.spawnAgent!({ command: "ignored", args: [], options: {} });
|
||||
return {
|
||||
requestId: "cold-turn",
|
||||
promptStarted: Promise.resolve(),
|
||||
events: { async *[Symbol.asyncIterator]() {} },
|
||||
result: Promise.resolve({ status: "completed" }),
|
||||
cancel: vi.fn(),
|
||||
closeStream: vi.fn(),
|
||||
};
|
||||
});
|
||||
const port = await openCodexAcpxRuntime(
|
||||
{
|
||||
...openOptions(owner.command),
|
||||
refreshConsumedCommand: owner.refreshConsumedCommand,
|
||||
},
|
||||
{
|
||||
createRegistry: () => registry(),
|
||||
createStore: () => store(),
|
||||
awaitProviderOwnership: providerOwnershipEstablished,
|
||||
awaitProviderExit: providerOwnershipEstablished,
|
||||
createRuntime: (options) => {
|
||||
runtimeOptions = options;
|
||||
return runtime;
|
||||
},
|
||||
},
|
||||
);
|
||||
await port.setModel!("gpt-5.6-sol");
|
||||
expect(openCommand).toHaveBeenCalledOnce();
|
||||
const turn = port.startTurn({ text: "Resume", requestId: "cold-turn" });
|
||||
await expect(turn.result).resolves.toMatchObject({ status: "completed" });
|
||||
expect(first.spawn).toHaveBeenCalledOnce();
|
||||
expect(second.spawn).toHaveBeenCalledOnce();
|
||||
await port.close({ reason: "test complete" });
|
||||
await owner.command.close();
|
||||
});
|
||||
|
||||
it("admits a verified provider that starts with the first recovered turn", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const child = fakeChild();
|
||||
|
|
|
|||
|
|
@ -265,6 +265,7 @@ export async function openQualifiedAcpxRuntime(
|
|||
update.goal === null ? null : structuredClone(update.goal),
|
||||
);
|
||||
};
|
||||
const commandLaunches = { count: 0, refreshConsumedCommand: options.refreshConsumedCommand };
|
||||
const runtimeOptions: GoalAwareAcpRuntimeOptions = {
|
||||
cwd: options.cwd,
|
||||
sessionStore,
|
||||
|
|
@ -327,6 +328,7 @@ export async function openQualifiedAcpxRuntime(
|
|||
// handshake cannot create a provider process after authority is gone.
|
||||
options.signal?.throwIfAborted();
|
||||
options.assertWorkspaceHeld?.();
|
||||
commandLaunches.count += 1;
|
||||
return children.add(
|
||||
options.command.spawn(input.args, input.options, {
|
||||
credentialFenceFds,
|
||||
|
|
@ -431,6 +433,7 @@ export async function openQualifiedAcpxRuntime(
|
|||
children,
|
||||
runtimeCloseTimeoutMs,
|
||||
goalState,
|
||||
commandLaunches,
|
||||
);
|
||||
} catch (error) {
|
||||
const cleanupReason = "ACPX runtime identity validation failed";
|
||||
|
|
@ -857,6 +860,7 @@ function runtimePort(
|
|||
children: SpawnedChildSet,
|
||||
runtimeCloseTimeoutMs: number,
|
||||
goalState: AcpxRuntimeGoalState,
|
||||
commandLaunches: { count: number; refreshConsumedCommand?: () => Promise<void> },
|
||||
): AcpxRuntimePort {
|
||||
type RuntimeCloseAttempt = {
|
||||
readonly outcome: Promise<unknown | null>;
|
||||
|
|
@ -1156,11 +1160,26 @@ function runtimePort(
|
|||
...(runtime.setConfigOption
|
||||
? {
|
||||
async setModel(model: string) {
|
||||
await runtime.setConfigOption?.({
|
||||
handle,
|
||||
key: "model",
|
||||
value: model,
|
||||
});
|
||||
// A restored handle can be lazy: selecting the pinned model may
|
||||
// launch its first provider before any prompt. Admit that spawn
|
||||
// only for this control call, and verify ownership before return.
|
||||
const finishOwnershipAdmission =
|
||||
children.beginLifetimeOwnershipAdmission();
|
||||
const spawnsBeforeControl = commandLaunches.count;
|
||||
try {
|
||||
await runtime.setConfigOption?.({
|
||||
handle,
|
||||
key: "model",
|
||||
value: model,
|
||||
});
|
||||
} finally {
|
||||
await finishOwnershipAdmission();
|
||||
}
|
||||
// Cold ACP config calls open and close a temporary connection.
|
||||
// A later prompt needs a newly verified single-use launch snapshot.
|
||||
if (commandLaunches.count > spawnsBeforeControl) {
|
||||
await commandLaunches.refreshConsumedCommand?.();
|
||||
}
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
|
@ -1212,11 +1231,20 @@ function turnWithVerifiedLifetimeOwnership(
|
|||
finishOwnershipAdmission(),
|
||||
);
|
||||
void ownershipVerified.catch(() => undefined);
|
||||
const promptStarted = ownershipVerified.then(() => turn.promptStarted);
|
||||
const result = ownershipVerified.then(() => turn.result);
|
||||
// Some consumers (including the sidecar) drain events and await the result
|
||||
// without awaiting this optional admission signal. Observe its rejection
|
||||
// immediately so a failed cold start cannot terminate the host process as an
|
||||
// unhandled rejection. Keep the original rejected promise for consumers.
|
||||
void promptStarted.catch(() => undefined);
|
||||
// Event drains can fail before their caller reaches the result promise.
|
||||
void result.catch(() => undefined);
|
||||
return {
|
||||
requestId: turn.requestId,
|
||||
promptStarted: ownershipVerified.then(() => turn.promptStarted),
|
||||
promptStarted,
|
||||
events: eventsAfterLifetimeOwnership(turn.events, ownershipVerified),
|
||||
result: ownershipVerified.then(() => turn.result),
|
||||
result,
|
||||
cancel: (input) => turn.cancel(input),
|
||||
closeStream: (input) => turn.closeStream(input),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import type { ChildProcess } from "node:child_process";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createAcpxCommandLeaseOwner } from "./command-lease-owner.js";
|
||||
import type { VerifiedAcpxCommandLease } from "./installation-integrity.js";
|
||||
|
||||
function lease() {
|
||||
let consumed = false;
|
||||
return {
|
||||
spawn: vi.fn(() => {
|
||||
if (consumed) throw new Error("single-use command already consumed");
|
||||
consumed = true;
|
||||
return {} as ChildProcess;
|
||||
}),
|
||||
close: vi.fn(async () => {
|
||||
consumed = true;
|
||||
}),
|
||||
} satisfies VerifiedAcpxCommandLease;
|
||||
}
|
||||
|
||||
describe("ACPX verified command lease owner", () => {
|
||||
it("refreshes only consumed snapshots and preserves single-use spawn enforcement", async () => {
|
||||
const first = lease();
|
||||
const second = lease();
|
||||
const open = vi.fn(async () => second);
|
||||
const owner = createAcpxCommandLeaseOwner(first, open);
|
||||
await owner.refreshConsumedCommand();
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
owner.command.spawn();
|
||||
expect(() => owner.command.spawn()).toThrow("already consumed");
|
||||
await Promise.all([owner.refreshConsumedCommand(), owner.refreshConsumedCommand()]);
|
||||
expect(open).toHaveBeenCalledOnce();
|
||||
owner.command.spawn();
|
||||
expect(second.spawn).toHaveBeenCalledOnce();
|
||||
expect(() => owner.command.spawn()).toThrow("already consumed");
|
||||
await owner.command.close();
|
||||
expect(first.close).toHaveBeenCalledOnce();
|
||||
expect(second.close).toHaveBeenCalledOnce();
|
||||
expect(() => owner.command.spawn()).toThrow("closing");
|
||||
await expect(owner.refreshConsumedCommand()).rejects.toThrow("closing");
|
||||
});
|
||||
|
||||
it("retains a replacement acquired during shutdown and retries its failed cleanup", async () => {
|
||||
const first = lease();
|
||||
const replacement = lease();
|
||||
replacement.close.mockRejectedValueOnce(new Error("close failed"));
|
||||
let acquired!: (value: VerifiedAcpxCommandLease) => void;
|
||||
const owner = createAcpxCommandLeaseOwner(
|
||||
first,
|
||||
() => new Promise((resolve) => {
|
||||
acquired = resolve;
|
||||
}),
|
||||
);
|
||||
owner.command.spawn();
|
||||
const refresh = owner.refreshConsumedCommand();
|
||||
const rejectedRefresh = expect(refresh).rejects.toThrow("closed during refresh");
|
||||
await Promise.resolve();
|
||||
const close = owner.command.close();
|
||||
const rejectedClose = expect(close).rejects.toThrow("leases did not close");
|
||||
acquired(replacement);
|
||||
await rejectedRefresh;
|
||||
await rejectedClose;
|
||||
expect(replacement.spawn).not.toHaveBeenCalled();
|
||||
await owner.command.close();
|
||||
expect(replacement.close).toHaveBeenCalledTimes(2);
|
||||
expect(first.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("fails closed when fresh command verification fails", async () => {
|
||||
const initial = lease();
|
||||
const owner = createAcpxCommandLeaseOwner(initial, async () => {
|
||||
throw new Error("installation changed");
|
||||
});
|
||||
owner.command.spawn();
|
||||
await expect(owner.refreshConsumedCommand()).rejects.toThrow("installation changed");
|
||||
expect(() => owner.command.spawn()).toThrow("already consumed");
|
||||
await owner.command.close();
|
||||
expect(initial.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import type { VerifiedAcpxCommandLease } from "./installation-integrity.js";
|
||||
|
||||
/** Keep each launch single-use while owning replacements for transient ACP controls. */
|
||||
export function createAcpxCommandLeaseOwner(
|
||||
initial: VerifiedAcpxCommandLease,
|
||||
openCommand: () => Promise<VerifiedAcpxCommandLease>,
|
||||
) {
|
||||
const leases = new Set([initial]);
|
||||
let current = initial;
|
||||
let consumed = false;
|
||||
let closing = false;
|
||||
let refresh: Promise<void> | null = null;
|
||||
const command: VerifiedAcpxCommandLease = {
|
||||
spawn(...args) {
|
||||
if (closing) throw new Error("Verified ACPX command owner is closing");
|
||||
consumed = true;
|
||||
return current.spawn(...args);
|
||||
},
|
||||
async close() {
|
||||
closing = true;
|
||||
// Late acquisitions remain owned. Retry every lease whose close fails.
|
||||
await refresh?.catch(() => undefined);
|
||||
const failures: unknown[] = [];
|
||||
for (const lease of leases) {
|
||||
try {
|
||||
await lease.close();
|
||||
leases.delete(lease);
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
if (failures.length) throw new AggregateError(failures, "ACPX command leases did not close");
|
||||
},
|
||||
};
|
||||
return {
|
||||
command,
|
||||
async refreshConsumedCommand(): Promise<void> {
|
||||
if (closing) throw new Error("Verified ACPX command owner is closing");
|
||||
if (!consumed) return;
|
||||
if (!refresh) {
|
||||
refresh = Promise.resolve()
|
||||
.then(openCommand)
|
||||
.then((replacement) => {
|
||||
leases.add(replacement);
|
||||
if (closing) throw new Error("Verified ACPX command owner closed during refresh");
|
||||
current = replacement;
|
||||
consumed = false;
|
||||
});
|
||||
}
|
||||
const pending = refresh;
|
||||
try {
|
||||
await pending;
|
||||
} finally {
|
||||
if (refresh === pending) refresh = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
type VerifiedAcpxCommandLease,
|
||||
type VerifiedAcpxInstallation,
|
||||
} from "./installation-integrity.js";
|
||||
import { createAcpxCommandLeaseOwner } from "./command-lease-owner.js";
|
||||
import {
|
||||
requireVerifiedAcpxModel,
|
||||
type AcpxModelStatus,
|
||||
|
|
@ -117,6 +118,8 @@ export interface AcpxRuntimePort {
|
|||
|
||||
export interface AcpxRuntimePortOpenOptions {
|
||||
command: VerifiedAcpxCommandLease;
|
||||
/** Replace a consumed launch snapshot after an ephemeral control session. */
|
||||
refreshConsumedCommand?: () => Promise<void>;
|
||||
profile: QualifiedAcpxProfile;
|
||||
cwd: string;
|
||||
stateDirectory: string;
|
||||
|
|
@ -401,6 +404,11 @@ export class AcpxRuntimeHost {
|
|||
reportFailure: (failure) =>
|
||||
dependencies.reportRetainedCleanupFailure(failure),
|
||||
});
|
||||
const commandOwner = createAcpxCommandLeaseOwner(
|
||||
command,
|
||||
() => installation.openCommand(),
|
||||
);
|
||||
command = commandOwner.command;
|
||||
toolBridge = options.semanticTools
|
||||
? await acquireAbortableAdmissionResource({
|
||||
signal: options.signal,
|
||||
|
|
@ -421,6 +429,7 @@ export class AcpxRuntimeHost {
|
|||
options.assertWorkspaceHeld?.();
|
||||
return dependencies.openRuntime({
|
||||
command: command!,
|
||||
refreshConsumedCommand: commandOwner.refreshConsumedCommand,
|
||||
profile,
|
||||
cwd: binding.workspacePath,
|
||||
stateDirectory: sandbox.stateDirectory,
|
||||
|
|
|
|||
|
|
@ -466,13 +466,13 @@ describe("workflow reports and stress traceability", () => {
|
|||
candidateFailures: 36,
|
||||
});
|
||||
expect(report.coverage).toMatchObject({
|
||||
canonicalOperations: 43,
|
||||
canonicalOperations: 45,
|
||||
capabilityCases: 106,
|
||||
workflows: 12,
|
||||
stressFindings: 44,
|
||||
stressExclusions: 1,
|
||||
});
|
||||
expect(report.coverage.operations).toHaveLength(43);
|
||||
expect(report.coverage.operations).toHaveLength(45);
|
||||
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(
|
||||
"43 operations · 106 capability cases · 12 workflows",
|
||||
"45 operations · 106 capability cases · 12 workflows",
|
||||
);
|
||||
expect(renderRunnerWorkflowJUnit(report)).toContain(
|
||||
'tests="36" failures="36" skipped="0"',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import { cp, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import Ajv2020 from "ajv/dist/2020.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
|
|
@ -10,8 +15,47 @@ import {
|
|||
parsePaperclipNativeExecution,
|
||||
} from "./native-execution.js";
|
||||
import { PAPERCLIP_RUNNER_BUILD_METADATA } from "./build-metadata.js";
|
||||
import { serializeCapabilityGeneratedSemanticContracts } from "../semantic-tools/provider-neutral.js";
|
||||
|
||||
describe("paperclip-runner/native-execution/v1", () => {
|
||||
it("refreshes a stale seeded catalog and its manifest with one semantic generator invocation", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-semantic-generator-"));
|
||||
const packageRoot = fileURLToPath(new URL("../../", import.meta.url));
|
||||
const run = promisify(execFile);
|
||||
try {
|
||||
await cp(join(packageRoot, "protocol"), join(root, "protocol"), { recursive: true });
|
||||
await mkdir(join(root, "scripts"));
|
||||
for (const script of ["generate-semantic-contracts.mjs", "generate-protocol-manifest.mjs", "protocol-contract.mjs"]) {
|
||||
await cp(join(packageRoot, "scripts", script), join(root, "scripts", script));
|
||||
}
|
||||
await symlink(join(packageRoot, "node_modules"), join(root, "node_modules"), "dir");
|
||||
await writeFile(join(root, "package.json"), JSON.stringify({ type: "module" }));
|
||||
await mkdir(join(root, "dist/semantic-tools"), { recursive: true });
|
||||
await mkdir(join(root, "dist/evals"), { recursive: true });
|
||||
await mkdir(join(root, "generated/capability"), { recursive: true });
|
||||
// Materialize current source exports without requiring a previous package build.
|
||||
await writeFile(join(root, "dist/semantic-tools/provider-neutral.js"),
|
||||
`export const serializeCapabilityGeneratedSemanticContracts = () => ${JSON.stringify(serializeCapabilityGeneratedSemanticContracts())};`);
|
||||
await writeFile(join(root, "dist/evals/build-metadata.js"),
|
||||
`export const PAPERCLIP_RUNNER_BUILD_METADATA = ${JSON.stringify(PAPERCLIP_RUNNER_BUILD_METADATA)};`);
|
||||
const fixturePath = join(root, "protocol/fixtures/evals/native-execution-seeded.json");
|
||||
const fixture = JSON.parse(await readFile(fixturePath, "utf8"));
|
||||
fixture.runner.catalogSha256 = `sha256:${"0".repeat(64)}`;
|
||||
await writeFile(fixturePath, `${JSON.stringify(fixture, null, 2)}\n`);
|
||||
// Leave a stale manifest even if the restored fixture bytes happen to match the original.
|
||||
await writeFile(join(root, "protocol/manifest.json"), "{}\n");
|
||||
const generator = join(root, "scripts/generate-semantic-contracts.mjs");
|
||||
await expect(run(process.execPath, [generator, "--check"])).rejects.toThrow();
|
||||
await run(process.execPath, [generator]);
|
||||
expect(JSON.parse(await readFile(fixturePath, "utf8")).runner.catalogSha256)
|
||||
.toBe(PAPERCLIP_RUNNER_BUILD_METADATA.semanticCatalog.sha256);
|
||||
await run(process.execPath, [generator, "--check"]);
|
||||
await run(process.execPath, [join(root, "scripts/generate-protocol-manifest.mjs"), "--check"]);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the shipped seeded fixture valid against the published JSON Schema", async () => {
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
||||
for (const schema of Object.values(prpSchemaBundle)) ajv.addSchema(schema);
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ export type CapabilityPrimaryDisposition =
|
|||
| "optional_agent_tool";
|
||||
|
||||
export const capabilityInventoryCounts = {
|
||||
"skillReferenceCapabilities": 153,
|
||||
"skillReferenceCapabilities": 154,
|
||||
"evalCases": 106,
|
||||
"normativeRows": 259,
|
||||
"normativeRows": 260,
|
||||
"legacyMcpAliases": 42
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -4032,8 +4032,18 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
|||
// A failed drain still proceeds through bounded suspension/containment,
|
||||
// but can never authorize a reusable checkpoint or deletion of evidence.
|
||||
}
|
||||
const lastDrain = [...core.store.state.commands]
|
||||
.reverse()
|
||||
.find((command) => command.type === "runner.drain");
|
||||
this.#diagnostic(
|
||||
"provider suffix did not prove durable drain before bounded runner suspension",
|
||||
"provider suffix did not prove durable drain before bounded runner suspension: " +
|
||||
JSON.stringify({
|
||||
providerState: this.#providerDrainState(),
|
||||
semanticResultsSettled: core.semanticToolResultsSettled(),
|
||||
drainStatus: lastDrain?.status ?? null,
|
||||
retainedEventsDrained:
|
||||
record(record(lastDrain?.result).result).retainedEventsDrained ?? null,
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,202 @@
|
|||
/** Existing GitHub repository references, never arbitrary network/resource URIs. */
|
||||
export const projectRepositoryUrlSchema = {
|
||||
type: "string",
|
||||
maxLength: 2000,
|
||||
pattern: "^https://github\\.com/(?!\\.{1,2}/)[A-Za-z0-9_.-]+/(?!\\.{1,2}/?$)[A-Za-z0-9_.-]+/?$",
|
||||
} as const;
|
||||
|
||||
/** Canonical project tool definition. */
|
||||
export const createProjectAction = {
|
||||
"id": "create_project",
|
||||
"canonical": {
|
||||
"operationId": "create_project",
|
||||
"surfaces": [
|
||||
"live"
|
||||
],
|
||||
"placement": "optional_agent_tool",
|
||||
"optionalGroup": "discovery",
|
||||
"requiredClaims": [],
|
||||
"taskModes": [
|
||||
"standard",
|
||||
"skill_test"
|
||||
],
|
||||
"sideEffectClass": "company_write",
|
||||
"idempotency": "required",
|
||||
"disabledByDefault": false,
|
||||
"realBindingStatus": "live_codex",
|
||||
"realServiceBinding": "PaperclipRunnerToolAuthority",
|
||||
"prpEvidence": "Authenticated project tools, persisted projects and repository workspaces, and run-bound activity.",
|
||||
"prpBindingStatus": "bound",
|
||||
"legacyAliases": []
|
||||
},
|
||||
"documentation": {
|
||||
"title": "Create project",
|
||||
"description": "Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.",
|
||||
"note": null
|
||||
},
|
||||
"examples": {
|
||||
"call": {
|
||||
"operationId": "create_project",
|
||||
"input": {
|
||||
"name": "Example",
|
||||
"repositoryIds": [
|
||||
"1",
|
||||
"2"
|
||||
],
|
||||
"idempotencyKey": "example"
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
"ok": true,
|
||||
"operationId": "create_project",
|
||||
"result": {}
|
||||
}
|
||||
},
|
||||
"live": {
|
||||
"order": 43,
|
||||
"descriptor": {
|
||||
"schema": "paperclip.semantic-tool.v1",
|
||||
"operationId": "create_project",
|
||||
"version": 1,
|
||||
"title": "Create project",
|
||||
"description": "Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.",
|
||||
"effect": "write",
|
||||
"requiredClaims": [],
|
||||
"allowedModes": [
|
||||
"standard",
|
||||
"skill_test"
|
||||
],
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"idempotencyKey": {
|
||||
"type": "string",
|
||||
"description": "Caller-stable retry key.",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Project name.",
|
||||
"minLength": 1,
|
||||
"maxLength": 500
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Project outcome and context.",
|
||||
"maxLength": 20000
|
||||
},
|
||||
"repositoryIds": {
|
||||
"type": "array",
|
||||
"description": "Authorized repository IDs from list_project_repositories; may contain multiple repositories.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"maxItems": 200,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"workspace": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"backlog",
|
||||
"planned",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"cancelled"
|
||||
]
|
||||
},
|
||||
"goalId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Goal ID.",
|
||||
"maxLength": 20000
|
||||
},
|
||||
"goalIds": {
|
||||
"type": "array",
|
||||
"description": "Goal IDs.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"maxItems": 200,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"leadAgentId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Lead agent ID.",
|
||||
"maxLength": 20000
|
||||
},
|
||||
"targetDate": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Target date.",
|
||||
"maxLength": 20000
|
||||
},
|
||||
"color": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Project color.",
|
||||
"maxLength": 20000
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Project icon.",
|
||||
"maxLength": 20000
|
||||
},
|
||||
"env": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"executionWorkspacePolicy": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"archivedAt": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Archive timestamp.",
|
||||
"maxLength": 20000
|
||||
},
|
||||
"repositoryUrls": {
|
||||
"type": "array",
|
||||
"items": projectRepositoryUrlSchema,
|
||||
"maxItems": 100,
|
||||
"description": "Existing HTTPS GitHub repository URLs, including repos absent from the catalog."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"idempotencyKey",
|
||||
"name"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"outputSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"exposure": "optional"
|
||||
}
|
||||
},
|
||||
"scenario": null
|
||||
} as const;
|
||||
|
|
@ -20,7 +20,7 @@ export const createTaskAction = {
|
|||
"idempotency": "required",
|
||||
"disabledByDefault": false,
|
||||
"realBindingStatus": "live_codex",
|
||||
"realServiceBinding": "issues.createChild",
|
||||
"realServiceBinding": "issues.create / issues.createChild",
|
||||
"prpEvidence": "semantic-operation item event plus company-entity state diff and audit record",
|
||||
"prpBindingStatus": "bound",
|
||||
"legacyAliases": [
|
||||
|
|
@ -28,8 +28,8 @@ export const createTaskAction = {
|
|||
]
|
||||
},
|
||||
"documentation": {
|
||||
"title": "Create child task",
|
||||
"description": "Create one durable standard child under the active task.",
|
||||
"title": "Create task",
|
||||
"description": "Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution.",
|
||||
"note": null
|
||||
},
|
||||
"examples": {
|
||||
|
|
@ -76,8 +76,8 @@ export const createTaskAction = {
|
|||
"schema": "paperclip.semantic-tool.v1",
|
||||
"operationId": "create_task",
|
||||
"version": 1,
|
||||
"title": "Create child task",
|
||||
"description": "Create one durable standard child under the active task. Use only when a real ownership, parallelism, dependency, review, or lifecycle boundary justifies delegation.",
|
||||
"title": "Create task",
|
||||
"description": "Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution.",
|
||||
"exposure": "optional",
|
||||
"requiredClaims": [
|
||||
"delegation:tasks:create"
|
||||
|
|
@ -134,6 +134,21 @@ export const createTaskAction = {
|
|||
},
|
||||
"maxItems": 200,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"projectId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Project ID for the task."
|
||||
},
|
||||
"initialPlan": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 200000,
|
||||
"description": "Relevant markdown plan saved on the new task before execution starts."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
|
@ -184,13 +199,42 @@ export const createTaskAction = {
|
|||
"task": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"identifier": { "type": ["string", "null"] },
|
||||
"parentId": { "type": "string", "minLength": 1 },
|
||||
"status": { "type": "string", "minLength": 1 },
|
||||
"assigneeActorId": { "type": ["string", "null"] }
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"identifier": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"parentId": {
|
||||
"type": ["string", "null"],
|
||||
"minLength": 1
|
||||
},
|
||||
"projectId": {
|
||||
"type": ["string", "null"],
|
||||
"minLength": 1
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"assigneeActorId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["id", "identifier", "parentId", "status", "assigneeActorId"],
|
||||
"required": [
|
||||
"id",
|
||||
"identifier",
|
||||
"parentId",
|
||||
"status",
|
||||
"assigneeActorId"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { createProjectAction } from "./create-project.js";
|
||||
import { listProjectRepositoriesAction } from "./list-project-repositories.js";
|
||||
import { searchApiAction } from "./search-api.js";
|
||||
import { callApiAction } from "./call-api.js";
|
||||
import { administerCompanyAction } from "./administer-company.js";
|
||||
|
|
@ -44,6 +46,8 @@ import { writeDocumentAction } from "./write-document.js";
|
|||
import { deepFreezeProtocolAction } from "./freeze.js";
|
||||
|
||||
export const PAPERCLIP_PROTOCOL_ACTIONS = deepFreezeProtocolAction([
|
||||
createProjectAction,
|
||||
listProjectRepositoriesAction,
|
||||
searchApiAction,
|
||||
callApiAction,
|
||||
administerCompanyAction,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
/** Canonical project tool definition. */
|
||||
export const listProjectRepositoriesAction = {
|
||||
"id": "list_project_repositories",
|
||||
"canonical": {
|
||||
"operationId": "list_project_repositories",
|
||||
"surfaces": [
|
||||
"live"
|
||||
],
|
||||
"placement": "optional_agent_tool",
|
||||
"optionalGroup": "discovery",
|
||||
"requiredClaims": [],
|
||||
"taskModes": [
|
||||
"standard",
|
||||
"ask",
|
||||
"planning",
|
||||
"skill_test"
|
||||
],
|
||||
"sideEffectClass": "read",
|
||||
"idempotency": "none",
|
||||
"disabledByDefault": false,
|
||||
"realBindingStatus": "live_codex",
|
||||
"realServiceBinding": "PaperclipRunnerToolAuthority",
|
||||
"prpEvidence": "Authenticated project tools, persisted projects and repository workspaces, and run-bound activity.",
|
||||
"prpBindingStatus": "bound",
|
||||
"legacyAliases": []
|
||||
},
|
||||
"documentation": {
|
||||
"title": "List available repositories",
|
||||
"description": "List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.",
|
||||
"note": null
|
||||
},
|
||||
"examples": {
|
||||
"call": {
|
||||
"operationId": "list_project_repositories",
|
||||
"input": {}
|
||||
},
|
||||
"success": {
|
||||
"ok": true,
|
||||
"operationId": "list_project_repositories",
|
||||
"result": {}
|
||||
}
|
||||
},
|
||||
"live": {
|
||||
"order": 44,
|
||||
"descriptor": {
|
||||
"schema": "paperclip.semantic-tool.v1",
|
||||
"operationId": "list_project_repositories",
|
||||
"version": 1,
|
||||
"title": "List available repositories",
|
||||
"description": "List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.",
|
||||
"effect": "read",
|
||||
"requiredClaims": [],
|
||||
"allowedModes": [
|
||||
"standard",
|
||||
"ask",
|
||||
"planning",
|
||||
"skill_test"
|
||||
],
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"outputSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"exposure": "optional"
|
||||
}
|
||||
},
|
||||
"scenario": null
|
||||
} as const;
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
/** Canonical definition and documentation for `list_projects`. */
|
||||
/** Canonical project discovery definition. */
|
||||
export const listProjectsAction = {
|
||||
"id": "list_projects",
|
||||
"canonical": {
|
||||
"operationId": "list_projects",
|
||||
"surfaces": [
|
||||
"scenario"
|
||||
"scenario",
|
||||
"live"
|
||||
],
|
||||
"placement": "optional_agent_tool",
|
||||
"optionalGroup": "discovery",
|
||||
|
|
@ -13,22 +14,23 @@ export const listProjectsAction = {
|
|||
],
|
||||
"taskModes": [
|
||||
"standard",
|
||||
"ask",
|
||||
"planning",
|
||||
"skill_test"
|
||||
],
|
||||
"sideEffectClass": "read",
|
||||
"idempotency": "none",
|
||||
"disabledByDefault": false,
|
||||
"realBindingStatus": "scenario_mock",
|
||||
"realServiceBinding": "unbound",
|
||||
"prpEvidence": "read projection surfaced via a tool-result item event; no control-plane state diff",
|
||||
"prpBindingStatus": "audit_pending",
|
||||
"legacyAliases": [],
|
||||
"note": "Scenario/eval-only discovery via mock extension; no live dispatcher binding yet."
|
||||
"realBindingStatus": "live_codex",
|
||||
"realServiceBinding": "PaperclipRunnerToolAuthority",
|
||||
"prpEvidence": "Authenticated project tools, persisted projects and repository workspaces, and run-bound activity.",
|
||||
"prpBindingStatus": "bound",
|
||||
"legacyAliases": []
|
||||
},
|
||||
"documentation": {
|
||||
"title": "List Projects",
|
||||
"description": "List Projects through the Capability discovery capability set.",
|
||||
"note": "Scenario/eval-only discovery via mock extension; no live dispatcher binding yet."
|
||||
"title": "List projects",
|
||||
"description": "Inspect available company projects before selecting a project for new work.",
|
||||
"note": null
|
||||
},
|
||||
"examples": {
|
||||
"call": {
|
||||
|
|
@ -38,18 +40,40 @@ export const listProjectsAction = {
|
|||
"success": {
|
||||
"ok": true,
|
||||
"operationId": "list_projects",
|
||||
"result": {
|
||||
"schema": "paperclip.capability.tool-result.v1",
|
||||
"ok": true,
|
||||
"operationId": "list_projects",
|
||||
"operationResultId": "example",
|
||||
"value": "example",
|
||||
"commandResult": "example",
|
||||
"authorization": "example"
|
||||
}
|
||||
"result": {}
|
||||
}
|
||||
},
|
||||
"live": {
|
||||
"order": 45,
|
||||
"descriptor": {
|
||||
"schema": "paperclip.semantic-tool.v1",
|
||||
"operationId": "list_projects",
|
||||
"version": 1,
|
||||
"title": "List projects",
|
||||
"description": "Inspect available company projects before selecting a project for new work.",
|
||||
"effect": "read",
|
||||
"requiredClaims": [
|
||||
"discovery:projects:read"
|
||||
],
|
||||
"allowedModes": [
|
||||
"standard",
|
||||
"ask",
|
||||
"planning",
|
||||
"skill_test"
|
||||
],
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"outputSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"exposure": "optional"
|
||||
}
|
||||
},
|
||||
"live": null,
|
||||
"scenario": {
|
||||
"order": 16,
|
||||
"descriptor": {
|
||||
|
|
@ -104,6 +128,8 @@ export const listProjectsAction = {
|
|||
],
|
||||
"taskModes": [
|
||||
"standard",
|
||||
"ask",
|
||||
"planning",
|
||||
"skill_test"
|
||||
],
|
||||
"sideEffectClass": "read",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ const NAMESPACE: Readonly<Record<CapabilitySemanticOperationId, string>> = Objec
|
|||
report_progress: "active_task", answer_status_question: "active_task", write_document: "documents",
|
||||
request_human_input: "documents", register_deliverable: "documents", finish_task: "active_task",
|
||||
block_task: "active_task", request_review: "active_task", search_tasks: "discovery",
|
||||
list_agents: "discovery", get_agent: "discovery", create_task: "delegation",
|
||||
list_agents: "discovery", get_agent: "discovery", create_task: "delegation", create_project: "projects", list_project_repositories: "projects", list_projects: "projects",
|
||||
set_dependencies: "delegation", list_approvals: "governance", get_approval: "governance",
|
||||
get_approval_context: "governance", request_approval: "governance",
|
||||
decide_approval: "governance", comment_on_approval: "governance",
|
||||
|
|
|
|||
|
|
@ -181,7 +181,18 @@ export class CapabilitySemanticDispatcher {
|
|||
return {
|
||||
...createCapabilitySemanticPolicyContext(
|
||||
context,
|
||||
scenario,
|
||||
{
|
||||
...scenario,
|
||||
// These descriptors belong to the server's authenticated project
|
||||
// authority. This mock command port has no project/repository binding;
|
||||
// it must neither advertise nor accept them merely for lacking claims.
|
||||
denyOperations: [...new Set([
|
||||
...(scenario.denyOperations ?? []),
|
||||
"create_project" as const,
|
||||
"list_project_repositories" as const,
|
||||
"list_projects" as const,
|
||||
])],
|
||||
},
|
||||
this.options.explicitClaims ?? context.capabilities,
|
||||
),
|
||||
runId,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ const NAMESPACE: Readonly<Record<PaperclipSemanticActionId, string>> =
|
|||
get_workspace_runtime: "workspace",
|
||||
control_workspace_service: "workspace",
|
||||
set_dependencies: "delegation",
|
||||
create_task: "delegation",
|
||||
create_task: "delegation", create_project: "projects", list_project_repositories: "projects", list_projects: "projects",
|
||||
request_approval: "governance",
|
||||
decide_approval: "governance",
|
||||
comment_on_approval: "governance",
|
||||
|
|
|
|||
|
|
@ -53,7 +53,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(30);
|
||||
expect(names).toHaveLength(33);
|
||||
expect(names).toContain("get_task_context");
|
||||
expect(names).toContain("finish_task");
|
||||
expect(names).not.toContain("checkout_task");
|
||||
|
|
@ -110,6 +110,15 @@ describe("Capability semantic catalog and authorization", () => {
|
|||
const found = dispatcher.discoverTools(OPEN.identity.runId, "create child task approval secret admin");
|
||||
expect(found.operations).toEqual([]);
|
||||
expect(JSON.stringify(found.operations)).not.toMatch(/create_task|approval|secret|administer_company/);
|
||||
const before = adapter.snapshot().revision;
|
||||
for (const operationId of ["create_project", "list_project_repositories", "list_projects"] as const) {
|
||||
expect(dispatcher.listTools(OPEN.identity.runId).map((tool) => tool.name)).not.toContain(operationId);
|
||||
expect(await dispatcher.dispatch({
|
||||
runId: OPEN.identity.runId, callId: `unbound-${operationId}`, operationId,
|
||||
input: operationId === "create_project" ? { name: "Unbound", idempotencyKey: "unbound-project" } : {},
|
||||
})).toMatchObject({ ok: false, denial: { code: "scenario_denied" } });
|
||||
}
|
||||
expect(adapter.snapshot().revision).toBe(before);
|
||||
});
|
||||
|
||||
it("executes a granted optional operation through the mock port", async () => {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ export type CapabilitySemanticOperationId =
|
|||
| "get_workspace_runtime"
|
||||
| "control_workspace_service"
|
||||
| "set_dependencies"
|
||||
| "create_project"
|
||||
| "list_project_repositories"
|
||||
| "list_projects"
|
||||
| "create_task"
|
||||
| "request_approval"
|
||||
| "decide_approval"
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ test("generated Capability inventory has full source coverage", async () => {
|
|||
readRows("eval-traceability.yaml"),
|
||||
]);
|
||||
|
||||
assert.equal(capabilities.length, 152);
|
||||
assert.equal(capabilities.length, 155);
|
||||
assert.equal(tools.length, 42);
|
||||
assert.equal(evals.length, 106);
|
||||
assert.equal(new Set(evals.map((row) => row.group)).size, 16);
|
||||
|
|
|
|||
|
|
@ -123,6 +123,13 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
|
|||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableAgentChat: {
|
||||
title: "Agent Chat",
|
||||
description: "Persistent task-backed conversations that clarify goals and hand work off to tasks.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableConferenceRoomChat: {
|
||||
title: "Conference Room Chat",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ export interface InstanceExperimentalSettings {
|
|||
enableChatConnectors: boolean;
|
||||
enablePipelines: boolean;
|
||||
enableCases: boolean;
|
||||
enableAgentChat: boolean;
|
||||
enableConferenceRoomChat: boolean;
|
||||
enableClassicTaskInterface: boolean;
|
||||
enableIssuePlanDecompositions: boolean;
|
||||
|
|
|
|||
|
|
@ -768,6 +768,11 @@ export interface IssueChangeReceiptEntry {
|
|||
export type IssueChanges = Record<string, IssueChangeReceiptEntry>;
|
||||
|
||||
export interface Issue {
|
||||
conversationAgentId?: string | null;
|
||||
conversationUserId?: string | null;
|
||||
conversationState?: "active" | "waiting" | null;
|
||||
conversationSessionGeneration?: number;
|
||||
conversationBoundaryCommentId?: string | null;
|
||||
activeRun?: { id: string; status: string; agentId: string; invocationSource: string;
|
||||
triggerDetail: string | null; startedAt: Date | string | null; finishedAt: Date | string | null;
|
||||
createdAt: Date | string; execution?: ExecutionProjection } | null;
|
||||
|
|
@ -933,6 +938,8 @@ export type IssueCommentDerivedAuthorSource =
|
|||
| "run_log_comment_post";
|
||||
|
||||
export interface IssueComment {
|
||||
clientRequestId?: string | null;
|
||||
conversationSessionGeneration?: number | null;
|
||||
id: string;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export const instanceExperimentalSettingsSchema = z.object({
|
|||
enableChatConnectors: z.boolean().default(false),
|
||||
enablePipelines: z.boolean().default(false),
|
||||
enableCases: z.boolean().default(false),
|
||||
enableAgentChat: z.boolean().default(false),
|
||||
enableConferenceRoomChat: z.boolean().default(false),
|
||||
enableClassicTaskInterface: z.boolean().default(false),
|
||||
enableIssuePlanDecompositions: z.boolean().default(false),
|
||||
|
|
|
|||
|
|
@ -758,6 +758,7 @@ function requireBlockedStatusForUnblockDescriptor(
|
|||
}
|
||||
|
||||
const createIssueDuplicateGuardSchema = {
|
||||
initialPlan: z.string().min(1).max(200000).optional().nullable(),
|
||||
idempotencyKey: z.string().trim().min(1).max(255).optional().nullable(),
|
||||
allowDuplicate: z
|
||||
.boolean()
|
||||
|
|
@ -1014,6 +1015,7 @@ export const issueCommentMetadataSchema = z
|
|||
export type IssueCommentMetadata = z.infer<typeof issueCommentMetadataSchema>;
|
||||
|
||||
export const addIssueCommentSchema = z.object({
|
||||
clientRequestId: z.string().uuid().optional(),
|
||||
body: multilineTextSchema.pipe(z.string().min(1)),
|
||||
attachmentIds: issueCommentAttachmentIdsSchema.optional(),
|
||||
onBehalfOfUserId: z.string().trim().min(1).optional().nullable(),
|
||||
|
|
|
|||
|
|
@ -117,9 +117,11 @@ const projectFields = {
|
|||
};
|
||||
|
||||
export const createProjectSchema = z.object({
|
||||
idempotencyKey: z.string().trim().min(1).max(255).optional(),
|
||||
...projectFields,
|
||||
workspace: createProjectWorkspaceSchema.optional(),
|
||||
repositoryIds: z.array(z.string().regex(/^\d+$/)).optional(),
|
||||
repositoryUrls: z.array(z.string().url().max(2000)).max(100).optional(),
|
||||
});
|
||||
|
||||
export type CreateProject = z.infer<typeof createProjectSchema>;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { AGENT_CHAT_DIRECTIVE } from "../server/src/services/agent-conversations.js";
|
||||
/** JSONL worker for the companion paperclip-evals API suite. Never selects cases or retries. */
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createReadStream, realpathSync } from "node:fs";
|
||||
|
|
@ -65,7 +66,7 @@ try {
|
|||
const isOpenRouter = OPENROUTER_MODELS.has(request.model);
|
||||
const provider = isOpenRouter ? "opencode" : request.model === "claude-sonnet-5" ? "acpx" : "codex";
|
||||
let providerVersion: string | null = null;
|
||||
const fixture = await server.fixture({ mode: request.mode, apiToolsEnabled: request.arm !== "baseline", reset: true, connectionScenario: request.connectionScenario });
|
||||
const fixture = await server.fixture({ mode: request.mode, apiToolsEnabled: request.arm !== "baseline", reset: true, conversation: request.conversation === true, connectionScenario: request.connectionScenario });
|
||||
const initialState = await fixture.snapshot();
|
||||
const substitutions = Object.fromEntries(Object.entries(fixture).filter(([, value]) => typeof value === "string"));
|
||||
const expand = (value: any): any => typeof value === "string" ? value.replace(/\{\{(\w+)\}\}/g, (_, key) => String(substitutions[key] ?? (() => { throw new Error(`Unknown fixture variable ${key}`); })())) : Array.isArray(value) ? value.map(expand) : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, expand(entry)])) : value;
|
||||
|
|
@ -129,7 +130,7 @@ try {
|
|||
completionContract: { revision: "runner-api-eval-v1", criterionIds: ["objective"] },
|
||||
config: { ...createSkilllessCodexThreadConfig(fixture.workspace), model_reasoning_effort: "low" },
|
||||
permissions: "paperclip-runner-workspace-only", runtimeWorkspaceRoots: [fixture.workspace], approvalPolicy: "never",
|
||||
baseInstructions: "You are operating a disposable real Paperclip company. Use the provided tools to do the user's task. Do not use shell, network, skills, or credentials. Stop when the requested work is verified. " + (request.arm === "baseline" ? "" : "Prefer available dedicated tools. Only use search_api and call_api when no dedicated tool supports the required operation or parameters. Do not search before ordinary dedicated tool use.") + "\n" + CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
baseInstructions: "You are operating a disposable real Paperclip company. Use the provided tools to do the user's task. Do not use shell, network, skills, or credentials. Stop when the requested work is verified. " + (request.arm === "baseline" ? "" : "Prefer available dedicated tools. Only use search_api and call_api when no dedicated tool supports the required operation or parameters. Do not search before ordinary dedicated tool use.") + "\n" + CONNECTION_INTENT_AGENT_GUIDANCE + (request.conversation ? "\n" + AGENT_CHAT_DIRECTIVE : ""),
|
||||
dynamicTools: definitions, experimentalRawEvents: true, persistExtendedHistory: true,
|
||||
});
|
||||
if (request.preflight) {
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ describeEmbeddedPostgres("activity service", () => {
|
|||
enormousBlob: "x".repeat(256_000),
|
||||
},
|
||||
resultJson: {
|
||||
conversationReset: true,
|
||||
billing_type: "metered",
|
||||
total_cost_usd: 0.42,
|
||||
stopReason: "timeout",
|
||||
|
|
@ -197,6 +198,7 @@ describeEmbeddedPostgres("activity service", () => {
|
|||
total_cost_usd: 0.42,
|
||||
});
|
||||
expect(runs[0]?.resultJson).toEqual({
|
||||
conversationReset: true,
|
||||
billingType: "metered",
|
||||
billing_type: "metered",
|
||||
costUsd: 0.42,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,19 @@ describe("adapter session codecs", () => {
|
|||
expect(claudeSessionCodec.getDisplayId?.(serialized ?? null)).toBe("claude-session-1");
|
||||
});
|
||||
|
||||
it("preserves Claude MCP identity across persistence so resumed turns keep their context", () => {
|
||||
const params = {
|
||||
sessionId: "11111111-1111-4111-8111-111111111111",
|
||||
cwd: "/tmp/workspace",
|
||||
mcpServerIdentity: JSON.stringify([{
|
||||
name: "Paperclip projects",
|
||||
url: "http://localhost:3100/api/mcp/project-tools",
|
||||
connectionId: "paperclip-project-tools",
|
||||
}]),
|
||||
};
|
||||
expect(claudeSessionCodec.deserialize(claudeSessionCodec.serialize(params))).toEqual(params);
|
||||
});
|
||||
|
||||
it("preserves claude ACP session params for ACP lane resumes", () => {
|
||||
const parsed = claudeSessionCodec.deserialize({
|
||||
sessionKey: "paperclip:company:agent:task:fingerprint",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,129 @@
|
|||
import { callProjectTool } from "../services/project-tools.js";
|
||||
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { issues, heartbeatRuns } from "@paperclipai/db";
|
||||
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { documentService } from "../services/documents.js";
|
||||
import { activityService } from "../services/activity.js";
|
||||
import { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("chat project tool handoff", () => {
|
||||
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; });
|
||||
const call = (fixture: Awaited<ReturnType<typeof server.fixture>>, tool: string, args: Record<string, unknown>) => fixture.authority.execute({ tool, arguments: args, callId: randomUUID() });
|
||||
|
||||
it("allows a conversation reply to enter review without manufacturing a review interaction", async () => {
|
||||
const f = await server.fixture({ conversation: true });
|
||||
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
||||
const response = await fetch(`${server.apiUrl}/api/issues/${f.issueId}`, {
|
||||
method: "PATCH", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "in_review", comment: "The plan is ready for our next discussion." }),
|
||||
});
|
||||
const result = await response.json();
|
||||
expect(response.status, JSON.stringify(result)).toBe(200);
|
||||
expect(result.status).toBe("in_review");
|
||||
});
|
||||
|
||||
it("creates an ordinary project task and its plan atomically, retaining the conversation plan", async () => {
|
||||
const f = await server.fixture({ conversation: true });
|
||||
await documentService(server.db).upsertIssueDocument({ issueId: f.issueId, key: "plan", format: "markdown", body: "Full discussion plan" });
|
||||
const input = { title: "Implement the clarified outcome", projectId: f.projectId, initialPlan: "# Execution plan\n\nBuild and verify the outcome.", idempotencyKey: "handoff" };
|
||||
const first = await call(f, "create_task", input) as any;
|
||||
const again = await call(f, "create_task", input) as any;
|
||||
expect(again.task.id).toBe(first.task.id);
|
||||
expect(first.task.parentId).toBeNull();
|
||||
const [task] = await server.db.select().from(issues).where(eq(issues.id, first.task.id));
|
||||
expect(task).toMatchObject({ projectId: f.projectId, assigneeAgentId: f.agentId, status: "todo" });
|
||||
expect((await documentService(server.db).getIssueDocumentByKey(task.id, "plan"))?.body).toBe(input.initialPlan);
|
||||
expect((await documentService(server.db).getIssueDocumentByKey(f.issueId, "plan"))?.body).toBe("Full discussion plan");
|
||||
await expect(call(f, "create_task", { ...input, title: "Different" })).rejects.toThrow(/idempotency/);
|
||||
});
|
||||
|
||||
it("rejects creation, child helpers, and reparenting under a chat, while retaining legacy children", async () => {
|
||||
const f = await server.fixture({ conversation: true });
|
||||
const svc = issueService(server.db);
|
||||
await expect(svc.create(f.companyId, { title: "Invalid", parentId: f.issueId })).rejects.toThrow(/cannot have new subtasks/);
|
||||
await expect(svc.createChild(f.issueId, { title: "Invalid" })).rejects.toThrow(/cannot have new subtasks/);
|
||||
await expect(svc.importIssues(f.companyId, [{
|
||||
id: randomUUID(), ref: "imported", title: "Imported child", parentId: f.issueId,
|
||||
projectId: null, projectWorkspaceId: null, description: null, assigneeAgentId: null,
|
||||
status: "backlog", priority: "medium", billingCode: null, assigneeAdapterOverrides: null,
|
||||
executionWorkspaceSettings: null, labelIds: [], monitorNotes: null, monitorScheduledBy: null,
|
||||
}])).rejects.toThrow(/cannot have new subtasks/);
|
||||
const ordinary = await svc.create(f.companyId, { title: "Ordinary" });
|
||||
await expect(svc.update(ordinary.id, { parentId: f.issueId })).rejects.toThrow(/cannot have new subtasks/);
|
||||
await server.db.update(issues).set({ parentId: f.issueId }).where(eq(issues.id, ordinary.id));
|
||||
expect(await svc.update(ordinary.id, { title: "Legacy edited", parentId: f.issueId })).toMatchObject({ title: "Legacy edited" });
|
||||
expect(await svc.update(ordinary.id, { parentId: null })).toMatchObject({ parentId: null });
|
||||
});
|
||||
|
||||
it("hands off through the same API used by Claude/Codex MCP with the plan present on return", async () => {
|
||||
const f = await server.fixture({ conversation: true });
|
||||
const result = await callProjectTool({ name: "create_task", arguments: { title: "MCP handoff", projectId: f.projectId, initialPlan: "# Plan\nImplement in the execution task.", idempotencyKey: "mcp" },
|
||||
apiUrl: server.apiUrl, token: createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!,
|
||||
companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, conversation: true });
|
||||
expect(result).toMatchObject({ parentId: null, projectId: f.projectId, assigneeAgentId: f.agentId });
|
||||
expect((await documentService(server.db).getIssueDocumentByKey(result.id, "plan"))?.body).toContain("Implement in the execution task");
|
||||
});
|
||||
|
||||
it("retains ordinary child delegation and projectless task creation", async () => {
|
||||
const f = await server.fixture();
|
||||
const result = await call(f, "create_task", { title: "Delegate ordinary work", idempotencyKey: "child" }) as any;
|
||||
expect(result.task.parentId).toBe(f.issueId);
|
||||
expect(await issueService(server.db).create(f.companyId, { title: "No project needed" })).toMatchObject({ projectId: null });
|
||||
});
|
||||
|
||||
it("creates a project once through the production API and records it on the source feed", async () => {
|
||||
const f = await server.fixture({ conversation: true });
|
||||
const input = { name: "New non-code project", description: "A well-scoped outcome", idempotencyKey: "project" };
|
||||
const results = await Promise.all(Array.from({ length: 4 }, () => call(f, "create_project", input))) as any[];
|
||||
const project = results[0];
|
||||
expect(new Set(results.map(result => result.id)).size).toBe(1);
|
||||
expect(project.id).toBeTruthy();
|
||||
expect((await call(f, "create_project", input) as any).id).toBe(project.id);
|
||||
const feed = await activityService(server.db).forIssue(f.issueId);
|
||||
expect(feed.filter(event => event.action === "project.created")).toHaveLength(1);
|
||||
expect(feed.find(event => event.action === "project.created")).toMatchObject({ entityId: project.id, runId: f.runId, details: { sourceIssueId: f.issueId } });
|
||||
await expect(call(f, "create_project", { ...input, name: "Changed" })).rejects.toThrow(/different inputs/);
|
||||
});
|
||||
|
||||
it("includes an explicit workspace repository in the committed project card", async () => {
|
||||
const f = await server.fixture({ conversation: true });
|
||||
const project = await call(f, "create_project", { name: "Workspace repo", workspace: { repoUrl: "https://github.com/example/web" }, idempotencyKey: "workspace" }) as any;
|
||||
const feed = await activityService(server.db).forIssue(f.issueId);
|
||||
expect(feed.find(event => event.entityId === project.id)?.details?.repositories).toEqual([
|
||||
expect.objectContaining({ url: "https://github.com/example/web" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("registers multiple previously unknown GitHub URLs and deduplicates equivalent URLs", async () => {
|
||||
const f = await server.fixture({ conversation: true });
|
||||
const project = await call(f, "create_project", { name: "Across repos", repositoryUrls: ["https://github.com/example/web.git", "https://github.com/example/api", "https://github.com/example/web/"], idempotencyKey: "urls" }) as any;
|
||||
expect(project.workspaces.map((w: any) => w.repoUrl).sort()).toEqual(["https://github.com/example/api", "https://github.com/example/web"]);
|
||||
expect(project.workspaces.filter((w: any) => w.isPrimary)).toHaveLength(1);
|
||||
await expect(call(f, "create_project", { name: "Invalid", repositoryUrls: ["https://github.com/example/api"], workspace: { repoUrl: "https://github.com/example/web" }, idempotencyKey: "conflict" })).rejects.toThrow(/either workspace/);
|
||||
await expect(call(f, "create_project", { name: "Invalid", repositoryUrls: ["https://user:password@github.com/example/api"], idempotencyKey: "credentials" })).rejects.toThrow(/without credentials/);
|
||||
});
|
||||
|
||||
it("allows planning documents while denying project/task creation in Plan and Ask mode", async () => {
|
||||
for (const mode of ["planning", "ask"] as const) {
|
||||
const f = await server.fixture({ conversation: true, mode });
|
||||
await expect(call(f, "create_project", { name: "No", idempotencyKey: "no" })).rejects.toThrow(/mode_denied/);
|
||||
await expect(call(f, "create_task", { title: "No", idempotencyKey: "no" })).rejects.toThrow(/mode_denied/);
|
||||
if (mode === "planning") await call(f, "write_document", { key: "plan", title: "Plan", body: "Clarify and plan here", idempotencyKey: "plan" });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invented repository IDs and cancelled runs without creating a project", async () => {
|
||||
const f = await server.fixture({ conversation: true });
|
||||
await expect(call(f, "create_project", { name: "Missing repo", repositoryIds: ["999999"], idempotencyKey: "missing" })).rejects.toThrow(/repository.*available/);
|
||||
await server.db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, f.runId));
|
||||
await expect(call(f, "create_project", { name: "Cancelled", idempotencyKey: "cancelled" })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
claudeSessionCwdMatchesExecutionTarget,
|
||||
execute,
|
||||
resetClaudeCliCapabilitiesCacheForTests,
|
||||
sessionCodec,
|
||||
} from "@paperclipai/adapter-claude-local/server";
|
||||
|
||||
async function writeFailingClaudeCommand(
|
||||
|
|
@ -1156,6 +1157,14 @@ describe("claude execute", () => {
|
|||
},
|
||||
},
|
||||
context: {},
|
||||
runtimeMcp: {
|
||||
getServers: () => [{
|
||||
name: "Paperclip projects",
|
||||
url: "http://localhost:3100/api/mcp/project-tools",
|
||||
connectionId: "paperclip-project-tools",
|
||||
token: "run-jwt-token",
|
||||
}],
|
||||
},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
|
@ -1179,7 +1188,7 @@ describe("claude execute", () => {
|
|||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: first.sessionParams ?? null,
|
||||
sessionParams: sessionCodec.deserialize(sessionCodec.serialize(first.sessionParams ?? null)),
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
|
|
@ -1231,6 +1240,14 @@ describe("claude execute", () => {
|
|||
fallbackFetchNeeded: false,
|
||||
},
|
||||
},
|
||||
runtimeMcp: {
|
||||
getServers: () => [{
|
||||
name: "Paperclip projects",
|
||||
url: "http://localhost:3100/api/mcp/project-tools",
|
||||
connectionId: "paperclip-project-tools",
|
||||
token: "next-run-jwt-token",
|
||||
}],
|
||||
},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -338,7 +338,14 @@ const SKIP_DIRS = new Set([
|
|||
"tmp",
|
||||
]);
|
||||
|
||||
const SKIP_PATH_PREFIXES = ["doc/logs/", "doc/plans/", "scripts/"];
|
||||
const SKIP_PATH_PREFIXES = [
|
||||
"doc/logs/",
|
||||
"doc/plans/",
|
||||
"scripts/",
|
||||
// Generated paid-run transcripts contain historical copies of instructions,
|
||||
// including escaped warning examples; they are not authored guidance.
|
||||
"tests/runner-e2e/results/",
|
||||
];
|
||||
|
||||
const SCAN_EXTENSIONS = new Set([
|
||||
".md",
|
||||
|
|
@ -366,6 +373,8 @@ function listGuidanceFiles(rootDir = repoRoot): string[] {
|
|||
if (entry.isSymbolicLink()) continue;
|
||||
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
if (SKIP_PATH_PREFIXES.some((prefix) => `${relPath}/`.startsWith(prefix))) continue;
|
||||
if (SKIP_DIRS.has(entry.name) || relPath === ".paperclip-runtime") continue;
|
||||
walk(path.join(absDir, entry.name), relPath);
|
||||
continue;
|
||||
|
|
@ -469,6 +478,28 @@ function scanForBrokenExecForm(): string[] {
|
|||
}
|
||||
|
||||
describe("paperclipai CLI invocation safety", () => {
|
||||
it("excludes generated runner evidence while preserving authored runner guidance", () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-guidance-"));
|
||||
const sourcePaths = [
|
||||
"doc/CLI.md",
|
||||
"tests/runner-e2e/README.md",
|
||||
"tests/runner-e2e/catalog.ts",
|
||||
];
|
||||
try {
|
||||
for (const relPath of [
|
||||
...sourcePaths,
|
||||
"tests/runner-e2e/results/campaign/attempt-1/snapshots/api-state.json",
|
||||
]) {
|
||||
const absPath = path.join(root, relPath);
|
||||
mkdirSync(path.dirname(absPath), { recursive: true });
|
||||
writeFileSync(absPath, "fixture");
|
||||
}
|
||||
expect(listGuidanceFiles(root).sort()).toEqual(sourcePaths.sort());
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("excludes root runtime recordings but still scans unsafe docs and source guidance", () => {
|
||||
const fixtureRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-guidance-"));
|
||||
try {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue