feat(issues): limit agent-created task trees to parent and child (#19312)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents split work into tasks. A task can have a parent task.
> - Today agents can nest tasks to any depth: parent, child, grandchild,
and deeper.
> - Deep agent-made trees are hard to follow, and an agent that works on
a child task makes grandchildren by default.
> - This pull request limits agent-created trees to two levels: a
top-level task and its children.
> - A request for a grandchild does not fail. The server puts the new
task beside the requester and tells the agent.
> - Board users keep full control. They can still nest to any depth.
Existing deep trees stay valid.
> - The pull request also records which task created each new task, so
the audit trail stays complete when the parent changes.

## Linked Issues or Issue Description

No public issue exists. This is the feature description. Related: #16853
(budgets that include descendant work) also reasons about task trees;
this change does not alter its traversal.

**Subsystem affected**

Issues (tasks): create, child create and move in the issue service and
routes. Agent tools, chat suggestions and creation provenance use these
paths.

**Problem or motivation**

Agents create subtasks as deep as they want. An agent that runs on a
child task gets that child as the default parent, so its follow-ups
become grandchildren. The trees become deep and hard to read. The parent
link is also the only record of which task created a follow-up, so that
record is lost when the parent changes.

**Proposed solution**

Limit agent-created trees to a parent and its children. When an agent
asks for a grandchild, put the new task under the requester's parent and
return `parentAdjusted`. Do not reject the request. Keep board users
unrestricted. Record the originating task in a new server-set
`origin_issue_id` column. Keep `origin_run_id` as the originating run.

**Alternatives considered**

Reject grandchild requests with an error. We did not do this, because
agents then fail mid-task. Add a database constraint. We did not do
this, because existing deep trees must stay valid and nesting can return
later.

## What Changed

- Add `MAX_AGENT_TASK_HIERARCHY_DEPTH` (2) and the
`IssueParentAdjustment` type in `packages/shared`.
- Add `resolveNewTaskParent` and `planIssueParentChange` in
`server/src/services/issues.ts`. `create`, `createChild` and `update`
use the agent limit by default (`parentDepthPolicy: "flatten"`).
- The issue create, `/children` and PATCH routes pass `allow_nested` for
board users. For agents, the routes check authorization against the
parent that the server actually writes.
- The requester keeps its behavior when the server moves a task beside
it: `blockParentUntilDone` blocks the requester, and the new task
inherits the requester's workspace and privacy.
- The server never moves a task above a low-trust boundary root that
contains the requester.
- These subsystems keep any depth, because they find their work by
parent: watchdog reviews, monitor recovery, email-channel placement, and
routines that a person configured.
- An agent move of a task that has children skips only the parent change
and returns `reason: "has_children"`.
- Add `issues.origin_issue_id` (nullable, `on delete set null`, indexed)
in migration `0434`. Only the server sets it. The `createdFrom` detail
field, the `createdFromIssueId` filter and task-creation provenance read
it first.
- Chat prompts now let a chat agent create one task, a task with
children, or separate tasks. The conversation is never a parent.
- The runner `create_task` tool accepts an optional `parentTaskId`. A
chat agent can create a top-level task and then put subtasks under it.
The run must be able to read and change the parent. A conversation is
never a parent. The depth limit still applies. Project tools map the
field to `parentId` through the issue route. The protocol manifest,
semantic contracts and eval fixture are regenerated.
- Watchdog runs that have an active watchdog keep their requested parent
on all create paths, because their access is limited to the watched
subtree.
- Fix a bug that the test drive found: the chat (MCP) `create_task`
ignored the requested `status`, so a `backlog` task became `todo` and
started to run. It now uses the same rule as the native tool.
- Update `skills/paperclip`, `doc/SPEC-implementation.md`, and add the
plan `doc/plans/2026-11-05-two-level-task-hierarchy.md`.

## Verification

- `cd server && npx vitest run
src/__tests__/agent-task-hierarchy-depth.test.ts
src/__tests__/agent-task-hierarchy-depth-routes.test.ts`: 19 new tests
for service and route behavior, all pass.
- Related suites (64 files: issues service, thread interactions,
privacy, watchdogs, provenance, runner tool authority, decisions,
routines, project tools): 1208 tests pass. Two tests changed because of
the intended behavior and pass now. Two `email-channels.integration`
tests fail locally because the test cannot find a bundled skill file
from this worktree path. This change does not touch that code.
- `packages/db`: migration safety, snapshot drift and journal tests
pass.
- Typecheck passes for `server`, `packages/shared`, `packages/db`, `cli`
and `ui` (local `ui` errors come only from generated Storybook catalog
files that are absent).
- Local test drive on an isolated instance with real Claude agents: 29
of 29 use cases pass, including an agent on a child task (follow-ups
placed beside it, with the notice) and a chat that creates a top-level
task with three subtasks.
- I did not run the full `pnpm test:run` or `pnpm build` locally. CI
runs them.

## Risks

- Behavior change for agents: some follow-ups that were grandchildren
are now siblings. The parent of the requester is woken when all its
children finish, which now includes these tasks.
- The runner `create_task` input contract adds one optional field, so
the protocol manifest and the pack contract change. The tool output does
not change: it shows the actual `parentId`, and the activity log records
`parentAdjusted`.
- Migration `0434` adds a nullable column, a foreign key and an index.
It does not backfill rows.
- `IssueCreationSource.run` is now nullable.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use
(shell, file edits, local test runs).

## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-05 21:22:33 -06:00
1 parent d8b0eae687
commit 958f9aa9f3
43 files changed
+2576 -391

No files matched your search

+5 -2
View File
@@ -429,6 +429,7 @@ retain their titles and default to no generation request.
- `visibility` enum-like text: `open | private`, default `open`
- `privacy_root_issue_id` uuid fk `issues.id` null; private-boundary metadata
- `privacy_parent_issue_id` uuid fk `issues.id` null; downward access inheritance, including standalone run-created handoffs
- `origin_issue_id` uuid fk `issues.id` null, on delete set null; audit-only creation provenance (see below), never hierarchy or authorization
- `title` text not null
- `description` text null
- `status` enum: `backlog | todo | in_progress | in_review | done | blocked | cancelled`
@@ -442,9 +443,11 @@ retain their titles and default to no generation request.
- `created_by_user_id` uuid fk `users.id` null
- identifier fields: `issue_number`, `identifier`
- origin fields: `origin_kind`, `origin_id`, `origin_run_id`, `origin_fingerprint`
- Creation stores the actor run in `origin_run_id` unless an explicit origin run is supplied. `GET /api/companies/:companyId/issues?createdFromIssueId=<uuid>` selects tasks created by runs bound to that source task, using native run issue identity or persisted legacy task context. Historical rows without an origin run may use their recorded creation activity; comments and shared creators do not establish provenance. Source, run, activity and result are company-scoped.
- Creation stores the actor run in `origin_run_id` unless an explicit origin run is supplied. `GET /api/companies/:companyId/issues?createdFromIssueId=<uuid>` selects tasks whose `origin_issue_id` is that source task. Only for rows without a recorded `origin_issue_id`, it falls back to tasks created by runs bound to the source task, using native run issue identity or persisted legacy task context; a recorded origin suppresses that fallback, so the list agrees with `createdFrom`. Historical rows without an origin run may use their recorded creation activity; comments and shared creators do not establish provenance. Source, run, activity and result are company-scoped.
- Delegated follow-ups keep their structural parent. When an agent run bound to an ordinary execution task creates an issue and omits `parentId`, the server defaults `parentId` to that run's task and records `parentDefaultedFromRunIssue: true` on the `issue.created` activity. An explicit `parentId: null` keeps the task standalone. The default never applies to conversation runs (chat handoffs stay top-level), to watchdog follow-ups, to board/user creates, to a run task the agent may not mutate, when the default parent would create a delegation cycle with the requested assignee, or when the agent may not create a child under the default parent (for example a protected assignment policy without a grant); those requests stay standalone instead of failing. An explicitly requested parent is still subject to the ordinary assignment check. Structural parentage, creation provenance and blockers stay distinct: the default adds no blocker.
- `GET /api/issues/:id` returns `createdFrom` (`{ issue: { id, identifier, title, status }, run: { id, agentId }, agent: { id, name } | null } | null`): the task the origin run was executing when it created this issue, resolved from `origin_run_id` or, for historical rows, the run on the recorded creation activity. It is present whether or not `parentId` is set, and only when the viewer may read the source task and its run; a private, hidden-from-viewer or cross-company source yields `null`, indistinguishable from missing provenance.
- Agent-created task trees are at most two levels (`MAX_AGENT_TASK_HIERARCHY_DEPTH`). When an agent, or automation acting for one (runner and project tools, chat suggestions, native delegation, accepted-plan decomposition, decision effects, plugins, external agents), would create or move a task to a third level, the server does not reject it: the task is placed under the requested parent's ancestor at the allowed depth, a sibling of the requester, and the create/update response and activity carry `parentAdjusted`. An agent move of a task that has its own children skips only the parent change (`reason: "has_children"`). The requester keeps blocking (`blockParentUntilDone` blocks the requester), workspace inheritance and privacy inheritance; provenance follows the `origin_issue_id` rule below. Placement never moves a task above a low-trust boundary root that contains the requester. The ancestor is written only when the caller authorized it and confirms it with `expectedParentId` (the issue routes, the runner `create_task` tool, and nested chat suggestions do); other callers get a standalone task instead (an unconfirmed move skips only the parent change), so no edge lands under an unchecked ancestor. Board users, watchdog and recovery machinery, channel-boundary placement and human-configured routines may nest to any depth; existing deeper trees stay valid and fully readable.
- `origin_issue_id` and `origin_run_id` are server-set only: the task the creating run was bound to (a chat included), otherwise the chat that handed the task off, otherwise the requested parent (before placement); and that run. Privacy still follows the requested parent. Because `origin_issue_id` is returned with the task, it is recorded only when it reveals nothing new: the task's own parent or privacy parent (already exposed on the task), or a fully public origin (open, outside private projects and subtrees, not hidden); otherwise it stays null and `createdFrom` resolves the source through the run with its access checks. They never change after create.
- `GET /api/issues/:id` returns `createdFrom` (`{ issue: { id, identifier, title, status }, run: { id, agentId } | null, agent: { id, name } | null } | null`): the task that created this issue, resolved from `origin_issue_id`, otherwise from the task the origin run was executing (`origin_run_id`, or for historical rows the run on the recorded creation activity). It is present whether or not `parentId` is set and requires that the viewer may read the source task. A recorded `origin_issue_id` is returned with `run` and `agent` null when no run created the task or the viewer may not see that run, so a null `run` is not missing provenance; a source resolved only through a run additionally requires that run to be visible. An unreadable, hidden-from-viewer or cross-company source yields `null`, indistinguishable from missing provenance.
- Relation lists can use `sortField=id&sortDir=asc&afterId=<uuid>` for stable pagination. The cursor excludes earlier IDs and cannot be combined with an offset or activity-based order.
- The streamlined task page's Tasks tab keeps two independent memberships: the existing subtask tree, and created tasks grouped by their current project (or No project). A created subtask appears in both. Only Subtasks has completion progress; groups collapse independently and unfinished tasks sort above finished tasks.
- `request_depth` int not null default 0
@@ -0,0 +1,342 @@
# Two-level task hierarchy for agent-created work
Date: 2026-11-05
Status: Implemented with the recommended answers to the open decisions; see the
implementation notes below for where the code differs from this plan.
## Implementation notes
- **Exempt system machinery.** Watchdog review issues and their serialized
follow-ups, monitor recovery, email-channel boundary placement, and
human-configured routines pass `allow_nested`. These subsystems locate their
work by `parentId`, or place it inside a trust boundary, so flattening them
would break them. They are not agent-authored decomposition. Agent-authored
routines (`createdByAgentId`) still flatten.
- **Low-trust guard.** Placement never moves a task above a low-trust boundary
root (`executionPolicy.authorizationPolicy.trustBoundary.rootIssueId`) that
contains the requester. In that case the requested parent is kept.
- **Origin tracking reuses `createdFrom`.** The existing "Created from" detail
field and the `createdFromIssueId` list filter now read `origin_issue_id`
first and fall back to the run-derived source. `createdFrom.run` is nullable.
`origin_run_id` was already harness-only: the create schema does not accept
it.
- **`create_task` takes an optional `parentTaskId`.** A local test drive showed
that chat agents could not build a parent and subtasks through the tool, so
the runner contract gained this input (regenerated manifest and semantic
contracts). The run must be able to read and change the named parent; a
conversation is never a parent; depth placement still applies. Project tools
map it to `parentId` through the issues route. The tool output is unchanged:
it reports the effective `parentId`, and the activity log records
`parentAdjusted`.
- **Chat parent scope (Open decision 5).** Not restricted to tasks from the
same conversation. Chat agents may parent under any task they may write, the
same as other agents, and the depth limit still applies.
## Goal
Agents (and the automation acting for them) may only build **parent → child**
trees. When an agent asks for a grandchild, the request **does not error**: the
new task becomes a **sibling** of the requesting child (parented to the root of
the tree), and the response says so.
Humans are not restricted. A board user can create or move tasks to any depth
from the UI, CLI or API.
Separately, every task records **where it came from**, both the originating task
and the originating run. That record is an audit trail, not hierarchy, and only
the harness sets it.
Kept exactly as-is:
- **Existing data.** Legacy grandchildren and deeper trees stay valid. There is
no backfill, and every read path (tree views, subtree pause/cancel, recovery,
watchdog subtree scans, privacy propagation, cost rollups) keeps walking
arbitrary depth.
- **Hierarchy data model.** `issues.parent_id` stays a plain self-FK with no
depth constraint, so deeper nesting can come back for agents by changing one
constant. The only schema change is the additive `origin_issue_id` column.
- **Goals and folders** (they are not tasks), and **`requestDepth`** (the
delegation counter).
## 1. Who the rule applies to
The depth rule is an **actor policy**, not a data rule.
| Creator | Policy |
|---|---|
| Board user (UI, CLI with user auth, API with board session) | `allow_nested`. No change from today |
| Agent via HTTP API, CLI with agent key, runner `create_task`, project tools `create_task` | `flatten` |
| Agent-authored chat suggestions (`suggest_tasks`), even when a user clicks accept | `flatten`, because the agent authored the shape. See Open decision 2 |
| Harness/system creators: native delegation, accepted-plan decomposition, decisions effects, recovery, watchdog, routines | `flatten` |
| Plugins (`issues.create`) | `flatten` (automation). See Open decision 3 |
| `importIssues` / company import | exempt; preserves trees verbatim |
Implementation: add `parentDepthPolicy?: "flatten" | "allow_nested"` to the
`create` / `createChild` / `update` inputs in `server/src/services/issues.ts`.
The **default is `flatten`**, so any internal caller is safe by default. Only
the board-actor branches of the routes pass `allow_nested`
(`req.actor.type === "board"`).
## 2. The flatten rule
Add to `packages/shared`:
```ts
export const MAX_AGENT_TASK_HIERARCHY_DEPTH = 2; // root + one level of children
```
Add one resolver next to `assertExecutionTaskParent`
(`server/src/services/issues.ts:2047`):
```ts
resolveNewTaskParent(db, companyId, requestedParentId, policy)
-> { effectiveParentId, requestedParentId, flattened: boolean }
```
- `allow_nested` or requested parent is top-level → unchanged.
- Requested parent has a parent → use the **root** of that tree. In a 2-level
tree that is exactly "sibling of the requester". For a legacy deep tree it
still yields a 2-level edge.
- `assertExecutionTaskParent` (conversations cannot be parents) still runs on
the effective parent. If the root is a conversation (only possible with legacy
data), fall back to top-level instead of erroring.
**Never an error for agents.** Flattening is silent at the data level and
visible in the response:
```jsonc
// create / createChild / update response
"parentAdjusted": {
"reason": "max_agent_depth",
"requestedParent": { "id": "…", "identifier": "ENG-34" },
"effectiveParent": { "id": "…", "identifier": "ENG-12" }
}
```
The same object goes into the `issue.created` / `issue.child_created` /
`issue.updated` activity-log details. Runner and project-tool results surface it
in the tool result text ("Created ENG-40 under ENG-12 as a sibling of ENG-34;
agents can nest only one level"), so the model learns in-loop.
**What the requester keeps when flattened:**
1. **Waiting.** `blockParentUntilDone: true` adds a `blocks` relation *new task →
requester* instead of blocking the root. Blocker resolution
(`issue-dependency-wakeups.ts`) wakes the requester.
2. **Workspace.** `inheritExecutionWorkspaceFromIssueId` = the requester, so the
sibling runs where the grandchild would have.
3. **Privacy.** `privacyParentIssueId` = the requester, not the root
(`issues.ts:~10621`). Otherwise a task spawned from a private child could end
up more visible than its creator.
4. **Origin.** `originIssueId` = the requester (§3).
**Re-parenting by an agent** (`PATCH parentId`):
- The target is a child → flatten to its root with a `parentAdjusted` notice.
- The moved issue itself has children (moving it under anything would make
grandchildren) → **do not error**. Skip only the parent change, apply the rest
of the patch, and return `parentAdjusted: { reason: "has_children", applied:
false }`. See Open decision 1.
## 3. Originating task and originating run
Both are nullable. **Only the harness sets them.** They are never agent tool
parameters or request-body fields, and they never change after create.
### `origin_issue_id` (new column)
`issues.origin_issue_id uuid references issues(id) on delete set null`, indexed
on `(company_id, origin_issue_id)`. It is never read for hierarchy,
authorization or wakes.
The harness derives it in this order:
1. the **requested** parent, before flattening (so for unflattened children it
equals `parentId`; that duplication is fine and keeps queries simple)
2. otherwise the run-bound task of the creating agent run, **including a
conversation**, so chat-created tasks record their chat
3. otherwise the source task of system creators: recovery and watchdog source,
decision target, native delegation source, plan-decomposition source, routine
`parentIssueId`, chat-suggestion interaction issue
4. otherwise null (plain board/UI creates)
### `origin_run_id` (existing column, tighten it)
`issues.origin_run_id` already exists (`packages/db/src/schema/issues.ts:67`),
and the create route already defaults it to `actor.runId`
(`routes/issues.ts:12838`). To make it harness-only and reliable:
- Stop honouring a client-supplied `originRunId` on create
(`routes/issues.ts:12757-12838` reads `createBody.originRunId`). Derive it only
from the authenticated actor's run, or from the internal caller. Confirm the
shared `createIssueSchema` does not accept it, and keep it out of the CLI,
plugin SDK and tool schemas.
- Set it in every harness path that has a run: runner tools (already
`binding.runId`), project tools, native delegation, accepted-plan
decomposition, chat suggestions (`interaction.sourceRunId`), recovery and
watchdog creators where a run triggered them.
- **Overload to resolve:** for `originKind = "routine_execution"` the column
holds a **routine-run** id (`routines.ts:1925`), not a heartbeat run, and
`task-creation-provenance.ts` depends on that. Options are in Open decision 4.
### Contracts and surfacing
- `packages/db` schema plus a `pnpm db:generate` migration (additive nullable
column + index).
- `packages/shared` `Issue` type (read-only fields `originIssueId`, plus
`originIssue: { id, identifier, title } | null` on detail reads); not present
in any create/update validator.
- Server: set both fields in `create()` / `createChild()`; include them in
serializers and the activity log. `task-creation-provenance.ts` follows
`originIssueId` as an edge, so routine/shareability provenance survives
flattening.
- UI: "Created from ENG-34 · run abc123" in issue properties, linking to the
task and the run.
- `importIssues`: carry the fields only if the bundle has them, else null.
## 4. Chat with an agent
Chat agents may create **one top-level task, a top-level task with subtasks, or
several independent top-level tasks**, whichever fits. The code allows all
three, and the prompts guide the choice. The agent depth rule still applies, so
nesting beyond one level is flattened.
What exists today, and what changes:
- **The conversation itself never becomes a parent.** Keep this
(`assertExecutionTaskParent`). Chat-created work hangs off its *own* top-level
task, not the chat. `originIssueId` = the conversation records the link.
- **Runner `create_task` in a conversation** always creates top-level and has no
parent input (`paperclip-runner-tool-authority.ts:1706/1803`). To allow
"top-level task + subtasks" in one chat, let the conversation-bound tool accept
an optional parent **that is a task created from this same conversation**
(`originIssueId` = this conversation). Anything else is ignored (created
top-level, with a notice). This keeps chat agents from attaching work to
arbitrary existing tasks while still allowing their own small trees. See Open
decision 5.
- **Project tools `create_task`** (`project-tools.ts:50`) hard-codes
`parentId: null` for conversations. Same change as the runner tool.
- **HTTP API from a chat run** (`POST /companies/:id/issues`): explicit
`parentId` is already allowed and goes through flatten. No change beyond §2.
- **Chat suggestions (`suggest_tasks`)** already support `parentClientKey`
nesting and default to top-level for conversations
(`issue-thread-interactions.ts:~4325`). This stays open to both shapes. Nesting
deeper than one level is flattened on accept (agent-authored → `flatten`).
- **Prompts.** `server/src/services/agent-conversations.ts:108` currently says
"Create ordinary assigned tasks, never subtasks of this conversation … omit
parentId", and `:119` says "Omit parentId: the accepted task is standalone".
Rewrite both to:
- never use the conversation as a parent;
- for one coherent outcome, create one top-level task;
- for a large outcome with parts one owner coordinates, create a top-level
task and child tasks under it;
- for unrelated outcomes, create separate top-level tasks;
- only one level of children; deeper requests become siblings;
- use `blockedByIssueIds` for ordering, not nesting.
## 5. Choke points
Enforcement lives in `server/src/services/issues.ts`, with routes resolving
**before** authorization so permission checks run against the parent actually
written:
| Method | Line | Change |
|---|---|---|
| `create()` | ~10247 | resolve parent per policy; set `originIssueId` / `originRunId`; apply "requester keeps" rules |
| `createChild()` | ~9644 | resolve parent before idempotency/child-count/inheritance; inheritance and `blockParentUntilDone` stay pointed at the requester |
| `update()` | ~11169 | agent policy: flatten target, or skip the move if the issue has children |
| `importIssues()` | ~10911 | exempt (comment why) |
Routes (`server/src/routes/issues.ts`):
- `POST /companies/:id/issues` (~12630-12860). Board → `allow_nested`. Agent →
resolve the effective parent first, including the **run-delegation default**
(`resolveRunDelegationParentDefault`, :3830), which is the main source of
agent grandchildren today. Run `assertIssueWriteInfluenceAllowed` /
`assertTaskBridgeCreateAllowed` / `assertTaskWatchdogCreateIssueAllowed`
against the effective parent, and keep the read check on the requested parent.
For the implicit default, any denial falls back to standalone, as today.
- `POST /issues/:id/children` (~13086-13300). Same treatment.
- `PATCH /issues/:id`. Same treatment.
## 6. Every agent/harness creator (covered by the choke points; one test each)
- Runner `create_task`: `paperclip-runner-tool-authority.ts:1803` always calls
`createChild(binding.issueId)`. Update its description in
`packages/paperclip-runner/src/protocol-actions/create-task.ts`.
- Project tools `create_task`: `project-tools.ts:50`.
- Native `create_delegated_issue`: `status-decision-committer.ts:729`.
- Accepted-plan decomposition: `issues.ts:~10021`. Its cursor tracks explicit
child IDs, not `parentId`, so it is unaffected.
- Decisions `create_issue`: `decisions.ts:466/530`.
- Routines: `routines.ts:1913` (`routine.parentIssueId`).
- Chat suggestions: `issue-thread-interactions.ts:~4325-4372`, plus the scope
resolver at `routes/issues.ts:~6200`.
- Muse `task.create`: `modules/external-agents/muse.ts:547-561`. Dot:
`modules/external-agents/dot.ts:353` (verify).
- Plugin SDK: `plugin-host-services.ts:2134/3161`.
- Recovery: `heartbeat/scheduling.ts:329` (its lookup keys on `originId`, so it
is safe). Watchdog: `task-watchdogs.ts:1445`. Audit `services/recovery/*` for
lookups keyed on `parentId` before flattening.
## 7. UI, CLI, docs
- **UI.** No new restrictions. "Add subtask", the parent picker and
`NewIssueDialog` keep allowing any depth for users. Add: "Created from …"
provenance, and a toast/inline note when an agent-facing response carries
`parentAdjusted` (for example in task-chat created-task cards,
`ui/src/components/task-chat/task-created-items.ts`).
- **CLI** (`cli/src/commands/client/issue.ts:289-352`). With an agent key, print
the `parentAdjusted` notice. With user auth, nesting is unrestricted.
- **Skills/docs.** Update `skills/paperclip/SKILL.md`,
`skills/paperclip/references/api-reference.md`,
`skills/paperclip-board/SKILL.md` and
`packages/skills-catalog/catalog/bundled/paperclip-operations/issue-triage/SKILL.md`:
agents nest one level, deeper requests become siblings, use
`blockedByIssueIds` for ordering. Do the same for the external
`paperclip-converting-plans-to-tasks` skill. Add the rule to
`doc/SPEC-implementation.md` (additive).
## 8. Tests
1. Agent create under a child → sibling, with `parentAdjusted` in the response
and the activity log, and `originIssueId` = the requester.
2. Board create under a child → real grandchild, no notice.
3. Agent `createChild` on a child with `blockParentUntilDone` → blocks and later
wakes the requester; the workspace is inherited from the requester; privacy
follows the requester.
4. Run-delegation default from a run on a child → sibling. A protected root
falls back to standalone (implicit) or 403 (explicit).
5. Runner `create_task` from a child binding → sibling, with the notice in the
tool result.
6. Agent PATCH onto a child → flattened; agent PATCH of an issue with children →
parent change skipped with a notice and the rest of the patch applied; board
PATCH → unrestricted.
7. Chat: runner/project-tool creates top-level with `originIssueId` = the
conversation; a child of a task created in the same chat is allowed; a parent
outside the chat → top-level with a notice; the conversation as parent →
top-level.
8. `originRunId` from the request body is ignored, and the harness value wins.
9. Legacy deep trees: reads, subtree pause/cancel and privacy are unchanged; an
agent create under a depth-3 node lands at the root; import preserves depth-3
trees.
About 26 existing test files mention grandchildren. Most seed with direct
inserts or board actors, which stays valid. Fix only the real failures (likely
`issue-thread-interaction-routes.test.ts` and `issues-service.test.ts`).
## Open decisions
1. **Agent moves a task that has children.** Skip the parent change with a
notice (recommended), or also lift its children to siblings (this mutates
other tasks).
2. **User-accepted agent suggestions.** Flatten (recommended; the agent
authored the shape), or treat acceptance as a user action and allow nesting.
3. **Plugins.** Flatten (recommended), or allow nesting when the plugin acts in
a user-initiated UI action.
4. **`origin_run_id` overload for routines.** Keep the routine-run id there and
document that `origin_run_id` means "the run record that created it"
(recommended; no churn), or add `origin_heartbeat_run_id` and keep routine
runs separate.
5. **Chat tool parent scope.** Allow only parents created from the same
conversation (recommended), or any task the agent may write.
+10 -2
View File
@@ -230,6 +230,14 @@ describeEmbeddedPostgres("merged Muse migration histories", () => {
}
}
// Later migrations may add columns; retained rows must keep every earlier value.
function retainedColumns<T extends Record<string, Record<string, unknown> | null>>(after: T, before: T): T {
return Object.fromEntries(Object.entries(after).map(([table, row]) => {
const earlier = before[table];
return [table, row && earlier ? Object.fromEntries(Object.keys(earlier).map(key => [key, row[key]])) : row];
})) as T;
}
async function seedRetainedRows(sql: ReturnType<typeof postgres>, withMuse: boolean) {
const userId = randomUUID(), clientId = `migration-client-${randomUUID()}`;
const [company] = await sql`INSERT INTO companies (name, issue_prefix) VALUES ('Retained migration company', 'MRG') RETURNING id`;
@@ -319,7 +327,7 @@ describeEmbeddedPostgres("merged Muse migration histories", () => {
const currentHash = await migrationHash(entry.file);
expect(afterHistory.filter(row => row.hash === currentHash)).toHaveLength(1);
}
if (retainedRows) expect(await retainedRows()).toEqual(beforeRows);
if (retainedRows) expect(retainedColumns(await retainedRows(), beforeRows!)).toEqual(beforeRows);
const indexes = await sql`
SELECT c.relname, i.indisvalid, i.indisready, pg_get_indexdef(c.oid) AS definition
FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace
@@ -336,7 +344,7 @@ describeEmbeddedPostgres("merged Muse migration histories", () => {
expect((await inspectMigrations(connectionString)).status).toBe("upToDate");
expect(await historyRows(sql)).toEqual(afterHistory);
expect(await schemaShape(sql)).toEqual(finalShape);
if (retainedRows) expect(await retainedRows()).toEqual(beforeRows);
if (retainedRows) expect(retainedColumns(await retainedRows(), beforeRows!)).toEqual(beforeRows);
} finally {
await Promise.all([sql.end(), reference.end()]);
}
@@ -0,0 +1,3 @@
ALTER TABLE "issues" ADD COLUMN "origin_issue_id" uuid;--> statement-breakpoint
ALTER TABLE "issues" ADD CONSTRAINT "issues_origin_issue_id_issues_id_fk" FOREIGN KEY ("origin_issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "issues_company_origin_issue_idx" ON "issues" USING btree ("company_id","origin_issue_id");
@@ -1,6 +1,6 @@
{
"id": "809726a1-762c-4c9d-a7fd-55670f1c856f",
"prevId": "fb4383f6-cbe6-4aff-9fd2-6eeb69a6927a",
"id": "5b40e9ac-12aa-4757-aac7-36218c7500a8",
"prevId": "10b23b32-3586-4a30-a993-618e7d3ca893",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -713,6 +713,12 @@
"notNull": true,
"default": "'{}'::jsonb"
},
"previous_usage_json": {
"name": "previous_usage_json",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"reason": {
"name": "reason",
"type": "text",
@@ -7171,6 +7177,147 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.budget_evaluation_work": {
"name": "budget_evaluation_work",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"agent_id": {
"name": "agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"project_id": {
"name": "project_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"budget_evaluation_work_company_pending_idx": {
"name": "budget_evaluation_work_company_pending_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"budget_evaluation_work_agent_pending_idx": {
"name": "budget_evaluation_work_agent_pending_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "agent_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"budget_evaluation_work_project_pending_idx": {
"name": "budget_evaluation_work_project_pending_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "project_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"budget_evaluation_work_company_id_companies_id_fk": {
"name": "budget_evaluation_work_company_id_companies_id_fk",
"tableFrom": "budget_evaluation_work",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.budget_incidents": {
"name": "budget_incidents",
"schema": "",
@@ -7518,6 +7665,13 @@
"notNull": true,
"default": 0
},
"evaluation_version": {
"name": "evaluation_version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_by_user_id": {
"name": "created_by_user_id",
"type": "text",
@@ -21885,6 +22039,12 @@
"primaryKey": false,
"notNull": false
},
"reported_usage_json": {
"name": "reported_usage_json",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"pricing_provenance": {
"name": "pricing_provenance",
"type": "jsonb",
@@ -34026,6 +34186,50 @@
"concurrently": false,
"method": "btree",
"with": {}
},
"heartbeat_run_events_usage_item_run_idx": {
"name": "heartbeat_run_events_usage_item_run_idx",
"columns": [
{
"expression": "run_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"heartbeat_run_events\".\"event_type\" = 'item.completed' and (\"heartbeat_run_events\".\"payload\" -> 'prpEvent' -> 'payload' ->> 'kind') = 'usage'",
"concurrently": false,
"method": "btree",
"with": {}
},
"heartbeat_run_events_compaction_session_idx": {
"name": "heartbeat_run_events_compaction_session_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "(\"payload\" -> 'prpEvent' ->> 'normalizedSessionId')",
"asc": true,
"isExpression": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"heartbeat_run_events\".\"event_type\" = 'context.compacted'",
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
@@ -42288,6 +42492,12 @@
"primaryKey": false,
"notNull": false
},
"origin_issue_id": {
"name": "origin_issue_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"origin_identity_context_id": {
"name": "origin_identity_context_id",
"type": "uuid",
@@ -42731,6 +42941,27 @@
"method": "btree",
"with": {}
},
"issues_company_origin_issue_idx": {
"name": "issues_company_origin_issue_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "origin_issue_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"issues_company_project_workspace_idx": {
"name": "issues_company_project_workspace_idx",
"columns": [
@@ -43330,6 +43561,19 @@
"onDelete": "no action",
"onUpdate": "no action"
},
"issues_origin_issue_id_issues_id_fk": {
"name": "issues_origin_issue_id_issues_id_fk",
"tableFrom": "issues",
"tableTo": "issues",
"columnsFrom": [
"origin_issue_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"issues_execution_workspace_id_execution_workspaces_id_fk": {
"name": "issues_execution_workspace_id_execution_workspaces_id_fk",
"tableFrom": "issues",
@@ -61680,6 +61924,13 @@
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"classic_workspace_enabled": {
"name": "classic_workspace_enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
+8 -1
View File
@@ -3025,6 +3025,13 @@
"when": 1793920110905,
"tag": "0433_workspace_defaults",
"breakpoints": true
},
{
"idx": 434,
"version": "7",
"when": 1793924947593,
"tag": "0434_brave_leo",
"breakpoints": true
}
]
}
}
+4
View File
@@ -65,6 +65,9 @@ export const issues = pgTable(
originKind: text("origin_kind").notNull().default("manual"),
originId: text("origin_id"),
originRunId: text("origin_run_id"),
// Audit-only provenance: the task whose work created this one. Set by the
// harness, never by callers, and never read for hierarchy or authorization.
originIssueId: uuid("origin_issue_id").references((): AnyPgColumn => issues.id, { onDelete: "set null" }),
originIdentityContextId: uuid("origin_identity_context_id"),
continuationIdentityContextId: uuid("continuation_identity_context_id"),
originFingerprint: text("origin_fingerprint").notNull().default("default"),
@@ -128,6 +131,7 @@ export const issues = pgTable(
privacyRootIdx: index("issues_company_privacy_root_idx").on(table.companyId, table.privacyRootIssueId),
projectIdx: index("issues_company_project_idx").on(table.companyId, table.projectId),
originIdx: index("issues_company_origin_idx").on(table.companyId, table.originKind, table.originId),
originIssueIdx: index("issues_company_origin_issue_idx").on(table.companyId, table.originIssueId),
projectWorkspaceIdx: index("issues_company_project_workspace_idx").on(table.companyId, table.projectWorkspaceId),
executionWorkspaceIdx: index("issues_company_execution_workspace_idx").on(table.companyId, table.executionWorkspaceId),
dueMonitorIdx: index("issues_company_monitor_due_idx").on(table.companyId, table.monitorNextCheckAt),
@@ -45,36 +45,36 @@ The skill/reference inventory and eval cases are the only normative behavior sou
| skill:skills/paperclip/SKILL.md:terminology:14 | optional_agent_tool | skills/paperclip/SKILL.md:14 |
| skill:skills/paperclip/SKILL.md:authentication:20 | control_plane_owned | skills/paperclip/SKILL.md:20 |
| skill:skills/paperclip/SKILL.md:conversation-tasks:32 | optional_agent_tool | skills/paperclip/SKILL.md:32 |
| skill:skills/paperclip/SKILL.md:server-verified-external-chat-turns:52 | control_plane_owned | skills/paperclip/SKILL.md:52 |
| skill:skills/paperclip/SKILL.md:the-heartbeat-procedure:92 | optional_agent_tool | skills/paperclip/SKILL.md:92 |
| skill:skills/paperclip/SKILL.md:generated-artifacts-and-work-products:204 | always_agent_tool | skills/paperclip/SKILL.md:204 |
| skill:skills/paperclip/SKILL.md:status-quick-guide:252 | control_plane_owned | skills/paperclip/SKILL.md:252 |
| skill:skills/paperclip/SKILL.md:monitors-and-watchers-say-only-what-you-actually-scheduled:262 | optional_agent_tool | skills/paperclip/SKILL.md:262 |
| skill:skills/paperclip/SKILL.md:delegating-review-tasks:275 | always_agent_tool | skills/paperclip/SKILL.md:275 |
| skill:skills/paperclip/SKILL.md:managing-a-user-s-inbox:286 | control_plane_owned | skills/paperclip/SKILL.md:286 |
| skill:skills/paperclip/SKILL.md:issue-dependencies-blockers:294 | control_plane_owned | skills/paperclip/SKILL.md:294 |
| skill:skills/paperclip/SKILL.md:requesting-board-approval:319 | optional_agent_tool | skills/paperclip/SKILL.md:319 |
| skill:skills/paperclip/SKILL.md:issue-thread-interactions:340 | optional_agent_tool | skills/paperclip/SKILL.md:340 |
| skill:skills/paperclip/SKILL.md:standalone-decisions:370 | optional_agent_tool | skills/paperclip/SKILL.md:370 |
| skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:474 | optional_agent_tool | skills/paperclip/SKILL.md:474 |
| skill:skills/paperclip/SKILL.md:niche-workflow-pointers:516 | optional_agent_tool | skills/paperclip/SKILL.md:516 |
| skill:skills/paperclip/SKILL.md:cases:526 | optional_agent_tool | skills/paperclip/SKILL.md:526 |
| skill:skills/paperclip/SKILL.md:company-skills-workflow:531 | optional_agent_tool | skills/paperclip/SKILL.md:531 |
| skill:skills/paperclip/SKILL.md:routines:542 | optional_agent_tool | skills/paperclip/SKILL.md:542 |
| skill:skills/paperclip/SKILL.md:task-files-and-workspace-choices:553 | optional_agent_tool | skills/paperclip/SKILL.md:553 |
| skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:561 | optional_agent_tool | skills/paperclip/SKILL.md:561 |
| skill:skills/paperclip/SKILL.md:proposing-credentials-safely:568 | optional_agent_tool | skills/paperclip/SKILL.md:568 |
| skill:skills/paperclip/SKILL.md:reading-granted-secrets:575 | optional_agent_tool | skills/paperclip/SKILL.md:575 |
| skill:skills/paperclip/SKILL.md:critical-rules:601 | optional_agent_tool | skills/paperclip/SKILL.md:601 |
| skill:skills/paperclip/SKILL.md:comment-style-required:622 | always_agent_tool | skills/paperclip/SKILL.md:622 |
| skill:skills/paperclip/SKILL.md:update:654 | optional_agent_tool | skills/paperclip/SKILL.md:654 |
| skill:skills/paperclip/SKILL.md:task-documents-and-deliverables:664 | always_agent_tool | skills/paperclip/SKILL.md:664 |
| skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:678 | optional_agent_tool | skills/paperclip/SKILL.md:678 |
| skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:709 | optional_agent_tool | skills/paperclip/SKILL.md:709 |
| skill:skills/paperclip/SKILL.md:searching-issues:739 | optional_agent_tool | skills/paperclip/SKILL.md:739 |
| skill:skills/paperclip/SKILL.md:full-reference:749 | optional_agent_tool | skills/paperclip/SKILL.md:749 |
| skill:skills/paperclip/SKILL.md:conversational-confirmation-answers:753 | always_agent_tool | skills/paperclip/SKILL.md:753 |
| skill:skills/paperclip/SKILL.md:incidental-feedback:770 | optional_agent_tool | skills/paperclip/SKILL.md:770 |
| skill:skills/paperclip/SKILL.md:server-verified-external-chat-turns:54 | control_plane_owned | skills/paperclip/SKILL.md:54 |
| skill:skills/paperclip/SKILL.md:the-heartbeat-procedure:94 | optional_agent_tool | skills/paperclip/SKILL.md:94 |
| skill:skills/paperclip/SKILL.md:generated-artifacts-and-work-products:206 | always_agent_tool | skills/paperclip/SKILL.md:206 |
| skill:skills/paperclip/SKILL.md:status-quick-guide:254 | control_plane_owned | skills/paperclip/SKILL.md:254 |
| skill:skills/paperclip/SKILL.md:monitors-and-watchers-say-only-what-you-actually-scheduled:264 | optional_agent_tool | skills/paperclip/SKILL.md:264 |
| skill:skills/paperclip/SKILL.md:delegating-review-tasks:285 | always_agent_tool | skills/paperclip/SKILL.md:285 |
| skill:skills/paperclip/SKILL.md:managing-a-user-s-inbox:296 | control_plane_owned | skills/paperclip/SKILL.md:296 |
| skill:skills/paperclip/SKILL.md:issue-dependencies-blockers:304 | control_plane_owned | skills/paperclip/SKILL.md:304 |
| skill:skills/paperclip/SKILL.md:requesting-board-approval:329 | optional_agent_tool | skills/paperclip/SKILL.md:329 |
| skill:skills/paperclip/SKILL.md:issue-thread-interactions:350 | optional_agent_tool | skills/paperclip/SKILL.md:350 |
| skill:skills/paperclip/SKILL.md:standalone-decisions:380 | optional_agent_tool | skills/paperclip/SKILL.md:380 |
| skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:484 | optional_agent_tool | skills/paperclip/SKILL.md:484 |
| skill:skills/paperclip/SKILL.md:niche-workflow-pointers:526 | optional_agent_tool | skills/paperclip/SKILL.md:526 |
| skill:skills/paperclip/SKILL.md:cases:536 | optional_agent_tool | skills/paperclip/SKILL.md:536 |
| skill:skills/paperclip/SKILL.md:company-skills-workflow:541 | optional_agent_tool | skills/paperclip/SKILL.md:541 |
| skill:skills/paperclip/SKILL.md:routines:552 | optional_agent_tool | skills/paperclip/SKILL.md:552 |
| skill:skills/paperclip/SKILL.md:task-files-and-workspace-choices:563 | optional_agent_tool | skills/paperclip/SKILL.md:563 |
| skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:571 | optional_agent_tool | skills/paperclip/SKILL.md:571 |
| skill:skills/paperclip/SKILL.md:proposing-credentials-safely:578 | optional_agent_tool | skills/paperclip/SKILL.md:578 |
| skill:skills/paperclip/SKILL.md:reading-granted-secrets:585 | optional_agent_tool | skills/paperclip/SKILL.md:585 |
| skill:skills/paperclip/SKILL.md:critical-rules:611 | optional_agent_tool | skills/paperclip/SKILL.md:611 |
| skill:skills/paperclip/SKILL.md:comment-style-required:632 | always_agent_tool | skills/paperclip/SKILL.md:632 |
| skill:skills/paperclip/SKILL.md:update:664 | optional_agent_tool | skills/paperclip/SKILL.md:664 |
| skill:skills/paperclip/SKILL.md:task-documents-and-deliverables:674 | always_agent_tool | skills/paperclip/SKILL.md:674 |
| skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:688 | optional_agent_tool | skills/paperclip/SKILL.md:688 |
| skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:719 | optional_agent_tool | skills/paperclip/SKILL.md:719 |
| skill:skills/paperclip/SKILL.md:searching-issues:749 | optional_agent_tool | skills/paperclip/SKILL.md:749 |
| skill:skills/paperclip/SKILL.md:full-reference:759 | optional_agent_tool | skills/paperclip/SKILL.md:759 |
| skill:skills/paperclip/SKILL.md:conversational-confirmation-answers:763 | always_agent_tool | skills/paperclip/SKILL.md:763 |
| skill:skills/paperclip/SKILL.md:incidental-feedback:780 | optional_agent_tool | skills/paperclip/SKILL.md:780 |
| skill:skills/paperclip/references/api-reference.md:paperclip-api-reference:1 | optional_agent_tool | skills/paperclip/references/api-reference.md:1 |
| skill:skills/paperclip/references/api-reference.md:response-schemas:9 | optional_agent_tool | skills/paperclip/references/api-reference.md:9 |
| skill:skills/paperclip/references/api-reference.md:agent-record-get-api-agents-me-or-get-api-agents-agentid:11 | optional_agent_tool | skills/paperclip/references/api-reference.md:11 |
@@ -134,17 +134,17 @@ The skill/reference inventory and eval cases are the only normative behavior sou
| skill:skills/paperclip/references/api-reference.md:checking-approval-status:1320 | optional_agent_tool | skills/paperclip/references/api-reference.md:1320 |
| skill:skills/paperclip/references/api-reference.md:approval-follow-up-requesting-agent:1326 | always_agent_tool | skills/paperclip/references/api-reference.md:1326 |
| skill:skills/paperclip/references/api-reference.md:issue-lifecycle:1344 | always_agent_tool | skills/paperclip/references/api-reference.md:1344 |
| skill:skills/paperclip/references/api-reference.md:error-handling:1374 | control_plane_owned | skills/paperclip/references/api-reference.md:1374 |
| skill:skills/paperclip/references/api-reference.md:full-api-reference:1388 | optional_agent_tool | skills/paperclip/references/api-reference.md:1388 |
| skill:skills/paperclip/references/api-reference.md:agents:1390 | optional_agent_tool | skills/paperclip/references/api-reference.md:1390 |
| skill:skills/paperclip/references/api-reference.md:issues-tasks:1411 | optional_agent_tool | skills/paperclip/references/api-reference.md:1411 |
| skill:skills/paperclip/references/api-reference.md:companies-projects-goals:1452 | optional_agent_tool | skills/paperclip/references/api-reference.md:1452 |
| skill:skills/paperclip/references/api-reference.md:routines:1476 | optional_agent_tool | skills/paperclip/references/api-reference.md:1476 |
| skill:skills/paperclip/references/api-reference.md:approvals-costs-activity-dashboard:1492 | optional_agent_tool | skills/paperclip/references/api-reference.md:1492 |
| skill:skills/paperclip/references/api-reference.md:secrets:1514 | optional_agent_tool | skills/paperclip/references/api-reference.md:1514 |
| skill:skills/paperclip/references/api-reference.md:agent-secret-proposals:1527 | optional_agent_tool | skills/paperclip/references/api-reference.md:1527 |
| skill:skills/paperclip/references/api-reference.md:agent-secret-access:1627 | optional_agent_tool | skills/paperclip/references/api-reference.md:1627 |
| skill:skills/paperclip/references/api-reference.md:common-mistakes:1667 | optional_agent_tool | skills/paperclip/references/api-reference.md:1667 |
| skill:skills/paperclip/references/api-reference.md:error-handling:1376 | control_plane_owned | skills/paperclip/references/api-reference.md:1376 |
| skill:skills/paperclip/references/api-reference.md:full-api-reference:1390 | optional_agent_tool | skills/paperclip/references/api-reference.md:1390 |
| skill:skills/paperclip/references/api-reference.md:agents:1392 | optional_agent_tool | skills/paperclip/references/api-reference.md:1392 |
| skill:skills/paperclip/references/api-reference.md:issues-tasks:1413 | optional_agent_tool | skills/paperclip/references/api-reference.md:1413 |
| skill:skills/paperclip/references/api-reference.md:companies-projects-goals:1454 | optional_agent_tool | skills/paperclip/references/api-reference.md:1454 |
| skill:skills/paperclip/references/api-reference.md:routines:1478 | optional_agent_tool | skills/paperclip/references/api-reference.md:1478 |
| skill:skills/paperclip/references/api-reference.md:approvals-costs-activity-dashboard:1494 | optional_agent_tool | skills/paperclip/references/api-reference.md:1494 |
| skill:skills/paperclip/references/api-reference.md:secrets:1516 | optional_agent_tool | skills/paperclip/references/api-reference.md:1516 |
| skill:skills/paperclip/references/api-reference.md:agent-secret-proposals:1529 | optional_agent_tool | skills/paperclip/references/api-reference.md:1529 |
| skill:skills/paperclip/references/api-reference.md:agent-secret-access:1629 | optional_agent_tool | skills/paperclip/references/api-reference.md:1629 |
| skill:skills/paperclip/references/api-reference.md:common-mistakes:1669 | optional_agent_tool | skills/paperclip/references/api-reference.md:1669 |
| 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 |
@@ -38,270 +38,270 @@
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:52",
"id": "skill:skills/paperclip/SKILL.md:54",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L52:server-verified-external-chat-turns",
"sourceAnchor": "skills/paperclip/SKILL.md#L54: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:92",
"id": "skill:skills/paperclip/SKILL.md:94",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L92:the-heartbeat-procedure",
"sourceAnchor": "skills/paperclip/SKILL.md#L94:the-heartbeat-procedure",
"heading": "The Heartbeat Procedure",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:204",
"id": "skill:skills/paperclip/SKILL.md:206",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L204:generated-artifacts-and-work-products",
"sourceAnchor": "skills/paperclip/SKILL.md#L206: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:252",
"id": "skill:skills/paperclip/SKILL.md:254",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L252:status-quick-guide",
"sourceAnchor": "skills/paperclip/SKILL.md#L254:status-quick-guide",
"heading": "Status Quick Guide",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:262",
"id": "skill:skills/paperclip/SKILL.md:264",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L262:monitors-and-watchers-say-only-what-you-actually-scheduled",
"sourceAnchor": "skills/paperclip/SKILL.md#L264: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:275",
"id": "skill:skills/paperclip/SKILL.md:285",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L275:delegating-review-tasks",
"sourceAnchor": "skills/paperclip/SKILL.md#L285:delegating-review-tasks",
"heading": "Delegating review tasks",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:286",
"id": "skill:skills/paperclip/SKILL.md:296",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L286:managing-a-user-s-inbox",
"sourceAnchor": "skills/paperclip/SKILL.md#L296: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:294",
"id": "skill:skills/paperclip/SKILL.md:304",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L294:issue-dependencies-blockers",
"sourceAnchor": "skills/paperclip/SKILL.md#L304:issue-dependencies-blockers",
"heading": "Issue Dependencies (Blockers)",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:319",
"id": "skill:skills/paperclip/SKILL.md:329",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L319:requesting-board-approval",
"sourceAnchor": "skills/paperclip/SKILL.md#L329:requesting-board-approval",
"heading": "Requesting Board Approval",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:340",
"id": "skill:skills/paperclip/SKILL.md:350",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L340:issue-thread-interactions",
"sourceAnchor": "skills/paperclip/SKILL.md#L350:issue-thread-interactions",
"heading": "Issue-Thread Interactions",
"primaryDisposition": "always_agent_tool",
"semanticOperation": "request_human_input",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:370",
"id": "skill:skills/paperclip/SKILL.md:380",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L370:standalone-decisions",
"sourceAnchor": "skills/paperclip/SKILL.md#L380:standalone-decisions",
"heading": "Standalone Decisions",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:474",
"id": "skill:skills/paperclip/SKILL.md:484",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L474:mcp-tool-approval-gates",
"sourceAnchor": "skills/paperclip/SKILL.md#L484: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:516",
"id": "skill:skills/paperclip/SKILL.md:526",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L516:niche-workflow-pointers",
"sourceAnchor": "skills/paperclip/SKILL.md#L526:niche-workflow-pointers",
"heading": "Niche Workflow Pointers",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:526",
"id": "skill:skills/paperclip/SKILL.md:536",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L526:cases",
"sourceAnchor": "skills/paperclip/SKILL.md#L536:cases",
"heading": "Cases",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:531",
"id": "skill:skills/paperclip/SKILL.md:541",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L531:company-skills-workflow",
"sourceAnchor": "skills/paperclip/SKILL.md#L541:company-skills-workflow",
"heading": "Company Skills Workflow",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:542",
"id": "skill:skills/paperclip/SKILL.md:552",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L542:routines",
"sourceAnchor": "skills/paperclip/SKILL.md#L552:routines",
"heading": "Routines",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:553",
"id": "skill:skills/paperclip/SKILL.md:563",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L553:task-files-and-workspace-choices",
"sourceAnchor": "skills/paperclip/SKILL.md#L563:task-files-and-workspace-choices",
"heading": "Task Files and Workspace Choices",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:561",
"id": "skill:skills/paperclip/SKILL.md:571",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L561:issue-workspace-runtime-controls",
"sourceAnchor": "skills/paperclip/SKILL.md#L571: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:568",
"id": "skill:skills/paperclip/SKILL.md:578",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L568:proposing-credentials-safely",
"sourceAnchor": "skills/paperclip/SKILL.md#L578:proposing-credentials-safely",
"heading": "Proposing Credentials Safely",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:575",
"id": "skill:skills/paperclip/SKILL.md:585",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L575:reading-granted-secrets",
"sourceAnchor": "skills/paperclip/SKILL.md#L585:reading-granted-secrets",
"heading": "Reading Granted Secrets",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:601",
"id": "skill:skills/paperclip/SKILL.md:611",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L601:critical-rules",
"sourceAnchor": "skills/paperclip/SKILL.md#L611:critical-rules",
"heading": "Critical Rules",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:622",
"id": "skill:skills/paperclip/SKILL.md:632",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L622:comment-style-required",
"sourceAnchor": "skills/paperclip/SKILL.md#L632:comment-style-required",
"heading": "Comment Style (Required)",
"primaryDisposition": "always_agent_tool",
"semanticOperation": "report_progress",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:654",
"id": "skill:skills/paperclip/SKILL.md:664",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L654:update",
"sourceAnchor": "skills/paperclip/SKILL.md#L664:update",
"heading": "Update",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:664",
"id": "skill:skills/paperclip/SKILL.md:674",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L664:task-documents-and-deliverables",
"sourceAnchor": "skills/paperclip/SKILL.md#L674:task-documents-and-deliverables",
"heading": "Task Documents and Deliverables",
"primaryDisposition": "always_agent_tool",
"semanticOperation": "write_document",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:678",
"id": "skill:skills/paperclip/SKILL.md:688",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L678:planning-required-when-planning-requested",
"sourceAnchor": "skills/paperclip/SKILL.md#L688: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:709",
"id": "skill:skills/paperclip/SKILL.md:719",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L709:key-endpoints-hot-routes",
"sourceAnchor": "skills/paperclip/SKILL.md#L719: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:739",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L739:searching-issues",
"heading": "Searching Issues",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:749",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L749:full-reference",
"sourceAnchor": "skills/paperclip/SKILL.md#L749:searching-issues",
"heading": "Searching Issues",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:759",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L759:full-reference",
"heading": "Full Reference",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/SKILL.md:753",
"id": "skill:skills/paperclip/SKILL.md:763",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L753:conversational-confirmation-answers",
"sourceAnchor": "skills/paperclip/SKILL.md#L763:conversational-confirmation-answers",
"heading": "Conversational confirmation answers",
"primaryDisposition": "always_agent_tool",
"semanticOperation": "call_api",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/SKILL.md:770",
"id": "skill:skills/paperclip/SKILL.md:780",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md#L770:incidental-feedback",
"sourceAnchor": "skills/paperclip/SKILL.md#L780:incidental-feedback",
"heading": "Incidental feedback",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
@@ -839,108 +839,108 @@
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1374",
"id": "skill:skills/paperclip/references/api-reference.md:1376",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1374:error-handling",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1376:error-handling",
"heading": "Error Handling",
"primaryDisposition": "control_plane_owned",
"semanticOperation": "runtime_reconciliation",
"expectedMockState": "runtime_decision_record"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1388",
"id": "skill:skills/paperclip/references/api-reference.md:1390",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1388:full-api-reference",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1390: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:1390",
"id": "skill:skills/paperclip/references/api-reference.md:1392",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1390:agents",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1392:agents",
"heading": "Agents",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1411",
"id": "skill:skills/paperclip/references/api-reference.md:1413",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1411:issues-tasks",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1413:issues-tasks",
"heading": "Issues (Tasks)",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1452",
"id": "skill:skills/paperclip/references/api-reference.md:1454",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1452:companies-projects-goals",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1454: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:1476",
"id": "skill:skills/paperclip/references/api-reference.md:1478",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1476:routines",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1478:routines",
"heading": "Routines",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1492",
"id": "skill:skills/paperclip/references/api-reference.md:1494",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1492:approvals-costs-activity-dashboard",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1494: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:1514",
"id": "skill:skills/paperclip/references/api-reference.md:1516",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1514:secrets",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1516:secrets",
"heading": "Secrets",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
"expectedMockState": "operation_result"
},
{
"id": "skill:skills/paperclip/references/api-reference.md:1527",
"id": "skill:skills/paperclip/references/api-reference.md:1529",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1527:agent-secret-proposals",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1529: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:1580",
"id": "skill:skills/paperclip/references/api-reference.md:1582",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1580:re-bind-an-existing-secret-under-a-new-path-no-secret-id",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1582: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:1627",
"id": "skill:skills/paperclip/references/api-reference.md:1629",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1627:agent-secret-access",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1629: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:1667",
"id": "skill:skills/paperclip/references/api-reference.md:1669",
"kind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1667:common-mistakes",
"sourceAnchor": "skills/paperclip/references/api-reference.md#L1669:common-mistakes",
"heading": "Common Mistakes",
"primaryDisposition": "optional_agent_tool",
"semanticOperation": "scoped_discovery",
@@ -5,6 +5,6 @@ Generated by `scripts/generate-capability-contract.mjs`; do not edit generated f
- Skill/reference headings: 167
- Legacy MCP tools: 42
- Eval cases: 106 across 16 groups
- Deterministic content SHA-256: `f0dad729b916cf00195cdae5ec141482a82a04f230692c26880cad65ee78b96a`
- Deterministic content SHA-256: `bd38d281bc178b72c2d8646313c0254b5022b799029a256f77bb3f4b8c878519`
Every row has exactly one primary disposition, a source anchor, a semantic operation, and a mock-state expectation.
@@ -1 +1 @@
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.","maxLength":120,"minLength":1,"pattern":"^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$","type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.","maxLength":120,"minLength":1,"pattern":"^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$","type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answeLine truncated
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.","maxLength":120,"minLength":1,"pattern":"^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$","type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.","maxLength":120,"minLength":1,"pattern":"^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$","type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answeLine truncated
@@ -24,7 +24,7 @@
"prpVersion": 1,
"nativeExecutionVersion": 1,
"catalogVersion": 1,
"catalogSha256": "sha256:a6c5d6b9cc2895ada81c7756a9aec47002d39740e0296d7a3291fba8242ae9b4",
"catalogSha256": "sha256:66571b4b1cacdaa628a07b09ebdc9bd89c4a6156d5f86064aa5e5b16303447e6",
"driverContractVersion": 1,
"driverKind": "paperclip-deterministic",
"driverVersion": "1.0.0"
@@ -185,7 +185,7 @@
},
{
"path": "fixtures/evals/native-execution-seeded.json",
"sha256": "ae2ff79cbf731bc3d347ec152b5c06f95aa7be5ff33bc6427f5bda7c30ce4f19",
"sha256": "304a03a28617531c395850b5f04c0108188f31a3d8617cd9d58cbb523e1b3b65",
"expectation": "accept",
"compatibilityCase": "canonical"
},
@@ -75,9 +75,9 @@
]
},
{
"id": "skill:skills/paperclip/SKILL.md:server-verified-external-chat-turns:52",
"id": "skill:skills/paperclip/SKILL.md:server-verified-external-chat-turns:54",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:52",
"sourceAnchor": "skills/paperclip/SKILL.md:54",
"title": "Server-Verified External Chat Turns",
"expectedSemantics": "Skill guidance headed “Server-Verified External Chat Turns”.",
"primaryDisposition": "control_plane_owned",
@@ -86,13 +86,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:52"
"skill:skills/paperclip/SKILL.md:54"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:the-heartbeat-procedure:92",
"id": "skill:skills/paperclip/SKILL.md:the-heartbeat-procedure:94",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:92",
"sourceAnchor": "skills/paperclip/SKILL.md:94",
"title": "The Heartbeat Procedure",
"expectedSemantics": "Skill guidance headed “The Heartbeat Procedure”.",
"primaryDisposition": "optional_agent_tool",
@@ -101,13 +101,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:92"
"skill:skills/paperclip/SKILL.md:94"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:generated-artifacts-and-work-products:204",
"id": "skill:skills/paperclip/SKILL.md:generated-artifacts-and-work-products:206",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:204",
"sourceAnchor": "skills/paperclip/SKILL.md:206",
"title": "Generated Artifacts and Work Products",
"expectedSemantics": "Skill guidance headed “Generated Artifacts and Work Products”.",
"primaryDisposition": "always_agent_tool",
@@ -116,13 +116,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:204"
"skill:skills/paperclip/SKILL.md:206"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:status-quick-guide:252",
"id": "skill:skills/paperclip/SKILL.md:status-quick-guide:254",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:252",
"sourceAnchor": "skills/paperclip/SKILL.md:254",
"title": "Status Quick Guide",
"expectedSemantics": "Skill guidance headed “Status Quick Guide”.",
"primaryDisposition": "control_plane_owned",
@@ -131,13 +131,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:252"
"skill:skills/paperclip/SKILL.md:254"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:monitors-and-watchers-say-only-what-you-actually-scheduled:262",
"id": "skill:skills/paperclip/SKILL.md:monitors-and-watchers-say-only-what-you-actually-scheduled:264",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:262",
"sourceAnchor": "skills/paperclip/SKILL.md:264",
"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",
@@ -146,13 +146,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:262"
"skill:skills/paperclip/SKILL.md:264"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:delegating-review-tasks:275",
"id": "skill:skills/paperclip/SKILL.md:delegating-review-tasks:285",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:275",
"sourceAnchor": "skills/paperclip/SKILL.md:285",
"title": "Delegating review tasks",
"expectedSemantics": "Skill guidance headed “Delegating review tasks”.",
"primaryDisposition": "always_agent_tool",
@@ -161,13 +161,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:275"
"skill:skills/paperclip/SKILL.md:285"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:managing-a-user-s-inbox:286",
"id": "skill:skills/paperclip/SKILL.md:managing-a-user-s-inbox:296",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:286",
"sourceAnchor": "skills/paperclip/SKILL.md:296",
"title": "Managing A User's Inbox",
"expectedSemantics": "Skill guidance headed “Managing A User's Inbox”.",
"primaryDisposition": "control_plane_owned",
@@ -176,13 +176,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:286"
"skill:skills/paperclip/SKILL.md:296"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:issue-dependencies-blockers:294",
"id": "skill:skills/paperclip/SKILL.md:issue-dependencies-blockers:304",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:294",
"sourceAnchor": "skills/paperclip/SKILL.md:304",
"title": "Issue Dependencies (Blockers)",
"expectedSemantics": "Skill guidance headed “Issue Dependencies (Blockers)”.",
"primaryDisposition": "control_plane_owned",
@@ -191,13 +191,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:294"
"skill:skills/paperclip/SKILL.md:304"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:requesting-board-approval:319",
"id": "skill:skills/paperclip/SKILL.md:requesting-board-approval:329",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:319",
"sourceAnchor": "skills/paperclip/SKILL.md:329",
"title": "Requesting Board Approval",
"expectedSemantics": "Skill guidance headed “Requesting Board Approval”.",
"primaryDisposition": "optional_agent_tool",
@@ -206,13 +206,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:319"
"skill:skills/paperclip/SKILL.md:329"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:issue-thread-interactions:340",
"id": "skill:skills/paperclip/SKILL.md:issue-thread-interactions:350",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:340",
"sourceAnchor": "skills/paperclip/SKILL.md:350",
"title": "Issue-Thread Interactions",
"expectedSemantics": "Skill guidance headed “Issue-Thread Interactions”.",
"primaryDisposition": "optional_agent_tool",
@@ -221,13 +221,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:340"
"skill:skills/paperclip/SKILL.md:350"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:standalone-decisions:370",
"id": "skill:skills/paperclip/SKILL.md:standalone-decisions:380",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:370",
"sourceAnchor": "skills/paperclip/SKILL.md:380",
"title": "Standalone Decisions",
"expectedSemantics": "Skill guidance headed “Standalone Decisions”.",
"primaryDisposition": "optional_agent_tool",
@@ -236,13 +236,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:370"
"skill:skills/paperclip/SKILL.md:380"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:474",
"id": "skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:484",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:474",
"sourceAnchor": "skills/paperclip/SKILL.md:484",
"title": "MCP Tool Approval Gates",
"expectedSemantics": "Skill guidance headed “MCP Tool Approval Gates”.",
"primaryDisposition": "optional_agent_tool",
@@ -251,30 +251,15 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:474"
"skill:skills/paperclip/SKILL.md:484"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:niche-workflow-pointers:516",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:516",
"title": "Niche Workflow Pointers",
"expectedSemantics": "Skill guidance headed “Niche Workflow Pointers”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:516"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:cases:526",
"id": "skill:skills/paperclip/SKILL.md:niche-workflow-pointers:526",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:526",
"title": "Cases",
"expectedSemantics": "Skill guidance headed “Cases”.",
"title": "Niche Workflow Pointers",
"expectedSemantics": "Skill guidance headed “Niche Workflow Pointers”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
@@ -285,9 +270,24 @@
]
},
{
"id": "skill:skills/paperclip/SKILL.md:company-skills-workflow:531",
"id": "skill:skills/paperclip/SKILL.md:cases:536",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:531",
"sourceAnchor": "skills/paperclip/SKILL.md:536",
"title": "Cases",
"expectedSemantics": "Skill guidance headed “Cases”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:536"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:company-skills-workflow:541",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:541",
"title": "Company Skills Workflow",
"expectedSemantics": "Skill guidance headed “Company Skills Workflow”.",
"primaryDisposition": "optional_agent_tool",
@@ -296,13 +296,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:531"
"skill:skills/paperclip/SKILL.md:541"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:routines:542",
"id": "skill:skills/paperclip/SKILL.md:routines:552",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:542",
"sourceAnchor": "skills/paperclip/SKILL.md:552",
"title": "Routines",
"expectedSemantics": "Skill guidance headed “Routines”.",
"primaryDisposition": "optional_agent_tool",
@@ -311,13 +311,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:542"
"skill:skills/paperclip/SKILL.md:552"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:task-files-and-workspace-choices:553",
"id": "skill:skills/paperclip/SKILL.md:task-files-and-workspace-choices:563",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:553",
"sourceAnchor": "skills/paperclip/SKILL.md:563",
"title": "Task Files and Workspace Choices",
"expectedSemantics": "Skill guidance headed “Task Files and Workspace Choices”.",
"primaryDisposition": "optional_agent_tool",
@@ -326,13 +326,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:553"
"skill:skills/paperclip/SKILL.md:563"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:561",
"id": "skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:571",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:561",
"sourceAnchor": "skills/paperclip/SKILL.md:571",
"title": "Issue Workspace Runtime Controls",
"expectedSemantics": "Skill guidance headed “Issue Workspace Runtime Controls”.",
"primaryDisposition": "optional_agent_tool",
@@ -341,13 +341,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:561"
"skill:skills/paperclip/SKILL.md:571"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:proposing-credentials-safely:568",
"id": "skill:skills/paperclip/SKILL.md:proposing-credentials-safely:578",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:568",
"sourceAnchor": "skills/paperclip/SKILL.md:578",
"title": "Proposing Credentials Safely",
"expectedSemantics": "Skill guidance headed “Proposing Credentials Safely”.",
"primaryDisposition": "optional_agent_tool",
@@ -356,13 +356,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:568"
"skill:skills/paperclip/SKILL.md:578"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:reading-granted-secrets:575",
"id": "skill:skills/paperclip/SKILL.md:reading-granted-secrets:585",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:575",
"sourceAnchor": "skills/paperclip/SKILL.md:585",
"title": "Reading Granted Secrets",
"expectedSemantics": "Skill guidance headed “Reading Granted Secrets”.",
"primaryDisposition": "optional_agent_tool",
@@ -371,13 +371,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:575"
"skill:skills/paperclip/SKILL.md:585"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:critical-rules:601",
"id": "skill:skills/paperclip/SKILL.md:critical-rules:611",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:601",
"sourceAnchor": "skills/paperclip/SKILL.md:611",
"title": "Critical Rules",
"expectedSemantics": "Skill guidance headed “Critical Rules”.",
"primaryDisposition": "optional_agent_tool",
@@ -386,13 +386,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:601"
"skill:skills/paperclip/SKILL.md:611"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:comment-style-required:622",
"id": "skill:skills/paperclip/SKILL.md:comment-style-required:632",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:622",
"sourceAnchor": "skills/paperclip/SKILL.md:632",
"title": "Comment Style (Required)",
"expectedSemantics": "Skill guidance headed “Comment Style (Required)”.",
"primaryDisposition": "always_agent_tool",
@@ -401,13 +401,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:622"
"skill:skills/paperclip/SKILL.md:632"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:update:654",
"id": "skill:skills/paperclip/SKILL.md:update:664",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:654",
"sourceAnchor": "skills/paperclip/SKILL.md:664",
"title": "Update",
"expectedSemantics": "Skill guidance headed “Update”.",
"primaryDisposition": "optional_agent_tool",
@@ -415,29 +415,29 @@
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:654"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:task-documents-and-deliverables:664",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:664",
"title": "Task Documents and Deliverables",
"expectedSemantics": "Skill guidance headed “Task Documents and Deliverables”.",
"primaryDisposition": "always_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:664"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:678",
"id": "skill:skills/paperclip/SKILL.md:task-documents-and-deliverables:674",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:678",
"sourceAnchor": "skills/paperclip/SKILL.md:674",
"title": "Task Documents and Deliverables",
"expectedSemantics": "Skill guidance headed “Task Documents and Deliverables”.",
"primaryDisposition": "always_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:674"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:688",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:688",
"title": "Planning (Required when planning requested)",
"expectedSemantics": "Skill guidance headed “Planning (Required when planning requested)”.",
"primaryDisposition": "optional_agent_tool",
@@ -446,13 +446,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:678"
"skill:skills/paperclip/SKILL.md:688"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:709",
"id": "skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:719",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:709",
"sourceAnchor": "skills/paperclip/SKILL.md:719",
"title": "Key Endpoints (Hot Routes)",
"expectedSemantics": "Skill guidance headed “Key Endpoints (Hot Routes)”.",
"primaryDisposition": "optional_agent_tool",
@@ -461,30 +461,15 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:709"
"skill:skills/paperclip/SKILL.md:719"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:searching-issues:739",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:739",
"title": "Searching Issues",
"expectedSemantics": "Skill guidance headed “Searching Issues”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:739"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:full-reference:749",
"id": "skill:skills/paperclip/SKILL.md:searching-issues:749",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:749",
"title": "Full Reference",
"expectedSemantics": "Skill guidance headed “Full Reference”.",
"title": "Searching Issues",
"expectedSemantics": "Skill guidance headed “Searching Issues”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
@@ -495,9 +480,24 @@
]
},
{
"id": "skill:skills/paperclip/SKILL.md:conversational-confirmation-answers:753",
"id": "skill:skills/paperclip/SKILL.md:full-reference:759",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:753",
"sourceAnchor": "skills/paperclip/SKILL.md:759",
"title": "Full Reference",
"expectedSemantics": "Skill guidance headed “Full Reference”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:759"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:conversational-confirmation-answers:763",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:763",
"title": "Conversational confirmation answers",
"expectedSemantics": "Skill guidance headed “Conversational confirmation answers”.",
"primaryDisposition": "always_agent_tool",
@@ -506,13 +506,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:753"
"skill:skills/paperclip/SKILL.md:763"
]
},
{
"id": "skill:skills/paperclip/SKILL.md:incidental-feedback:770",
"id": "skill:skills/paperclip/SKILL.md:incidental-feedback:780",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/SKILL.md:770",
"sourceAnchor": "skills/paperclip/SKILL.md:780",
"title": "Incidental feedback",
"expectedSemantics": "Skill guidance headed “Incidental feedback”.",
"primaryDisposition": "optional_agent_tool",
@@ -521,7 +521,7 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/SKILL.md:770"
"skill:skills/paperclip/SKILL.md:780"
]
},
{
@@ -1410,9 +1410,9 @@
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:error-handling:1374",
"id": "skill:skills/paperclip/references/api-reference.md:error-handling:1376",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1374",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1376",
"title": "Error Handling",
"expectedSemantics": "Skill guidance headed “Error Handling”.",
"primaryDisposition": "control_plane_owned",
@@ -1421,30 +1421,15 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1374"
"skill:skills/paperclip/references/api-reference.md:1376"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:full-api-reference:1388",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1388",
"title": "Full API Reference",
"expectedSemantics": "Skill guidance headed “Full API Reference”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1388"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:agents:1390",
"id": "skill:skills/paperclip/references/api-reference.md:full-api-reference:1390",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1390",
"title": "Agents",
"expectedSemantics": "Skill guidance headed “Agents”.",
"title": "Full API Reference",
"expectedSemantics": "Skill guidance headed “Full API Reference”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
@@ -1455,9 +1440,24 @@
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:issues-tasks:1411",
"id": "skill:skills/paperclip/references/api-reference.md:agents:1392",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1411",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1392",
"title": "Agents",
"expectedSemantics": "Skill guidance headed “Agents”.",
"primaryDisposition": "optional_agent_tool",
"requiredGrants": [],
"assertionClasses": [
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1392"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:issues-tasks:1413",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1413",
"title": "Issues (Tasks)",
"expectedSemantics": "Skill guidance headed “Issues (Tasks)”.",
"primaryDisposition": "optional_agent_tool",
@@ -1466,13 +1466,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1411"
"skill:skills/paperclip/references/api-reference.md:1413"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:companies-projects-goals:1452",
"id": "skill:skills/paperclip/references/api-reference.md:companies-projects-goals:1454",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1452",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1454",
"title": "Companies, Projects, Goals",
"expectedSemantics": "Skill guidance headed “Companies, Projects, Goals”.",
"primaryDisposition": "optional_agent_tool",
@@ -1481,13 +1481,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1452"
"skill:skills/paperclip/references/api-reference.md:1454"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:routines:1476",
"id": "skill:skills/paperclip/references/api-reference.md:routines:1478",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1476",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1478",
"title": "Routines",
"expectedSemantics": "Skill guidance headed “Routines”.",
"primaryDisposition": "optional_agent_tool",
@@ -1496,13 +1496,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1476"
"skill:skills/paperclip/references/api-reference.md:1478"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:approvals-costs-activity-dashboard:1492",
"id": "skill:skills/paperclip/references/api-reference.md:approvals-costs-activity-dashboard:1494",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1492",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1494",
"title": "Approvals, Costs, Activity, Dashboard",
"expectedSemantics": "Skill guidance headed “Approvals, Costs, Activity, Dashboard”.",
"primaryDisposition": "optional_agent_tool",
@@ -1511,13 +1511,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1492"
"skill:skills/paperclip/references/api-reference.md:1494"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:secrets:1514",
"id": "skill:skills/paperclip/references/api-reference.md:secrets:1516",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1514",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1516",
"title": "Secrets",
"expectedSemantics": "Skill guidance headed “Secrets”.",
"primaryDisposition": "optional_agent_tool",
@@ -1526,13 +1526,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1514"
"skill:skills/paperclip/references/api-reference.md:1516"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:agent-secret-proposals:1527",
"id": "skill:skills/paperclip/references/api-reference.md:agent-secret-proposals:1529",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1527",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1529",
"title": "Agent secret proposals",
"expectedSemantics": "Skill guidance headed “Agent secret proposals”.",
"primaryDisposition": "optional_agent_tool",
@@ -1541,13 +1541,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1527"
"skill:skills/paperclip/references/api-reference.md:1529"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:agent-secret-access:1627",
"id": "skill:skills/paperclip/references/api-reference.md:agent-secret-access:1629",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1627",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1629",
"title": "Agent secret access",
"expectedSemantics": "Skill guidance headed “Agent secret access”.",
"primaryDisposition": "optional_agent_tool",
@@ -1556,13 +1556,13 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1627"
"skill:skills/paperclip/references/api-reference.md:1629"
]
},
{
"id": "skill:skills/paperclip/references/api-reference.md:common-mistakes:1667",
"id": "skill:skills/paperclip/references/api-reference.md:common-mistakes:1669",
"sourceKind": "skill_heading",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1667",
"sourceAnchor": "skills/paperclip/references/api-reference.md:1669",
"title": "Common Mistakes",
"expectedSemantics": "Skill guidance headed “Common Mistakes”.",
"primaryDisposition": "optional_agent_tool",
@@ -1571,7 +1571,7 @@
"control_plane_invariant"
],
"evidenceIds": [
"skill:skills/paperclip/references/api-reference.md:1667"
"skill:skills/paperclip/references/api-reference.md:1669"
]
},
{
@@ -29,7 +29,7 @@ export const createTaskAction = {
},
"documentation": {
"title": "Create task",
"description": "Create a project task from a conversation, or a child from an ordinary task. When the user names an existing teammate or project, resolve their IDs with list_agents or list_projects and set assigneeActorId or projectId in this creation call. A todo task can start its assignee immediately; creating it for yourself and reassigning later can start the wrong agent. Persist initialPlan before execution. Use backlog to save or plan without starting work; backlog tasks never wake an agent. Omitted status means todo, subject to blockers. Include required inputs and constraints in self-contained child instructions.",
"description": "Create a project task from a conversation, or a child from an ordinary task. Set parentTaskId to place the task under a specific task, such as a top-level task you just created. When the user names an existing teammate or project, resolve their IDs with list_agents or list_projects and set assigneeActorId or projectId in this creation call. A todo task can start its assignee immediately; creating it for yourself and reassigning later can start the wrong agent. Persist initialPlan before execution. Use backlog to save or plan without starting work; backlog tasks never wake an agent. Omitted status means todo, subject to blockers. Include required inputs and constraints in self-contained child instructions.",
"note": null
},
"examples": {
@@ -81,7 +81,7 @@ export const createTaskAction = {
"operationId": "create_task",
"version": 1,
"title": "Create task",
"description": "Create a project task from a conversation, or a child from an ordinary task. When the user names an existing teammate or project, resolve their IDs with list_agents or list_projects and set assigneeActorId or projectId in this creation call. A todo task can start its assignee immediately; creating it for yourself and reassigning later can start the wrong agent. Persist initialPlan before execution. Use backlog to save or plan without starting work; backlog tasks never wake an agent. Omitted status means todo, subject to blockers. Include required inputs and constraints in self-contained child instructions.",
"description": "Create a project task from a conversation, or a child from an ordinary task. Set parentTaskId to place the task under a specific task, such as a top-level task you just created. When the user names an existing teammate or project, resolve their IDs with list_agents or list_projects and set assigneeActorId or projectId in this creation call. A todo task can start its assignee immediately; creating it for yourself and reassigning later can start the wrong agent. Persist initialPlan before execution. Use backlog to save or plan without starting work; backlog tasks never wake an agent. Omitted status means todo, subject to blockers. Include required inputs and constraints in self-contained child instructions.",
"exposure": "optional",
"requiredClaims": [
"delegation:tasks:create"
@@ -151,6 +151,15 @@ export const createTaskAction = {
],
"description": "Project ID from list_projects. Set the requested project in this creation call so the task does not inherit a different project."
},
"parentTaskId": {
"type": [
"string",
"null"
],
"description": "Optional parent task ID. Omit it to create a child of the current task, or a top-level task from a conversation. To split work into a parent and subtasks, create the parent first, then pass its ID here for each subtask. Agents nest one level: a child of a child is placed beside it, and the result reports the parent actually used. A conversation is never a parent.",
"minLength": 1,
"maxLength": 200
},
"initialPlan": {
"type": [
"string",
+7
View File
@@ -223,6 +223,13 @@ export type IssueWorkMode = (typeof ISSUE_WORK_MODES)[number];
export const ISSUE_HARNESS_KINDS = ["skill_test"] as const;
export type IssueHarnessKind = (typeof ISSUE_HARNESS_KINDS)[number];
export const MAX_ISSUE_REQUEST_DEPTH = 1024;
/**
* Deepest task tree an agent (or automation acting for one) may create: a root
* task plus one level of children. A deeper request is not rejected; the new
* task is parented at this depth instead, as a sibling of the requester. Board
* users are not limited, and existing deeper trees stay valid.
*/
export const MAX_AGENT_TASK_HIERARCHY_DEPTH = 2;
export const SUMMARY_SLOT_SCOPE_KINDS = [
"project",
+3
View File
@@ -375,6 +375,7 @@ export {
ISSUE_WORK_MODES,
ISSUE_HARNESS_KINDS,
MAX_ISSUE_REQUEST_DEPTH,
MAX_AGENT_TASK_HIERARCHY_DEPTH,
SUMMARY_SLOT_SCOPE_KINDS,
SUMMARY_SLOT_KEYS,
SUMMARY_SLOT_STATUSES,
@@ -1223,6 +1224,8 @@ export type {
LegacyPlanDocument,
IssueAttachment,
IssueCreationSource,
IssueParentAdjustment,
IssueParentAdjustmentReason,
IssueLabel,
IssueTreeControlPreview,
IssueTreeHold,
+2
View File
@@ -827,6 +827,8 @@ export type {
IssueAncestorGoal,
IssueAttachment,
IssueCreationSource,
IssueParentAdjustment,
IssueParentAdjustmentReason,
IssueLabel,
IssueWatchdog,
IssueWatchdogStatus,
+22 -1
View File
@@ -143,10 +143,27 @@ export interface IssueCreationSource {
title: string;
status: string;
};
run: { id: string; agentId: string };
/** The run that created the task, when one did and the viewer may see it. */
run: { id: string; agentId: string } | null;
agent: { id: string; name: string } | null;
}
/**
* Why a create or move did not use the requested parent. `max_agent_depth`:
* agents may only build parent → child trees, so the task was placed as a
* sibling of the requested parent. `has_children`: an agent tried to move a task
* that has its own children under a parent; the move was skipped.
*/
export type IssueParentAdjustmentReason = "max_agent_depth" | "has_children";
export interface IssueParentAdjustment {
reason: IssueParentAdjustmentReason;
/** Whether the parent change was applied, with `effectiveParent` in place of the request. */
applied: boolean;
requestedParent: { id: string; identifier: string | null } | null;
effectiveParent: { id: string; identifier: string | null } | null;
}
export interface IssueAssigneeAdapterOverrides {
adapterConfig?: Record<string, unknown>;
useProjectWorkspace?: boolean;
@@ -891,6 +908,8 @@ export interface Issue {
ancestors?: IssueAncestor[];
/** Detail responses only: access-checked creation provenance, or null. */
createdFrom?: IssueCreationSource | null;
/** Create/update responses only: present when the requested parent was not used as-is. */
parentAdjusted?: IssueParentAdjustment | null;
title: string;
description: string | null;
descriptionTruncated?: boolean;
@@ -912,6 +931,8 @@ export interface Issue {
originKind?: IssueOriginKind;
originId?: string | null;
originRunId?: string | null;
/** Audit-only: the task whose work created this one. Set by the server; not hierarchy. */
originIssueId?: string | null;
originIdentityContextId?: string | null;
continuationIdentityContextId?: string | null;
originFingerprint?: string | null;
@@ -0,0 +1,192 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { and, eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { activityLog, agents, companies, createDb, heartbeatRuns, issues } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { issueRoutes } from "../routes/issues.js";
import { errorHandler } from "../middleware/index.js";
const support = await getEmbeddedPostgresTestSupport();
const describePostgres = support.supported ? describe : describe.skip;
describePostgres("agent task hierarchy depth through the issue routes", () => {
let db: ReturnType<typeof createDb>;
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
const companyId = randomUUID();
const agentId = randomUUID();
const rootId = randomUUID();
const childId = randomUUID();
const conversationId = randomUUID();
const runOnChild = randomUUID();
const runOnConversation = randomUUID();
const legacyConversationChildId = randomUUID();
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("paperclip-agent-task-depth-routes-");
db = createDb(database.connectionString);
await db.insert(companies).values({
id: companyId, name: "Paperclip", issuePrefix: "DEP", defaultResponsibleUserId: "board-user",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({ id: agentId, companyId, name: "Coder", role: "engineer", adapterType: "codex_local", status: "active" });
await db.insert(issues).values([
{ id: rootId, companyId, title: "Ship the feature", identifier: "DEP-1", issueNumber: 1, status: "in_progress", assigneeAgentId: agentId },
{ id: childId, companyId, parentId: rootId, title: "Build the API", identifier: "DEP-2", issueNumber: 2, status: "in_progress", assigneeAgentId: agentId },
{ id: conversationId, companyId, title: "Chat", identifier: "DEP-3", issueNumber: 3, status: "in_review", assigneeAgentId: agentId,
conversationAgentId: agentId, conversationUserId: "board-user", conversationState: "waiting" },
]);
await db.insert(issues).values({
id: legacyConversationChildId, companyId, parentId: conversationId, title: "Legacy chat child",
identifier: "DEP-4", issueNumber: 4, status: "todo", assigneeAgentId: agentId,
});
await db.insert(heartbeatRuns).values([
{ id: runOnChild, companyId, agentId, scopeKind: "issue", issueId: childId, nativeIssueId: childId, status: "running", contextSnapshot: { issueId: childId } },
{ id: runOnConversation, companyId, agentId, scopeKind: "issue", issueId: conversationId, status: "running", contextSnapshot: { issueId: conversationId } },
]);
}, 30_000);
afterAll(async () => { await database?.cleanup(); });
const boardActor = { type: "board", userId: "board-user", source: "local_implicit", isInstanceAdmin: true } as Express.Request["actor"];
function agentActor(runId: string): Express.Request["actor"] {
return { type: "agent", companyId, agentId, runId, source: "agent_jwt", onBehalfOfUserId: null, onBehalfOfMemberships: [] } as Express.Request["actor"];
}
function app(actor: Express.Request["actor"]) {
const instance = express();
instance.use(express.json());
instance.use((req, _res, next) => { req.actor = actor; next(); });
instance.use("/api", issueRoutes(db, {} as never));
instance.use(errorHandler);
return instance;
}
async function activity(issueId: string, action: string) {
const [row] = await db.select().from(activityLog)
.where(and(eq(activityLog.entityId, issueId), eq(activityLog.action, action)));
return row ?? null;
}
const expectedAdjustment = {
reason: "max_agent_depth",
applied: true,
requestedParent: { id: childId, identifier: "DEP-2" },
effectiveParent: { id: rootId, identifier: "DEP-1" },
};
it("places a run's default follow-up beside the child task it is working on", async () => {
const created = await request(app(agentActor(runOnChild)))
.post(`/api/companies/${companyId}/issues`)
.send({ title: "Write the migration" });
expect(created.status, JSON.stringify(created.body)).toBe(201);
expect(created.body.parentId).toBe(rootId);
expect(created.body.originIssueId).toBe(childId);
expect(created.body.originRunId).toBe(runOnChild);
expect(created.body.parentAdjusted).toEqual(expectedAdjustment);
expect((await activity(created.body.id, "issue.created"))?.details).toMatchObject({
parentId: rootId,
parentAdjusted: expectedAdjustment,
parentDefaultedFromRunIssue: true,
});
const detail = await request(app(boardActor)).get(`/api/issues/${created.body.id}`);
expect(detail.body.createdFrom).toMatchObject({ issue: { id: childId, identifier: "DEP-2" } });
});
it("places an explicit agent grandchild beside the requester instead of failing", async () => {
const created = await request(app(agentActor(runOnChild)))
.post(`/api/companies/${companyId}/issues`)
.send({ title: "Add tests", parentId: childId });
expect(created.status, JSON.stringify(created.body)).toBe(201);
expect(created.body.parentId).toBe(rootId);
expect(created.body.parentAdjusted).toEqual(expectedAdjustment);
});
it("places an agent child created through the children route beside the requester", async () => {
const created = await request(app(agentActor(runOnChild)))
.post(`/api/issues/${childId}/children`)
.send({ title: "Update the docs" });
expect(created.status, JSON.stringify(created.body)).toBe(201);
expect(created.body.parentId).toBe(rootId);
expect(created.body.originIssueId).toBe(childId);
expect(created.body.parentAdjusted).toEqual(expectedAdjustment);
expect((await activity(created.body.id, "issue.child_created"))?.details).toMatchObject({
parentId: rootId,
requestedParentId: childId,
});
});
it("lets board users create grandchildren", async () => {
const created = await request(app(boardActor))
.post(`/api/companies/${companyId}/issues`)
.send({ title: "Board grandchild", parentId: childId });
expect(created.status, JSON.stringify(created.body)).toBe(201);
expect(created.body.parentId).toBe(childId);
expect(created.body).not.toHaveProperty("parentAdjusted");
const viaChildren = await request(app(boardActor))
.post(`/api/issues/${childId}/children`)
.send({ title: "Board grandchild via children route" });
expect(viaChildren.status, JSON.stringify(viaChildren.body)).toBe(201);
expect(viaChildren.body.parentId).toBe(childId);
});
it("flattens agent moves into a child and lets board users move freely", async () => {
const [agentMoved, boardMoved] = [randomUUID(), randomUUID()];
await db.insert(issues).values([
{ id: agentMoved, companyId, title: "Loose task", status: "todo", assigneeAgentId: agentId },
{ id: boardMoved, companyId, title: "Board loose task", status: "todo", assigneeAgentId: agentId },
]);
const byAgent = await request(app(agentActor(runOnChild)))
.patch(`/api/issues/${agentMoved}`)
.send({ parentId: childId });
expect(byAgent.status, JSON.stringify(byAgent.body)).toBe(200);
expect(byAgent.body.parentId).toBe(rootId);
expect(byAgent.body.parentAdjusted).toEqual(expectedAdjustment);
expect((await activity(agentMoved, "issue.updated"))?.details).toMatchObject({
parentId: rootId,
requestedParentId: childId,
parentAdjusted: expectedAdjustment,
});
const byBoard = await request(app(boardActor))
.patch(`/api/issues/${boardMoved}`)
.send({ parentId: childId });
expect(byBoard.status, JSON.stringify(byBoard.body)).toBe(200);
expect(byBoard.body.parentId).toBe(childId);
expect(byBoard.body).not.toHaveProperty("parentAdjusted");
});
it("records a top-level placement when an agent moves under a legacy conversation child", async () => {
const movingId = randomUUID();
await db.insert(issues).values({ id: movingId, companyId, parentId: rootId, title: "Move to top", status: "todo", assigneeAgentId: agentId });
const moved = await request(app(agentActor(runOnChild)))
.patch(`/api/issues/${movingId}`)
.send({ parentId: legacyConversationChildId });
expect(moved.status, JSON.stringify(moved.body)).toBe(200);
expect(moved.body.parentId).toBeNull();
expect(moved.body.parentAdjusted).toMatchObject({ reason: "max_agent_depth", effectiveParent: null });
expect((await activity(movingId, "issue.updated"))?.details).toMatchObject({
parentId: null,
requestedParentId: legacyConversationChildId,
});
});
it("lets a chat create a top-level task and a child under it", async () => {
const top = await request(app(agentActor(runOnConversation)))
.post(`/api/companies/${companyId}/issues`)
.send({ title: "Plan the launch", assigneeAgentId: agentId });
expect(top.status, JSON.stringify(top.body)).toBe(201);
expect(top.body.parentId).toBeNull();
expect(top.body.originIssueId).toBe(conversationId);
const child = await request(app(agentActor(runOnConversation)))
.post(`/api/companies/${companyId}/issues`)
.send({ title: "Draft the announcement", parentId: top.body.id, assigneeAgentId: agentId });
expect(child.status, JSON.stringify(child.body)).toBe(201);
expect(child.body.parentId).toBe(top.body.id);
// Both originate from the chat that created them.
expect(child.body.originIssueId).toBe(conversationId);
expect(child.body).not.toHaveProperty("parentAdjusted");
});
});
@@ -0,0 +1,484 @@
import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { agents, companies, createDb, heartbeatRuns, issueRelations, issues } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { issueService } from "../services/issues.ts";
import { planIssueParentChange, resolveNewTaskParent } from "../services/issue-hierarchy.ts";
import { resolveIssueCreationSource } from "../services/issue-creation-source.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres("agent task hierarchy depth", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-agent-task-depth-");
db = createDb(tempDb.connectionString);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedCompany() {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `D${companyId.replace(/-/g, "").slice(0, 5).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const agentId = randomUUID();
await db.insert(agents).values({
id: agentId,
companyId,
name: "Coder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
return { companyId, agentId };
}
async function seedIssue(
companyId: string,
values: Partial<typeof issues.$inferInsert> = {},
) {
const id = randomUUID();
await db.insert(issues).values({
id,
companyId,
title: `Task ${id.slice(0, 6)}`,
status: "todo",
priority: "medium",
...values,
});
return id;
}
async function seedTree(companyId: string) {
const suffix = randomUUID().slice(0, 8);
const rootIdentifier = `ROOT-${suffix}`;
const childIdentifier = `CHILD-${suffix}`;
const rootId = await seedIssue(companyId, { identifier: rootIdentifier });
const childId = await seedIssue(companyId, { parentId: rootId, identifier: childIdentifier });
return { rootId, childId, rootIdentifier, childIdentifier };
}
it("places an agent's child-of-a-child beside the requester and keeps it as the origin", async () => {
const { companyId } = await seedCompany();
const { rootId, childId, rootIdentifier, childIdentifier } = await seedTree(companyId);
// expectedParentId: the route authorized the placed ancestor.
const created = await issueService(db).create(companyId, {
title: "Follow-up from the child",
parentId: childId,
expectedParentId: rootId,
});
expect(created.parentId).toBe(rootId);
expect(created.originIssueId).toBe(childId);
expect(created.privacyParentIssueId).toBe(childId);
expect(created.parentAdjusted).toEqual({
reason: "max_agent_depth",
applied: true,
requestedParent: { id: childId, identifier: childIdentifier },
effectiveParent: { id: rootId, identifier: rootIdentifier },
});
});
it("lets board users create grandchildren", async () => {
const { companyId } = await seedCompany();
const { childId } = await seedTree(companyId);
const created = await issueService(db).create(companyId, {
title: "Board grandchild",
parentId: childId,
parentDepthPolicy: "allow_nested",
});
expect(created.parentId).toBe(childId);
expect(created.originIssueId).toBe(childId);
expect(created.parentAdjusted).toBeUndefined();
});
it("creates direct children of a top-level task unchanged", async () => {
const { companyId } = await seedCompany();
const { rootId } = await seedTree(companyId);
const created = await issueService(db).create(companyId, { title: "Second child", parentId: rootId });
expect(created.parentId).toBe(rootId);
expect(created.originIssueId).toBe(rootId);
expect(created.parentAdjusted).toBeUndefined();
});
it("records the creating run's task as the origin of a top-level task", async () => {
const { companyId, agentId } = await seedCompany();
const sourceId = await seedIssue(companyId);
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", issueId: sourceId });
const created = await issueService(db).create(companyId, {
title: "Standalone follow-up",
parentId: null,
createdByAgentId: agentId,
actorRunId: runId,
});
expect(created.parentId).toBeNull();
expect(created.originIssueId).toBe(sourceId);
expect(created.originRunId).toBe(runId);
});
it("flattens legacy deep trees to their root", async () => {
const { companyId } = await seedCompany();
const { rootId, childId } = await seedTree(companyId);
const grandchildId = await seedIssue(companyId, { parentId: childId });
const created = await issueService(db).create(companyId, { title: "Under legacy grandchild", parentId: grandchildId, expectedParentId: rootId });
expect(created.parentId).toBe(rootId);
expect(created.originIssueId).toBe(grandchildId);
expect(created.parentAdjusted?.requestedParent?.id).toBe(grandchildId);
});
it("blocks the requester, not the root, when a flattened child should block its parent", async () => {
const { companyId } = await seedCompany();
const { rootId, childId } = await seedTree(companyId);
const { issue, parentBlockerAdded } = await issueService(db).createChild(childId, {
title: "Blocking follow-up",
blockParentUntilDone: true,
expectedParentId: rootId,
});
expect(issue.parentId).toBe(rootId);
expect(issue.originIssueId).toBe(childId);
expect(issue.parentAdjusted?.reason).toBe("max_agent_depth");
expect(parentBlockerAdded).toBe(true);
const blockers = await db
.select({ relatedIssueId: issueRelations.relatedIssueId })
.from(issueRelations)
.where(and(eq(issueRelations.issueId, issue.id), eq(issueRelations.type, "blocks")));
expect(blockers.map((row) => row.relatedIssueId)).toEqual([childId]);
});
it("reuses a flattened child for the same idempotency key", async () => {
const { companyId } = await seedCompany();
const { childId } = await seedTree(companyId);
const idempotencyKey = `flatten-${randomUUID()}`;
const first = await issueService(db).createChild(childId, { title: "Once", idempotencyKey });
const second = await issueService(db).createChild(childId, { title: "Once", idempotencyKey });
expect(second.issue.id).toBe(first.issue.id);
});
it("places under the real root of a legacy tree deeper than 64 levels", async () => {
const { companyId } = await seedCompany();
const rootId = await seedIssue(companyId);
let deepestId = rootId;
for (let level = 1; level < 70; level += 1) deepestId = await seedIssue(companyId, { parentId: deepestId });
const created = await issueService(db).create(companyId, { title: "From the 70th level", parentId: deepestId, expectedParentId: rootId });
expect(created.parentId).toBe(rootId);
expect(created.originIssueId).toBe(deepestId);
});
it("does not move a task above a low-trust boundary root", async () => {
const { companyId } = await seedCompany();
const rootId = await seedIssue(companyId);
const boundaryId = randomUUID();
await seedIssue(companyId, {
id: boundaryId,
parentId: rootId,
executionPolicy: {
authorizationPolicy: {
trustBoundary: { mode: "low_trust_review", companyId, rootIssueId: boundaryId },
},
},
});
const placement = await resolveNewTaskParent(db, companyId, boundaryId, "flatten");
expect(placement).toEqual({ effectiveParentId: boundaryId, adjustment: null });
});
it("falls back to top level when a legacy tree is rooted at a conversation", async () => {
const { companyId, agentId } = await seedCompany();
const conversationId = await seedIssue(companyId, { conversationAgentId: agentId, conversationUserId: "user-1", conversationState: "active", assigneeAgentId: agentId,
});
const legacyChildId = await seedIssue(companyId, { parentId: conversationId });
const created = await issueService(db).create(companyId, { title: "From legacy chat child", parentId: legacyChildId });
expect(created.parentId).toBeNull();
expect(created.originIssueId).toBe(legacyChildId);
expect(created.parentAdjusted).toMatchObject({ reason: "max_agent_depth", effectiveParent: null });
});
it("rejects a create whose locked placement differs from the authorized one", async () => {
const { companyId } = await seedCompany();
const { rootId, childId } = await seedTree(companyId);
const otherRootId = await seedIssue(companyId);
// The route authorized placement under rootId; the child then moved.
await db.update(issues).set({ parentId: otherRootId }).where(eq(issues.id, childId));
await expect(issueService(db).create(companyId, {
title: "Stale placement",
parentId: childId,
expectedParentId: rootId,
})).rejects.toMatchObject({ status: 409 });
});
it("dedupes recent titles per requester, not across siblings placed under the same root", async () => {
const { companyId } = await seedCompany();
const { rootId, childId } = await seedTree(companyId);
const otherChildId = await seedIssue(companyId, { parentId: rootId });
const first = await issueService(db).create(companyId, { title: "Write tests", parentId: childId, allowDuplicate: false });
const second = await issueService(db).create(companyId, { title: "Write tests", parentId: otherChildId, allowDuplicate: false });
const repeat = await issueService(db).create(companyId, { title: "Write tests", parentId: childId, allowDuplicate: false });
expect(second.id).not.toBe(first.id);
expect(second.originIssueId).toBe(otherChildId);
expect(repeat.id).toBe(first.id);
});
it("ignores caller-supplied originIssueId", async () => {
const { companyId } = await seedCompany();
const { childId } = await seedTree(companyId);
const forgedId = await seedIssue(companyId);
const created = await issueService(db).create(companyId, {
title: "Provenance is server-derived",
parentId: childId,
...({ originIssueId: forgedId } as Record<string, unknown>),
});
expect(created.originIssueId).toBe(childId);
});
it("rejects a parent from another company on create and move", async () => {
const { companyId } = await seedCompany();
const { companyId: otherCompanyId } = await seedCompany();
const foreignParentId = await seedIssue(otherCompanyId);
await expect(issueService(db).create(companyId, { title: "Foreign parent", parentId: foreignParentId }))
.rejects.toMatchObject({ status: 404 });
await expect(issueService(db).create(companyId, {
title: "Foreign parent, board", parentId: foreignParentId, parentDepthPolicy: "allow_nested",
})).rejects.toMatchObject({ status: 404 });
const movingId = await seedIssue(companyId);
await expect(issueService(db).update(movingId, { parentId: foreignParentId, parentDepthPolicy: "allow_nested" }))
.rejects.toMatchObject({ status: 404 });
});
it("originates from the creating run's task, not the named parent, in detail and lists", async () => {
const { companyId, agentId } = await seedCompany();
const { childId } = await seedTree(companyId);
const runTaskId = await seedIssue(companyId);
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", issueId: runTaskId });
const created = await issueService(db).create(companyId, {
title: "Named parent from another run", parentId: childId, createdByAgentId: agentId, actorRunId: runId,
});
// A run working on runTask filed this under childId: it originates from runTask.
expect(created.originIssueId).toBe(runTaskId);
expect(created.privacyParentIssueId).toBe(childId);
const fromRunTask = await issueService(db).list(companyId, { createdFromIssueId: runTaskId });
const fromChild = await issueService(db).list(companyId, { createdFromIssueId: childId });
expect(fromRunTask.map((row) => row.id)).toContain(created.id);
expect(fromChild.map((row) => row.id)).not.toContain(created.id);
expect((await resolveIssueCreationSource(db, created))?.sourceIssue.id).toBe(runTaskId);
});
it("originates from a legacy run bound only by a task identifier", async () => {
const { companyId, agentId } = await seedCompany();
const { childId } = await seedTree(companyId);
const identifier = `LEG-${randomUUID().slice(0, 6)}`;
const runTaskId = await seedIssue(companyId, { identifier });
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", contextSnapshot: { taskKey: identifier } });
const created = await issueService(db).create(companyId, {
title: "From a legacy-bound run", parentId: childId, createdByAgentId: agentId, actorRunId: runId,
});
expect(created.originIssueId).toBe(runTaskId);
});
it("makes unconfirmed placements standalone instead of writing under an unchecked ancestor", async () => {
const { companyId } = await seedCompany();
const { childId } = await seedTree(companyId);
// Internal callers authorize only the parent they request.
const created = await issueService(db).create(companyId, { title: "Internal follow-up", parentId: childId });
expect(created.parentId).toBeNull();
expect(created.privacyParentIssueId).toBe(childId);
expect(created.originIssueId).toBe(childId);
expect(created.parentAdjusted).toMatchObject({ reason: "max_agent_depth", applied: true, effectiveParent: null });
const { issue } = await issueService(db).createChild(childId, { title: "Internal child helper" });
expect(issue.parentId).toBeNull();
const movingId = await seedIssue(companyId);
const moved = await issueService(db).update(movingId, { parentId: childId, title: "Renamed anyway" });
expect(moved?.parentId).toBeNull();
expect(moved?.title).toBe("Renamed anyway");
expect(moved?.parentAdjusted).toMatchObject({ reason: "max_agent_depth", applied: false, effectiveParent: null });
// An already-parented task keeps its parent, and the notice names it.
const otherRootId = await seedIssue(companyId, { identifier: `KEEP-${randomUUID().slice(0, 6)}` });
const parentedId = await seedIssue(companyId, { parentId: otherRootId });
const kept = await issueService(db).update(parentedId, { parentId: childId });
expect(kept?.parentId).toBe(otherRootId);
expect(kept?.parentAdjusted).toMatchObject({ applied: false, effectiveParent: { id: otherRootId } });
});
it("does not expose a private origin on a task its readers may see", async () => {
const { companyId, agentId } = await seedCompany();
const openParentId = await seedIssue(companyId);
const privateTaskId = randomUUID();
await seedIssue(companyId, { id: privateTaskId, visibility: "private", privacyRootIssueId: privateTaskId });
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", issueId: privateTaskId });
const created = await issueService(db).create(companyId, {
title: "Open work filed from a private task", parentId: openParentId, createdByAgentId: agentId, actorRunId: runId,
});
expect(created.visibility).toBe("open");
expect(created.originIssueId).toBeNull();
// The audit link stays server-side through the run, checked for access on read.
expect(created.originRunId).toBe(runId);
expect((await resolveIssueCreationSource(db, created))?.sourceIssue.id).toBe(privateTaskId);
});
it("does not expose a hidden origin or a private sibling's id", async () => {
const { companyId, agentId } = await seedCompany();
const runOn = async (taskId: string) => {
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", issueId: taskId });
return runId;
};
const openParentId = await seedIssue(companyId);
const hiddenTaskId = await seedIssue(companyId, { hiddenAt: new Date() });
const fromHidden = await issueService(db).create(companyId, {
title: "From a hidden task", parentId: openParentId, createdByAgentId: agentId, actorRunId: await runOn(hiddenTaskId),
});
expect(fromHidden.originIssueId).toBeNull();
const rootId = randomUUID();
await seedIssue(companyId, { id: rootId, visibility: "private", privacyRootIssueId: rootId });
const siblingA = await seedIssue(companyId, { parentId: rootId, visibility: "private", privacyRootIssueId: rootId });
const siblingB = await seedIssue(companyId, { parentId: rootId, visibility: "private", privacyRootIssueId: rootId });
const fromSibling = await issueService(db).create(companyId, {
title: "Filed under a private sibling", parentId: siblingB, createdByAgentId: agentId,
actorRunId: await runOn(siblingA), expectedParentId: rootId,
});
expect(fromSibling.privacyParentIssueId).toBe(siblingB);
expect(fromSibling.originIssueId).toBeNull();
expect((await resolveIssueCreationSource(db, fromSibling))?.sourceIssue.id).toBe(siblingA);
});
describe("moves", () => {
it("keeps the requested private parent as the privacy source of a placed move", async () => {
const { companyId } = await seedCompany();
const rootId = await seedIssue(companyId);
const privateChildId = randomUUID();
await seedIssue(companyId, { id: privateChildId, parentId: rootId, visibility: "private", privacyRootIssueId: privateChildId });
const movingId = await seedIssue(companyId);
const updated = await issueService(db).update(movingId, { parentId: privateChildId, expectedParentId: rootId });
expect(updated?.parentId).toBe(rootId);
expect(updated?.privacyParentIssueId).toBe(privateChildId);
expect(updated?.visibility).toBe("private");
expect(updated?.privacyRootIssueId).toBe(privateChildId);
});
it("rejects a move whose placement differs from the authorized one", async () => {
const { companyId } = await seedCompany();
const { childId } = await seedTree(companyId);
const movingId = await seedIssue(companyId);
await expect(issueService(db).update(movingId, { parentId: childId, expectedParentId: childId }))
.rejects.toMatchObject({ status: 409 });
});
it("places an agent move into a child beside it", async () => {
const { companyId } = await seedCompany();
const { rootId, childId } = await seedTree(companyId);
const movingId = await seedIssue(companyId);
const updated = await issueService(db).update(movingId, { parentId: childId, expectedParentId: rootId });
expect(updated?.parentId).toBe(rootId);
expect(updated?.parentAdjusted).toMatchObject({ reason: "max_agent_depth", applied: true });
});
it("skips only the parent change when the moved task has children", async () => {
const { companyId } = await seedCompany();
const { rootId } = await seedTree(companyId);
const movingId = await seedIssue(companyId);
await seedIssue(companyId, { parentId: movingId });
const updated = await issueService(db).update(movingId, { parentId: rootId, title: "Renamed" });
expect(updated?.parentId).toBeNull();
expect(updated?.title).toBe("Renamed");
expect(updated?.parentAdjusted).toMatchObject({
reason: "has_children",
applied: false,
requestedParent: { id: rootId },
effectiveParent: null,
});
});
it("lets board users move to any depth", async () => {
const { companyId } = await seedCompany();
const { childId } = await seedTree(companyId);
const movingId = await seedIssue(companyId);
await seedIssue(companyId, { parentId: movingId });
const plan = await planIssueParentChange(
db,
{ id: movingId, companyId, parentId: null },
childId,
"allow_nested",
);
expect(plan).toEqual({ parentId: childId, adjustment: null });
const updated = await issueService(db).update(movingId, { parentId: childId, parentDepthPolicy: "allow_nested" });
expect(updated?.parentId).toBe(childId);
expect(updated?.parentAdjusted).toBeUndefined();
});
});
it("resolves creation provenance from the recorded origin task", async () => {
const { companyId } = await seedCompany();
const { childId } = await seedTree(companyId);
const created = await issueService(db).create(companyId, { title: "Flattened", parentId: childId });
const source = await resolveIssueCreationSource(db, created);
expect(source?.sourceIssue.id).toBe(childId);
expect(source?.run).toBeNull();
});
});
@@ -126,6 +126,23 @@ import { recoveryPostgresSupport as support } from "./helpers/recovery-postgres.
}
});
it.each(["native", "mcp"])("honors backlog status and parentTaskId through %s creation", async (transport) => {
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
const create = async (args: Record<string, unknown>) => {
const result = transport === "native" ? await call(f, "create_task", args) as any : await callProjectTool({
name: "create_task", arguments: args, 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,
});
const [task] = await server.db.select().from(issues).where(eq(issues.id, transport === "native" ? result.task.id : result.id));
return task;
};
const parent = await create({ title: `Backlog parent ${randomUUID()}`, projectId: f.projectId, idempotencyKey: randomUUID(), status: "backlog" });
expect(parent).toMatchObject({ status: "backlog", parentId: null });
const child = await create({ title: `Backlog child ${randomUUID()}`, projectId: f.projectId, idempotencyKey: randomUUID(), status: "backlog", parentTaskId: parent.id });
expect(child).toMatchObject({ status: "backlog", parentId: parent.id });
});
it("persists required output review supplied through MCP creation", async () => {
const f = await server.fixture({ conversation: true });
const requirement = { version: 1, deliverables: [{ key: "copy", label: "Launch copy" }] };
@@ -64,7 +64,7 @@ describe.skipIf(!support.supported)("creation source run projections", () => {
expect(reads.reduce((count, event) => count + Number(event.calls), 0)).toBe(1);
expect(reads.reduce((bytes, event) => bytes + Number(event.responseValueBytes), 0)).toBeLessThan(8192);
expect(result).toMatchObject({ run: { id: run.id, companyId, agentId }, sourceIssue: { id: sourceId } });
expect(runBoundIssueReference(result!.run)).toBe(binding === "scoped" ? sourceId : "CSR-1");
expect(runBoundIssueReference(result!.run!)).toBe(binding === "scoped" ? sourceId : "CSR-1");
for (const key of ["runnerProfileJson", "stdoutExcerpt", "stderrExcerpt", "resultJson"]) expect(result!.run).not.toHaveProperty(key);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0]).toEqual(run);
expect(JSON.stringify(events)).not.toContain("retained-source-context");
@@ -1334,14 +1334,18 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
]),
);
// Agent-authored suggestions nest one level: the nested suggestion would be
// a grandchild, so it lands beside its suggested parent and records it as origin.
const children = await issuesSvc.list(companyId, { parentId: issueId });
expect(children).toHaveLength(1);
expect(children[0]?.title).toBe("Create the root follow-up");
const nestedChildren = await issuesSvc.list(companyId, { parentId: children[0]!.id });
expect(nestedChildren).toHaveLength(1);
expect(nestedChildren[0]?.title).toBe("Create the nested follow-up");
expect(nestedChildren[0]?.requestDepth).toBe(4);
expect(children.map((child) => child.title).sort()).toEqual([
"Create the nested follow-up",
"Create the root follow-up",
]);
const rootFollowUp = children.find((child) => child.title === "Create the root follow-up")!;
const nestedFollowUp = children.find((child) => child.title === "Create the nested follow-up")!;
expect(nestedFollowUp.originIssueId).toBe(rootFollowUp.id);
expect(nestedFollowUp.requestDepth).toBe(4);
expect(await issuesSvc.list(companyId, { parentId: rootFollowUp.id })).toHaveLength(0);
const listed = await interactionsSvc.listForIssue(issueId);
expect(listed).toHaveLength(1);
@@ -1352,7 +1356,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
})).rejects.toThrow("Interaction has already been resolved");
const childrenAfterDuplicateAccept = await issuesSvc.list(companyId, { parentId: issueId });
expect(childrenAfterDuplicateAccept).toHaveLength(1);
expect(childrenAfterDuplicateAccept).toHaveLength(2);
});
it("accepts a selected subset of suggested tasks and records the skipped drafts", async () => {
@@ -513,6 +513,18 @@ describeEmbeddedPostgres("issue watchdog routes", () => {
.where(and(eq(issueWatchdogs.companyId, companyId), eq(issueWatchdogs.issueId, watchedChildId)));
expect(nestedWatchdogs).toHaveLength(0);
// Watchdog runs are authorized for the watched subtree, so the agent depth
// limit does not move their tasks above the requested parent. Remove the
// task afterwards so the watched subtree returns to its stopped state.
const allowedCreate = await request(app)
.post(`/api/companies/${companyId}/issues`)
.send({ title: "Allowed watched grandchild", parentId: watchedChildId });
expect(allowedCreate.status, JSON.stringify(allowedCreate.body)).toBe(201);
expect(allowedCreate.body.parentId).toBe(watchedChildId);
expect(allowedCreate.body).not.toHaveProperty("parentAdjusted");
await db.delete(activityLog).where(eq(activityLog.entityId, allowedCreate.body.id));
await db.delete(issues).where(eq(issues.id, allowedCreate.body.id));
const allowedChild = await request(app)
.post(`/api/issues/${watchedChildId}/children`)
.send({ title: "Allowed watched child" });
@@ -42,7 +42,9 @@ const support = await getEmbeddedPostgresTestSupport();
title: "Intermediate task", assigneeAgentId: f.agentId, executionPolicy: f.policy }).returning();
f.input.parentId = parent.id;
}
const input = scenario === "omitted_policy" ? { ...f.input, executionPolicy: undefined } : f.input;
const baseInput = scenario === "omitted_policy" ? { ...f.input, executionPolicy: undefined } : f.input;
// Descendant trust is the subject here, so keep the deeper placement (board-style nesting).
const input = scenario === "descendant" ? { ...baseInput, parentDepthPolicy: "allow_nested" as const } : baseInput;
await expect(issueService(db).create(f.companyId, input)).resolves.toMatchObject({
parentId: f.input.parentId, executionWorkspaceId: f.workspace.id,
});
+203 -20
View File
@@ -148,6 +148,7 @@ import {
isMarkdownAttachmentContent,
isUuidLike,
type IssueCreationSource,
type IssueParentAdjustment,
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
type CompactIssue,
type CompanySearchExtractQuery,
@@ -326,6 +327,13 @@ import {
readAcceptedPlanConfirmationTarget,
type IssuePostCommitAction,
} from "../services/issues.js";
import {
loadParentChainWith,
placeTaskParent,
planParentChange,
type IssueParentDepthPolicy,
type ParentPlacement,
} from "../services/issue-hierarchy.js";
import {
authorizationDeniedDetails,
issueReadSqlCondition,
@@ -3816,6 +3824,39 @@ export function issueRoutes(
);
}
/** A requested parent's ancestor chain, read through the issue service (company-scoped). */
function loadRouteParentChain(companyId: string, parentId: string) {
return loadParentChainWith(async (id) => {
const row = await svc.getById(id);
if (!row || row.companyId !== companyId) return null;
return {
id: row.id,
identifier: row.identifier ?? null,
parentId: row.parentId ?? null,
conversationAgentId: row.conversationAgentId ?? null,
executionPolicy: row.executionPolicy ?? null,
};
}, parentId);
}
/**
* Where a new parent edge lands for this actor. Routes authorize this
* placement and pass it to the service as `expectedParentId`, which rejects
* the write if the tree changed before the service's locked placement.
*/
async function resolveRouteParentPlacement(
companyId: string,
requestedParentId: string | null | undefined,
policy: IssueParentDepthPolicy,
): Promise<ParentPlacement> {
if (!requestedParentId || policy === "allow_nested") {
return { effectiveParentId: requestedParentId ?? null, adjustment: null };
}
const chain = await loadRouteParentChain(companyId, requestedParentId);
if (chain.length === 0) return { effectiveParentId: requestedParentId, adjustment: null };
return placeTaskParent(chain, policy);
}
/**
* Default structural parent for a delegated follow-up: the task the agent's
* run is executing. Returns null when the new task should stay standalone:
@@ -3870,13 +3911,26 @@ export function issueRoutes(
// parent in scope. A protected parent (or project it supplies) denies
// child creation without a grant; that must fall back to standalone work
// rather than fail a request that succeeded before the default existed.
// Under the agent depth limit a run on a child task delegates beside it.
// The placed parent must also accept the edge, or the default falls back
// to standalone work.
const { effectiveParentId: placedParentId } = await resolveRouteParentPlacement(
companyId, source.id, "flatten",
);
if (!placedParentId) return null;
if (placedParentId !== source.id) {
const placedParent = await svc.getById(placedParentId);
if (!placedParent || isConversation(placedParent)) return null;
const placedDecision = await decideIssueAccess(req, placedParent, "issue:mutate");
if (!placedDecision.allowed) return null;
}
const assignmentScope: TaskAssignmentAuthorizationScope = {
projectId: await resolveCreateAssignmentProjectId({
...rawCreateBody,
companyId,
parentId: source.id,
}),
parentIssueId: source.id,
parentIssueId: placedParentId,
assigneeAgentId,
assigneeUserId:
typeof rawCreateBody.assigneeUserId === "string"
@@ -3891,7 +3945,7 @@ export function issueRoutes(
companyId,
issueId: null,
projectId: assignmentScope.projectId ?? null,
parentIssueId: source.id,
parentIssueId: placedParentId,
assigneeAgentId,
assigneeUserId: assignmentScope.assigneeUserId ?? null,
},
@@ -3908,20 +3962,22 @@ export function issueRoutes(
*/
async function resolveVisibleIssueCreationSource(
req: Request,
issue: { id: string; companyId: string; originRunId: string | null },
issue: { id: string; companyId: string; originRunId: string | null; originIssueId?: string | null },
): Promise<IssueCreationSource | null> {
const source = await resolveIssueCreationSource(db, issue);
if (!source) return null;
const { run, sourceIssue } = source;
const readable = await decideIssueAccess(req, sourceIssue, "issue:read");
if (!readable.allowed) return null;
const runVisible = await canActorReadHeartbeatRun(db, access, req.actor, {
// A recorded origin task stands on its own; the run is shown only when visible.
const runVisible = run ? await canActorReadHeartbeatRun(db, access, req.actor, {
companyId: run.companyId,
scopeKind: run.scopeKind,
issueId: run.issueId ?? run.nativeIssueId ?? sourceIssue.id,
});
if (!runVisible) return null;
const agent = await loadCreationSourceAgent(db, issue.companyId, run.agentId);
}) : false;
if (!runVisible && !issue.originIssueId) return null;
const visibleRun = run && runVisible ? run : null;
const agent = visibleRun ? await loadCreationSourceAgent(db, issue.companyId, visibleRun.agentId) : null;
return {
issue: {
id: sourceIssue.id,
@@ -3929,7 +3985,7 @@ export function issueRoutes(
title: sourceIssue.title,
status: sourceIssue.status,
},
run: { id: run.id, agentId: run.agentId },
run: visibleRun ? { id: visibleRun.id, agentId: visibleRun.agentId } : null,
agent,
};
}
@@ -12675,6 +12731,22 @@ export function issueRoutes(
const effectiveParentId = watchdogProductBugFollowUp
? null
: (runDelegationParentId ?? rawCreateBody.parentId);
// Board users nest freely. Agents build at most parent → child trees: a
// deeper request is placed beside the requester, never rejected. The
// service applies the same placement; authorization below checks both the
// requested parent and the parent that is actually written.
// Watchdog-scoped runs are authorized for the watched subtree, so their
// tasks keep the requested parent, as on the /children route.
const parentDepthPolicy: IssueParentDepthPolicy =
req.actor.type === "board" ||
(Boolean(effectiveParentId) && (await resolveTaskWatchdogMutationScope(db, req.actor)).kind === "watchdog")
? "allow_nested"
: "flatten";
const parentPlacement = await resolveRouteParentPlacement(
companyId,
effectiveParentId ?? null,
parentDepthPolicy,
);
let createParent: Awaited<ReturnType<typeof svc.getById>> | null = null;
if (req.actor.type === "agent" && effectiveParentId) {
createParent = await svc.getById(effectiveParentId);
@@ -12687,6 +12759,18 @@ export function issueRoutes(
!(await assertIssueWriteInfluenceAllowed(req, res, createParent))
)
return;
const placedParentId = parentPlacement.effectiveParentId;
if (placedParentId && placedParentId !== createParent.id) {
const placedParent = await svc.getById(placedParentId);
if (
!placedParent ||
(!isTaskBridgeKeyActor(req) &&
!(await assertIssueWriteInfluenceAllowed(req, res, placedParent)))
) {
if (!res.headersSent) res.status(404).json({ error: "Parent issue not found" });
return;
}
}
}
if (
!watchdogProductBugFollowUp &&
@@ -12733,6 +12817,10 @@ export function issueRoutes(
const createBody = {
...rawCreateBody,
parentId: effectiveParentId,
parentDepthPolicy,
...(parentDepthPolicy === "flatten" && effectiveParentId
? { expectedParentId: parentPlacement.effectiveParentId }
: {}),
...(normalizedAssigneeAgentId !== undefined
? { assigneeAgentId: normalizedAssigneeAgentId }
: {}),
@@ -12814,7 +12902,7 @@ export function issueRoutes(
}
const createAssignmentScope = {
projectId: await resolveCreateAssignmentProjectId({ ...createBody, companyId }),
parentIssueId: createBody.parentId ?? null,
parentIssueId: parentPlacement.effectiveParentId ?? null,
assigneeAgentId: createBody.assigneeAgentId ?? null,
assigneeUserId: rawCreateBody.assigneeUserId ?? null,
};
@@ -12951,6 +13039,13 @@ export function issueRoutes(
...(runDelegationParentId && issue.parentId === runDelegationParentId
? { parentId: issue.parentId, parentDefaultedFromRunIssue: true }
: {}),
...(issue.parentAdjusted
? {
parentId: issue.parentId,
parentAdjusted: issue.parentAdjusted,
...(runDelegationParentId ? { parentDefaultedFromRunIssue: true } : {}),
}
: {}),
...(watchdogProductBugFollowUp
? {
watchdogDiscovery: {
@@ -13173,9 +13268,38 @@ export function issueRoutes(
? { assigneeAgentId: normalizedAssigneeAgentId }
: {}),
};
// Board users nest freely, and watchdog follow-ups are located by parent.
// Other agents build at most parent → child trees: a child of a child is
// placed beside it instead (never rejected).
const childSerializationContext =
await resolveWatchdogFollowUpSerializationContext(req, parent);
const childParentDepthPolicy: IssueParentDepthPolicy =
req.actor.type === "board" ||
childSerializationContext ||
(await resolveTaskWatchdogMutationScope(db, req.actor)).kind === "watchdog"
? "allow_nested"
: "flatten";
const childPlacement = await resolveRouteParentPlacement(
parent.companyId, parent.id, childParentDepthPolicy,
);
if (
req.actor.type === "agent" &&
childPlacement.effectiveParentId &&
childPlacement.effectiveParentId !== parent.id
) {
const placedParent = await svc.getById(childPlacement.effectiveParentId);
if (
!placedParent ||
(!isTaskBridgeKeyActor(req) &&
!(await assertIssueWriteInfluenceAllowed(req, res, placedParent)))
) {
if (!res.headersSent) res.status(404).json({ error: "Parent issue not found" });
return;
}
}
const childAssignmentScope = {
projectId: await resolveCreateAssignmentProjectId({ ...createBody, companyId: parent.companyId, parentId: parent.id }),
parentIssueId: parent.id,
parentIssueId: childPlacement.effectiveParentId,
assigneeAgentId: createBody.assigneeAgentId ?? null,
assigneeUserId: createBody.assigneeUserId ?? null,
};
@@ -13197,8 +13321,7 @@ export function issueRoutes(
);
const actor = getActorInfo(req);
const serializationContext =
await resolveWatchdogFollowUpSerializationContext(req, parent);
const serializationContext = childSerializationContext;
const currentSerializedChild = serializationContext
? await findCurrentSerializedWatchdogChild(parent)
: null;
@@ -13230,6 +13353,8 @@ export function issueRoutes(
);
const { issue, parentBlockerAdded } = await svc.createChild(parent.id, {
...createBody,
parentDepthPolicy: childParentDepthPolicy,
...(childParentDepthPolicy === "flatten" ? { expectedParentId: childPlacement.effectiveParentId } : {}),
workspaceSelectionActor: req.actor,
projectId: childAssignmentScope.projectId,
...(taskBridgeOriginForActor(req) ?? {}),
@@ -13268,7 +13393,10 @@ export function issueRoutes(
entityType: "issue",
entityId: issue.id,
details: {
parentId: parent.id,
parentId: issue.parentId,
...(issue.parentAdjusted
? { requestedParentId: parent.id, parentAdjusted: issue.parentAdjusted }
: {}),
createdByRequest: childCreatedByRequest(issueId, issue.id),
identifier: issue.identifier,
title: issue.title,
@@ -14176,6 +14304,56 @@ export function issueRoutes(
onBehalfOfUserId: _requestedOnBehalfOfUserId,
...updateFields
} = req.body;
// Board users move tasks to any depth. An agent move into a child is
// placed beside it, and an agent move of a task with children is skipped,
// both with a notice instead of an error.
// The service places the move again and rejects it if the tree changed
// after this check (`expectedParentId`); it also keeps the requested
// parent as the privacy source when the move is placed beside it.
const parentDepthPolicy: IssueParentDepthPolicy = req.actor.type === "board" ? "allow_nested" : "flatten";
let parentAdjusted: IssueParentAdjustment | null = null;
let expectedParentId: string | null | undefined;
if (
parentDepthPolicy === "flatten" &&
typeof updateFields.parentId === "string" &&
updateFields.parentId !== existing.parentId
) {
const requestedParentId = updateFields.parentId;
const [existingChild] = await db
.select({ id: issueRows.id })
.from(issueRows)
.where(and(eq(issueRows.companyId, existing.companyId), eq(issueRows.parentId, existing.id)))
.limit(1);
const currentParent = existingChild && existing.parentId ? await svc.getById(existing.parentId) : null;
const parentPlan = planParentChange(
existing,
await loadRouteParentChain(existing.companyId, requestedParentId),
requestedParentId,
Boolean(existingChild),
currentParent ? { id: currentParent.id, identifier: currentParent.identifier ?? null } : null,
parentDepthPolicy,
);
parentAdjusted = parentPlan.adjustment;
// `undefined` means the move is skipped; `null` is a valid top-level placement.
expectedParentId = parentPlan.parentId === undefined ? existing.parentId : parentPlan.parentId;
if (
req.actor.type === "agent" &&
parentPlan.parentId &&
parentPlan.parentId !== requestedParentId
) {
const placedParent = await svc.getById(parentPlan.parentId);
if (!placedParent || !(await assertIssueWriteInfluenceAllowed(req, res, placedParent))) {
if (!res.headersSent) res.status(404).json({ error: "Parent issue not found" });
return;
}
}
}
/** The parent this update will actually write, for checks that depend on it. */
const plannedParentId = expectedParentId !== undefined ? expectedParentId : updateFields.parentId;
// Activity records the parent actually written and why it differs from the request.
const parentAdjustedActivityDetails = parentAdjusted
? { parentId: plannedParentId ?? null, requestedParentId: parentAdjusted.requestedParent?.id ?? null, parentAdjusted }
: {};
if (req.body.visibility === "private" && !existing.projectId && updateFields.projectId === undefined) {
const responsibleUserId = existing.responsibleUserId ?? (req.actor.type === "board"
? req.actor.userId ?? null
@@ -14787,13 +14965,13 @@ export function issueRoutes(
updateFields.projectId === undefined
? existing.projectId
: (updateFields.projectId as string | null | undefined),
parentIssueId: (updateFields.parentId === undefined
parentIssueId: (plannedParentId === undefined
? existing.parentId
: updateFields.parentId) as string | null | undefined,
: plannedParentId) as string | null | undefined,
}),
parentIssueId: (updateFields.parentId === undefined
parentIssueId: (plannedParentId === undefined
? existing.parentId
: updateFields.parentId) as string | null | undefined,
: plannedParentId) as string | null | undefined,
assigneeAgentId: nextAssigneeAgentId,
assigneeUserId: nextAssigneeUserId,
});
@@ -14927,9 +15105,9 @@ export function issueRoutes(
}
const nextParentId =
updateFields.parentId === undefined
plannedParentId === undefined
? existing.parentId
: (updateFields.parentId as string | null);
: (plannedParentId as string | null);
const shouldRelayStop =
Boolean(nextParentId) &&
existing.status !== updateFields.status &&
@@ -14956,6 +15134,8 @@ export function issueRoutes(
const postCommitIssueActions: IssuePostCommitAction[] = [];
const issueUpdateData = {
...updateFields,
...(updateFields.parentId !== undefined ? { parentDepthPolicy } : {}),
...(expectedParentId !== undefined ? { expectedParentId } : {}),
workspaceSelectionActor: req.actor,
expectedExecutionPolicy,
expectedStatusVersion,
@@ -15045,6 +15225,7 @@ export function issueRoutes(
entityId: updated.id,
details: {
...updateFields,
...parentAdjustedActivityDetails,
identifier: updated.identifier,
authorizationReason: issueMutationAuthorizationReason,
changes,
@@ -15431,6 +15612,7 @@ export function issueRoutes(
entityId: issue.id,
details: {
...updateFields,
...parentAdjustedActivityDetails,
identifier: issue.identifier,
authorizationReason: issueMutationAuthorizationReason,
changes: issueChanges,
@@ -16403,10 +16585,11 @@ export function issueRoutes(
updatedAt: issueResponse.updatedAt,
changes,
comment,
...(parentAdjusted ? { parentAdjusted } : {}),
});
return;
}
res.json({ ...issueResponse, changes, comment });
res.json({ ...issueResponse, changes, comment, ...(parentAdjusted ? { parentAdjusted } : {}) });
},
);
+2 -2
View File
@@ -105,7 +105,7 @@ When a user answers a pending confirmation in chat, read the latest cards and us
Before handing off work, inspect available projects and repositories. Every task you create from this chat must belong to a suitable project. Reuse an appropriate existing project; otherwise use create_project. Consider all relevant available repositories and pass repositoryIds for one or multiple repositories when the work spans them. For existing GitHub repositories you can access that are absent from the catalog, pass their HTTPS repositoryUrls; this registers them with the project without creating remote GitHub repositories. You may combine known IDs and URLs and attach multiple repositories. The direct HTTP equivalent is POST /api/companies/{companyId}/projects with name, repositoryIds and/or repositoryUrls arrays, and an idempotencyKey. Include all selected repositories in that creation; do not combine these arrays with workspace. Never invent repository IDs or substitute inaccessible repositories. Ask when the choice is materially ambiguous or required access is missing. Non-code projects may need no repository.
Create ordinary assigned tasks, never subtasks of this conversation. Give each task a clear outcome, context, acceptance criteria, and project. Default the assignee to the agent in this conversation (yourself). Assign a different agent or person only when the user explicitly specifies that owner. A task requested "for me" does not by itself mean assigning it to the user. Do not choose a different agent based on perceived suitability. Use create_task with initialPlan to copy the relevant plan into the new task before execution starts. If using the HTTP API directly, POST /api/companies/{companyId}/issues with projectId, assigneeAgentId, status: "todo", initialPlan containing the relevant plan Markdown, and an idempotencyKey; omit parentId. Putting a plan in description does not create the task's plan document. Verify the new task's plan document before claiming the handoff is complete. Preserve the original plan here. Carry forward only the remaining execution steps, not completed planning, approval, project creation, or task creation steps. Include the source conversation ID, approved plan revision ID, and accepted interaction ID so the worker can verify the recorded approval. State the approved scope and what is already done; do not claim the new plan document has its own approval. The worker should execute that authorized scope, and ask again only if scope changes or another applicable gate requires it. When splitting work, include the relevant part of the plan in each task. Create and link each task before claiming it exists.
Create ordinary assigned tasks; this conversation is never a parent. Choose the shape that fits the work: one task for one coherent outcome, a top-level task with child tasks under it when one owner coordinates several parts, or separate top-level tasks for unrelated outcomes. With create_task, create the top-level task first, then pass its ID as parentTaskId for each child. Nest only one level: a child of a child is placed beside it as a sibling. Use blockers, not nesting, for ordering. Give each task a clear outcome, context, acceptance criteria, and project. Default the assignee to the agent in this conversation (yourself). Assign a different agent or person only when the user explicitly specifies that owner. A task requested "for me" does not by itself mean assigning it to the user. Do not choose a different agent based on perceived suitability. Use create_task with initialPlan to copy the relevant plan into the new task before execution starts. If using the HTTP API directly, POST /api/companies/{companyId}/issues with projectId, assigneeAgentId, status: "todo", initialPlan containing the relevant plan Markdown, and an idempotencyKey; omit parentId for a top-level task, or set it to a top-level task you created from this conversation for a child. Putting a plan in description does not create the task's plan document. Verify the new task's plan document before claiming the handoff is complete. Preserve the original plan here. Carry forward only the remaining execution steps, not completed planning, approval, project creation, or task creation steps. Include the source conversation ID, approved plan revision ID, and accepted interaction ID so the worker can verify the recorded approval. State the approved scope and what is already done; do not claim the new plan document has its own approval. The worker should execute that authorized scope, and ask again only if scope changes or another applicable gate requires it. When splitting work, include the relevant part of the plan in each task. Create and link each task before claiming it exists.
For review or follow-up tasks, include the existing source materials the assignee needs, not just task identifiers or a rubric. Read the source task's saved documents and comments, then copy the relevant existing content into the handoff or attach resources the recipient can actually read. A new task's tools may only expose its own documents and history. Supplying existing source material is coordination, not creating the assigned deliverable.
@@ -116,7 +116,7 @@ For status updates, distinguish recorded task status from active execution and v
Keep discussion here and leave the conversation available for the next message. Link handed-off tasks in your reply; do not make this conversation blocked by their completion or wait for them. Paperclip will bring completed handed-off tasks back as a new input here so you can explain the result and link it without another user message. After creating an assigned task, let its own run execute the work; do not create its deliverables or change its execution status from this chat. Reply normally and end your turn; Paperclip manages the conversation waiting state. Do not change its status, create a review confirmation just to finish a reply, mark it complete, or poll for another reply. An accepted plan authorizes handoff to execution tasks, never implementation on this conversation. Honor normal approvals. Ask mode is non-mutating. Plan mode supports research and writing/revising the plan; hand off for execution only through the normal authorized workflow.`;
/** Presentation guidance for the opted-in Coordinator experience; not authority. */
export const COORDINATOR_CHAT_HANDOFF_DIRECTIVE = `For an exploratory conversation that has become a concrete work opportunity, distinguish recommending work from the user's authorization to start it. Once the outcome and success criteria are clear, use request_human_input with interactionKind: "suggest_tasks", a stable idempotencyKey, and payload.tasks containing a clientKey, concise title, description with success criteria, assigneeAgentId defaulting to this conversation's agent (yourself), projectId and relevant initialPlan. Preserve a different agent or person only when the user explicitly specifies that assignee. Omit parentId: the accepted task is standalone. The suggestion card is a proposal, not a created task. Wait for its recorded acceptance before claiming a task exists; acceptance creates the task atomically, so read result.createdTasks instead of creating it again. Revise or clarify an ambiguous proposal in chat. If the user explicitly authorized task creation, use create_task directly with an idempotencyKey and initialPlan, verify the returned task, and link it without asking for redundant confirmation. An existing approved plan also retains its normal handoff authority. Ask mode remains non-mutating.`;
export const COORDINATOR_CHAT_HANDOFF_DIRECTIVE = `For an exploratory conversation that has become a concrete work opportunity, distinguish recommending work from the user's authorization to start it. Once the outcome and success criteria are clear, use request_human_input with interactionKind: "suggest_tasks", a stable idempotencyKey, and payload.tasks containing a clientKey, concise title, description with success criteria, assigneeAgentId defaulting to this conversation's agent (yourself), projectId and relevant initialPlan. Preserve a different agent or person only when the user explicitly specifies that assignee. Accepted tasks are standalone by default. For several related parts one owner coordinates, propose one top-level task plus child tasks that set parentClientKey to it; for unrelated outcomes, propose separate tasks. Nest only one level. The suggestion card is a proposal, not a created task. Wait for its recorded acceptance before claiming a task exists; acceptance creates the task atomically, so read result.createdTasks instead of creating it again. Revise or clarify an ambiguous proposal in chat. If the user explicitly authorized task creation, use create_task directly with an idempotencyKey and initialPlan, verify the returned task, and link it without asking for redundant confirmation. An existing approved plan also retains its normal handoff authority. Ask mode remains non-mutating.`;
/** A reset keeps history visible, but parked input from a stopped session cannot become a new turn. */
export function currentConversationCommentCondition() {
+4
View File
@@ -1355,6 +1355,8 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
{
...(defaults.projectId !== undefined ? { projectId: defaults.projectId } : {}),
...placement,
// Boundary placement is a trust decision; the agent depth limit must not move it.
parentDepthPolicy: "allow_nested",
...(defaults.workspace ? { workspaceSelection: defaults.workspace, workspaceSelectionSource: "channel" as const } : {}),
title: message.subject.slice(0, 200),
description: `Email conversation for ${current.botExternalId}`,
@@ -1585,6 +1587,8 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
{
title: input.subject!,
parentId: input.parentIssueId,
// Channel tasks stay inside their source/boundary subtree.
parentDepthPolicy: "allow_nested",
projectId: sourceTask.projectId,
executionWorkspaceSettings: sourceTask.executionWorkspaceSettings,
description: `Email conversation from ${endpoint.botExternalId}`,
@@ -337,6 +337,8 @@ export function createHeartbeatScheduling(
status: "todo",
priority: "high",
parentId: input.claimed.id,
// Recovery work is structural system machinery tied to the stranded task.
parentDepthPolicy: "allow_nested",
projectId: input.claimed.projectId,
goalId: input.claimed.goalId,
assigneeAgentId: input.claimed.assigneeAgentId,
+5 -2
View File
@@ -24,11 +24,14 @@ export function createdFromIssueCondition(companyId: string, sourceIssueId: stri
nullif(${heartbeatRuns.contextSnapshot}->>'taskKey', '')
) IN (${source.id}::text, ${source.identifier})
`;
// A recorded origin task wins; run and activity fallbacks apply only to rows
// without one, so the list agrees with the detail's createdFrom.
return sql<boolean>`
${issues.id} <> ${sourceIssueId}
AND (
${issues.originRunId} IN (${originatingRuns})
OR (${issues.originRunId} IS NULL AND EXISTS (
${issues.originIssueId} = ${sourceIssueId}
OR (${issues.originIssueId} IS NULL AND ${issues.originRunId} IN (${originatingRuns}))
OR (${issues.originIssueId} IS NULL AND ${issues.originRunId} IS NULL AND EXISTS (
SELECT 1 FROM ${activityLog}
WHERE ${activityLog.companyId} = ${companyId}
AND ${activityLog.entityType} = 'issue'
+28 -11
View File
@@ -9,9 +9,9 @@ type CreationSourceRun = Pick<HeartbeatRunRow, "id" | "companyId" | "agentId" |
contextSnapshot: { issueId: unknown; taskId: unknown; taskKey: unknown; paperclipIssue: { id: unknown } } | null;
};
/** The task a run was executing when it created another task. */
/** The task that created another task, and the run that did so when one did. */
export interface IssueCreationSourceRecord {
run: CreationSourceRun;
run: CreationSourceRun | null;
sourceIssue: IssueRow;
}
@@ -67,15 +67,35 @@ export async function loadRunBoundIssue(
}
/**
* Resolves the task from which an issue was created, using recorded run
* provenance: the issue's origin run, or for historical rows without one, the
* run on its creation activity. Company-scoped; a run or source task from
* another company never resolves. Does not check viewer access.
* Resolves the task from which an issue was created. The recorded
* `origin_issue_id` wins; otherwise the task the origin run (or, for historical
* rows, the run on the creation activity) was executing. Company-scoped; a run
* or source task from another company never resolves. Does not check viewer
* access.
*/
export async function resolveIssueCreationSource(
db: Db,
issue: Pick<IssueRow, "id" | "companyId" | "originRunId">,
issue: Pick<IssueRow, "id" | "companyId" | "originRunId"> & { originIssueId?: string | null },
): Promise<IssueCreationSourceRecord | null> {
const run = await loadIssueCreationRun(db, issue);
if (issue.originIssueId && issue.originIssueId !== issue.id) {
const [sourceIssue] = await db
.select()
.from(issues)
.where(and(eq(issues.id, issue.originIssueId), eq(issues.companyId, issue.companyId)))
.limit(1);
if (sourceIssue) return { run, sourceIssue };
}
if (!run) return null;
const sourceIssue = await loadRunBoundIssue(db, run);
if (!sourceIssue || sourceIssue.id === issue.id) return null;
return { run, sourceIssue };
}
async function loadIssueCreationRun(
db: Db,
issue: Pick<IssueRow, "id" | "companyId" | "originRunId">,
): Promise<CreationSourceRun | null> {
let runId = issue.originRunId;
if (!runId) {
const [creation] = await db
@@ -113,10 +133,7 @@ export async function resolveIssueCreationSource(
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, issue.companyId)))
.limit(1);
if (!run) return null;
const sourceIssue = await loadRunBoundIssue(db, run);
if (!sourceIssue || sourceIssue.id === issue.id) return null;
return { run, sourceIssue };
return run ?? null;
}
/** Company-scoped agent summary for a creation source. */
+216
View File
@@ -0,0 +1,216 @@
import { and, eq, sql } from "drizzle-orm";
import { issues, type Db } from "@paperclipai/db";
import { MAX_AGENT_TASK_HIERARCHY_DEPTH, type IssueParentAdjustment } from "@paperclipai/shared";
/**
* `flatten` (the default) applies the agent hierarchy limit: a new parent edge
* may not make the task deeper than MAX_AGENT_TASK_HIERARCHY_DEPTH. Board users
* pass `allow_nested` and may build trees of any depth.
*/
export type IssueParentDepthPolicy = "flatten" | "allow_nested";
/** One issue on the path from a requested parent up to its tree root. */
export type ParentChainRow = {
id: string;
identifier: string | null;
parentId: string | null;
conversationAgentId: string | null;
executionPolicy: unknown;
};
export type ParentPlacement = { effectiveParentId: string | null; adjustment: IssueParentAdjustment | null };
// Ancestor walks are not depth-capped: placement needs the real root even for
// legacy trees deeper than any fixed bound. Both loaders stop on a cycle.
/** Root of a low-trust authorization boundary recorded on an issue's execution policy. */
function lowTrustBoundaryRootIssueId(executionPolicy: unknown): string | null {
const policy = executionPolicy as { authorizationPolicy?: { trustBoundary?: { rootIssueId?: unknown } | null } | null } | null;
const root = policy?.authorizationPolicy?.trustBoundary?.rootIssueId;
return typeof root === "string" && root.length > 0 ? root : null;
}
/**
* Places a new parent edge given the requested parent's ancestor chain
* (`chain[0]` is the requested parent, the last entry its root). Under
* `flatten`, a parent already at the maximum depth is replaced by its ancestor
* at the allowed depth, which makes the task a sibling of the requester. Never
* throws for depth.
*/
export function placeTaskParent(chain: ParentChainRow[], policy: IssueParentDepthPolicy): ParentPlacement {
const requested = chain[0];
if (!requested || policy === "allow_nested") {
return { effectiveParentId: requested?.id ?? null, adjustment: null };
}
// A parent at depth d (root = 1) gives the new task depth d + 1.
const maxParentDepth = MAX_AGENT_TASK_HIERARCHY_DEPTH - 1;
if (chain.length <= maxParentDepth) return { effectiveParentId: requested.id, adjustment: null };
const targetIndex = chain.length - maxParentDepth;
// Low-trust boundaries are subtrees. Never move a task above the root of a
// boundary that contains the requester; keep the requested parent instead.
const boundaryRoots = new Set(chain.flatMap((row) => lowTrustBoundaryRootIssueId(row.executionPolicy) ?? []));
if (chain.slice(0, Math.max(targetIndex, 0)).some((row) => boundaryRoots.has(row.id))) {
return { effectiveParentId: requested.id, adjustment: null };
}
const target = maxParentDepth > 0 ? chain[targetIndex] : null;
// Conversations never take new children; legacy trees under one fall back to top level.
const effective = target && !target.conversationAgentId ? target : null;
return {
effectiveParentId: effective?.id ?? null,
adjustment: {
reason: "max_agent_depth",
applied: true,
requestedParent: { id: requested.id, identifier: requested.identifier },
effectiveParent: effective ? { id: effective.id, identifier: effective.identifier } : null,
},
};
}
/**
* Callers authorize the parent they request. When depth placement moves a task
* above that parent, the ancestor is written only if the caller also authorized
* it and says so with `expectedParentId` (the routes and the runner do). Other
* callers get standalone work instead: no structural parent, never an edge
* under an ancestor nobody checked.
*/
export function confirmPlacement(placement: ParentPlacement, expectedParentId: string | null | undefined): ParentPlacement {
if (expectedParentId !== undefined || !placement.adjustment?.applied || placement.effectiveParentId === null) {
return placement;
}
return { effectiveParentId: null, adjustment: { ...placement.adjustment, effectiveParent: null } };
}
/** Loads a requested parent's ancestor chain in one recursive query. Empty when it does not exist. */
export async function loadParentChain(
db: Pick<Db, "execute">,
companyId: string,
parentId: string,
): Promise<ParentChainRow[]> {
const result = await db.execute(sql`
WITH RECURSIVE chain AS (
SELECT ${issues.id} AS id, ${issues.identifier} AS identifier, ${issues.parentId} AS parent_id,
${issues.conversationAgentId} AS conversation_agent_id, ${issues.executionPolicy} AS execution_policy,
1 AS depth, ARRAY[${issues.id}] AS path
FROM ${issues}
WHERE ${issues.companyId} = ${companyId} AND ${issues.id} = ${parentId}
UNION ALL
SELECT parent.id, parent.identifier, parent.parent_id, parent.conversation_agent_id, parent.execution_policy,
chain.depth + 1, chain.path || parent.id
FROM ${issues} AS parent
JOIN chain ON parent.id = chain.parent_id
WHERE parent.company_id = ${companyId}
AND NOT parent.id = ANY(chain.path)
)
SELECT id, identifier, parent_id, conversation_agent_id, execution_policy FROM chain ORDER BY depth
`);
const rows = (Array.isArray(result) ? result : (result as { rows?: unknown[] }).rows ?? []) as Array<{
id: string; identifier: string | null; parent_id: string | null; conversation_agent_id: string | null; execution_policy: unknown;
}>;
return rows.map((row) => ({
id: row.id,
identifier: row.identifier,
parentId: row.parent_id,
conversationAgentId: row.conversation_agent_id,
executionPolicy: row.execution_policy,
}));
}
/** Loads an ancestor chain one issue at a time through a caller-supplied, company-scoped reader. */
export async function loadParentChainWith(
load: (id: string) => Promise<ParentChainRow | null>,
parentId: string,
): Promise<ParentChainRow[]> {
const chain: ParentChainRow[] = [];
const visited = new Set<string>();
let nextId: string | null = parentId;
while (nextId && !visited.has(nextId)) {
visited.add(nextId);
const row = await load(nextId);
if (!row) break;
chain.push(row);
nextId = row.parentId;
}
return chain;
}
/**
* Resolves the parent a new edge should use. Unknown parents pass through
* unchanged so the caller's own existence and authorization checks still apply.
*/
export async function resolveNewTaskParent(
db: Pick<Db, "execute">,
companyId: string,
requestedParentId: string | null | undefined,
policy: IssueParentDepthPolicy = "flatten",
): Promise<ParentPlacement> {
if (!requestedParentId || policy === "allow_nested") {
return { effectiveParentId: requestedParentId ?? null, adjustment: null };
}
const chain = await loadParentChain(db, companyId, requestedParentId);
if (chain.length === 0) return { effectiveParentId: requestedParentId, adjustment: null };
return placeTaskParent(chain, policy);
}
/**
* Plans a parent change for an existing issue from the requested parent's
* chain. Under `flatten`, a move into a child is placed beside it, and a move
* of an issue that has its own children is skipped (`parentId: undefined`),
* since either would create grandchildren. Never throws for depth.
*/
export function planParentChange(
existing: { parentId: string | null },
chain: ParentChainRow[],
requestedParentId: string,
hasChildren: boolean,
currentParent: { id: string; identifier: string | null } | null,
policy: IssueParentDepthPolicy,
): { parentId: string | null | undefined; adjustment: IssueParentAdjustment | null } {
const requested = chain[0];
if (policy === "allow_nested" || requestedParentId === existing.parentId || !requested) {
return { parentId: requestedParentId, adjustment: null };
}
if (hasChildren) {
return {
parentId: undefined,
adjustment: {
reason: "has_children",
applied: false,
requestedParent: { id: requested.id, identifier: requested.identifier },
effectiveParent: currentParent,
},
};
}
const placement = placeTaskParent(chain, "flatten");
return { parentId: placement.effectiveParentId, adjustment: placement.adjustment };
}
/** Database-backed {@link planParentChange} for the issue service. */
export async function planIssueParentChange(
db: Pick<Db, "execute" | "select">,
existing: { id: string; companyId: string; parentId: string | null },
requestedParentId: string,
policy: IssueParentDepthPolicy = "flatten",
) {
if (policy === "allow_nested" || requestedParentId === existing.parentId) {
return { parentId: requestedParentId as string | null | undefined, adjustment: null as IssueParentAdjustment | null };
}
const chain = await loadParentChain(db, existing.companyId, requestedParentId);
const [child] = await db
.select({ id: issues.id })
.from(issues)
.where(and(eq(issues.companyId, existing.companyId), eq(issues.parentId, existing.id)))
.limit(1);
const [currentParent] = existing.parentId && child ? await db
.select({ id: issues.id, identifier: issues.identifier })
.from(issues)
.where(and(eq(issues.companyId, existing.companyId), eq(issues.id, existing.parentId))) : [];
return planParentChange(existing, chain, requestedParentId, Boolean(child), currentParent ?? null, policy);
}
/** Adds `parentAdjusted` to a create/update result only when the parent was adjusted. */
export function withParentAdjustment<T extends object>(
issue: T,
adjustment: IssueParentAdjustment | null,
): T & { parentAdjusted?: IssueParentAdjustment } {
return adjustment ? { ...issue, parentAdjusted: adjustment } : issue;
}
@@ -1,3 +1,4 @@
import { resolveNewTaskParent } from "./issue-hierarchy.js";
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
import { notifyMergeConfirmationWork, type MergeConfirmationScope } from "./merged-pr-confirmation-notifications.js";
import { notifyNativeStatusReassessment } from "./native-status-reassessment-notifications.js";
@@ -4371,8 +4372,18 @@ export function issueThreadInteractionService(
} } : {}),
} satisfies Parameters<ReturnType<typeof issueService>["createChild"]>[1];
const service = issueService(tx as unknown as Db);
// A nested suggestion may be placed beside its suggested parent, under
// that parent's own requested parent. The accept scope authorized that
// parent, so confirm the placement only when it lands exactly there.
const nestedUnder = task.parentClientKey ? createdByClientKey.get(task.parentClientKey) : undefined;
const placement = parentIssueId && nestedUnder
? await resolveNewTaskParent(tx as unknown as Db, issue.companyId, parentIssueId, "flatten")
: null;
const confirmedParent = placement && placement.effectiveParentId === nestedUnder?.parentIssueId
? { expectedParentId: placement.effectiveParentId }
: {};
const createdIssue = parentIssueId
? (await service.createChild(parentIssueId, createInput)).issue
? (await service.createChild(parentIssueId, { ...createInput, ...confirmedParent })).issue
: await service.create(issue.companyId, createInput);
const parentIdentifier =
+169 -14
View File
@@ -52,8 +52,17 @@ import { externalConversationStateSql, nonIdleSlackIssueCondition, resumeSlackCo
import { documentService } from "./documents.js";
import { parseTaskSearch, taskSearchCtes, taskSearchScore } from "./task-search.js";
import { createdFromIssueCondition } from "./issue-creation-origin.js";
import { loadRunBoundIssue } from "./issue-creation-source.js";
import { executionProjectionsForRuns } from "./execution-projection.js";
import type { ExecutionProjection } from "@paperclipai/shared";
import type { IssueParentAdjustment } from "@paperclipai/shared";
import {
confirmPlacement,
planIssueParentChange,
resolveNewTaskParent,
withParentAdjustment,
type IssueParentDepthPolicy,
} from "./issue-hierarchy.js";
import { Buffer } from "node:buffer";
import { isDeepStrictEqual } from "node:util";
import { createHash, randomUUID } from "node:crypto";
@@ -2043,6 +2052,18 @@ type IssueUserContextInput = {
};
type ProjectGoalReader = Pick<Db, "select">;
type DbReader = Pick<Db, "select">;
/**
* A new or moved parent edge must point at an issue in the same company. The
* foreign key alone checks only existence, so a parent deleted or moved after
* a route's check could otherwise land as a cross-company edge.
*/
async function assertParentInCompany(db: Pick<Db, "select">, companyId: string, parentId: string | null | undefined) {
if (!parentId) return;
const [parent] = await db.select({ id: issues.id }).from(issues)
.where(and(eq(issues.id, parentId), eq(issues.companyId, companyId)));
if (!parent) throw notFound("Parent issue not found");
}
/** Conversation containers cannot acquire new child edges, even with the experiment disabled. */
async function assertExecutionTaskParent(db: Db, companyId: string, parentId?: string | null) {
if (!parentId) return;
@@ -2052,7 +2073,15 @@ async function assertExecutionTaskParent(db: Db, companyId: string, parentId?: s
}
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId" | "title" | "titleNeedsGeneration" | "workspaceSelection"> & {
// `originIssueId` is server-derived provenance; callers cannot supply it.
type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId" | "title" | "titleNeedsGeneration" | "workspaceSelection" | "originIssueId"> & {
/** Defaults to `flatten` (agent/automation limit). Board-user routes pass `allow_nested`. */
parentDepthPolicy?: IssueParentDepthPolicy;
/**
* The placed parent the caller authorized. If the tree changed and placement
* under the privacy-tree lock differs, the create is rejected, not written.
*/
expectedParentId?: string | null;
workspaceSelection?: TaskWorkspaceSelection;
workspaceSelectionSource?: "explicit" | "channel";
workspaceSelectionActor?: AuthorizationActor;
@@ -5097,6 +5126,7 @@ const issueListSelect = {
originKind: issues.originKind,
originId: issues.originId,
originRunId: issues.originRunId,
originIssueId: issues.originIssueId,
originIdentityContextId: issues.originIdentityContextId,
continuationIdentityContextId: issues.continuationIdentityContextId,
originFingerprint: issues.originFingerprint,
@@ -9677,7 +9707,8 @@ export function issueService(db: Db) {
.limit(1)
.then((rows) => rows[0]?.issue ?? null);
if (existingChild) {
if (existingChild.parentId !== parent.id) {
// A placed child keeps the requester as its privacy parent.
if (existingChild.parentId !== parent.id && existingChild.privacyParentIssueId !== parent.id) {
throw conflict(
"Child creation idempotency key belongs to another parent issue",
);
@@ -9692,19 +9723,30 @@ export function issueService(db: Db) {
db,
);
return {
issue: withRelations,
issue: withParentAdjustment(withRelations, null),
parentBlockerAdded: false,
};
}
}
// Under the agent depth limit a child of a child lands beside it; count
// the requester's own fan-out either way.
const { effectiveParentId } = confirmPlacement(
await resolveNewTaskParent(db, parent.companyId, parent.id, data.parentDepthPolicy ?? "flatten"),
data.expectedParentId,
);
const [{ childCount }] = await db
.select({ childCount: sql<number>`count(*)::int` })
.from(issues)
.where(
and(
eq(issues.companyId, parent.companyId),
eq(issues.parentId, parent.id),
effectiveParentId === parent.id
? eq(issues.parentId, parent.id)
: and(
effectiveParentId ? eq(issues.parentId, effectiveParentId) : isNull(issues.parentId),
eq(issues.privacyParentIssueId, parent.id),
),
notInArray(issues.status, ["done", "cancelled"]),
),
);
@@ -9803,11 +9845,13 @@ export function issueService(db: Db) {
tx,
);
});
const parentAdjusted = child.parentAdjusted;
[child] = await withIssueRelationSummaries(
parent.companyId,
[child],
db,
);
child = withParentAdjustment(child, parentAdjusted ?? null);
}
return {
@@ -10204,8 +10248,14 @@ export function issueService(db: Db) {
allowDuplicate,
assertCanReuseIssue,
onDeduplicated,
parentDepthPolicy = "flatten",
expectedParentId,
...issueData
} = data;
// The parent the caller asked for. It still drives provenance, privacy and
// workspace inheritance when the depth policy places the task elsewhere.
const requestedParentId = issueData.parentId ?? null;
let parentAdjustment: IssueParentAdjustment | null = null;
const explicitTitle = issueData.title?.trim();
const provisionalTitle = issueData.description
? provisionalTitleFromDescription(issueData.description)
@@ -10244,6 +10294,18 @@ export function issueService(db: Db) {
}
const persist = async (tx: DbTransaction) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`issue-privacy-tree:${companyId}`}, 0))`);
const parentResolution = confirmPlacement(
await resolveNewTaskParent(tx, companyId, requestedParentId, parentDepthPolicy),
expectedParentId,
);
if (expectedParentId !== undefined && parentResolution.effectiveParentId !== expectedParentId) {
throw conflict("The task tree changed while this task was checked. Read the tasks again and retry.", {
code: "issue_parent_placement_changed",
});
}
parentAdjustment = parentResolution.adjustment;
issueData.parentId = parentResolution.effectiveParentId;
await assertParentInCompany(tx, companyId, issueData.parentId);
await assertExecutionTaskParent(tx as unknown as Db, companyId, issueData.parentId);
if (issueData.conversationAgentId && issueData.conversationUserId) {
const identity = `conversation:${companyId}:${issueData.conversationAgentId}:${issueData.conversationUserId}`;
@@ -10259,7 +10321,10 @@ export function issueService(db: Db) {
const idempotencyKey = rawIdempotencyKey?.trim() || null;
const normalizedTitle = normalizeCreateIssueTitle(resolvedTitle);
if (deduplicateByTitle) {
const titleGuardKey = `issue-create:title:${companyId}:${issueData.parentId ?? "root"}:${normalizedTitle}`;
// A task placed beside its requester dedupes within that requester's
// follow-ups, not across every requester under the same root.
const titleScope = parentAdjustment?.applied ? `origin:${requestedParentId}` : (issueData.parentId ?? "root");
const titleGuardKey = `issue-create:title:${companyId}:${titleScope}:${normalizedTitle}`;
await tx.execute(
sql`select pg_advisory_xact_lock(hashtextextended(${titleGuardKey}, 0))`,
);
@@ -10317,6 +10382,9 @@ export function issueService(db: Db) {
issueData.parentId
? eq(issues.parentId, issueData.parentId)
: isNull(issues.parentId),
...(parentAdjustment?.applied && requestedParentId
? [eq(issues.privacyParentIssueId, requestedParentId)]
: []),
isNull(issues.hiddenAt),
notInArray(issues.status, ["done", "cancelled"]),
gte(
@@ -10372,7 +10440,7 @@ export function issueService(db: Db) {
const workspaceInheritanceIssueId = skipExecutionWorkspaceInheritance
? null
: (inheritExecutionWorkspaceFromIssueId ??
issueData.parentId ??
requestedParentId ??
null);
const hasExplicitExecutionWorkspaceOverride =
(workspaceSelection !== undefined && workspaceSelectionSource === "explicit") ||
@@ -10618,14 +10686,19 @@ export function issueService(db: Db) {
companyId, ...conversationHandoff,
userId: issueData.createdByUserId, agentId: issueData.createdByAgentId,
}) : null;
let privacyParentIssueId = issueData.parentId ?? handoffSource?.id ?? null;
// The task the creating run was bound to, when a run created this task.
let runSourceIssueId: string | null = null;
const sourceRunId = issueData.originRunId ?? actorRunId;
if (!privacyParentIssueId && sourceRunId) {
if (sourceRunId && isUuidLike(sourceRunId)) {
const [sourceRun] = await tx.select().from(heartbeatRuns)
.where(and(eq(heartbeatRuns.id, sourceRunId), eq(heartbeatRuns.companyId, companyId)));
const sourceId = sourceRun?.nativeIssueId ?? sourceRun?.issueId ?? sourceRun?.contextSnapshot?.issueId;
if (typeof sourceId === "string") privacyParentIssueId = sourceId;
// Same binding rules as creation-source reads: native identity, issue
// scope, then legacy task context (including identifiers), company-scoped.
if (sourceRun) runSourceIssueId = (await loadRunBoundIssue(tx as unknown as Db, sourceRun))?.id ?? null;
}
// Privacy follows structure: the requested parent (even when placed
// beside it), the chat that handed this off, or the run's task.
let privacyParentIssueId = requestedParentId ?? handoffSource?.id ?? runSourceIssueId;
let inheritedPrivateSubtree = false;
if (privacyParentIssueId) {
const [source] = await tx.select().from(issues)
@@ -10667,12 +10740,33 @@ export function issueService(db: Db) {
issueData.projectId = personalProject.id;
}
// origin_issue_id is returned with the task, so record it only when it
// reveals nothing new: the task's own parent or privacy parent (already
// exposed on the task), or a fully public origin (open, outside private
// projects and subtrees, not hidden). Otherwise leave it null; createdFrom
// then resolves the source through the run, which checks access on read.
let originIssueId: string | null = runSourceIssueId ?? handoffSource?.id ?? privacyParentIssueId;
if (originIssueId && originIssueId !== issueData.parentId && originIssueId !== privacyParentIssueId) {
const [origin] = await tx.select({
visibility: issues.visibility, privacyRootIssueId: issues.privacyRootIssueId,
projectId: issues.projectId, hiddenAt: issues.hiddenAt,
}).from(issues).where(and(eq(issues.id, originIssueId), eq(issues.companyId, companyId)));
const originPublic = Boolean(origin) && origin!.visibility !== "private" && !origin!.privacyRootIssueId &&
!origin!.hiddenAt && !(origin!.projectId && await tx.select({ id: projects.id }).from(projects)
.where(and(eq(projects.id, origin!.projectId), eq(projects.visibility, "private"))).limit(1)
.then((rows) => rows.length > 0));
if (!originPublic) originIssueId = null;
}
const values = {
...issueData,
workspaceSelection: workspaceIntent,
visibility,
privacyRootIssueId,
privacyParentIssueId,
// Audit-only provenance; never read for hierarchy or authorization.
// Audit-only provenance: the task whose work created this one. A run
// working on task A that files work under task B originates from A.
originIssueId,
titleNeedsGeneration,
originRunId: issueData.originRunId ?? actorRunId ?? null,
responsibleUserId,
@@ -10789,10 +10883,15 @@ export function issueService(db: Db) {
[enriched],
tx,
);
insertedNewIssue = true;
return withRelations;
};
if (dbOrTx === db) return db.transaction(persist);
return persist(dbOrTx as DbTransaction);
let insertedNewIssue = false;
const created = dbOrTx === db
? await db.transaction(persist)
: await persist(dbOrTx as DbTransaction);
// Deduplicated returns are existing rows; only a fresh insert reports an adjustment.
return withParentAdjustment(created, insertedNewIssue ? parentAdjustment : null);
},
/**
@@ -11139,6 +11238,13 @@ export function issueService(db: Db) {
workspaceSelectionActor?: AuthorizationActor;
expectedExecutionPolicy?: typeof issues.$inferInsert.executionPolicy;
expectedStatusVersion?: number;
/** Defaults to `flatten` for parent changes. Board-user routes pass `allow_nested`. */
parentDepthPolicy?: IssueParentDepthPolicy;
/**
* The placed parent the caller authorized. A different placement (the
* tree changed since that check) is rejected instead of written.
*/
expectedParentId?: string | null;
},
dbOrTx: any = db,
postCommitActivityPublications?: ActivityPublication[],
@@ -11166,6 +11272,40 @@ export function issueService(db: Db) {
.where(idPredicate)
.then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
if (!existing) return null;
let parentAdjustment: IssueParentAdjustment | null = null;
// A move placed beside its requested parent still inherits that parent's privacy.
let privacySourceParentId: string | null = null;
// Re-checked under the privacy-tree lock: a concurrent child create can
// change whether this move is allowed after the unlocked plan.
let lockedParentPlan: { requestedParentId: string; policy: IssueParentDepthPolicy; plannedParentId: string | null } | null = null;
if (data.parentId && data.parentId !== existing.parentId) {
// A skipped move drops only `parentId`; the rest of the patch applies.
const requestedParentId = data.parentId;
let plan = await planIssueParentChange(dbOrTx, existing, requestedParentId, data.parentDepthPolicy ?? "flatten");
// A placement above the requested parent needs the caller's confirmed
// authorization (expectedParentId); otherwise only the move is skipped.
if (plan.adjustment?.applied && plan.parentId !== null && data.expectedParentId === undefined) {
// The task keeps its current parent; report that one.
const [currentParent] = existing.parentId ? await dbOrTx
.select({ id: issues.id, identifier: issues.identifier })
.from(issues)
.where(and(eq(issues.companyId, existing.companyId), eq(issues.id, existing.parentId))) : [];
plan = { parentId: undefined, adjustment: { ...plan.adjustment, applied: false, effectiveParent: currentParent ?? null } };
}
const plannedParentId = plan.parentId === undefined ? existing.parentId : plan.parentId;
if (data.expectedParentId !== undefined && plannedParentId !== data.expectedParentId) {
throw conflict("The task tree changed while this move was checked. Read the tasks again and retry.", {
code: "issue_parent_placement_changed",
});
}
parentAdjustment = plan.adjustment;
if (plan.adjustment?.applied) privacySourceParentId = requestedParentId;
lockedParentPlan = { requestedParentId, policy: data.parentDepthPolicy ?? "flatten", plannedParentId };
const { parentId: _requestedParentId, ...rest } = data;
data = plan.parentId === undefined || plan.parentId === existing.parentId
? rest
: { ...rest, parentId: plan.parentId };
}
if (data.parentId !== undefined && data.parentId !== existing.parentId) {
await assertExecutionTaskParent(dbOrTx, existing.companyId, data.parentId);
}
@@ -11190,6 +11330,10 @@ export function issueService(db: Db) {
workspaceSelectionActor,
expectedExecutionPolicy,
expectedStatusVersion,
parentDepthPolicy: _parentDepthPolicy,
expectedParentId: _expectedParentId,
// Creation provenance never changes after create.
originIssueId: _originIssueId,
...issueData
} = data;
if (expectedStatusVersion !== undefined && (
@@ -11440,6 +11584,16 @@ export function issueService(db: Db) {
const runUpdate = async (tx: any) => {
const changesPrivacy = issueData.visibility !== undefined || issueData.parentId !== undefined || issueData.projectId !== undefined;
if (changesPrivacy) await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`issue-privacy-tree:${existing.companyId}`}, 0))`);
if (issueData.parentId) await assertParentInCompany(tx, existing.companyId, issueData.parentId);
if (lockedParentPlan && issueData.parentId !== undefined) {
const relocked = await planIssueParentChange(tx, existing, lockedParentPlan.requestedParentId, lockedParentPlan.policy);
const relockedParentId = relocked.parentId === undefined ? existing.parentId : relocked.parentId;
if (relockedParentId !== lockedParentPlan.plannedParentId) {
throw conflict("The task tree changed while this move was checked. Read the tasks again and retry.", {
code: "issue_parent_placement_changed",
});
}
}
// The receipt baseline must be read under the same row lock as the
// write. Otherwise a concurrent update can be mistaken for a change
// made by this request.
@@ -11512,7 +11666,8 @@ export function issueService(db: Db) {
patch.privacyRootIssueId = receiptExisting.privacyRootIssueId ?? receiptExisting.id;
}
}
const privacyParentId = issueData.parentId !== undefined ? issueData.parentId : receiptExisting.privacyParentIssueId;
const privacyParentId = privacySourceParentId
?? (issueData.parentId !== undefined ? issueData.parentId : receiptExisting.privacyParentIssueId);
const [privacyParent] = privacyParentId ? await tx.select().from(issues)
.where(and(eq(issues.id, privacyParentId), eq(issues.companyId, existing.companyId))) : [];
patch.privacyParentIssueId = privacyParent?.id ?? null;
@@ -12043,7 +12198,7 @@ export function issueService(db: Db) {
if (dbOrTx === db && !postCommitActions) {
await executeIssuePostCommitActions(db, ownedPostCommitActions);
}
return result;
return result ? withParentAdjustment(result, parentAdjustment) : result;
},
clearExecutionWorkspaceEnvironmentSelection: async (
@@ -19,7 +19,10 @@ import {
issueApprovals,
issueComments,
issueThreadInteractions,
issueWatchdogs,
issues,
projectWorkspaces,
projects,
} from "@paperclipai/db";
import { createFullDatabaseResponseCapture, installDatabaseResponseCapture } from "../../__tests__/helpers/full-database-response-capture.js";
import { startRecoveryPostgres } from "../../__tests__/helpers/recovery-postgres.js";
@@ -1379,6 +1382,130 @@ describe("PaperclipRunnerToolAuthority", () => {
});
});
it("places create_task beside a child task, except for watchdog-scoped runs", async () => {
const rootId = randomUUID();
await db.insert(issues).values({ id: rootId, companyId, title: "Depth root", status: "todo", workMode: "standard" });
async function childRun(watchdog: boolean, watchdogStatus = "active") {
const childId = randomUUID();
const childRunId = randomUUID();
await db.insert(issues).values({
id: childId, companyId, parentId: rootId, title: `Depth child ${watchdog ? "watchdog" : "ordinary"}`,
status: "in_progress", workMode: "standard", assigneeAgentId: agentId,
});
await db.insert(heartbeatRuns).values({
id: childRunId, companyId, agentId, status: "running", runtimeMode: "native", nativeIssueId: childId,
invocationSource: "assignment", triggerDetail: "system",
contextSnapshot: watchdog ? { issueId: childId, taskWatchdog: { watchedIssueId: childId } } : { issueId: childId },
});
await db.update(issues).set({ executionRunId: childRunId }).where(eq(issues.id, childId));
if (watchdog) await db.insert(issueWatchdogs).values({ companyId, issueId: childId, watchdogAgentId: agentId, status: watchdogStatus });
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId: childId, runId: childRunId, workMode: "standard" });
const created = await authority.execute({
tool: "create_task",
callId: `depth-${childId}`,
arguments: { idempotencyKey: `depth-${childId}`, title: "Follow-up from the child", status: "backlog" },
}) as { task: { id: string; parentId: string | null } };
const [row] = await db.select({ originIssueId: issues.originIssueId }).from(issues).where(eq(issues.id, created.task.id));
return { childId, parentId: created.task.parentId, originIssueId: row?.originIssueId };
}
const ordinary = await childRun(false);
expect(ordinary).toMatchObject({ parentId: rootId, originIssueId: ordinary.childId });
const watchdog = await childRun(true);
expect(watchdog).toMatchObject({ parentId: watchdog.childId, originIssueId: watchdog.childId });
// A disabled watchdog no longer earns the exception.
const disabled = await childRun(true, "disabled");
expect(disabled).toMatchObject({ parentId: rootId, originIssueId: disabled.childId });
// An active watchdog may not name a parent outside its watched subtree.
const outsideId = randomUUID();
await db.insert(issues).values({ id: outsideId, companyId, title: "Outside the watched subtree", status: "todo", workMode: "standard" });
const [watchdogRun] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns)
.where(eq(heartbeatRuns.nativeIssueId, watchdog.childId));
const scoped = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId: watchdog.childId, runId: watchdogRun!.id, workMode: "standard" });
await expect(scoped.execute({
tool: "create_task", callId: "watchdog-outside",
arguments: { idempotencyKey: "watchdog-outside", title: "Elsewhere", status: "backlog", parentTaskId: outsideId },
})).rejects.toThrow(/watched issue subtree/);
});
it("authorizes the placed parent: a hidden root rejects a named child parent and makes the default standalone", async () => {
const hiddenRootId = randomUUID();
const openChildId = randomUUID();
const projectId = randomUUID();
const featureWorkspaceId = randomUUID();
await db.insert(projects).values({ id: projectId, companyId, name: "Depth workspace project" });
await db.insert(projectWorkspaces).values([
{ companyId, projectId, name: "Default checkout", isPrimary: true },
{ id: featureWorkspaceId, companyId, projectId, name: "Feature checkout" },
]);
await db.insert(issues).values({
id: hiddenRootId, companyId, title: "Private root", status: "todo", workMode: "standard",
visibility: "private", privacyRootIssueId: hiddenRootId, responsibleUserId: "someone-else",
});
await db.insert(issues).values({
id: openChildId, companyId, parentId: hiddenRootId, title: "Open child", status: "in_progress",
workMode: "standard", assigneeAgentId: agentId, projectId, projectWorkspaceId: featureWorkspaceId,
});
const named = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId, workMode: "standard" });
await expect(named.execute({
tool: "create_task", callId: "hidden-root-named",
arguments: { idempotencyKey: "hidden-root-named", title: "Under the open child", status: "backlog", parentTaskId: openChildId },
})).rejects.toThrow();
const childRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: childRunId, companyId, agentId, status: "running", runtimeMode: "native", nativeIssueId: openChildId,
invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId: openChildId },
});
await db.update(issues).set({ executionRunId: childRunId }).where(eq(issues.id, openChildId));
const fromChild = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId: openChildId, runId: childRunId, workMode: "standard" });
const created = await fromChild.execute({
tool: "create_task", callId: "hidden-root-default",
arguments: { idempotencyKey: "hidden-root-default", title: "Default follow-up", status: "backlog" },
}) as { task: { id: string; parentId: string | null } };
expect(created.task.parentId).toBeNull();
const [row] = await db.select({ originIssueId: issues.originIssueId, projectWorkspaceId: issues.projectWorkspaceId })
.from(issues).where(eq(issues.id, created.task.id));
expect(row?.originIssueId).toBe(openChildId);
// Dropping the structural parent keeps the requester's workspace.
expect(row?.projectWorkspaceId).toBe(featureWorkspaceId);
});
it("lets a conversation create a top-level task and subtasks with parentTaskId", async () => {
const conversationId = randomUUID();
const conversationRunId = randomUUID();
await db.insert(issues).values({
id: conversationId, companyId, title: "Chat with runner agent", status: "in_progress", workMode: "standard",
assigneeAgentId: agentId, conversationAgentId: agentId, conversationUserId: "chat-user", conversationState: "active",
});
await db.insert(heartbeatRuns).values({
id: conversationRunId, companyId, agentId, status: "running", runtimeMode: "native", nativeIssueId: conversationId,
invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId: conversationId },
});
await db.update(issues).set({ executionRunId: conversationRunId }).where(eq(issues.id, conversationId));
const authority = new PaperclipRunnerToolAuthority(db, {
companyId, agentId, issueId: conversationId, runId: conversationRunId, workMode: "standard",
});
const create = async (key: string, args: Record<string, unknown>) => await authority.execute({
tool: "create_task", callId: key, arguments: { idempotencyKey: key, status: "backlog", ...args },
}) as { task: { id: string; parentId: string | null } };
const parent = await create("chat-parent", { title: "Referral launch" });
expect(parent.task.parentId).toBeNull();
const subtask = await create("chat-subtask", { title: "Landing page", parentTaskId: parent.task.id });
expect(subtask.task.parentId).toBe(parent.task.id);
const [row] = await db.select({ originIssueId: issues.originIssueId }).from(issues).where(eq(issues.id, subtask.task.id));
// Provenance names the chat whose run created it; the parent is structure.
expect(row?.originIssueId).toBe(conversationId);
// A child of a child is placed beside it.
const deeper = await create("chat-deeper", { title: "Landing copy", parentTaskId: subtask.task.id });
expect(deeper.task.parentId).toBe(parent.task.id);
await expect(create("chat-under-conversation", { title: "Under the chat", parentTaskId: conversationId }))
.rejects.toThrow(/conversation cannot be a parent/i);
});
it("creates ordinary children, preserves blockers, and deduplicates across runs", async () => {
const wakes: Array<{ agentId: string; options: Record<string, unknown> }> =
[];
@@ -1,4 +1,6 @@
import { budgetServiceInTransaction } from "../budgets.js";
import { resolveTaskWatchdogMutationScope, taskWatchdogScopeAllowsIssueMutation } from "../task-watchdog-scope.js";
import { resolveNewTaskParent } from "../issue-hierarchy.js";
import { DOWNLOAD_ARTIFACT, DOWNLOAD_ARTIFACT_DEFINITION, downloadArtifactSchema, createArtifactInstaller, type ArtifactInstaller } from "./artifact-transfer.js";
import { notifyStatusCardIssueChange } from "../status-card-notifications.js";
import { nativeRunKeyScope, nativeSkillTestToolAllowed, assertNativeSkillTestTool } from "./native-skill-test-scope.js";
@@ -1698,19 +1700,81 @@ export class PaperclipRunnerToolAuthority {
const inputFingerprint = createHash("sha256")
.update(canonicalJson(input))
.digest("hex");
/** Why this run may not add a child under `parent`, or null when it may. */
const parentWriteDenial = async (tx: Db, run: typeof heartbeatRuns.$inferSelect, parent: typeof issues.$inferSelect) => {
for (const action of ["issue:read", "issue:mutate"] as const) {
const decision = await authorizationService(tx).decide({ actor: this.#privacyActor(run), action, resource: {
type: "issue", companyId: this.binding.companyId, issueId: parent.id, projectId: parent.projectId,
parentIssueId: parent.parentId, assigneeAgentId: parent.assigneeAgentId, assigneeUserId: parent.assigneeUserId, status: parent.status,
}, scope: { issueId: parent.id, projectId: parent.projectId, parentIssueId: parent.parentId } });
if (!decision.allowed) return decision.explanation;
}
return null;
};
const authorizeCreate = async (tx: Db, context: {
run: typeof heartbeatRuns.$inferSelect;
issue: typeof issues.$inferSelect;
actor: typeof agents.$inferSelect;
}) => {
const parentIssueId = context.issue.conversationAgentId ? null : context.issue.id;
const projectId = nullableProviderId(input.projectId) ?? (parentIssueId ? context.issue.projectId : null);
// Default parent: the current task, or none from a conversation. An
// explicit parentTaskId names another task the run may read and change
// (for example a top-level task this conversation just created).
let parentIssueId = context.issue.conversationAgentId ? null : context.issue.id;
let parentProjectId = parentIssueId ? context.issue.projectId : null;
const requestedParentId = nullableProviderId(input.parentTaskId);
const watchdogScope = await resolveTaskWatchdogMutationScope(tx, {
type: "agent", agentId: this.binding.agentId, runId: this.binding.runId, companyId: this.binding.companyId,
});
if (requestedParentId && requestedParentId !== parentIssueId) {
const [parent] = await tx.select().from(issues).where(and(
eq(issues.id, requestedParentId), eq(issues.companyId, this.binding.companyId),
));
if (!parent) throw new Error("paperclip_runner_task_not_found");
if (parent.conversationAgentId) {
throw forbidden("A conversation cannot be a parent; omit parentTaskId to create a top-level task");
}
const denied = await parentWriteDenial(tx, context.run, parent);
if (denied) throw forbidden(denied);
// A watchdog run may only add work inside its watched subtree, as on the
// issues route.
if (watchdogScope.kind !== "none") {
const allowed = await taskWatchdogScopeAllowsIssueMutation(tx, watchdogScope, { id: parent.id, companyId: parent.companyId });
if (allowed.kind === "invalid") throw forbidden(allowed.detail);
}
parentIssueId = parent.id;
parentProjectId = parent.projectId;
}
// Place the edge the way createChild will, and authorize the parent that
// is actually written. Active watchdog runs keep their requested parent.
const parentDepthPolicy = watchdogScope.kind === "watchdog" ? "allow_nested" as const : "flatten" as const;
let placedParentId = parentIssueId;
// Set when the structural parent is dropped; the follow-up still works in
// the requester's workspace.
let workspaceSourceIssueId: string | null = null;
if (parentIssueId) {
placedParentId = (await resolveNewTaskParent(tx, this.binding.companyId, parentIssueId, parentDepthPolicy)).effectiveParentId;
if (placedParentId && placedParentId !== parentIssueId) {
const [placed] = await tx.select().from(issues).where(and(
eq(issues.id, placedParentId), eq(issues.companyId, this.binding.companyId),
));
const denied = placed ? await parentWriteDenial(tx, context.run, placed) : "Parent issue not found";
if (denied) {
// A named parent fails closed; the implicit current-task default
// falls back to standalone work, as the issues route does.
if (requestedParentId) throw forbidden(denied);
workspaceSourceIssueId = parentIssueId;
parentIssueId = null;
placedParentId = null;
}
}
}
const projectId = nullableProviderId(input.projectId) ?? parentProjectId;
if (this.binding.museRuntime && projectId) {
const access = await authorizationService(tx).decide({ actor: this.#privacyActor(context.run), action: "project:read",
resource: { type: "project", companyId: this.binding.companyId, projectId } });
if (!access.allowed) throw forbidden("Project is unavailable to this Muse run");
}
const scope = { projectId, parentIssueId, assigneeAgentId, assigneeUserId };
const scope = { projectId, parentIssueId: placedParentId, assigneeAgentId, assigneeUserId };
const decision = await authorizationService(tx).decide({
actor: this.#privacyActor(context.run),
action: "tasks:assign",
@@ -1728,12 +1792,11 @@ export class PaperclipRunnerToolAuthority {
run: { companyId: this.binding.companyId, executionPolicy: context.run.contextSnapshot?.executionPolicy },
});
if (trust.kind === "denied") throw forbidden(trust.detail);
return { projectId, trust };
return { projectId, trust, parentIssueId, placedParentId, parentDepthPolicy, workspaceSourceIssueId };
};
let publication: Awaited<ReturnType<typeof persistActivity>>["publication"] | null = null;
const result = await this.#withMutationReceipt("create_task", idempotencyKey, input, async (tx, context) => {
const { projectId, trust } = await authorizeCreate(tx, context);
const conversation = Boolean(context.issue.conversationAgentId);
const { projectId, trust, parentIssueId, placedParentId, parentDepthPolicy, workspaceSourceIssueId } = await authorizeCreate(tx, context);
const existingChild = await tx.select().from(issues).where(and(
eq(issues.companyId, this.binding.companyId),
eq(issues.originId, durableIdempotencyKey),
@@ -1787,6 +1850,10 @@ export class PaperclipRunnerToolAuthority {
assigneeUserId,
blockedByIssueIds,
blockParentUntilDone: false,
// Placement was authorized above; reject the write if the tree changed since.
parentDepthPolicy,
...(parentIssueId ? { expectedParentId: placedParentId } : {}),
...(workspaceSourceIssueId ? { inheritExecutionWorkspaceFromIssueId: workspaceSourceIssueId } : {}),
createdByAgentId: this.binding.agentId,
originKind: "manual" as const,
originId: durableIdempotencyKey,
@@ -1799,9 +1866,9 @@ export class PaperclipRunnerToolAuthority {
idempotencyKey: durableIdempotencyKey,
onDeduplicated: () => { deduplicated = true; },
};
const child = conversation
? await issueService(tx).create(this.binding.companyId, createInput)
: (await issueService(tx).createChild(this.binding.issueId, createInput)).issue;
const child = parentIssueId
? (await issueService(tx).createChild(parentIssueId, createInput)).issue
: await issueService(tx).create(this.binding.companyId, createInput);
if (deduplicated && child.originFingerprint !== inputFingerprint) {
throw new Error("paperclip_runner_tool_idempotency_conflict");
}
@@ -1826,7 +1893,8 @@ export class PaperclipRunnerToolAuthority {
agentId: this.binding.agentId, runId: this.binding.runId, issueId: child.id,
action: "issue.created", entityType: "issue", entityId: child.id,
details: { identifier: child.identifier, title: child.title, parentId: child.parentId,
assigneeAgentId: child.assigneeAgentId, assigneeUserId: child.assigneeUserId, status: childStatus, source: "paperclip_runner_protocol" },
assigneeAgentId: child.assigneeAgentId, assigneeUserId: child.assigneeUserId, status: childStatus, source: "paperclip_runner_protocol",
...("parentAdjusted" in child && child.parentAdjusted ? { parentAdjusted: child.parentAdjusted } : {}) },
});
publication = activity.publication;
}
+9 -2
View File
@@ -47,8 +47,15 @@ export async function callProjectTool(input: {
} : {}),
assigneeAgentId: args.assigneeActorId ?? (args.assigneeUserId ? null : input.agentId),
assigneeUserId: args.assigneeUserId,
parentId: input.conversation ? null : input.issueId,
status: Array.isArray(args.blockedByTaskIds) && args.blockedByTaskIds.length ? "blocked" : "todo",
// An explicit parent goes through the route's depth placement and
// authorization; a conversation itself is never a parent.
parentId: typeof args.parentTaskId === "string" && args.parentTaskId
? args.parentTaskId
: (input.conversation ? null : input.issueId),
// Same rule as the native tool: backlog is honored and never wakes an
// agent; otherwise unresolved blockers make the task blocked.
status: args.status === "backlog" ? "backlog"
: Array.isArray(args.blockedByTaskIds) && args.blockedByTaskIds.length ? "blocked" : "todo",
blockedByIssueIds: args.blockedByTaskIds,
idempotencyKey: `chat-handoff:${input.issueId}:${key}`,
});
+3
View File
@@ -1911,6 +1911,9 @@ export function routineService(
projectWorkspaceId,
goalId: input.routine.goalId,
parentId: input.routine.parentIssueId,
// A routine's parent is configured, not chosen per run. Human-configured
// routines keep any depth; agent-authored ones follow the agent limit.
parentDepthPolicy: input.routine.createdByAgentId ? "flatten" : "allow_nested",
title,
description,
status: "todo",
@@ -62,7 +62,7 @@ export async function readTaskCreationProvenance(db: Db, companyId: string, task
if (!batch.length) break;
if (depth >= MAX_DEPTH || visited.size + batch.length > MAX_TASKS) { complete = false; break; }
batch.forEach(id => visited.add(id));
const tasks = await read(() => db.select({ id: issues.id, parentId: issues.parentId,
const tasks = await read(() => db.select({ id: issues.id, parentId: issues.parentId, originIssueId: issues.originIssueId,
originKind: issues.originKind, originRunId: issues.originRunId, createdByAgentId: issues.createdByAgentId,
visibility: issues.visibility, privacyRootIssueId: issues.privacyRootIssueId, projectId: issues.projectId,
}).from(issues).where(and(eq(issues.companyId, companyId), inArray(issues.id, batch))));
@@ -79,6 +79,8 @@ export async function readTaskCreationProvenance(db: Db, companyId: string, task
if (task.id === taskId) shareable = publicTask;
else if (!publicTask) shareable = false;
if (task.parentId) connect(task.id, task.parentId);
// A task placed beside its requester keeps the requester as its origin.
if (task.originIssueId && task.originIssueId !== task.parentId) connect(task.id, task.originIssueId);
}
const originIds = [...new Set(tasks.flatMap(task => task.originRunId && isUuidLike(task.originRunId) ? [task.originRunId] : []))];
const receipts = originIds.length ? await read(() => db.select({ id: routineRuns.id, routineId: routineRuns.routineId,
+4
View File
@@ -1403,6 +1403,7 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {})
status: "todo",
assigneeAgentId: input.watchdog.watchdogAgentId,
parentId: input.sourceIssue.id,
parentDepthPolicy: "allow_nested",
projectId: input.sourceIssue.projectId,
goalId: input.sourceIssue.goalId,
billingCode: input.sourceIssue.billingCode,
@@ -1455,6 +1456,9 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {})
status: "todo",
priority: input.sourceIssue.priority,
parentId: input.sourceIssue.id,
// Watchdog review issues and their serialized follow-ups are located by
// parent; they are system machinery, not agent-authored decomposition.
parentDepthPolicy: "allow_nested",
projectId: input.sourceIssue.projectId,
goalId: input.sourceIssue.goalId,
assigneeAgentId: input.watchdog.watchdogAgentId,
+13 -3
View File
@@ -34,8 +34,10 @@ Manual local CLI mode (outside heartbeat runs): use `paperclipai agent local-cli
When the task context says **Chat mode** (the issue has `conversationAgentId`),
follow that directive for the conversation lifecycle. Research, clarify, and
revise the conversation's `plan` document here. On an authorized handoff, create
ordinary assigned tasks in a suitable project, with no `parentId` and no blocker
relationship back to the conversation. Link them in your reply and let them run
ordinary assigned tasks in a suitable project, never with the conversation as
`parentId` and with no blocker relationship back to the conversation. Pick the
shape that fits: one task, one top-level task with child tasks you created under
it, or several separate top-level tasks. Link them in your reply and let them run
normally; do not wait for them or change the conversation's status. Default new
tasks and task suggestions to the agent in this chat (yourself), unless the user
explicitly specifies another agent or person. "For me" alone is not a request to
@@ -270,6 +272,14 @@ Because of that, follow these rules:
- **Never imply a live watcher on a task you are marking `done`.** `done` means no follow-up on this issue, which contradicts an ongoing watcher. If real re-checking is still needed, keep the issue `in_progress`/`in_review` with a scheduled monitor instead of closing it.
- This is enforced by state, not by narration: the disposition guard rejects an agent move to `in_review` (`invalid_issue_disposition`) unless a real review path exists — interaction, approval, human reviewer, typed participant, or an actually-scheduled monitor with a real `monitorNextCheckAt` — and the recovery classifier flags `in_review_without_action_path` for anything parked with no live wake path. Keep your comments consistent with that real state.
**Task depth.** Agents build at most two levels: a top-level task and its
children. If you ask for a child of a task that is already a child, the server
does not error: it creates the task beside your task (under the same parent),
keeps your task as the originating task, and returns `parentAdjusted` in the
response. Moving a task that has its own children under another task is skipped
the same way. Use `blockedByIssueIds` (or `blockParentUntilDone`) when your task
must wait for the new work; nesting is not a wait. Board users are not limited.
**Step 9 — Delegate if needed.** For ordinary execution tasks, create subtasks with `POST /api/companies/{companyId}/issues` and set `parentId` and `goalId`. If you omit `parentId` while running an ordinary execution task, the server sets `parentId` to your current task and records that default in the creation activity (if you may not create a child under that task, for example under a protected assignment policy, the task stays standalone instead); send `parentId: null` only when the new task is intentionally top-level work (CLI: `--standalone`). Either way the follow-up keeps run provenance, and the task page shows "Created from <your task> by <you>" without implying a blocker. For conversation tasks, use the project handoff above instead. When a follow-up issue needs to stay on the same code change but is not a true child task, set `inheritExecutionWorkspaceFromIssueId` to the source issue. Set `billingCode` for cross-team work.
### Delegating review tasks
@@ -351,7 +361,7 @@ Five issue-thread interaction kinds are supported. Pick the smallest kind that f
| `request_checkbox_confirmation` | A responder selects any subset of a known list (up to 200 options) and then confirms or rejects. | Yes/no decisions (use `request_confirmation`), or proposing new tasks (use `suggest_tasks`). |
| `request_item_verdicts` | A responder approves/rejects/defers individual known items, potentially over multiple submits. | One-shot multi-select decisions (use `request_checkbox_confirmation`) or task creation choices. |
| `ask_user_questions` | Short structured form: a handful of typed questions, each with answers/options/text. | Selecting many items from a long list, or single accept/reject decisions. |
| `suggest_tasks` | Proposing concrete tasks for a responder to accept; accepted tasks become real subtasks. | Confirming a plan or an arbitrary selection. Tasks are the unit; not arbitrary ids. |
| `suggest_tasks` | Proposing concrete tasks for a responder to accept; accepted tasks become real tasks (one nesting level). | Confirming a plan or an arbitrary selection. Tasks are the unit; not arbitrary ids. |
| `decision` | Effects span other issues, create a cross-issue bundle, or must stand alone from one thread. | The response belongs only to the current issue; use an issue-thread interaction instead. |
Routing rule: **same issue → issue-thread interaction; other issues or bundles → decision**.
@@ -1365,6 +1365,8 @@ Terminal states: `done`, `cancelled`
- `completed_at` is auto-set on `done`.
- One assignee per task at a time.
- `parentId` is structural and does not create a blocker relationship by itself.
- Agent-created task trees are at most two levels deep (a top-level task and its children). A create or move that would make a grandchild is not rejected: the task is placed under the requested parent's parent, and the response carries `parentAdjusted` (`reason`, `requestedParent`, `effectiveParent`, `applied`). Moving a task that has children is skipped with `reason: "has_children"`. Board users are not limited, and existing deeper trees stay valid.
- `originIssueId` (read-only, server-set) records the task whose work created this one: the task your run was on, otherwise the chat that handed it off or the requested parent. It is an audit trail, not hierarchy. `originRunId` is the run that created it.
- Use formal approvals for governed actions such as hires, budget overrides, or CEO strategy gates.
- Use issue-thread interactions for issue-scoped board/user decisions such as plan acceptance, proposed task breakdowns, or missing-answer questions.
- Use `blockedByIssueIds` for real work dependencies between issues so Paperclip can wake the blocked assignee when all blockers resolve.