diff --git a/CHANGELOG.md b/CHANGELOG.md
index 17c04769..fef642f8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,22 @@ date, grouped by `Added` / `Changed` / `Fixed` / `Removed`.
## [Unreleased]
+## [2026-07-13] — v0.9.0 · Context-aware plans & issue archive
+
+### Added
+
+- **Plan-created issues now explain the work around them.** Issue detail shows the source Goal, Plan, step instructions, completion contract, dependencies, dependents, and sibling progress. Agents receive the same bounded context as an immutable run snapshot, so later plan edits cannot rewrite what an in-flight or historical run was told.
+- **Issues can be archived and restored cleanly.** The Issues page has an archived-only view with individual and bulk restore. Archive removes work from active views, clears queue and claim state, closes live agent work, and safely synchronizes an unambiguous materialized plan step while preserving history and relationships.
+
+### Changed
+
+- **Plan execution now enforces its declared constraints.** Crew concurrency is capped across all of a crew's active plans; dependencies and cross-workspace references are validated; role assignment never guesses between ambiguous crew members; and materialized Issue/ExecutionStep terminal state stays synchronized in both directions.
+- **Issue capture and bulk handling are more predictable.** Board and status-group quick-create preserve their originating status, command-palette creation stays in issue mode, MCP creation accepts status and labels within narrowed-key lanes, counts refresh with mutations, and bulk selection accurately says “Select loaded.”
+
+### Fixed
+
+- **Archived work can no longer leak back into agent or editing surfaces.** MCP issue/context tools, agent inboxes, comments, labels, relations, assignment, queueing, slash commands, and bulk status changes now respect the archive boundary. Comment deletion is tenant-scoped and label assignment rejects foreign-workspace labels.
+
## [2026-07-11] — v0.8.2 · Plan recovery ordering
### Fixed
diff --git a/DEVLOG.md b/DEVLOG.md
index 27fe1407..92c250d5 100644
--- a/DEVLOG.md
+++ b/DEVLOG.md
@@ -2,6 +2,49 @@
> Append-only session log. Read at session start. Update at session end.
+## 2026-07-13 — Plan context integrity + reversible issue archive
+
+Closed the execution-context gaps that let materialized plan issues look like
+standalone tasks, then carried the same integrity pass through issue creation,
+handling, and archive/restore.
+
+- **Durable plan context.** Materialized issue detail now shows Goal → Plan →
+ Step provenance, instructions, output/verification contracts, dependencies,
+ dependents, and sibling progress. Agent runs capture a versioned immutable
+ orchestration-context snapshot at dispatch; inbox and MCP context prefer that
+ snapshot so later plan edits cannot rewrite a run's instructions.
+- **Orchestration invariants.** Step dependencies, assignees, roles, and source
+ runs are workspace/plan validated; structural edits are draft-only; role
+ resolution never guesses between multiple crew members. Crew `maxParallel`
+ is enforced across all of a crew's plans under row locks, with fair refill.
+ Materialized Issue and ExecutionStep terminal lifecycle now synchronizes in
+ both directions, including reaper/abandon paths.
+- **Reversible issue archive.** Added `issue.archive`, `restore`, and
+ `bulkRestore`, an archived-only Issues view, archived detail tombstones, and
+ single/bulk restore. Archive atomically clears queue/claim/snooze state,
+ abandons live runs, cancels one unambiguous active materialized step, and
+ records audit/activity; restore returns visibility without resurrecting old
+ work. MCP, agent inbox, relations, comments, labels, and active mutations now
+ respect the archive boundary. Added migrations `0097` (run context snapshot)
+ and `0098` (archive-list index).
+- **Issue-flow cleanup.** Board/status quick-add preserves its originating
+ status; command-palette create always opens issue mode; MCP create accepts
+ status/labels and enforces narrowed-key creation lanes; bulk copy says
+ “Select loaded”; list/count invalidation stays coherent. Reversible archive
+ confirmations no longer use destructive type-to-confirm friction. Comment
+ deletion is now tenant-scoped and label assignment validates workspace ids.
+- **Usability evidence.** The flow pass was grounded in the live deployment
+ behavior already inspected plus current UI/contracts and existing Forge
+ components/tokens. No screenshot-based browser audit was claimed because an
+ in-app browser surface was unavailable; Playwright CLI was intentionally not
+ substituted without an explicit browser choice.
+
+Verification: Prisma validate/format, `pnpm lint` (existing repository warnings
+only), `pnpm typecheck`, the full Vitest suite (**1,149 passed; 1 skipped**),
+`git diff --check`, a fresh Next.js production build, and the full Playwright
+suite (**37 passed**). Prepared as the v0.9.0 production release; rollout and
+live smoke verification follow the merge to `main`.
+
## 2026-07-11 — Recoverable agent review handoff
Closed the Plan state-machine gap where a step could say “Needs review” after
@@ -12194,3 +12237,47 @@ reconciliation mutation occurred: PostgreSQL's descending sort places null
the newer COMPLETED run. v0.8.2 orders attempts by `lastEventAt` instead (set on
every run state) and adds the exact stalled-then-completed history to the
watchdog regression fixture.
+
+## 2026-07-13 — Plan-linked issue context and orchestration integrity
+
+Inspected the current `forge` and `forge-worker` production services, the live
+Rich Rendering goal/plan, current source, and orchestration references. Live
+data confirmed that materialized plan-step issues retained only a small plan
+backlink: four DONE steps still had BACKLOG issues, dependency edges were not
+visible on issue Relations, runtime prompts omitted Goal/Plan/DAG context, and
+terminal STALLED runs could be selected as an agent's current run.
+
+- Added a bounded shared orchestration-context builder used by provider runtime
+ starts/resumes, `agent.inbox.list`, and `agent.context.bundle`. It includes
+ Goal/Plan/Step contracts, retry feedback, dependency/dependent issue links,
+ completed worker evidence, and non-excluded ContextSet refs without guessing
+ a step for multi-step plan-anchor issues.
+- Materialized issue detail now renders a primary Goal → Plan → Step context
+ card with success criteria, instructions, completion/verification contract,
+ feedback, sibling progress, and navigable dependencies. The Relations graph
+ derives plan dependency edges from `dependsOnStepIds` without persisting
+ duplicate `IssueRelation` rows.
+- Runtime-only planners with a linked Goal issue now open durable DISCUSS runs
+ and receive the decompose prompt; unanchored runtime planners are reported as
+ non-dispatchable instead of queueing a webhook guaranteed to dead-letter.
+ Worker/reviewer evidence and the planner trigger survive runtime handoff.
+- Ordinary assignment of a materialized issue synchronizes its intended step
+ worker, but TODO/BLOCKED/REVIEW or non-running-plan steps are scheduled rather
+ than dispatched. Only readiness can open the step-bound execution run.
+ Both inbox delivery and the background runtime scanner honor that gate;
+ provider/output start now audits READY → RUNNING.
+- Current-run resolution is limited to ACTIVE/WAITING and the linked agent.
+ Verdicts require a REVIEW step on the active RUNNING attempt; agent verdicts
+ additionally require that agent's active REVIEW run. CANCELED steps no longer
+ count toward Goal achievement.
+- Added safety guards around live-plan archive/delete and step removal, routed
+ RUNNING transitions through activation, validated updated ContextSet scope,
+ and fixed initiative-only API-key narrowing for issue reads/list filters.
+- Updated orchestration, engagement-mode, primitive, and MCP reference docs to
+ match the delivered behavior and to state honestly that crew `maxParallel`
+ remains informational rather than enforced.
+
+Verification: lint and typecheck passed; 1,126 unit/integration tests passed
+(one live-only test skipped); and a forced fresh production build plus all 37
+Playwright tests passed. This change is local only; production was inspected
+read-only and was not deployed or mutated.
diff --git a/docs/agents/engagement-modes.md b/docs/agents/engagement-modes.md
index 80925477..19223b60 100644
--- a/docs/agents/engagement-modes.md
+++ b/docs/agents/engagement-modes.md
@@ -1,16 +1,16 @@
# Engagement Modes
-> Set a mode when you assign or mention an agent to say *how far* you want the
+> Set a mode when you assign or mention an agent to say _how far_ you want the
> work taken. Configure the defaults in **Settings → Dispatch Rules**. The
> resolved mode rides on the `AgentRun` and shows as a chip on the run strip and
> in Mission Control.
## Why modes exist
-When you hand an agent some work, the agent knows *what* you wrote — never *how
-far* you want it taken. Without a mode, every dispatch surface tells the agent
-essentially the same thing: *"You're assigned this issue. Work it, then
-summarise."* So the agent picks the safest-for-it reading: **do the whole
+When you hand an agent some work, the agent knows _what_ you wrote — never _how
+far_ you want it taken. Without a mode, every dispatch surface tells the agent
+essentially the same thing: _"You're assigned this issue. Work it, then
+summarise."_ So the agent picks the safest-for-it reading: **do the whole
thing, to its own interpretation, right now.**
That guess is wrong as often as it's right. Sometimes you want a full build.
@@ -27,12 +27,12 @@ run the completion gates.
## The four modes
-| Mode | Intent | What the agent may do | "Done" means |
-|------|--------|------------------------|--------------|
-| **Execute** | Do the work to completion | Edit code, transition status, open PRs/artifacts | Issue moved to review/done; `runs.complete` with artifacts; honors `artifactRequired` + `verificationChecklist` |
-| **Research** | Investigate & report | Read, search, run read-only tools | A findings comment **with a confidence flag**; issue status untouched |
-| **Review** | Critique existing work | Inspect a PR/diff/issue, judge it | A verdict (approve / request changes) |
-| **Discuss** | Answer / weigh in | Reply in-thread, ask questions | A reply comment; no work product |
+| Mode | Intent | What the agent may do | "Done" means |
+| ------------ | ------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
+| **Execute** | Do the work to completion | Edit code, transition status, open PRs/artifacts | Issue moved to review/done; `runs.complete` with artifacts; honors `artifactRequired` + `verificationChecklist` |
+| **Research** | Investigate & report | Read, search, run read-only tools | A findings comment **with a confidence flag**; issue status untouched |
+| **Review** | Critique existing work | Inspect a PR/diff/issue, judge it | A verdict (approve / request changes) |
+| **Discuss** | Answer / weigh in | Reply in-thread, ask questions | A reply comment; no work product |
These map onto primitives Forge already has — they don't add a parallel
universe. **Research** leans on the existing confidence flag; **Review** leans
@@ -43,7 +43,7 @@ on review gates / action requests; **Execute** leans on `expectedOutput` /
"Do the work but stop before the irreversible step" is **Execute + an
approval/review gate**, not a fifth mode. Set `requireApprovalBeforeStart` or
open a review gate on the deploy step. Deploy-safety stays orthogonal to mode,
-so it composes with *all* modes.
+so it composes with _all_ modes.
:::
## Where the mode lives
@@ -55,22 +55,22 @@ prompt, and read by the watchdog, the auto-transition logic, and Mission
Control.
This is distinct from `Workspace.autoDispatchMode` (ROUND_ROBIN /
-CAPABILITY_MATCH / … — which decides *which agent gets picked*). Engagement mode
-is *what the picked agent is asked to do*. Different axis.
+CAPABILITY_MATCH / … — which decides _which agent gets picked_). Engagement mode
+is _what the picked agent is asked to do_. Different axis.
## Defaults by surface
-Defaults depend on *how* the work was dispatched — "mentions talk, assignments
+Defaults depend on _how_ the work was dispatched — "mentions talk, assignments
act":
-| Surface | Entry point | Default mode | Why |
-|---------|-------------|--------------|-----|
-| **Assignment** | assign an agent → `AGENT_ASSIGNED` | **Execute** | Assigning *is* handing over the work |
-| **Queue / auto-dispatch** | queued issue + auto-dispatch | **Execute** | Queued = ready to be worked |
-| **@-mention in a comment** | mention in a comment | **policy-driven** (below) | A mention is a tap on the shoulder, not a work order |
-| **Chat** | Mission Control chat | **Discuss** | Chat is conversational by nature |
-| **Goal / plan step / crew** | plan or crew dispatch | **the step's mode**, else **Execute** | A plan declares its own intent per step |
-| **Watcher fan-out** | stalled / priority change / … | **awareness only** | Watchers observe; they don't get a work order |
+| Surface | Entry point | Default mode | Why |
+| --------------------------- | ---------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------- |
+| **Assignment** | assign an agent → `AGENT_ASSIGNED` | **Execute** | Assigning _is_ handing over the work |
+| **Queue / auto-dispatch** | queued issue + auto-dispatch | **Execute** | Queued = ready to be worked |
+| **@-mention in a comment** | mention in a comment | **policy-driven** (below) | A mention is a tap on the shoulder, not a work order |
+| **Chat** | Mission Control chat | **Discuss** | Chat is conversational by nature |
+| **Goal / plan step / crew** | plan or crew dispatch | **Execute** for workers; **Review** for judges | Worker and judge roles define the run intent; `ExecutionStep` does not store a separate mode. |
+| **Watcher fan-out** | stalled / priority change / … | **awareness only** | Watchers observe; they don't get a work order |
Every default is overridable per dispatch, and the assignment + mention defaults
are configurable per workspace.
@@ -80,7 +80,7 @@ are configurable per workspace.
A mention is the one surface where teams genuinely disagree, so it's a workspace
policy — `Workspace.mentionEngagementPolicy`:
-- **Infer** *(default)* — no explicit marker → the agent infers intent from the
+- **Infer** _(default)_ — no explicit marker → the agent infers intent from the
comment, **and if it's ambiguous, asks before doing anything irreversible.**
Maximum fluidity; the "ask first" clause is the safety net. This is the direct
antidote to "it assumed I wanted it fully built."
@@ -109,11 +109,11 @@ Each mode has a canonical instruction block injected into the agent's turn (via
`RunInput.instructions`, prepended in the dispatcher) and surfaced in
`agent.context.bundle` so pull-based agents see it too. For example:
-- **Research:** *"This is a research pass. Investigate and report your findings
+- **Research:** _"This is a research pass. Investigate and report your findings
as a comment with a confidence flag. Do not modify code, move the issue, or
- open a PR. If you believe execution is warranted, say so and stop."*
-- **Execute:** *"Take this to completion. The definition of done is in
- `expectedOutput`; verify against `verificationChecklist`."* (+ artifact /
+ open a PR. If you believe execution is warranted, say so and stop."_
+- **Execute:** _"Take this to completion. The definition of done is in
+ `expectedOutput`; verify against `verificationChecklist`."_ (+ artifact /
approval clauses when set.)
Forge also injects the shared run protocol, including a contract version:
@@ -196,7 +196,7 @@ is reused everywhere, with the warm-earthy tokens:
- [Agents overview](/agents/overview.html) — the two ways to run agent work
(direct dispatch vs. Goal orchestration), both riding on `AgentRun`.
- [Auto-dispatch](/agents/auto-dispatch.html) — queued issues default to
- Execute; how an agent gets *picked* (a different axis from mode).
+ Execute; how an agent gets _picked_ (a different axis from mode).
- [Dispatch Rules](/agents/dispatch-rules.html) — where the mode defaults and
mention policy are configured.
- [SLAs & Watchdogs](/agents/slas-and-watchdogs.html) — why a Research run isn't
diff --git a/docs/concepts/orchestration.md b/docs/concepts/orchestration.md
index 0c8c3a0f..e6469a58 100644
--- a/docs/concepts/orchestration.md
+++ b/docs/concepts/orchestration.md
@@ -63,7 +63,7 @@ and time caps you set.
A Goal is one of [**two ways to run agent work**](/agents/overview.html) — the
autonomous one. The other is direct dispatch (assign an agent to an issue). The
-two are different ways to *start* work, but they share the same observable
+two are different ways to _start_ work, but they share the same observable
substrate, so orchestrated work isn't a black box:
- **Each step that starts running opens a real `AgentRun`.** When a step
@@ -84,8 +84,24 @@ substrate, so orchestrated work isn't a black box:
one issue.
So planning, issues, and runs are one connected graph: a Goal owns a plan, a
-plan's steps can become issues, and running a step opens a run — all linked, all
-visible in the same surfaces.
+plan's steps can become issues, and running a step opens a run. A materialized
+issue renders its Goal → Plan → Step contract and sibling progress in the main
+issue column; its Relations graph derives dependency edges from
+`dependsOnStepIds` without duplicating `IssueRelation` rows. Agent runtime
+prompts, `agent.inbox.list`, and `agent.context.bundle` use the same structured
+orchestration context, including retry feedback and completed dependency
+evidence. That bounded context is snapshotted onto the `AgentRun` when the run
+opens; later Goal, Plan, Step, or ContextSet edits therefore do not rewrite the
+instructions a historical run received. Legacy rows without a snapshot fall
+back to live hydration.
+
+The ExecutionStep owns in-flight lifecycle state. Its `READY`, `RUNNING`,
+`REVIEW`, `DONE`, and `CANCELED` transitions project onto a materialized Issue
+using the workspace's configured/fallback status rows. Moving an Issue around
+non-terminal board columns cannot bypass DAG readiness or manufacture review
+evidence. An operator's explicit `DONE` or `CANCELED` Issue transition is
+authoritative in the other direction: Forge updates the one unambiguous linked
+step, cascading dependents on Done or blocking the plan on Cancel.
## The loop
@@ -98,7 +114,7 @@ visible in the same surfaces.
└────┬────┘
│ plans.decompose (picks PLANNER; creates DRAFT plan)
▼
- ┌──────────┐ PLANNER dispatched via webhook + event
+ ┌──────────┐ PLANNER dispatched via webhook or runtime run
│ PLANNING │──────────────────────────────────────────┐
└────┬─────┘ │
│ plans.addSteps (PLANNER fills the DRAFT plan) │
@@ -161,11 +177,21 @@ When a step reaches `DONE` (via `plans.recordVerdict` PASS **or** a manual
`READY`, which resolves a worker and dispatches it. This cascade is
transactional with the status change.
+For crew-owned plans, admission to `READY` also reserves a crew execution slot.
+`AgentCrew.maxParallel` counts `READY` + `RUNNING` steps across every RUNNING
+plan owned by that crew; `0` means unlimited. Admission locks the crew row so
+concurrent activations cannot oversubscribe it. A slot is released at REVIEW or
+a terminal transition, then Forge deterministically rechecks dependency-ready
+TODO work across the crew's plans.
+
### Worker dispatch payload
-A step entering `READY` emits `EXECUTION_STEP_READY` and queues a webhook
-delivery to the resolved worker (explicit `assignedAgentId`, else the
-crew's `WORKER`). The event payload carries:
+A step entering `READY` emits `EXECUTION_STEP_READY` and dispatches the
+resolved worker (explicit `assignedAgentId`, else the crew's `WORKER`). A
+webhook worker receives the event payload below. A provider-backed runtime
+receives the same step fields plus a hydrated Goal/Plan/DAG context block in
+its prompt; pull-based agents see the structured form in `agent.inbox.list`
+and `agent.context.bundle`.
```jsonc
{
@@ -174,11 +200,13 @@ crew's `WORKER`). The event payload carries:
"title": "...",
"body": "...",
"expectedOutput": "...",
- "verification": [ /* completion-contract checklist */ ],
- "contextSetId": "...", // the plan's shared ContextSet
+ "verification": [
+ /* completion-contract checklist */
+ ],
+ "contextSetId": "...", // the plan's shared ContextSet
"assignedAgentId": "...",
- "lastFeedback": "...", // populated on a retry dispatch
- "retryCount": 1
+ "lastFeedback": "...", // populated on a retry dispatch
+ "retryCount": 1,
}
```
@@ -260,17 +288,22 @@ agentCrews.archive({ id })
## Crews
-A **crew** is a reusable, standing team — distinct from a *plan run*,
+A **crew** is a reusable, standing team — distinct from a _plan run_,
which is a single decompose-and-execute pass against one goal. The crew
is the roster (who can plan / work / review); the plan run is the work.
One crew runs many goals over its lifetime; each goal/plan points back at
-its crew via `Goal.crewId` / `ExecutionPlan.crewId`. `maxParallel` caps
-how many of the crew's steps run simultaneously.
+its crew via `Goal.crewId` / `ExecutionPlan.crewId`. `maxParallel` is stored
+and enforced as the crew-wide execution ceiling at readiness admission. READY
+and RUNNING steps reserve slots across all plans sharing the crew; `0` is
+unlimited.
The loop resolves roles from crew membership: the PLANNER decomposes, a
WORKER executes each READY step, a REVIEWER judges steps that enter
REVIEW (when `autoJudge` is on). The same agent can hold multiple roles
-on one crew.
+on one crew. A planner can set `assignedRole` on `plans.addSteps`; Forge resolves
+it automatically when exactly one active crew member has that role. No match is
+rejected; multiple matches require an explicit `assignedAgentId` that actually
+holds the role, so Forge never guesses.
### Roles
diff --git a/docs/concepts/primitives.md b/docs/concepts/primitives.md
index 0309b0ea..e8ff6a6c 100644
--- a/docs/concepts/primitives.md
+++ b/docs/concepts/primitives.md
@@ -23,14 +23,14 @@ tracking toggle. Bailey's standing rule is that workspace-level values
should be columns here, not constants in handlers. See
[Settings → Workspace](/guide/settings.html) for the full set.
-| Field of note | Meaning |
-| --- | --- |
-| `key` | Immutable issue prefix. |
-| `cycleLengthDays` | Default sprint length (default 7). |
-| `cycleCooldownDays` | Gap between sprints (default 0). |
-| `attachmentQuotaMb` | Per-workspace attachment cap (default 1024). |
-| `timeTrackingEnabled` | Master toggle for `TimeEntry`. |
-| `autoDispatch`, `autoDispatchMode` | Dispatcher config. |
+| Field of note | Meaning |
+| ---------------------------------- | -------------------------------------------- |
+| `key` | Immutable issue prefix. |
+| `cycleLengthDays` | Default sprint length (default 7). |
+| `cycleCooldownDays` | Gap between sprints (default 0). |
+| `attachmentQuotaMb` | Per-workspace attachment cap (default 1024). |
+| `timeTrackingEnabled` | Master toggle for `TimeEntry`. |
+| `autoDispatch`, `autoDispatchMode` | Dispatcher config. |
## Project
@@ -56,20 +56,26 @@ they reach them through their project.
The unit of work.
-| Field of note | Meaning |
-| --- | --- |
-| `key` | Display id (e.g. `WRK-42`). Workspace key + auto-incremented number. |
-| `priority` | `NONE`, `LOW`, `MEDIUM`, `HIGH`, `URGENT`. |
-| `status` | One of the workspace's `Status` rows; default categories `BACKLOG`, `TODO`, `IN_PROGRESS`, `IN_REVIEW`, `DONE`, `CANCELED`. |
-| `claimedById` | Human user owning the issue (nullable). |
-| `assignedAgentId` | Agent doing the work (nullable). |
-| `projectId` | Optional project membership. |
-| `cycleId` | Optional sprint membership. |
-| `slaMinutes` | Per-issue SLA for the breach watchdog. |
+| Field of note | Meaning |
+| ----------------- | --------------------------------------------------------------------------------------------------------------------------- |
+| `key` | Display id (e.g. `WRK-42`). Workspace key + auto-incremented number. |
+| `priority` | `NONE`, `LOW`, `MEDIUM`, `HIGH`, `URGENT`. |
+| `status` | One of the workspace's `Status` rows; default categories `BACKLOG`, `TODO`, `IN_PROGRESS`, `IN_REVIEW`, `DONE`, `CANCELED`. |
+| `claimedById` | Human user owning the issue (nullable). |
+| `assignedAgentId` | Agent doing the work (nullable). |
+| `projectId` | Optional project membership. |
+| `cycleId` | Optional sprint membership. |
+| `slaMinutes` | Per-issue SLA for the breach watchdog. |
+| `deletedAt` | Reversible archive timestamp. Archived issues leave active/agent surfaces until restored. |
The two assignment slots — `claimedById` and `assignedAgentId` — coexist
intentionally. See [Agents → Overview](/agents/overview.html).
+Archiving an issue is operational teardown, not a hard delete: queue and
+claim state are cleared, live runs are abandoned, and one unambiguous active
+materialized plan step is canceled. Restoring clears `deletedAt` but does not
+restart that work automatically.
+
The AI Triage columns (`aiTriageStatus`, `aiSuggestedPriority`,
`aiSuggestedLabelIds`, `aiSuggestedAgentId`, `aiTriageReasoning`,
`aiTriagedAt`, `aiTriageDecidedAt`) also live on Issue. See
@@ -121,14 +127,15 @@ deliberate.
Directed, typed link between two issues.
-| Field | Meaning |
-| --- | --- |
-| `fromIssueId` | The source issue. |
-| `toIssueId` | The target issue. |
-| `kind` | One of `BLOCKS`, `BLOCKED_BY`, `DUPLICATES`, `DUPLICATED_BY`, `RELATED`, `PARENT_OF`, `CHILD_OF`. |
+| Field | Meaning |
+| ------------- | ---------------------------------------------------------- |
+| `fromIssueId` | The source issue. |
+| `toIssueId` | The target issue. |
+| `kind` | One of `BLOCKS`, `BLOCKED_BY`, `DUPLICATES`, `RELATES_TO`. |
-Relations are cascade-deleted from either end — when you delete an
-issue, all relations involving it disappear. Pairs of inverse kinds
+Relations are cascade-deleted from either end only on hard deletion. Issue
+archive preserves the rows for restore and hides archived targets from active
+relation views. Pairs of inverse kinds
(`BLOCKS` / `BLOCKED_BY`) are typically created as two rows so traversal
is symmetric.
@@ -146,12 +153,12 @@ namespace.
Polymorphic via `targetType` + `targetId`.
-| Field | Meaning |
-| --- | --- |
-| `targetType` | `issue`, `comment`, `project`, `initiative`, ... |
-| `targetId` | The id of the target row. |
-| `objectKey` | MinIO object key. |
-| `mimeType`, `sizeBytes`, `name` | Standard file metadata. |
+| Field | Meaning |
+| ------------------------------- | ------------------------------------------------ |
+| `targetType` | `issue`, `comment`, `project`, `initiative`, ... |
+| `targetId` | The id of the target row. |
+| `objectKey` | MinIO object key. |
+| `mimeType`, `sizeBytes`, `name` | Standard file metadata. |
Attachments live in MinIO behind presigned URLs. The init/finalize/
download/delete pattern is in the `attachments.*` MCP namespace and the
@@ -215,7 +222,7 @@ ordering. See [Saved Views](/guide/saved-views.html).
Per-(workspace, user) markdown scratchpad. Surfaced via the dashboard
`` and the `notes.*` MCP namespace. Pinned notes
float to the top; `archivedAt` is the soft-delete path. Notes are
-**personal** — agents leave notes for *themselves*, not for the
+**personal** — agents leave notes for _themselves_, not for the
operator. The `note.convertToIssue` mutation spawns a real Issue from
a note's body without auto-archiving the source. See
[Quick Notes](/guide/quick-notes.html).
@@ -297,10 +304,14 @@ agent starts work: `expectedOutput` (markdown spec),
`artifactRequired` (must the agent attach an artifact?). On the
other side, AgentRun gains `producedArtifactIds`,
`verificationResult`, and `followUps` so the final response is
-structured.
-
-MCP `agent.context.bundle` for issues now exposes a
-`completionContract` block; MCP `runs.complete` is the structured
+structured. `AgentRun.orchestrationContextSnapshot` stores the bounded Goal /
+Plan / Step / DAG context captured when the run opens, keeping historical run
+instructions stable after later orchestration edits.
+
+MCP `agent.context.bundle` for issues exposes both a `completionContract`
+block and, for plan-linked issues, an `orchestrationContext` block with the
+Goal, Plan, current Step, dependencies, retry feedback, source-run evidence,
+and non-excluded ContextSet references. MCP `runs.complete` is the structured
submission tool.
### ExecutionPlan + ExecutionStep
@@ -310,7 +321,10 @@ project. Steps form an ordered list with optional
`dependsOnStepIds` so the runner can mark later steps READY only
when prerequisites are DONE. Steps can be assigned to agents or
humans and carry their own per-step `expectedOutput` /
-`verification`.
+`verification`. Assignee, source-run, and dependency references are validated
+inside the workspace; dependencies must remain inside one plan and acyclic.
+Materialized Issues mirror step lifecycle progress, while explicit operator
+Done/Cancel decisions feed back into their unique linked step.
Plan lifecycle: `DRAFT` → `APPROVED` → `RUNNING` →
`BLOCKED` / `COMPLETED` / `CANCELED`.
@@ -326,6 +340,8 @@ A crew is a group of agents bound to roles (`PLANNER`, `WORKER`,
`REVIEWER`, `OBSERVER`, `OPERATOR_PROXY`). The same agent can
hold multiple roles on the same crew. ExecutionPlans optionally
point at a crew via `crewId`.
+`maxParallel` is a hard crew-wide cap on READY + RUNNING steps across its active
+plans (`0` means unlimited); readiness admission is serialized on the crew row.
ReviewGate is an approval checkpoint attached to any reviewable
target (artifact / execution-plan / execution-step /
diff --git a/docs/guide/issues.md b/docs/guide/issues.md
index 54a3f72b..19a05920 100644
--- a/docs/guide/issues.md
+++ b/docs/guide/issues.md
@@ -7,26 +7,26 @@ queue concept, bulk operations, and how relations link issues together.
## Fields
-| Field | Type | Notes |
-|---|---|---|
-| `id` | uuid | Internal identifier |
-| `key` | string | Public key, e.g. `WRK-42`. Workspace-prefixed and unique within the workspace |
-| `kind` | enum | `ISSUE` or `TASK`. Tasks are lighter-weight; defaults are typically tighter |
-| `title` | string | Required, single line |
-| `description` | markdown | Optional, free-form |
-| `statusId` | fk | Status row in this workspace's status set |
-| `priority` | enum | `NONE` / `LOW` / `MEDIUM` / `HIGH` / `URGENT` |
-| `authorId` | fk | The user who created the issue |
-| `dueDate` | date | Optional |
-| `estimate` | int | Optional, dimensionless points |
-| `slaMinutes` | int | Optional per-issue SLA in minutes; nonzero overrides workspace default |
-| `projectId` | fk | Optional grouping |
-| `parentId` | fk | Optional sub-issue parent |
-| `cycleId` | fk | Optional sprint membership |
-| `claimedById` | fk | Human assignee (independent slot) |
-| `assignedAgentId` | fk | Agent assignee (independent slot) |
-| `queued` | bool | Is this issue ready for an agent to pick up? |
-| `aiTriageStatus` | enum | `PENDING` / `READY` / `APPLIED` / `DISMISSED` / `ERROR` / null |
+| Field | Type | Notes |
+| ----------------- | -------- | ----------------------------------------------------------------------------- |
+| `id` | uuid | Internal identifier |
+| `key` | string | Public key, e.g. `WRK-42`. Workspace-prefixed and unique within the workspace |
+| `kind` | enum | `ISSUE` or `TASK`. Tasks are lighter-weight; defaults are typically tighter |
+| `title` | string | Required, single line |
+| `description` | markdown | Optional, free-form |
+| `statusId` | fk | Status row in this workspace's status set |
+| `priority` | enum | `NONE` / `LOW` / `MEDIUM` / `HIGH` / `URGENT` |
+| `authorId` | fk | The user who created the issue |
+| `dueDate` | date | Optional |
+| `estimate` | int | Optional, dimensionless points |
+| `slaMinutes` | int | Optional per-issue SLA in minutes; nonzero overrides workspace default |
+| `projectId` | fk | Optional grouping |
+| `parentId` | fk | Optional sub-issue parent |
+| `cycleId` | fk | Optional sprint membership |
+| `claimedById` | fk | Human assignee (independent slot) |
+| `assignedAgentId` | fk | Agent assignee (independent slot) |
+| `queued` | bool | Is this issue ready for an agent to pick up? |
+| `aiTriageStatus` | enum | `PENDING` / `READY` / `APPLIED` / `DISMISSED` / `ERROR` / null |
::: info
Status is not an enum — it's a foreign key to a workspace-defined status row.
@@ -41,14 +41,14 @@ can aggregate without caring about the local labels.
Forge ships these status categories. Workspaces can have many statuses
within each category, but the category set is fixed.
-| Category | Meaning |
-|---|---|
-| `BACKLOG` | Not yet planned |
-| `TODO` | Planned, not started |
-| `IN_PROGRESS` | Actively being worked |
-| `IN_REVIEW` | Submitted for review or QA |
-| `DONE` | Shipped, closed positively |
-| `CANCELED` | Closed without shipping |
+| Category | Meaning |
+| ------------- | -------------------------- |
+| `BACKLOG` | Not yet planned |
+| `TODO` | Planned, not started |
+| `IN_PROGRESS` | Actively being worked |
+| `IN_REVIEW` | Submitted for review or QA |
+| `DONE` | Shipped, closed positively |
+| `CANCELED` | Closed without shipping |
## The two assignment slots
@@ -102,9 +102,11 @@ re-route.
Two paths.
-**Quick create.** Press ⇧C from anywhere. The dialog covers
-title, description, status, priority, project, kind. ⌘ Enter to
-submit. This is the path for ~95% of creation.
+**Quick create.** Press ⇧C from anywhere. The dialog covers title,
+description, priority, project, and kind. ⌘ Enter submits. Creating
+from a board column or status group also preserves that originating status;
+creating from the command palette always opens issue mode, even while viewing
+an existing issue. This is the primary creation path.
The QuickCreate input also recognises [slash commands](/guide/slash-commands.html)
at the start of the line:
@@ -121,14 +123,13 @@ the issue title; the matching field updates fire as
`applyCommands` on `issue.create`. The same parsing works in the
issue detail comment composer.
-**Full-page create.** Navigate to `/w//issues/new` for the longer
-form: sub-issue parent, cycle, attachments, relations, agent assignment.
-
-Both paths call `issues.create` under the hood. From the API:
+Sub-issues are created from the parent issue's Sub-issues panel. Attachments,
+relations, sprint membership, and agent assignment can be added from the issue
+detail after capture. All creation paths call `issue.create` under the hood.
+From the API:
```ts
-await trpc.issues.create.mutate({
- workspaceId,
+await trpc.issue.create.mutate({
title: "Wire onboarding email",
priority: "HIGH",
projectId,
@@ -148,6 +149,7 @@ bulk operations that are also available on the API surface:
- `issue.bulkAssignAgent` — set `assignedAgentId` across N issues
- `issue.bulkArchive` — confirmation-gated soft-delete via
`Issue.deletedAt`
+- `issue.bulkRestore` — restore selected rows from the Archived issues view
- `issue.snoozeMany` — set `snoozedUntil` across N issues
Each bulk call writes a single audit envelope per issue and fans out one
@@ -156,17 +158,30 @@ opaque batch.
### Selection chords
-| Chord | Action |
-|---|---|
-| x | Toggle selection on the row your cursor is over |
-| ⇧X | Select range from the last selected row to the cursor |
-| esc | Clear selection |
+| Chord | Action |
+| -------------- | ----------------------------------------------------- |
+| x | Toggle selection on the row your cursor is over |
+| ⇧X | Select range from the last selected row to the cursor |
+| esc | Clear selection |
Selection is shared with the Inbox bulk bar — the same ``
primitive renders on both surfaces. The Issues board view intentionally
skips bulk-select; drag-and-drop covers the highest-frequency op and
checkbox-on-cards is visually noisy.
+## Archive and restore
+
+Issue archive is reversible. Archiving sets `Issue.deletedAt`, removes the
+issue from active lists and boards, clears queue/claim state, abandons live
+agent runs, and cancels an unambiguous active materialized plan step. History
+and relations remain stored for a later restore.
+
+Use **Archive** on an issue detail or select rows and choose **Archive** in the
+list bulk bar. The **Archive** control on the issues page opens the archived-only
+view. Open an archived issue or select archived rows to restore them. Restore
+only returns the issue to active visibility; it does not resurrect claims,
+agent runs, or canceled plan steps.
+
::: tip
For very large bulk operations (hundreds of issues), use the MCP REST
endpoints and chunk into batches of ~50 to stay under request size
@@ -202,14 +217,14 @@ When `Workspace.aiEnabled` and `aiTriageOnCreate` are both on, every newly
created issue runs through AI triage. The state lives on the issue as
`aiTriageStatus`:
-| State | Meaning |
-|---|---|
-| `null` | AI triage didn't run (feature off, or not yet) |
-| `PENDING` | Queued for triage |
-| `READY` | Suggestions generated, awaiting review |
-| `APPLIED` | Suggestions accepted by a human |
-| `DISMISSED` | Suggestions rejected |
-| `ERROR` | Triage failed (provider error, timeout, etc) |
+| State | Meaning |
+| ----------- | ---------------------------------------------- |
+| `null` | AI triage didn't run (feature off, or not yet) |
+| `PENDING` | Queued for triage |
+| `READY` | Suggestions generated, awaiting review |
+| `APPLIED` | Suggestions accepted by a human |
+| `DISMISSED` | Suggestions rejected |
+| `ERROR` | Triage failed (provider error, timeout, etc) |
Triage suggestions cover priority, labels, and project — never the title or
description. See [Agents → AI triage and coach](/agents/ai-triage-and-coach.html)
@@ -220,12 +235,12 @@ and SLA breaches.
Issues are linked through `IssueRelation` rows: directed and typed.
-| Type | Meaning |
-|---|---|
-| `BLOCKS` | This issue blocks the target |
-| `BLOCKED_BY` | This issue is blocked by the target |
+| Type | Meaning |
+| ------------ | -------------------------------------------------- |
+| `BLOCKS` | This issue blocks the target |
+| `BLOCKED_BY` | This issue is blocked by the target |
| `DUPLICATES` | This issue duplicates the target (close as dup of) |
-| `RELATES_TO` | Soft link, no semantic constraint |
+| `RELATES_TO` | Soft link, no semantic constraint |
`BLOCKS` and `BLOCKED_BY` are inverses; creating one writes both records
so both sides of the graph are queryable cheaply.
@@ -262,11 +277,11 @@ seven structured slash commands. Templates expand to a templated body
in-place (and, in two cases, fire a follow-up mutation). They show up in
the same autocomplete dropdown as the commands, listed after them.
-| Template | Expands to | Side-effect |
-|---|---|---|
-| `/status []` | `**Status:** Working on ` | — |
-| `/blocked ` | `**Blocked:** ` | Opens a `FREE_FORM` action request titled "Unblock needed" on this issue |
-| `/approve` | `**Approved** ✓` | — |
+| Template | Expands to | Side-effect |
+| ------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
+| `/status []` | `**Status:** Working on ` | — |
+| `/blocked ` | `**Blocked:** ` | Opens a `FREE_FORM` action request titled "Unblock needed" on this issue |
+| `/approve` | `**Approved** ✓` | — |
| `/handoff @ []` | Injects `/assign @` at the top, then `**Handing off to @:** ` | Reassigns the issue to the named agent via the existing `/assign` command path |
Picking a template from the dropdown replaces the line and parks your
diff --git a/docs/reference/mcp.md b/docs/reference/mcp.md
index 39d91b96..d9ceaccd 100644
--- a/docs/reference/mcp.md
+++ b/docs/reference/mcp.md
@@ -92,7 +92,7 @@ Both compose with **scope pruning**: whenever a key is presented, `tools/list`
drops any tool the key couldn't call anyway (same check as `tools/call`). A
FULL-scope key sees everything within the selected profile; a narrowly-scoped
key sees less. `tools/call` is never restricted by these selectors — they only
-shape what's *advertised*, so full capability stays reachable for any tool the
+shape what's _advertised_, so full capability stays reachable for any tool the
caller names and is authorized for.
```
@@ -125,22 +125,22 @@ long tail without advertising every direct tool:
### `issues`
-| Tool | Summary |
-|--------------|------------------------------------------------------------------------------|
-| `list` | Paged list with filters: `status`, `priority`, `projectId`, `assignedAgentId`, `labelIds[]`, `queued`. |
-| `get` | Fetch by `id`. Optional `include` hydrates description / comments / attachments / relations / currentRun / labels in one round-trip. |
-| `create` | `{ title, description?, projectId?, priority?, statusId?, labelIds? }` → full issue. |
-| `queue` | Set `queued: true`. Dispatcher only sees queued + unassigned issues. |
-| `transition` | Change status by `statusId`. |
-| `claim` | Set human `claimedById = caller`. Sets soft expiry `claimExpiresAt`. |
-| `release` | Clear human claim. |
-| `assign` | Assign agent. Identify by `agentId` or `profileKey`. |
-| `reassign` | Atomic handoff — see below. |
-| `assigned` | List issues assigned to an agent. |
-| `watch` | Add the caller as a watcher of `issueId`. Idempotent. Identity is inferred from the API key (`linkedAgentId` → agent-watch, otherwise user-watch). |
-| `unwatch` | Remove the caller's watch on `issueId`. |
-| `listWatchers` | List watchers of an issue with user/agent identity fields. |
-| `listWatching` | List issues the caller is currently watching. |
+| Tool | Summary |
+| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `list` | Paged list with filters: `status`, `priority`, `projectId`, `assignedAgentId`, `labelIds[]`, `queued`. |
+| `get` | Fetch by `id`. Optional `include` hydrates description / comments / attachments / relations / currentRun / labels in one round-trip. |
+| `create` | `{ title, description?, projectId?, priority?, statusId?, labelIds? }` → full issue. |
+| `queue` | Set `queued: true`. Dispatcher only sees queued + unassigned issues. |
+| `transition` | Change status by `statusId`. |
+| `claim` | Set human `claimedById = caller`. Sets soft expiry `claimExpiresAt`. |
+| `release` | Clear human claim. |
+| `assign` | Assign agent. Identify by `agentId` or `profileKey`. |
+| `reassign` | Atomic handoff — see below. |
+| `assigned` | List issues assigned to an agent. |
+| `watch` | Add the caller as a watcher of `issueId`. Idempotent. Identity is inferred from the API key (`linkedAgentId` → agent-watch, otherwise user-watch). |
+| `unwatch` | Remove the caller's watch on `issueId`. |
+| `listWatchers` | List watchers of an issue with user/agent identity fields. |
+| `listWatching` | List issues the caller is currently watching. |
Agent-linked keys are mode-gated for issue state. When the calling agent has an
active/waiting `AgentRun` on the issue in **RESEARCH**, **REVIEW**, or
@@ -171,27 +171,33 @@ to hydrate optional sections in one round-trip:
"id": "cle9k...",
"include": {
"description": true,
- "comments": true, // or { "limit": 50 } — max 100, default 20
+ "comments": true, // or { "limit": 50 } — max 100, default 20
"attachments": true,
"relations": true,
- "currentRun": true, // most recent non-terminal AgentRun
+ "currentRun": true, // most recent non-terminal AgentRun
"labels": true
}
}
```
-`currentRun` is the most recent AgentRun whose status is not `COMPLETED`
-or `ABANDONED` (so `ACTIVE` and `STALLED` qualify).
+`currentRun` is the most recent `ACTIVE` or `WAITING` AgentRun. Agent-linked
+keys only see their own run; terminal `STALLED`, `ABANDONED`, and `COMPLETED`
+history never masquerades as current work.
+
+Archived issues are excluded from `issues.list`, `issues.get`, agent inboxes,
+and `agent.context.bundle`. Archiving is an operator lifecycle action in the
+web app; restoring visibility does not restart abandoned runs or canceled plan
+steps.
### `comments`
-| Tool | Summary |
-|------------------|------------------------------------------------------------------------|
-| `create` | Post a comment on an issue. Optional `confidence: "LOW" \| "MEDIUM" \| "HIGH"` annotates how much to scrutinize the claim (only rendered for agent-authored rows). Optional `actionRequest` bundles a recommendation card in the same call; pass `actionRequest.options[]` to make that card a multi-vote poll. |
-| `update` | Edit an issue comment body. Input `{ id, body }`; authors can edit their own comments, while workspace OWNER/ADMIN or ADMIN-scoped MCP keys can override. Preserves prior bodies in `revisions`, sets `editedAt`, and emits normal comment audit/activity. |
-| `delete` | Soft-delete/archive an issue comment. Input `{ id }`; same author/admin authorization as `update`. Sets `deletedAt`, keeps the row for audit/history, and removes it from agent-facing comment lists/context bundles. |
-| `upsertStatus` | Idempotent rolling STATUS comment for the calling agent's run. |
-| `list` | Paginated history. `{ issueId, before?, limit? = 50 (max 200) }` — newest first, hides soft-deleted, includes `author` + `authoringAgent`. Scope: `READ_ISSUES`. |
+| Tool | Summary |
+| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `create` | Post a comment on an issue. Optional `confidence: "LOW" \| "MEDIUM" \| "HIGH"` annotates how much to scrutinize the claim (only rendered for agent-authored rows). Optional `actionRequest` bundles a recommendation card in the same call; pass `actionRequest.options[]` to make that card a multi-vote poll. |
+| `update` | Edit an issue comment body. Input `{ id, body }`; authors can edit their own comments, while workspace OWNER/ADMIN or ADMIN-scoped MCP keys can override. Preserves prior bodies in `revisions`, sets `editedAt`, and emits normal comment audit/activity. |
+| `delete` | Soft-delete/archive an issue comment. Input `{ id }`; same author/admin authorization as `update`. Sets `deletedAt`, keeps the row for audit/history, and removes it from agent-facing comment lists/context bundles. |
+| `upsertStatus` | Idempotent rolling STATUS comment for the calling agent's run. |
+| `list` | Paginated history. `{ issueId, before?, limit? = 50 (max 200) }` — newest first, hides soft-deleted, includes `author` + `authoringAgent`. Scope: `READ_ISSUES`. |
**`confidence`** is persisted regardless of caller, but the UI only
renders the chip for agent-authored comments. Use it from agents to
@@ -213,48 +219,48 @@ capped at 20 entries — oldest dropped first). Read history via
### `projects`
-| Tool | Summary |
-|-----------|----------------------------------------|
-| `list` | List workspace projects. |
-| `create` | Create a project. |
-| `update` | Update name/description/initiativeId. |
-| `archive` | Archive (soft-delete). |
+| Tool | Summary |
+| --------- | ------------------------------------- |
+| `list` | List workspace projects. |
+| `create` | Create a project. |
+| `update` | Update name/description/initiativeId. |
+| `archive` | Archive (soft-delete). |
### `cycles`
> The product label is **"Sprints"**, but the namespace, route, and data
> model stay `cycle*` — only display strings were renamed.
-| Tool | Summary |
-|---------------|--------------------------------------------------------|
-| `list` | List cycles for the workspace. |
-| `get` | Fetch by id. |
-| `current` | Return the active cycle (or `null`). |
-| `create` | Create a cycle. |
-| `update` | Update name/dates. |
-| `plan` | Bulk add/remove issues for upcoming cycle. |
-| `rollover` | Move incomplete issues from current to next cycle. |
-| `addIssue` | Add a single issue to a cycle. |
-| `removeIssue` | Remove a single issue from a cycle. |
+| Tool | Summary |
+| ------------- | -------------------------------------------------- |
+| `list` | List cycles for the workspace. |
+| `get` | Fetch by id. |
+| `current` | Return the active cycle (or `null`). |
+| `create` | Create a cycle. |
+| `update` | Update name/dates. |
+| `plan` | Bulk add/remove issues for upcoming cycle. |
+| `rollover` | Move incomplete issues from current to next cycle. |
+| `addIssue` | Add a single issue to a cycle. |
+| `removeIssue` | Remove a single issue from a cycle. |
### `initiatives`
-| Tool | Summary |
-|-----------------|--------------------------------------------------|
-| `list` | List initiatives. |
-| `get` | Fetch by id. |
-| `create` | Create initiative. |
-| `update` | Update name/description. |
-| `linkProject` | Attach a project to this initiative. |
-| `unlinkProject` | Detach a project (sets `initiativeId = null`). |
+| Tool | Summary |
+| --------------- | ---------------------------------------------- |
+| `list` | List initiatives. |
+| `get` | Fetch by id. |
+| `create` | Create initiative. |
+| `update` | Update name/description. |
+| `linkProject` | Attach a project to this initiative. |
+| `unlinkProject` | Detach a project (sets `initiativeId = null`). |
### `relations`
-| Tool | Summary |
-|----------------|------------------------------------------------------------------|
-| `add` | `{ fromIssueId, toIssueId, kind }` — directed link. |
-| `remove` | Remove a relation by id. |
-| `listForIssue` | List both inbound and outbound relations for an issue. |
+| Tool | Summary |
+| -------------- | ------------------------------------------------------ |
+| `add` | `{ fromIssueId, toIssueId, kind }` — directed link. |
+| `remove` | Remove a relation by id. |
+| `listForIssue` | List both inbound and outbound relations for an issue. |
`kind` is one of `BLOCKS`, `BLOCKED_BY`, `DUPLICATES`, `RELATES_TO`.
@@ -265,14 +271,14 @@ workspace's active `ConnectionMapping(kind="repo")` and only mutate Forge
state. They do not comment on GitHub, close GitHub issues, edit PRs, or apply
GitHub labels.
-| Tool | Summary |
-|---|---|
-| `parseUrl` | Parse a GitHub issue/PR URL into `{ owner, repo, repoFullName, type, number, url }`. Scope: `READ_ISSUES`. |
-| `listLinked` | List GitHub resources linked to a Forge issue. Scope: `READ_ISSUES`. |
-| `link` | Link a GitHub issue or PR URL to an existing Forge issue with `kind: SOURCE \| IMPLEMENTS \| REVIEWS \| RELATES_TO`. Scope: `WRITE_ISSUES`. |
-| `importIssue` | Create or return the Forge issue sourced from a mapped GitHub issue number. Scope: `WRITE_ISSUES`. |
-| `sync` | Refresh a linked GitHub resource snapshot and apply configured Forge status/title rules. Scope: `WRITE_ISSUES`. |
-| `search` | Search issues/PRs within a mapped repository. Scope: `READ_ISSUES`. |
+| Tool | Summary |
+| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
+| `parseUrl` | Parse a GitHub issue/PR URL into `{ owner, repo, repoFullName, type, number, url }`. Scope: `READ_ISSUES`. |
+| `listLinked` | List GitHub resources linked to a Forge issue. Scope: `READ_ISSUES`. |
+| `link` | Link a GitHub issue or PR URL to an existing Forge issue with `kind: SOURCE \| IMPLEMENTS \| REVIEWS \| RELATES_TO`. Scope: `WRITE_ISSUES`. |
+| `importIssue` | Create or return the Forge issue sourced from a mapped GitHub issue number. Scope: `WRITE_ISSUES`. |
+| `sync` | Refresh a linked GitHub resource snapshot and apply configured Forge status/title rules. Scope: `WRITE_ISSUES`. |
+| `search` | Search issues/PRs within a mapped repository. Scope: `READ_ISSUES`. |
`importIssue` input accepts `{ mappingId?, repoFullName?, number, projectId?,
labelIds?, queue? }`; pass either `mappingId` or a repo full name that has an
@@ -284,26 +290,26 @@ already imported.
> Gated on `Workspace.timeTrackingEnabled`. All tools `403` when disabled.
-| Tool | Summary |
-|-----------|----------------------------------------------------------|
-| `start` | Start a running timer on an issue. |
-| `stop` | Stop the caller's running timer. |
-| `log` | Manually log a `{ issueId, durationMin }` entry. |
-| `list` | List entries (filter by user/issue/date range). |
-| `summary` | Aggregated totals by user/issue/day. |
-| `running` | Return the caller's running timer (or `null`). |
+| Tool | Summary |
+| --------- | ------------------------------------------------ |
+| `start` | Start a running timer on an issue. |
+| `stop` | Stop the caller's running timer. |
+| `log` | Manually log a `{ issueId, durationMin }` entry. |
+| `list` | List entries (filter by user/issue/date range). |
+| `summary` | Aggregated totals by user/issue/day. |
+| `running` | Return the caller's running timer (or `null`). |
### `attachments`
-| Tool | Summary |
-|------------------|----------------------------------------------------------------------|
-| `initUpload` | Get a presigned MinIO/S3 PUT URL. Returns `{ uploadUrl, key }`. |
-| `finalize` | Register the uploaded blob as an Attachment row. |
-| `attachLink` | Record an external URL (LINK kind). No bytes uploaded. |
-| `list` | List attachments for a `(targetType, targetId)` pair. |
-| `getDownloadUrl` | Get a presigned GET URL for browser/agent download. |
-| `getInline` | Server-side bytes fetch — returns `{ id, mimeType, sizeBytes, filename, base64 }`. Default cap 1 MB; allowlist is image/*+pdf+text. |
-| `delete` | Delete attachment + remove blob. |
+| Tool | Summary |
+| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
+| `initUpload` | Get a presigned MinIO/S3 PUT URL. Returns `{ uploadUrl, key }`. |
+| `finalize` | Register the uploaded blob as an Attachment row. |
+| `attachLink` | Record an external URL (LINK kind). No bytes uploaded. |
+| `list` | List attachments for a `(targetType, targetId)` pair. |
+| `getDownloadUrl` | Get a presigned GET URL for browser/agent download. |
+| `getInline` | Server-side bytes fetch — returns `{ id, mimeType, sizeBytes, filename, base64 }`. Default cap 1 MB; allowlist is image/\*+pdf+text. |
+| `delete` | Delete attachment + remove blob. |
**`attachLink`** input: `{ targetType, targetId, externalUrl, linkTitle? }`.
`targetType` is one of `"issue" | "initiative" | "cycle" | "comment"`.
@@ -324,7 +330,7 @@ suggestion to fall back to `getDownloadUrl`. `maxBytes` defaults to
### `pins`
| Tool | Summary |
-|--------|---------------------------------------------------------------|
+| ------ | ------------------------------------------------------------- |
| `list` | List the caller's pinned issues. |
| `set` | Set the full pinned set (idempotent — pass the desired list). |
@@ -338,19 +344,19 @@ is tRPC-only via `pin.listAll` / `pin.add` / `pin.remove` /
### `notes`
Per-(workspace, user) markdown scratchpad. Each tool operates **only**
-on the calling actor's own notes — agents leave notes for *themselves*,
+on the calling actor's own notes — agents leave notes for _themselves_,
not for the operator. To leave a note for someone else, use
`comments.create` on the relevant issue (their inbox picks up the
@-mention via the existing fan-out).
-| Tool | Scope | Summary |
-|-----------------|----------------|------------------------------------------------------------------------|
-| `notes.create` | `WRITE_ISSUES` | Create a personal note. Inputs: `{ title?, body, pinned?, kind?, journalDate? }`. |
-| `notes.list` | `READ_ISSUES` | List the caller's own notes (NOTE-kind by default). Inputs: `{ archived?, kind?, limit? }`. |
-| `notes.update` | `WRITE_ISSUES` | Patch one of the caller's notes. Inputs: `{ id, title?, body?, pinned? }`. |
-| `notes.archive` | `WRITE_ISSUES` | Soft-archive one of the caller's notes. Inputs: `{ id }`. |
-| `notes.todayJournal` | `WRITE_ISSUES` | Get-or-create today's JOURNAL entry for the caller (timezone-aware). Idempotent. |
-| `notes.listJournal` | `READ_ISSUES` | List recent journal entries. Inputs: `{ from?, to?, limit? = 30 }`. |
+| Tool | Scope | Summary |
+| -------------------- | -------------- | ------------------------------------------------------------------------------------------- |
+| `notes.create` | `WRITE_ISSUES` | Create a personal note. Inputs: `{ title?, body, pinned?, kind?, journalDate? }`. |
+| `notes.list` | `READ_ISSUES` | List the caller's own notes (NOTE-kind by default). Inputs: `{ archived?, kind?, limit? }`. |
+| `notes.update` | `WRITE_ISSUES` | Patch one of the caller's notes. Inputs: `{ id, title?, body?, pinned? }`. |
+| `notes.archive` | `WRITE_ISSUES` | Soft-archive one of the caller's notes. Inputs: `{ id }`. |
+| `notes.todayJournal` | `WRITE_ISSUES` | Get-or-create today's JOURNAL entry for the caller (timezone-aware). Idempotent. |
+| `notes.listJournal` | `READ_ISSUES` | List recent journal entries. Inputs: `{ from?, to?, limit? = 30 }`. |
Returns the resulting `Note` row(s). Cross-actor mutation is blocked
at the resolver — passing an `id` owned by another user yields a
@@ -362,8 +368,8 @@ tRPC proc covers that case.
### `analytics`
-| Tool | Summary |
-|-----------|------------------------------------------------------------------------|
+| Tool | Summary |
+| --------- | ----------------------------------------------------------------------- |
| `summary` | Workspace summary: counts by status, throughput, breaches. Coarse only. |
> Dispatch analytics (`analytics.dispatch.*`) are tRPC-only — see
@@ -371,8 +377,8 @@ tRPC proc covers that case.
### `standup`
-| Tool | Summary |
-|---------|----------------------------------------------------------------------|
+| Tool | Summary |
+| ------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `draft` | Compose a "closed / opened / continuing / blocked" markdown draft from the caller's last 24h (configurable up to 168h) of activity. |
`standup.draft` accepts `{ sinceHours?: number }` (default 24, max 168)
@@ -388,10 +394,10 @@ matching `issues.create`'s fallback). Scopes: `READ_ISSUES`,
### `agents`
-| Tool | Summary |
-|-------------|----------------------------------------------------------------------|
-| `me` | Returns the calling agent's row. Inferred from `ApiKey.linkedAgentId`. |
-| `heartbeat` | Update presence: `{ status?: ONLINE | BUSY | OFFLINE }`. |
+| Tool | Summary |
+| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- | ----------- |
+| `me` | Returns the calling agent's row. Inferred from `ApiKey.linkedAgentId`. |
+| `heartbeat` | Update presence: `{ status?: ONLINE | BUSY | OFFLINE }`. |
| `list` | Workspace agents. `{ includeArchived? = false, runtimeId? }` → `{ id, profileKey, name, status, runtimeMode, provider, capabilities, archivedAt, runtime: { id, name, kind } }[]`. Scope: `READ_USERS`. |
**`me`** rejects keys without `linkedAgentId` set. The intended pattern is
@@ -415,13 +421,13 @@ Forge expects a persistent or single-session runtime.
Scope required: `WRITE_COMMENTS`.
-| Tool | Summary |
-|---|---|
-| `appendMessage` | Single-shot agent reply. Persists a `ChatMessage` (role: AGENT). |
-| `startDraft` | Begin a streaming reply. Returns `{ draftId }`. Publishes a `started` event on the `chat-thread-stream` channel. Nothing persisted yet. |
-| `appendDraftChunk` | Publish one token delta. Ephemeral SSE only; no DB write. |
-| `finalizeDraft` | Persist the complete reply, swap the client draft bubble, publish `finalized`. |
-| `getThread` | Read `{ thread, messages[] }` for a thread the calling agent is the addressee of. `{ threadId, before?, limit? = 50 (max 200) }`. Each message includes `id, role, body, contextSnapshot, sourceRunId, createdAt, finalizedDraftId`. |
+| Tool | Summary |
+| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `appendMessage` | Single-shot agent reply. Persists a `ChatMessage` (role: AGENT). |
+| `startDraft` | Begin a streaming reply. Returns `{ draftId }`. Publishes a `started` event on the `chat-thread-stream` channel. Nothing persisted yet. |
+| `appendDraftChunk` | Publish one token delta. Ephemeral SSE only; no DB write. |
+| `finalizeDraft` | Persist the complete reply, swap the client draft bubble, publish `finalized`. |
+| `getThread` | Read `{ thread, messages[] }` for a thread the calling agent is the addressee of. `{ threadId, before?, limit? = 50 (max 200) }`. Each message includes `id, role, body, contextSnapshot, sourceRunId, createdAt, finalizedDraftId`. |
**Single-shot:**
@@ -430,7 +436,7 @@ Scope required: `WRITE_COMMENTS`.
{
"threadId": "cle9k...",
"body": "Here's the summary: ...",
- "sourceRunId": "run_01H..." // optional — links to an AgentRun
+ "sourceRunId": "run_01H..." // optional — links to an AgentRun
}
// → { "messageId": "...", "threadId": "..." }
```
@@ -465,13 +471,13 @@ Scope required: `ADMIN`. Powers the `forge` CLI's local daemon registration
and heartbeat loop. See [/agents/runtimes.html](/agents/runtimes.html) for
the broader Runtime primitive.
-| Tool | Summary |
-|---|---|
-| `register` | Create (or restore) a Runtime row. `{ name, kind, endpoint?, providersAvailable, info? }`. `ownerId` is set from the calling key's `userId`; AGENT-kind keys leave it null. |
-| `heartbeat` | Bump `Runtime.heartbeatAt`. `{ runtimeId, info? }`. Optional `info` updates sanitized runtime version/environment metadata. |
-| `list` | List workspace runtimes. `{ kind?, includeArchived? = false }` → mirror of `trpc.runtime.list` shape, includes `_count: { agents }` + `owner` summary. Secrets are redacted to `hasSecret`. |
-| `configure` | Update adapter config. `{ runtimeId, config, merge? = true }`. Hermes accepts `localWorkspaceTools`, `toolCapabilities`, `workspaceRoot`, and `modeToolPolicyEnforced`; Codex app-server also accepts sandbox/approval config. Scope `ADMIN`. |
-| `archive` | Deregister (archive) a runtime — `{ runtimeId }` sets `archivedAt`, so it drops out of `list` (unless `includeArchived`). The teardown counterpart of `register`; exposed as `forge runtimes archive `. Scope `ADMIN`. |
+| Tool | Summary |
+| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `register` | Create (or restore) a Runtime row. `{ name, kind, endpoint?, providersAvailable, info? }`. `ownerId` is set from the calling key's `userId`; AGENT-kind keys leave it null. |
+| `heartbeat` | Bump `Runtime.heartbeatAt`. `{ runtimeId, info? }`. Optional `info` updates sanitized runtime version/environment metadata. |
+| `list` | List workspace runtimes. `{ kind?, includeArchived? = false }` → mirror of `trpc.runtime.list` shape, includes `_count: { agents }` + `owner` summary. Secrets are redacted to `hasSecret`. |
+| `configure` | Update adapter config. `{ runtimeId, config, merge? = true }`. Hermes accepts `localWorkspaceTools`, `toolCapabilities`, `workspaceRoot`, and `modeToolPolicyEnforced`; Codex app-server also accepts sandbox/approval config. Scope `ADMIN`. |
+| `archive` | Deregister (archive) a runtime — `{ runtimeId }` sets `archivedAt`, so it drops out of `list` (unless `includeArchived`). The teardown counterpart of `register`; exposed as `forge runtimes archive `. Scope `ADMIN`. |
| `reportInfo` | Agent-linked runtime metadata report. `{ runtimeId?, info }`; omitted `runtimeId` resolves to the calling agent's attached Runtime. Stores a sanitized whitelist such as `runtimeVersion`, `bridgeVersion`, `codexVersion`, `containerImage`, `hostname`, `os`, `arch`, and `workspaceRoot`. |
**`register`** is intentionally not deduping server-side — the CLI caches
@@ -483,14 +489,14 @@ heartbeat returns a missing-row error.
`recordUsage` requires `WRITE_ISSUES` and an agent-linked key whose
`linkedAgentId` matches the run's `agentId`. `list` requires `READ_ISSUES`.
-| Tool | Summary |
-|---|---|
-| `open` | Open (or resume) a tracked run on an issue **you** are working — `{ issueId, summary?, mode? }`. Requires an agent-linked key; the run belongs to the key's `linkedAgentId`. Resumes an existing ACTIVE/WAITING run for the `(issue, agent)` pair instead of stacking a duplicate, and stamps a STARTED event so it shows in Mission Control. Lets an agent (e.g. a local CLI session) proactively self-report work rather than only getting a run as a dispatch side-effect. Issue-anchored; drive it afterward with `recordUsage` / `setWaiting` / `complete`. Scope `WRITE_ISSUES`. |
-| `recordUsage` | Update token + cost columns on an `AgentRun`. `{ runId, tokensIn?, tokensOut?, tokensCached?, costUsd? }`. Idempotent — latest call replaces (cumulative as reported by the agent). |
-| `complete` | Close an active/waiting run. `{ runId, summary, producedArtifactIds?, verificationResult?, followUps?, confidence?, verdict? }`. Requires an agent-linked key matching the run. Execute enforces issue artifact/checklist gates; Research requires `confidence`; Review requires `verdict`; Discuss is reply-only. Stores `completionMeta` with the Forge run contract version. |
-| `setWaiting` / `resumeWork` | Mark a run blocked on the operator, or resume it. Requires an agent-linked key matching the run. |
-| `list` | List `AgentRun` rows for "my recent history" introspection. `{ agentId?, issueId?, status?, limit? = 50, before? }` → newest-first by `startedAt`. Each row includes scalars (status, currentStep, startedAt, finishedAt, lastEventAt, tokensIn, tokensOut, tokensCached, costUsd) plus `issue { id, number, title, workspace: { key } }` and `agent { id, profileKey }`. |
-| `kick` | Operator-driven nudge for a stalled run. `{ runId }`. Re-fires the dispatch webhook for the underlying issue without changing assignment or `controlState`. Only kicks an `ACTIVE` run that's been quiet 5+ minutes; younger runs return `{ ok: true, kicked: false }`. Records `AGENT_RUN_KICKED`. Scope: `WRITE_ISSUES`. |
+| Tool | Summary |
+| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `open` | Open (or resume) a tracked run on an issue **you** are working — `{ issueId, summary?, mode? }`. Requires an agent-linked key; the run belongs to the key's `linkedAgentId`. Resumes an existing ACTIVE/WAITING run for the `(issue, agent)` pair instead of stacking a duplicate, and stamps a STARTED event so it shows in Mission Control. Lets an agent (e.g. a local CLI session) proactively self-report work rather than only getting a run as a dispatch side-effect. Issue-anchored; drive it afterward with `recordUsage` / `setWaiting` / `complete`. Scope `WRITE_ISSUES`. |
+| `recordUsage` | Update token + cost columns on an `AgentRun`. `{ runId, tokensIn?, tokensOut?, tokensCached?, costUsd? }`. Idempotent — latest call replaces (cumulative as reported by the agent). |
+| `complete` | Close an active/waiting run. `{ runId, summary, producedArtifactIds?, verificationResult?, followUps?, confidence?, verdict? }`. Requires an agent-linked key matching the run. Execute enforces issue artifact/checklist gates; Research requires `confidence`; Review requires `verdict`; Discuss is reply-only. Stores `completionMeta` with the Forge run contract version. |
+| `setWaiting` / `resumeWork` | Mark a run blocked on the operator, or resume it. Requires an agent-linked key matching the run. |
+| `list` | List `AgentRun` rows for "my recent history" introspection. `{ agentId?, issueId?, status?, limit? = 50, before? }` → newest-first by `startedAt`. Each row includes scalars (status, currentStep, startedAt, finishedAt, lastEventAt, tokensIn, tokensOut, tokensCached, costUsd) plus `issue { id, number, title, workspace: { key } }` and `agent { id, profileKey }`. |
+| `kick` | Operator-driven nudge for a stalled run. `{ runId }`. Re-fires the dispatch webhook for the underlying issue without changing assignment or `controlState`. Only kicks an `ACTIVE` run that's been quiet 5+ minutes; younger runs return `{ ok: true, kicked: false }`. Records `AGENT_RUN_KICKED`. Scope: `WRITE_ISSUES`. |
`costUsd` is taken verbatim from the agent for v1; a server-side
rate table per model is a future enhancement.
@@ -500,25 +506,25 @@ rate table per model is a future enhancement.
Scope: `READ_ISSUES`. Reads `ActivityEvent` rows for the calling
workspace.
-| Tool | Summary |
-|---|---|
+| Tool | Summary |
+| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `recent` | `{ subjectType?, subjectId?, kinds?, limit? = 50 (max 200), before? }` → newest-first by `createdAt`. Includes `actor { id, name, image }`. When `subjectType="issue"` + `subjectId` are set, the calling key's project/label narrowing is enforced on that issue. |
### `workspace`
Scope: `READ_ISSUES`.
-| Tool | Summary |
-|---|---|
+| Tool | Summary |
+| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `get` | Returns dispatch-time settings: `{ id, slug, key, name, cycleLengthDays, cycleCooldownDays, timeTrackingEnabled, attachmentQuotaMb, requiredAckSeconds, autoDispatch, autoDispatchMode }`. No member list. |
### `statuses`
Scope: `READ_ISSUES`.
-| Tool | Summary |
-|---|---|
-| `list` | `{ category? }`. Returns `{ id, name, category, color, position, isDefault }[]` ordered by `position`. Optional `category` filter (`BACKLOG | TODO | IN_PROGRESS | IN_REVIEW | DONE | CANCELED`). Used by agents to discover the right `statusId` for an `issues.transition` call without inventing ids — e.g., the local `forge` daemon calls `statuses.list({ category: "IN_PROGRESS" })` on `AGENT_ASSIGNED` to flip the issue into work-in-progress before spawning Claude. |
+| Tool | Summary |
+| ------ | ------------------------------------------------------------------------------------------------------------------------------------------- | ---- | ----------- | --------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `list` | `{ category? }`. Returns `{ id, name, category, color, position, isDefault }[]` ordered by `position`. Optional `category` filter (`BACKLOG | TODO | IN_PROGRESS | IN_REVIEW | DONE | CANCELED`). Used by agents to discover the right `statusId`for an`issues.transition`call without inventing ids — e.g., the local`forge`daemon calls`statuses.list({ category: "IN_PROGRESS" })`on`AGENT_ASSIGNED` to flip the issue into work-in-progress before spawning Claude. |
### `actionRequests`
@@ -530,16 +536,16 @@ Accept / Decline. Created by `comments.create` (inline via the
Scope: `WRITE_ISSUES` for mutations, `READ_ISSUES` for queries.
-| Tool | Summary |
-|---|---|
-| `list` | `{ status?, assignedAgentId?, assignedUserId?, issueId?, limit? = 50 }`. Newest first. |
-| `create` | Create a request. See "Polls" below for multi-vote variant. |
-| `transition` | Flip status (RESOLVED / DISMISSED / SNOOZED / REJECTED). |
-| `accept` | Run the bound dispatch (transition / setLabels / etc.) and resolve. Permission-gated to assignee / watcher / OWNER / ADMIN. |
-| `decline` | Reject without dispatching. Same permission gate as Accept. |
-| `vote` | Cast or update the caller's vote on a poll-style request. `{ id, optionKey }`. |
-| `results` | Live vote tallies for a poll. `{ id }` → `{ options[], total, myVote, winningOptionKey, votingClosedAt }`. |
-| `closeVoting` | Close voting on a poll. Only the requester can call this. `{ id }` → `{ id, winningOptionKey }`. |
+| Tool | Summary |
+| ------------- | --------------------------------------------------------------------------------------------------------------------------- |
+| `list` | `{ status?, assignedAgentId?, assignedUserId?, issueId?, limit? = 50 }`. Newest first. |
+| `create` | Create a request. See "Polls" below for multi-vote variant. |
+| `transition` | Flip status (RESOLVED / DISMISSED / SNOOZED / REJECTED). |
+| `accept` | Run the bound dispatch (transition / setLabels / etc.) and resolve. Permission-gated to assignee / watcher / OWNER / ADMIN. |
+| `decline` | Reject without dispatching. Same permission gate as Accept. |
+| `vote` | Cast or update the caller's vote on a poll-style request. `{ id, optionKey }`. |
+| `results` | Live vote tallies for a poll. `{ id }` → `{ options[], total, myVote, winningOptionKey, votingClosedAt }`. |
+| `closeVoting` | Close voting on a poll. Only the requester can call this. `{ id }` → `{ id, winningOptionKey }`. |
**`create`** accepts the usual fields (`title, body, severity, kind,
payload, assignedUserId, assignedAgentId, issueId, dueAt, sourceType,
@@ -552,9 +558,13 @@ sourceId`) plus an optional `options[]` for poll-style requests:
"kind": "FREE_FORM",
"issueId": "iss_...",
"options": [
- { "key": "retry", "label": "Add retry-on-failure", "description": "Cheapest, masks the underlying race." },
- { "key": "isolate", "label": "Isolate the test in its own worker" },
- { "key": "rewrite", "label": "Rewrite against a deterministic fixture" }
+ {
+ "key": "retry",
+ "label": "Add retry-on-failure",
+ "description": "Cheapest, masks the underlying race."
+ },
+ { "key": "isolate", "label": "Isolate the test in its own worker" },
+ { "key": "rewrite", "label": "Rewrite against a deterministic fixture" }
]
}
```
@@ -608,12 +618,12 @@ Scope: `READ_ISSUES` for reads, `WRITE_ISSUES` for the loop mutations,
#### Goals
-| Tool | Summary |
-|---|---|
-| `goals.list` | `{ status?, includeArchived?, limit? }` → goal rows with plan counts. |
-| `goals.get` | `{ id }` → goal + its plans + an `aggregate` block (`activePlanId`, `totalSteps`, `doneSteps`, `blockedSteps`). |
-| `goals.create` | `{ title, description?, issueId?, crewId?, maxTotalCostUsd?, maxWallTimeMinutes? }` → `{ id }`. Emits `GOAL_CREATED`. |
-| `goals.abandon` | `{ id, reason? }` → terminal `ABANDONED`; cancels any active plan attempt. |
+| Tool | Summary |
+| --------------- | --------------------------------------------------------------------------------------------------------------------- |
+| `goals.list` | `{ status?, includeArchived?, limit? }` → goal rows with plan counts. |
+| `goals.get` | `{ id }` → goal + its plans + an `aggregate` block (`activePlanId`, `totalSteps`, `doneSteps`, `blockedSteps`). |
+| `goals.create` | `{ title, description?, issueId?, crewId?, maxTotalCostUsd?, maxWallTimeMinutes? }` → `{ id }`. Emits `GOAL_CREATED`. |
+| `goals.abandon` | `{ id, reason? }` → terminal `ABANDONED`; cancels any active plan attempt. |
```json
// goals.create
@@ -623,14 +633,14 @@ Scope: `READ_ISSUES` for reads, `WRITE_ISSUES` for the loop mutations,
#### Plans (loop)
-| Tool | Summary |
-|---|---|
-| `plans.decompose` | `{ goalId, plannerAgentId?, contextSetId? }` → `{ planId, status: "PLANNING", plannerAgentId }`. Creates a DRAFT plan (`isActiveAttempt=true`, prior attempts flipped false), flips goal → PLANNING, dispatches the PLANNER (override > crew PLANNER > caller's agent). |
-| `plans.addSteps` | `{ planId, steps: [{ title, body?, expectedOutput?, verification?, dependsOnStepIndexes?, assignedAgentId?, assignedRole? }] }` → `{ stepIds }`. Bulk-adds to a DRAFT plan; `dependsOnStepIndexes` are 0-based positions within the batch, resolved to real ids. |
-| `plans.requestApproval` | `{ planId, assignedUserId? }` → `{ actionRequestId }`. Raises a FREE_FORM ActionRequest (`sourceType="execution-plan"`). Accepting it activates the plan. |
-| `plans.activate` | `{ planId }` → `{ ok }`. DRAFT/APPROVED → RUNNING, goal → ACTIVE + `startedAt`, root steps cascade READY. Usually fired by accepting the approval ActionRequest. |
-| `plans.judge` | `{ stepId, judgeAgentId? }` → `{ judgeAgentId }`. Dispatches a REVIEWER (override > crew REVIEWER) to evaluate a step in REVIEW. |
-| `plans.recordVerdict` | `{ stepId, verdict: "PASS"\|"FAIL", feedback, score? }` → `{ outcome: "DONE"\|"RETRY"\|"BLOCKED", retryCount }`. PASS → DONE + cascade. FAIL → READY+retry (feedback stored) or BLOCKED + ReviewGate when retries exhausted. Emits `EXECUTION_STEP_JUDGED`. |
+| Tool | Summary |
+| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `plans.decompose` | `{ goalId, plannerAgentId?, contextSetId? }` → `{ planId, status: "PLANNING", plannerAgentId }`. Creates a DRAFT plan (`isActiveAttempt=true`, prior attempts flipped false), flips goal → PLANNING, dispatches the PLANNER (override > crew PLANNER > caller's agent). |
+| `plans.addSteps` | `{ planId, steps: [{ title, body?, expectedOutput?, verification?, dependsOnStepIndexes?, assignedAgentId?, assignedRole? }] }` → `{ stepIds }`. Bulk-adds to a DRAFT plan; dependency indexes must be in-range, non-self, and acyclic. `assignedRole` auto-resolves a unique crew member; multiple matches require an explicit matching `assignedAgentId`, and no match is rejected. |
+| `plans.requestApproval` | `{ planId, assignedUserId? }` → `{ actionRequestId }`. Raises a FREE_FORM ActionRequest (`sourceType="execution-plan"`). Accepting it activates the plan. |
+| `plans.activate` | `{ planId }` → `{ ok }`. DRAFT/APPROVED → RUNNING, goal → ACTIVE + `startedAt`, root steps cascade READY. Usually fired by accepting the approval ActionRequest. |
+| `plans.judge` | `{ stepId, judgeAgentId? }` → `{ judgeAgentId }`. Dispatches a REVIEWER (override > crew REVIEWER) to evaluate a step in REVIEW. |
+| `plans.recordVerdict` | `{ stepId, verdict: "PASS"\|"FAIL", feedback, score? }` → `{ outcome: "DONE"\|"RETRY"\|"BLOCKED", retryCount }`. PASS → DONE + cascade. FAIL → READY+retry (feedback stored) or BLOCKED + ReviewGate when retries exhausted. Emits `EXECUTION_STEP_JUDGED`. |
```json
// plans.addSteps — index-based deps
@@ -650,27 +660,27 @@ Scope: `READ_ISSUES` for reads, `WRITE_ISSUES` for the loop mutations,
#### Plan substrate (existing)
-| Tool | Summary |
-|---|---|
-| `executionPlans.list` / `.get` / `.create` / `.transition` | Plan CRUD + status transitions. |
-| `executionPlans.transitionStep` | `{ stepId, status, sourceRunId? }`. Marking a step DONE cascades downstream readiness; marking REVIEW triggers auto-judge when the plan opts in. |
+| Tool | Summary |
+| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `executionPlans.list` / `.get` / `.create` / `.transition` | Plan CRUD + status transitions. |
+| `executionPlans.transitionStep` | `{ stepId, status, sourceRunId? }`. References are workspace/plan validated. READY/RUNNING are orchestration-owned and cannot be forced through this generic mutation. Marking DONE cascades downstream readiness; REVIEW triggers auto-judge when enabled. |
#### Crews
-| Tool | Summary |
-|---|---|
-| `agentCrews.list` | Existing read. Crews + member/plan counts. |
-| `agentCrews.create` | `{ name, description?, maxParallel?, members?: [{ agentId, role }] }` → `{ id }`. Roles: `PLANNER`/`WORKER`/`REVIEWER`/`OBSERVER`/`OPERATOR_PROXY`. |
-| `agentCrews.update` | `{ id, name?, description?, maxParallel? }`. |
-| `agentCrews.addMember` | `{ crewId, agentId, role }` → `{ id }`. |
-| `agentCrews.removeMember` | `{ memberId }`. |
-| `agentCrews.setMemberRole` | `{ memberId, role }` → `{ id }`. |
-| `agentCrews.archive` | `{ id }`. Soft-archive. |
+| Tool | Summary |
+| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `agentCrews.list` | Existing read. Crews + member/plan counts. |
+| `agentCrews.create` | `{ name, description?, maxParallel?, members?: [{ agentId, role }] }` → `{ id }`. Roles: `PLANNER`/`WORKER`/`REVIEWER`/`OBSERVER`/`OPERATOR_PROXY`. |
+| `agentCrews.update` | `{ id, name?, description?, maxParallel? }`. The cap is enforced across READY + RUNNING steps on every plan sharing the crew; `0` is unlimited. |
+| `agentCrews.addMember` | `{ crewId, agentId, role }` → `{ id }`. |
+| `agentCrews.removeMember` | `{ memberId }`. |
+| `agentCrews.setMemberRole` | `{ memberId, role }` → `{ id }`. |
+| `agentCrews.archive` | `{ id }`. Soft-archive. |
#### Review gates
-| Tool | Summary |
-|---|---|
+| Tool | Summary |
+| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `reviewGates.list` / `.open` / `.resolve` | Approval checkpoints. The loop auto-opens gates on a step BLOCKED (retries exhausted) and on a plan budget breach. |
### `notification`
@@ -682,8 +692,8 @@ the whole notification feed.
Scope: `READ_USERS` for the query, `WRITE_USERS` for the mutation.
-| Tool | Summary |
-|---|---|
+| Tool | Summary |
+| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `setPreference` | Upsert a preference row. `{ eventKind, enabled, delivery?, scope? = "workspace" }`. Pass `scope: "global"` to set the cross-workspace default. |
```json
@@ -702,11 +712,12 @@ ships).
Scope: `READ_ISSUES`. The single composite tool that saves agents 4–5
round-trips on dispatch — bundles workspace + issue (or thread) + comments
-+ attachments + relations + currentRun in one call.
-| Tool | Summary |
-|---|---|
-| `context.bundle` | `{ issueId? }` xor `{ threadId? }`. For `issueId`: returns `{ workspace, issue (full row), description, comments (last 50), attachments, relations, currentRun, artifacts, completionContract, runProtocol, externalResources }`. `externalResources` contains linked GitHub issues/PRs with their link kind and latest local snapshot. `runProtocol` includes `{ contractVersion, runId, engagementMode, modeInstruction, protocolInstruction, mayMutateIssue }` so agents can ack/output-start/complete the correct run and know whether issue mutations are allowed. For `threadId`: returns `{ workspace, thread, messages (last 50), agent (peer summary), linkedIssues (any issues mentioned in messages' contextSnapshots) }`. Addressee gating mirrors `chat.getThread` for the threadId branch; issue-narrowing applies for the issueId branch. |
+- attachments + relations + currentRun in one call.
+
+| Tool | Summary |
+| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `context.bundle` | `{ issueId? }` xor `{ threadId? }`. For `issueId`: returns `{ workspace, issue (full row), description, comments (last 50), attachments, relations, currentRun, artifacts, orchestrationContext, completionContract, runProtocol, externalResources }`. `currentRun` is limited to `ACTIVE`/`WAITING` and, for an agent key, to that linked agent. `orchestrationContext` carries the Goal, Plan, current Step (when unambiguous), dependencies/dependents, sibling progress, retry feedback, worker evidence, and non-excluded ContextSet refs. For a current run it prefers the immutable dispatch snapshot; legacy/no-run contexts use live hydration. `externalResources` contains linked GitHub issues/PRs with their link kind and latest local snapshot. `runProtocol` includes `{ contractVersion, runId, engagementMode, modeInstruction, protocolInstruction, mayMutateIssue }` so agents can ack/output-start/complete the correct run and know whether issue mutations are allowed. For `threadId`: returns `{ workspace, thread, messages (last 50), agent (peer summary), linkedIssues (any issues mentioned in messages' contextSnapshots) }`. Addressee gating mirrors `chat.getThread` for the threadId branch; issue-narrowing applies for the issueId branch. |
## Not on MCP
@@ -738,16 +749,16 @@ JSON shape on every error response:
`issues` is present only on Zod validation failures; otherwise just `error`.
-| Status | Meaning |
-|--------|---------------------------------------------------------------|
-| 400 | Invalid input (Zod). `issues` map populated. |
-| 401 | Auth failed (invalid/revoked/expired key, missing JWT). |
-| 403 | Scope or narrowing rejected. |
-| 404 | Subject does not exist or is not visible to this key. |
-| 409 | Conflict (e.g., reassigning to the same agent). |
-| 422 | Workspace precondition failed (e.g. time tracking disabled). |
-| 429 | Rate limited. Includes `Retry-After` header in seconds. |
-| 5xx | Server error. |
+| Status | Meaning |
+| ------ | ------------------------------------------------------------ |
+| 400 | Invalid input (Zod). `issues` map populated. |
+| 401 | Auth failed (invalid/revoked/expired key, missing JWT). |
+| 403 | Scope or narrowing rejected. |
+| 404 | Subject does not exist or is not visible to this key. |
+| 409 | Conflict (e.g., reassigning to the same agent). |
+| 422 | Workspace precondition failed (e.g. time tracking disabled). |
+| 429 | Rate limited. Includes `Retry-After` header in seconds. |
+| 5xx | Server error. |
tRPC errors over the in-app client follow `TRPCClientError` shape — code,
message, data with `httpStatus` and `path`. The MCP transports translate
diff --git a/docs/reference/trpc.md b/docs/reference/trpc.md
index 03f4d50a..bce8086f 100644
--- a/docs/reference/trpc.md
+++ b/docs/reference/trpc.md
@@ -25,40 +25,40 @@ error shapes all flow from this router definition.
## Router catalog
-| Router | Procedures |
-|-------------------|-----------------------------------------------------------------------------------------------------------------|
-| `workspace` | `list`, `current`, `create`, `listMembers`, `addMember`, `setMemberRole`, `removeMember` (admin) |
-| `project` | `list`, `byId`, `create`, `update`, `archive` |
-| `issue` | `list`, `byId`, `create`, `update`, `assign`, `softDelete`, `snooze`, `unsnooze`, `snoozeMany`, `nudge`, `bulkTransition`, `bulkAddLabel`, `bulkRemoveLabel`, `bulkAssign`, `bulkAssignAgent`, `bulkArchive`, `applyCommands`, `watch`, `unwatch`, `watchers`, `watching` |
-| `comment` | `create`, `update`, `softDelete` |
-| `analytics` | `summary`, `statusDistribution`, `throughput`, `cycleTime`, `slaBreaches`, `dispatch.summary`, `dispatch.timeseries` |
-| `plugin` | `list`, `byId`, `register`, `restoreBackup`, `exportBackup`, `approve`, `suspend`, `issueApiKey`, `revokeApiKey`, `remove`, `rotateSecret` |
-| `status` | `list`, `create`, `reorder` |
-| `template` | `list`, `byId`, `create`, `update`, `delete` (issue templates) |
-| `projectTemplate` | `list`, `create`, `update`, `delete` |
-| `agent` | `list`, `byId`, `byProfileKey`, `create`, `update`, `archive`, `delete`, `testWebhook`, `heartbeat`, `pipeline`, `timeline`, `uptime`, `webhookHealth` |
-| `agentRun` | `activeForIssue`, `events`, `activeAll`, `recentTerminal`, `heatmap`, `eventsInRange`, `recentEventCounts`, `coachDiagnosis`, `runsInRange`, `eta`, `abandon`, `redispatch`, `nudge` |
+| Router | Procedures |
+| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `workspace` | `list`, `current`, `create`, `listMembers`, `addMember`, `setMemberRole`, `removeMember` (admin) |
+| `project` | `list`, `byId`, `create`, `update`, `archive` |
+| `issue` | `list`, `count`, `byId`, `create`, `update`, `assign`, `archive`, `restore`, `softDelete` (compat alias), `snooze`, `unsnooze`, `snoozeMany`, `nudge`, `bulkTransition`, `bulkAddLabel`, `bulkRemoveLabel`, `bulkAssign`, `bulkAssignAgent`, `bulkArchive`, `bulkRestore`, `applyCommands`, `watch`, `unwatch`, `watchers`, `watching` |
+| `comment` | `create`, `update`, `softDelete` |
+| `analytics` | `summary`, `statusDistribution`, `throughput`, `cycleTime`, `slaBreaches`, `dispatch.summary`, `dispatch.timeseries` |
+| `plugin` | `list`, `byId`, `register`, `restoreBackup`, `exportBackup`, `approve`, `suspend`, `issueApiKey`, `revokeApiKey`, `remove`, `rotateSecret` |
+| `status` | `list`, `create`, `reorder` |
+| `template` | `list`, `byId`, `create`, `update`, `delete` (issue templates) |
+| `projectTemplate` | `list`, `create`, `update`, `delete` |
+| `agent` | `list`, `byId`, `byProfileKey`, `create`, `update`, `archive`, `delete`, `testWebhook`, `heartbeat`, `pipeline`, `timeline`, `uptime`, `webhookHealth` |
+| `agentRun` | `activeForIssue`, `events`, `activeAll`, `recentTerminal`, `heatmap`, `eventsInRange`, `recentEventCounts`, `coachDiagnosis`, `runsInRange`, `eta`, `abandon`, `redispatch`, `nudge` |
| `chat` | `threads`, `defaultThread`, `thread`, `createConversation`, `forkThread`, `updateConversation`, `setOverride`, `markRead`, `compactThread`, `clearThread`, `getThread`, `send`, `createPendingMessage`, `dispatchMessage`, `appendAgentMessage`, `threadDiagnostics`, `chatReadiness`, `retryLastUserMessage`, `kickThreadRun`, `stopThreadRun`, `deleteThread`, `archiveThread`, `restoreThread`, `history` |
-| `event` | `recent`, `unreadCount` |
-| `dispatchRule` | `list`, `create`, `update`, `reorder`, `toggle`, `delete` (admin) |
-| `admin` | `webhookDeliveries.list`, `webhookDeliveries.retry` (admin) |
-| `user` | `me`, `updateAppearance` |
-| `cycle` | `list`, `byId`, `current`, `create`, `update`, `plan`, `rollover`, `addIssue`, `removeIssue` |
-| `initiative` | `list`, `byId`, `create`, `update`, `linkProject`, `unlinkProject` |
-| `relation` | `add`, `remove`, `listForIssue` |
-| `time` | `start`, `stop`, `log`, `list`, `summary`, `running` |
-| `attachment` | `initUpload`, `finalize`, `attachLink`, `list`, `getDownloadUrl`, `delete` |
-| `access` | `list`, `create`, `update`, `revoke`, `delete`, `rotate`, `createPersonal`, `createSession` |
-| `integration` | `list`, `byKind`, `applyToAgent` |
-| `runtime` | `list`, `byId`, `register`, `heartbeat`, `archive`, `update` |
-| `pin` | `list`, `set`, `toggle` (legacy issue-only); `listAll`, `add`, `remove`, `toggleEntity`, `reorder` (polymorphic) |
-| `recentItem` | `list`, `track` |
-| `commandPalette` | `search` |
-| `savedView` | `list`, `create`, `update`, `delete` |
-| `note` | `list`, `create`, `update`, `archive`, `unarchive`, `delete`, `convertToIssue`, `todayJournal`, `listJournal` |
-| `inbox` | `list`, `badge`, `visit` |
-| `dashboard` | `suggestions`, `stalledInProgress` |
-| `notification` | `list`, `markRead`, `markAllRead`, `dismiss` |
+| `event` | `recent`, `unreadCount` |
+| `dispatchRule` | `list`, `create`, `update`, `reorder`, `toggle`, `delete` (admin) |
+| `admin` | `webhookDeliveries.list`, `webhookDeliveries.retry` (admin) |
+| `user` | `me`, `updateAppearance` |
+| `cycle` | `list`, `byId`, `current`, `create`, `update`, `plan`, `rollover`, `addIssue`, `removeIssue` |
+| `initiative` | `list`, `byId`, `create`, `update`, `linkProject`, `unlinkProject` |
+| `relation` | `add`, `remove`, `listForIssue` |
+| `time` | `start`, `stop`, `log`, `list`, `summary`, `running` |
+| `attachment` | `initUpload`, `finalize`, `attachLink`, `list`, `getDownloadUrl`, `delete` |
+| `access` | `list`, `create`, `update`, `revoke`, `delete`, `rotate`, `createPersonal`, `createSession` |
+| `integration` | `list`, `byKind`, `applyToAgent` |
+| `runtime` | `list`, `byId`, `register`, `heartbeat`, `archive`, `update` |
+| `pin` | `list`, `set`, `toggle` (legacy issue-only); `listAll`, `add`, `remove`, `toggleEntity`, `reorder` (polymorphic) |
+| `recentItem` | `list`, `track` |
+| `commandPalette` | `search` |
+| `savedView` | `list`, `create`, `update`, `delete` |
+| `note` | `list`, `create`, `update`, `archive`, `unarchive`, `delete`, `convertToIssue`, `todayJournal`, `listJournal` |
+| `inbox` | `list`, `badge`, `visit` |
+| `dashboard` | `suggestions`, `stalledInProgress` |
+| `notification` | `list`, `markRead`, `markAllRead`, `dismiss` |
## Notable procedures
@@ -153,7 +153,7 @@ agent.stalled({ agentId }) → {
- **Stalled runs** are `AgentRun` rows still `ACTIVE` whose `lastEventAt`
is older than `STALE_RUN_MS` (5 min). UI signal — distinct from the
- watchdog that *closes* runs (`Workspace.agentRunStaleMinutes`).
+ watchdog that _closes_ runs (`Workspace.agentRunStaleMinutes`).
- **Stalled issues** are issues currently assigned to the agent in an
IN_PROGRESS / IN_REVIEW status that haven't been updated in
`Workspace.stalledThresholdDays`. Snoozed rows are excluded. When
@@ -236,72 +236,72 @@ require a server-side read receipt model.
The chat router manages per-(workspace, user, agent) persistent threads.
-| Procedure | Type | Summary |
-|---|---|---|
-| `threads` | query | List the caller's threads with all agents. Returns up to 75, newest last-message first, including `lastReadAt` for unread badges. |
-| `thread({ agentId })` | mutation | Upsert and open a thread. Returns `{ thread, agent, messages }` (last 50 messages). |
-| `getThread({ threadId })` | query | Fetch a concrete thread by id. Owner-scoped and includes `lastReadAt`, diagnostics, recent messages, and attachments. |
-| `markRead({ threadId, readAt? })` | mutation | Move the caller's per-thread read anchor forward. Used by the Chat page and Mission Control Chat tab; timestamps never move backward. |
-| `defaultThread({ agentId })` / `thread({ agentId })` | mutation | Upsert and open the caller's always-on DM thread for one agent. |
-| `createConversation({ agentId, title?, topic?, ... })` | mutation | Create a named side conversation with an agent. |
-| `forkThread({ threadId, fromMessageId? })` | mutation | Start a new conversation copied from an existing thread prefix. |
-| `updateConversation({ threadId, title?, topic?, contextMode? })` | mutation | Update conversation metadata and context policy. |
-| `setOverride({ threadId, provider? })` | mutation | Set or clear a per-thread provider override. |
-| `compactThread({ threadId })` | mutation | Summarize older messages into durable context and keep recent messages live. |
-| `clearThread({ threadId })` | mutation | Delete the thread's messages/attachments/events and reset summary context while preserving the thread row. |
-| `send({ agentId, body, context? })` | mutation | Legacy tRPC send path: persist a USER message and trigger dispatch. The interactive UI now uses `/api/chat/stream` so runs/completions can stream. |
-| `createPendingMessage({ threadId, ... })` / `dispatchMessage({ messageId })` | mutation | Two-step dispatch path used by clients that need an optimistic pending row before waking an agent. |
-| `appendAgentMessage({ threadId, body, sourceRunId? })` | mutation | Agent-only path. Requires the calling API key's `linkedAgentId` to match the thread's agent; acknowledges the latest unfinished USER turn and returns `{ messageId }`. |
-| `threadDiagnostics({ threadId })` | query | Return provider-neutral turn state, latest USER lifecycle, stream error/interruption, last run, and last delivery for the status rail. |
-| `chatReadiness({ agentId, threadId? })` | query | Resolve whether chat reaches a model/runtime and report the effective transport/capabilities/hint. |
-| `retryLastUserMessage({ threadId })` | mutation | Re-wake the latest dispatched USER message when a reply or delivery stalls. |
-| `kickThreadRun({ threadId, runId })` | mutation | Emit a kick event for a stale active run linked to the conversation. |
-| `stopThreadRun({ threadId, runId })` | mutation | Best-effort stop a live managed-runtime run and close the Forge mirror. |
-| `archiveThread({ threadId })` / `restoreThread({ threadId })` | mutation | Hide or restore a conversation without deleting its history. |
-| `deleteThread({ threadId })` | mutation | Permanently delete a conversation, its messages, and attachments; stops a live managed run when possible. |
-| `history({ threadId, before?, limit })` | query | Paginate older messages. `before` is a date cursor; `limit` max 100. Scoped to the caller's own threads. |
+| Procedure | Type | Summary |
+| ---------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `threads` | query | List the caller's threads with all agents. Returns up to 75, newest last-message first, including `lastReadAt` for unread badges. |
+| `thread({ agentId })` | mutation | Upsert and open a thread. Returns `{ thread, agent, messages }` (last 50 messages). |
+| `getThread({ threadId })` | query | Fetch a concrete thread by id. Owner-scoped and includes `lastReadAt`, diagnostics, recent messages, and attachments. |
+| `markRead({ threadId, readAt? })` | mutation | Move the caller's per-thread read anchor forward. Used by the Chat page and Mission Control Chat tab; timestamps never move backward. |
+| `defaultThread({ agentId })` / `thread({ agentId })` | mutation | Upsert and open the caller's always-on DM thread for one agent. |
+| `createConversation({ agentId, title?, topic?, ... })` | mutation | Create a named side conversation with an agent. |
+| `forkThread({ threadId, fromMessageId? })` | mutation | Start a new conversation copied from an existing thread prefix. |
+| `updateConversation({ threadId, title?, topic?, contextMode? })` | mutation | Update conversation metadata and context policy. |
+| `setOverride({ threadId, provider? })` | mutation | Set or clear a per-thread provider override. |
+| `compactThread({ threadId })` | mutation | Summarize older messages into durable context and keep recent messages live. |
+| `clearThread({ threadId })` | mutation | Delete the thread's messages/attachments/events and reset summary context while preserving the thread row. |
+| `send({ agentId, body, context? })` | mutation | Legacy tRPC send path: persist a USER message and trigger dispatch. The interactive UI now uses `/api/chat/stream` so runs/completions can stream. |
+| `createPendingMessage({ threadId, ... })` / `dispatchMessage({ messageId })` | mutation | Two-step dispatch path used by clients that need an optimistic pending row before waking an agent. |
+| `appendAgentMessage({ threadId, body, sourceRunId? })` | mutation | Agent-only path. Requires the calling API key's `linkedAgentId` to match the thread's agent; acknowledges the latest unfinished USER turn and returns `{ messageId }`. |
+| `threadDiagnostics({ threadId })` | query | Return provider-neutral turn state, latest USER lifecycle, stream error/interruption, last run, and last delivery for the status rail. |
+| `chatReadiness({ agentId, threadId? })` | query | Resolve whether chat reaches a model/runtime and report the effective transport/capabilities/hint. |
+| `retryLastUserMessage({ threadId })` | mutation | Re-wake the latest dispatched USER message when a reply or delivery stalls. |
+| `kickThreadRun({ threadId, runId })` | mutation | Emit a kick event for a stale active run linked to the conversation. |
+| `stopThreadRun({ threadId, runId })` | mutation | Best-effort stop a live managed-runtime run and close the Forge mirror. |
+| `archiveThread({ threadId })` / `restoreThread({ threadId })` | mutation | Hide or restore a conversation without deleting its history. |
+| `deleteThread({ threadId })` | mutation | Permanently delete a conversation, its messages, and attachments; stops a live managed run when possible. |
+| `history({ threadId, before?, limit })` | query | Paginate older messages. `before` is a date cursor; `limit` max 100. Scoped to the caller's own threads. |
### `agentRun.*` additions
In addition to the original `activeForIssue`, `events`, `activeAll`, `recentTerminal`,
and `heatmap` procedures, the following were added:
-| Procedure | Type | Summary |
-|---|---|---|
-| `recentEventCounts({ windowMinutes?, bucketSeconds? })` | query | Per-minute bucketed event counts for the activity sparkline in Mission Control. Default 30-minute window, 60-second buckets. |
-| `coachDiagnosis({ runId })` | query | Latest AI Coach comment for a run (or `null` when coaching is disabled). |
-| `runsInRange({ fromMinutesAgo?, limit? })` | query | All runs (active + terminal) overlapping a sliding window. Powers the swimlane/Gantt view. |
-| `eta({ runId })` | query | Predictive ETA based on median agent+label duration over the past 30 days. Returns `{ medianMs, sampleSize, etaMs }` or `null`. |
-| `eventsInRange({ from, to, limit? })` | query | `AgentRunEvent` rows in an explicit time range with run+agent+issue summary. Powers the timeline scrubber. |
-| `abandon({ runId, summary?, alsoUnassign? })` | mutation | Mark a run ABANDONED, optionally clear the issue assignment. |
-| `redispatch({ runId })` | mutation | Abandon the current run, re-queue the issue, and trigger auto-dispatch. |
-| `nudge({ runId, message? })` | mutation | Post a `@{profileKey} {message}` comment on the issue; the audit fan-out routes it to the agent's webhook. |
-| `kick({ runId })` | mutation | Re-fire the dispatch webhook for a stalled run without changing assignment or `controlState`. Eligibility: run is `ACTIVE` and quiet 5+ minutes (`STALE_RUN_MS` from `src/lib/agent-stale.ts`). Younger runs return `{ ok: true, kicked: false }`; non-active runs throw. Records `AGENT_RUN_KICKED`. |
+| Procedure | Type | Summary |
+| ------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `recentEventCounts({ windowMinutes?, bucketSeconds? })` | query | Per-minute bucketed event counts for the activity sparkline in Mission Control. Default 30-minute window, 60-second buckets. |
+| `coachDiagnosis({ runId })` | query | Latest AI Coach comment for a run (or `null` when coaching is disabled). |
+| `runsInRange({ fromMinutesAgo?, limit? })` | query | All runs (active + terminal) overlapping a sliding window. Powers the swimlane/Gantt view. |
+| `eta({ runId })` | query | Predictive ETA based on median agent+label duration over the past 30 days. Returns `{ medianMs, sampleSize, etaMs }` or `null`. |
+| `eventsInRange({ from, to, limit? })` | query | `AgentRunEvent` rows in an explicit time range with run+agent+issue summary. Powers the timeline scrubber. |
+| `abandon({ runId, summary?, alsoUnassign? })` | mutation | Mark a run ABANDONED, optionally clear the issue assignment. |
+| `redispatch({ runId })` | mutation | Abandon the current run, re-queue the issue, and trigger auto-dispatch. |
+| `nudge({ runId, message? })` | mutation | Post a `@{profileKey} {message}` comment on the issue; the audit fan-out routes it to the agent's webhook. |
+| `kick({ runId })` | mutation | Re-fire the dispatch webhook for a stalled run without changing assignment or `controlState`. Eligibility: run is `ACTIVE` and quiet 5+ minutes (`STALE_RUN_MS` from `src/lib/agent-stale.ts`). Younger runs return `{ ok: true, kicked: false }`; non-active runs throw. Records `AGENT_RUN_KICKED`. |
### `access.*`
Workspace API key management. Admin-gated for all mutations.
-| Procedure | Summary |
-|---|---|
-| `list` | List non-plugin keys for the workspace. |
-| `create` | Create a key with explicit `kind` (or infer from `linkedAgentId`). |
-| `createPersonal` | Shorthand for `kind: PERSONAL`. No agent link. Permanent until revoked. |
-| `createSession` | Shorthand for `kind: SESSION`. Requires `ttlHours` (1–168, default 24). Auto-expires. |
-| `update` | Edit name or narrowing arrays. Scopes and hash are immutable. |
-| `revoke` | Set `revokedAt`; immediately rejects all further calls. |
-| `delete` | Hard-delete a non-plugin key. |
-| `rotate` | Revoke and re-issue with the same name, scopes, and narrowing. Returns `rawKey` once. |
+| Procedure | Summary |
+| ---------------- | ------------------------------------------------------------------------------------- |
+| `list` | List non-plugin keys for the workspace. |
+| `create` | Create a key with explicit `kind` (or infer from `linkedAgentId`). |
+| `createPersonal` | Shorthand for `kind: PERSONAL`. No agent link. Permanent until revoked. |
+| `createSession` | Shorthand for `kind: SESSION`. Requires `ttlHours` (1–168, default 24). Auto-expires. |
+| `update` | Edit name or narrowing arrays. Scopes and hash are immutable. |
+| `revoke` | Set `revokedAt`; immediately rejects all further calls. |
+| `delete` | Hard-delete a non-plugin key. |
+| `rotate` | Revoke and re-issue with the same name, scopes, and narrowing. Returns `rawKey` once. |
### `integration.*`
Read-only adapter manifest queries plus one mutation for tagging legacy agents.
-| Procedure | Summary |
-|---|---|
-| `list` | Return all adapter manifests merged with matching agents in this workspace. |
-| `byKind({ kind, presence? })` | Return one adapter manifest + its installed agents. `presence` disambiguates the two `CLAUDE` adapters. |
-| `applyToAgent({ agentId, kind, presence? })` | Stamp an existing agent with the adapter's `provider` and `defaultRuntimeMode`. |
+| Procedure | Summary |
+| -------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
+| `list` | Return all adapter manifests merged with matching agents in this workspace. |
+| `byKind({ kind, presence? })` | Return one adapter manifest + its installed agents. `presence` disambiguates the two `CLAUDE` adapters. |
+| `applyToAgent({ agentId, kind, presence? })` | Stamp an existing agent with the adapter's `provider` and `defaultRuntimeMode`. |
### `runtime.*`
@@ -309,14 +309,14 @@ CRUD for the `Runtime` primitive — the compute environment that hosts one
or more agents. See [/agents/runtimes.html](/agents/runtimes.html) for the
broader concept.
-| Procedure | Summary |
-|---|---|
-| `list` | All non-archived runtimes for the workspace, with `_count: { agents }` and `owner` summary. |
-| `byId({ id })` | Single runtime + its agents (id, name, profileKey, status, runtimeMode). |
-| `register` | Create a runtime. `{ name, kind, endpoint?, providersAvailable }`. Sets `ownerId` from session. Used by the `forge` daemon and by admins manually wiring a REMOTE_HTTP runtime. |
-| `heartbeat({ id })` | Bump `heartbeatAt`. The local daemon calls every 60s. |
-| `update({ id, name?, providersAvailable? })` | Edit metadata. Endpoint and secret are not editable post-creation (rotate by archive + re-register). |
-| `archive({ id })` | Soft-delete. Agents on the runtime keep their `runtimeId` but the runtime is hidden from the index. |
+| Procedure | Summary |
+| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `list` | All non-archived runtimes for the workspace, with `_count: { agents }` and `owner` summary. |
+| `byId({ id })` | Single runtime + its agents (id, name, profileKey, status, runtimeMode). |
+| `register` | Create a runtime. `{ name, kind, endpoint?, providersAvailable }`. Sets `ownerId` from session. Used by the `forge` daemon and by admins manually wiring a REMOTE_HTTP runtime. |
+| `heartbeat({ id })` | Bump `heartbeatAt`. The local daemon calls every 60s. |
+| `update({ id, name?, providersAvailable? })` | Edit metadata. Endpoint and secret are not editable post-creation (rotate by archive + re-register). |
+| `archive({ id })` | Soft-delete. Agents on the runtime keep their `runtimeId` but the runtime is hidden from the index. |
### `agent.unifiedTimeline`
@@ -348,29 +348,29 @@ Per-(workspace, user) markdown scratchpad. The dashboard
`notes.*` mirrors a narrower agent-facing subset (no `unarchive`, no
`delete`, no `convertToIssue` — those are human-only).
-| Procedure | Summary |
-|---|---|
-| `list({ archived?, kind?, limit? })` | Caller's notes ordered by `(pinned desc, updatedAt desc)`. Default `kind = NOTE`, unarchived. |
-| `create({ title?, body, pinned?, kind?, journalDate? })` | Create a note for the calling user. `kind = JOURNAL` requires `journalDate` (or defaults to now). |
-| `update({ id, title?, body?, pinned? })` | Patch a note the caller owns. `title: null` clears the title. Empty body allowed (for journal entries that are still being filled in). |
-| `archive({ id })` | Soft-archive. |
-| `unarchive({ id })` | Reverse archive. |
-| `delete({ id })` | Hard-delete. Prefer `archive` — this is the cleanup path. |
-| `convertToIssue({ id, projectId? })` | Spawn an Issue with `title = note.title \|\| first line of body` and `description = body`. The note is left in place. Returns `{ issueId, issueKey, number }`. |
-| `todayJournal()` | Get-or-create today's `JOURNAL` entry for the caller, anchored to UTC midnight on the user's wall-clock date (driven by `User.timezone`). Idempotent across calls in the same day. |
-| `listJournal({ from?, to?, limit? = 30 })` | List recent journal entries for the caller, ordered by `journalDate desc`. |
+| Procedure | Summary |
+| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `list({ archived?, kind?, limit? })` | Caller's notes ordered by `(pinned desc, updatedAt desc)`. Default `kind = NOTE`, unarchived. |
+| `create({ title?, body, pinned?, kind?, journalDate? })` | Create a note for the calling user. `kind = JOURNAL` requires `journalDate` (or defaults to now). |
+| `update({ id, title?, body?, pinned? })` | Patch a note the caller owns. `title: null` clears the title. Empty body allowed (for journal entries that are still being filled in). |
+| `archive({ id })` | Soft-archive. |
+| `unarchive({ id })` | Reverse archive. |
+| `delete({ id })` | Hard-delete. Prefer `archive` — this is the cleanup path. |
+| `convertToIssue({ id, projectId? })` | Spawn an Issue with `title = note.title \|\| first line of body` and `description = body`. The note is left in place. Returns `{ issueId, issueKey, number }`. |
+| `todayJournal()` | Get-or-create today's `JOURNAL` entry for the caller, anchored to UTC midnight on the user's wall-clock date (driven by `User.timezone`). Idempotent across calls in the same day. |
+| `listJournal({ from?, to?, limit? = 30 })` | List recent journal entries for the caller, ordered by `journalDate desc`. |
### `issue.watch / unwatch / watchers / watching`
Per-(issue, user OR agent) subscriptions. See
[Watching](/guide/watching.html) for the full breakdown.
-| Procedure | Summary |
-|---|---|
-| `watch({ issueId })` | Add caller as watcher. Idempotent. Identity inferred from API key (`linkedAgentId` → agent-watch, otherwise user-watch). |
-| `unwatch({ issueId })` | Remove caller's watch. No-op if not watching. |
-| `watchers({ issueId })` | List watchers with hydrated `user` / `agent` identity fields. |
-| `watching({ limit? = 50 })` | Issues the caller currently watches, ordered by issue `updatedAt desc`. Powers the inbox **Watching** section. |
+| Procedure | Summary |
+| --------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
+| `watch({ issueId })` | Add caller as watcher. Idempotent. Identity inferred from API key (`linkedAgentId` → agent-watch, otherwise user-watch). |
+| `unwatch({ issueId })` | Remove caller's watch. No-op if not watching. |
+| `watchers({ issueId })` | List watchers with hydrated `user` / `agent` identity fields. |
+| `watching({ limit? = 50 })` | Issues the caller currently watches, ordered by issue `updatedAt desc`. Powers the inbox **Watching** section. |
### `issue.applyCommands`
@@ -388,7 +388,7 @@ issue.applyCommands.mutate({
{ kind: "label", name: "deploy" },
{ kind: "watch" },
],
-})
+});
// → { results: [{ kind, status: "applied" | "skipped", reason? }] }
```
@@ -407,13 +407,13 @@ The legacy issue-only procs (`list`, `set`, `toggle`) are preserved
verbatim for backward compat with existing Hermes/agent runtimes. New
consumers use the polymorphic surface:
-| Procedure | Summary |
-|---|---|
-| `listAll({ workspaceId? })` | All pins for the caller, hydrated to small "card" objects. Dead targets are silently dropped. |
-| `add({ targetType, targetId, workspaceId? })` | Add a pin. |
-| `remove({ id })` | Remove by Pin id. |
-| `toggleEntity({ targetType, targetId, workspaceId? })` | Convenience: add if absent, remove if present. |
-| `reorder({ ids })` | Set `orderIndex` based on the array position. |
+| Procedure | Summary |
+| ------------------------------------------------------ | --------------------------------------------------------------------------------------------- |
+| `listAll({ workspaceId? })` | All pins for the caller, hydrated to small "card" objects. Dead targets are silently dropped. |
+| `add({ targetType, targetId, workspaceId? })` | Add a pin. |
+| `remove({ id })` | Remove by Pin id. |
+| `toggleEntity({ targetType, targetId, workspaceId? })` | Convenience: add if absent, remove if present. |
+| `reorder({ ids })` | Set `orderIndex` based on the array position. |
`targetType` is `PinTargetType` (`ISSUE` / `PROJECT` / `INITIATIVE` /
`SAVED_VIEW` / `CYCLE` / `AGENT`). `workspaceId` may be null for
@@ -421,10 +421,10 @@ cross-workspace pins (the legacy topbar-strip semantic).
### `recentItem.*`
-| Procedure | Summary |
-|---|---|
-| `list({ limit? })` | Most-recently-visited entities for the caller in this workspace, hydrated. |
-| `track({ targetType, targetId })` | Upsert a row. Server-side debounced 5s — fast nav doesn't spam writes. |
+| Procedure | Summary |
+| --------------------------------- | -------------------------------------------------------------------------- |
+| `list({ limit? })` | Most-recently-visited entities for the caller in this workspace, hydrated. |
+| `track({ targetType, targetId })` | Upsert a row. Server-side debounced 5s — fast nav doesn't spam writes. |
## Cross-references
diff --git a/package.json b/package.json
index ab1fdef0..5187902b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "forge",
- "version": "0.8.2",
+ "version": "0.9.0",
"private": true,
"description": "Forge — a fast, minimalist, keyboard-driven project management platform with pluggable agents.",
"license": "MIT",
diff --git a/prisma/migrations/0097_agent_run_orchestration_context_snapshot/migration.sql b/prisma/migrations/0097_agent_run_orchestration_context_snapshot/migration.sql
new file mode 100644
index 00000000..fb2a4c58
--- /dev/null
+++ b/prisma/migrations/0097_agent_run_orchestration_context_snapshot/migration.sql
@@ -0,0 +1,6 @@
+-- Preserve the bounded Goal / Plan / ExecutionStep context an agent received
+-- at dispatch so historical runs remain truthful after orchestration edits.
+-- Nullable keeps legacy and non-orchestrated runs compatible; application
+-- readers fall back to live hydration only when no snapshot exists.
+ALTER TABLE "AgentRun"
+ ADD COLUMN "orchestrationContextSnapshot" JSONB;
diff --git a/prisma/migrations/0098_issue_archive_list_index/migration.sql b/prisma/migrations/0098_issue_archive_list_index/migration.sql
new file mode 100644
index 00000000..69d10af7
--- /dev/null
+++ b/prisma/migrations/0098_issue_archive_list_index/migration.sql
@@ -0,0 +1,2 @@
+CREATE INDEX "Issue_workspaceId_deletedAt_updatedAt_idx"
+ON "Issue"("workspaceId", "deletedAt", "updatedAt");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 457d36ed..f8b594a5 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1181,6 +1181,7 @@ model Issue {
@@index([workspaceId, cycleId])
@@index([workspaceId, assignedAgentId])
@@index([workspaceId, aiTriageStatus])
+ @@index([workspaceId, deletedAt, updatedAt])
@@index([snoozedUntil])
}
@@ -2443,96 +2444,101 @@ model DispatchRule {
/// can exist over an issues lifetime if it is re-dispatched. The single
/// 1:1 STATUS Comment for this run is referenced via Comment.runId.
model AgentRun {
- id String @id @default(cuid())
- workspaceId String
- issueId String
- agentId String
- status AgentRunStatus @default(ACTIVE)
- startedAt DateTime @default(now())
+ id String @id @default(cuid())
+ workspaceId String
+ issueId String
+ agentId String
+ status AgentRunStatus @default(ACTIVE)
+ startedAt DateTime @default(now())
/// Updated on every appended AgentRunEvent — what the watchdog reads.
- lastEventAt DateTime @default(now())
- finishedAt DateTime?
+ lastEventAt DateTime @default(now())
+ finishedAt DateTime?
/// Compact label rendered by the live pulse strip ("running tests").
- currentStep String?
+ currentStep String?
/// Optional final summary populated when the agent reports completion.
- summary String? @db.Text
+ summary String? @db.Text
/// Optional list of artifact ids the agent produced for this run.
/// Populated by `runs.complete` so the UI can render evidence links
/// alongside the summary.
- producedArtifactIds String[] @default([])
+ producedArtifactIds String[] @default([])
/// Optional structured verification result: snapshot of the issue's
/// verificationChecklist at completion time with `done` flags set.
- verificationResult Json?
+ verificationResult Json?
/// Optional list of follow-up items the agent flagged for next steps.
/// Shape: array of `{ title: string, body?: string, kind?: string }`.
- followUps Json?
+ followUps Json?
/// Mode-specific structured completion metadata. Examples:
/// `{ confidence: "HIGH" }` for RESEARCH, `{ verdict: "APPROVE" }`
/// for REVIEW, plus the Forge run contract version used at completion.
- completionMeta Json?
+ completionMeta Json?
/// Provider-side run id when this AgentRun is backed by a structured
/// agent-run connector (e.g. Hermes `/v1/runs`). Lets Forge poll /
/// approve / stop the live run and correlate streamed `/events`.
/// Null for webhook/completions-driven runs.
- externalRunId String?
+ externalRunId String?
/// Effective runtime/tool policy snapshot captured at dispatch. Stored
/// on the run so old cards remain truthful after Runtime.config changes.
- runtimePolicy Json?
+ runtimePolicy Json?
+ /// Immutable, bounded Goal / Plan / ExecutionStep context captured when
+ /// this run is opened (or first dispatched for legacy rows). Run-scoped
+ /// consumers prefer this snapshot so later plan edits cannot rewrite the
+ /// instructions and dependency evidence that the agent actually received.
+ orchestrationContextSnapshot Json?
/// Set when a connector-driven run is paused awaiting operator
/// permission (e.g. Hermes flagged a dangerous command). Drives the
/// Approve/Reject affordance in the Live tab; cleared when the run
/// resumes (approved) or finishes (rejected/stopped). The stale
/// watchdog skips runs with this set so a blocked run isn't
/// misclassified as STALLED.
- awaitingApprovalAt DateTime?
+ awaitingApprovalAt DateTime?
/// Detail of the pending approval captured from the live `/events`
/// stream (Hermes `approval.request`): `{ command, description,
/// choices }`. Lets the Live-tab banner show *what* is being approved,
/// not just that something is. Null when not blocked.
- pendingApproval Json?
+ pendingApproval Json?
/// Optional pointer at the AGENT_ASSIGNED ActivityEvent that triggered
/// this run. Useful for ack-window reasoning later.
- assignmentEventId String?
+ assignmentEventId String?
/// ActivityEvent.id that *caused* the most recent wake. For
/// AGENT_ASSIGNED runs this mirrors `assignmentEventId`; for runs
/// kept warm by mentions / priority bumps / watcher fan-out it holds
/// the latest trigger so the inbox can distinguish "Victor was just
/// re-mentioned" from "Victor is the static assignee".
- triggerEventId String?
+ triggerEventId String?
/// String mirror of ActivityEvent.kind for the latest trigger. Not
/// enum-typed on purpose — EventKind grows and we don't want to
/// migrate AgentRun every time. Conventional values: AGENT_ASSIGNED,
/// ISSUE_QUEUED, ISSUE_PRIORITY_CHANGED, COMMENT_CREATED,
/// CHAT_MESSAGE_POSTED.
- triggerKind String?
+ triggerKind String?
/// Set when the assigned agent has acknowledged the dispatch and
/// intends to act on it. Drives the UI transition from "wake sent" →
/// "acknowledged" and silences the chat typing animation diagnostic.
- acknowledgedAt DateTime?
+ acknowledgedAt DateTime?
/// Set the first time the agent's reply / status / step output lands.
/// Drives the UI transition from "acknowledged" → "running".
- outputStartedAt DateTime?
+ outputStartedAt DateTime?
/// Worker-side: latest wake attempt timestamp regardless of HTTP
/// success. Diagnostic only — canonical work state lives elsewhere.
- lastWakeAt DateTime?
+ lastWakeAt DateTime?
/// Number of wake attempts the worker has made for this run. Distinct
/// from WebhookDelivery.attempt (which counts retries on one row).
- wakeAttempts Int @default(0)
+ wakeAttempts Int @default(0)
/// Optional pointer at the most recent WebhookDelivery so the
/// diagnostics panel can link straight to the (potentially failed)
/// delivery row.
- lastWakeDeliveryId String?
+ lastWakeDeliveryId String?
/// Token-usage telemetry reported by the agent via `runs.recordUsage`.
/// Cumulative as reported — the latest call replaces the prior values
/// (idempotent), so agents should send running totals, not per-step
/// deltas. Null = agent did not report.
- tokensIn Int?
- tokensOut Int?
- tokensCached Int?
+ tokensIn Int?
+ tokensOut Int?
+ tokensCached Int?
/// Cost the agent attributes to the run, in USD. Source-of-truth is
/// the agent's own pricing knowledge for v1; a server-side rate table
/// is a future cleanup.
- costUsd Decimal? @db.Decimal(10, 4)
+ costUsd Decimal? @db.Decimal(10, 4)
/// Operator-requested control signal. NONE is the steady state;
/// PAUSE_REQUESTED / CANCEL_REQUESTED are pending instructions for
/// the agent's runtime to honor on its next loop tick. The runtime
@@ -2540,19 +2546,19 @@ model AgentRun {
/// transitioning the run to an appropriate terminal state on
/// CANCEL). Webhook fan-out via `recordChange` carries the same
/// payload to the agent's `webhookUrl`.
- controlState AgentRunControlState @default(NONE)
+ controlState AgentRunControlState @default(NONE)
/// When the most recent control was requested. Null when
/// `controlState = NONE` (no signal pending).
- controlRequestedAt DateTime?
+ controlRequestedAt DateTime?
/// User who requested the control. SetNull on user delete so the
/// run row survives membership churn.
- controlRequestedById String?
+ controlRequestedById String?
/// Operator-cleared terminal failures stay in durable history but are
/// hidden from Command Center / pipeline failure queues by default.
- clearedAt DateTime?
+ clearedAt DateTime?
/// User who cleared the terminal failure from operational surfaces.
/// SetNull on user delete so the run row survives membership churn.
- clearedById String?
+ clearedById String?
/// Set when a newer run for the same (issue, agent) supersedes this
/// one (added 2026-06-23). A re-dispatch / kick / grant that opens a
/// fresh run links the prior STALLED/abandoned attempts here so
@@ -2560,12 +2566,12 @@ model AgentRun {
/// showing N repeated cards (the AXI-75 pathology). The run keeps its
/// own terminal status for history; surfaces filter on
/// `supersededByRunId IS NULL` to show only the head of each chain.
- supersededByRunId String?
+ supersededByRunId String?
/// Set only by clean completion via `runs.complete` (added
/// 2026-06-23). Distinct from `finishedAt`, which is stamped for ANY
/// terminal transition (including STALLED/ABANDONED). Rollups/surfaces
/// that mean "succeeded" key off `completedAt`, not `finishedAt`.
- completedAt DateTime?
+ completedAt DateTime?
/// Optional ExecutionStep this run is executing (migration: AXI-57). Set
/// when the Goal-orchestration loop opens a run for a step, so orchestrated
diff --git a/src/app/(app)/w/[slug]/issues/[id]/page.tsx b/src/app/(app)/w/[slug]/issues/[id]/page.tsx
index 7fde0cbf..531810be 100644
--- a/src/app/(app)/w/[slug]/issues/[id]/page.tsx
+++ b/src/app/(app)/w/[slug]/issues/[id]/page.tsx
@@ -1,9 +1,9 @@
"use client";
import { use, useEffect, useRef, useState } from "react";
-import { useRouter } from "next/navigation";
+import { useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
import type { AgentStatus, WorkItemKind } from "@prisma/client";
-import { Bot, Paperclip, Plus, Timer, Trash2, Workflow } from "lucide-react";
+import { Archive, ArchiveRestore, Bot, Paperclip, Plus, Timer, Workflow } from "lucide-react";
import { Topbar } from "@/components/topbar";
import { Badge } from "@/components/ui/badge";
import { Avatar } from "@/components/ui/avatar";
@@ -79,6 +79,8 @@ const PRIORITIES = ["NONE", "LOW", "MEDIUM", "HIGH", "URGENT"] as const;
export default function IssueDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const router = useRouter();
+ const searchParams = useSearchParams();
+ const includeArchived = searchParams?.get("archived") === "1";
const workspace = useWorkspace();
const slug = workspace.slug;
const isAdmin = workspace.role === "OWNER" || workspace.role === "ADMIN";
@@ -93,7 +95,7 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
data: issue,
isLoading,
error,
- } = trpc.issue.byId.useQuery({ id }, { refetchInterval: 15_000 });
+ } = trpc.issue.byId.useQuery({ id, includeArchived }, { refetchInterval: 15_000 });
const { data: statuses } = trpc.status.list.useQuery();
const { data: members } = trpc.workspace.members.useQuery();
const { data: projects } = trpc.project.list.useQuery({ archived: false, limit: 100 });
@@ -134,19 +136,21 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
const update = trpc.issue.update.useMutation({
onMutate: async (input) => {
- await utils.issue.byId.cancel({ id: input.id });
- const prev = utils.issue.byId.getData({ id: input.id });
- utils.issue.byId.setData({ id: input.id }, (old) => {
+ await utils.issue.byId.cancel({ id: input.id, includeArchived });
+ const prev = utils.issue.byId.getData({ id: input.id, includeArchived });
+ utils.issue.byId.setData({ id: input.id, includeArchived }, (old) => {
if (!old) return old;
return { ...old, ...input } as typeof old;
});
return { prev };
},
onError: (err, input, ctx) => {
- if (ctx?.prev) utils.issue.byId.setData({ id: input.id }, ctx.prev);
+ if (ctx?.prev) {
+ utils.issue.byId.setData({ id: input.id, includeArchived }, ctx.prev);
+ }
toast.error(err.message);
},
- onSettled: () => utils.issue.byId.invalidate({ id }),
+ onSettled: () => utils.issue.byId.invalidate({ id, includeArchived }),
});
const assign = trpc.issue.assign.useMutation({
@@ -180,18 +184,30 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
onError: (e) => toast.error(e.message),
});
- const softDelete = trpc.issue.softDelete.useMutation({
+ const archiveIssue = trpc.issue.archive.useMutation({
+ onSuccess: () => {
+ toast.success("Issue archived.");
+ utils.issue.list.invalidate();
+ utils.issue.count.invalidate();
+ router.push(`/w/${slug}/issues?archived=1`);
+ },
+ onError: (e) => toast.error(e.message),
+ });
+
+ const restoreIssue = trpc.issue.restore.useMutation({
onSuccess: () => {
- toast.success("Issue deleted.");
+ toast.success("Issue restored.");
+ utils.issue.byId.invalidate({ id, includeArchived: true });
utils.issue.list.invalidate();
- router.push(`/w/${slug}/issues`);
+ utils.issue.count.invalidate();
+ router.replace(`/w/${slug}/issues/${id}`);
},
onError: (e) => toast.error(e.message),
});
const [titleDraft, setTitleDraft] = useState("");
const [editingTitle, setEditingTitle] = useState(false);
- const [deleteOpen, setDeleteOpen] = useState(false);
+ const [archiveOpen, setArchiveOpen] = useState(false);
const [releaseOpen, setReleaseOpen] = useState(false);
const [agentPickerOpen, setAgentPickerOpen] = useState(false);
@@ -257,9 +273,7 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
const top = Math.max(16, el.getBoundingClientRect().top);
const available = Math.floor(window.innerHeight - top - 16);
const next = Math.max(240, available);
- setRailCardHeight((prev) =>
- prev !== null && Math.abs(prev - next) < 2 ? prev : next,
- );
+ setRailCardHeight((prev) => (prev !== null && Math.abs(prev - next) < 2 ? prev : next));
});
};
@@ -308,6 +322,62 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
{ label: issueKey, mono: true },
];
+ if (issue.deletedAt) {
+ return (
+ <>
+ }
+ subtitle={archived}
+ actions={
+
+ }
+ />
+
+
+ }
+ title={`${issueKey} is archived`}
+ description={
+
+ {issue.title} left active views on {formatDate(issue.deletedAt, timePrefs)}. Its
+ history and relationships are preserved.
+
+ }
+ action={
+
+
+
+
+ }
+ />
+
+
+ >
+ );
+ }
+
return (
<>
setDeleteOpen(true)}
- title="Delete issue"
- aria-label="Delete issue"
+ onClick={() => setArchiveOpen(true)}
+ title="Archive issue"
+ aria-label="Archive issue"
>
-
- Delete
+
+ Archive
>
}
@@ -543,6 +613,7 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
kind={issue.kind}
projectId={issue.projectId ?? null}
parent={issue.parent ?? null}
+ executionSteps={issue.executionSteps}
onDescriptionSave={(next) => update.mutate({ id: issue.id, description: next })}
canResolveActions={(() => {
if (!me?.id) return false;
@@ -640,9 +711,7 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
- update.mutate({ id: issue.id, slaMinutes })
- }
+ onChange={(slaMinutes) => update.mutate({ id: issue.id, slaMinutes })}
/>
@@ -692,15 +761,13 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
softDelete.mutate({ id })}
+ open={archiveOpen}
+ onOpenChange={setArchiveOpen}
+ title={`Archive ${issueKey}?`}
+ description="Moves the issue out of active views and stops its active agent work. History and relationships stay preserved, and you can restore it later."
+ primaryLabel="Archive issue"
+ loading={archiveIssue.isPending}
+ onConfirm={() => archiveIssue.mutate({ id })}
/>
+ }
+ title="Archive is empty"
+ description="Archived issues stay here until you restore them."
+ action={
+
+ }
+ />
+
+ );
+}
+
/**
* Inline empty-state shown inside the list when the user's filters
* have narrowed to zero matches. Distinct from the workspace-empty
diff --git a/src/components/command-palette.tsx b/src/components/command-palette.tsx
index 5a40e3de..50ae5e33 100644
--- a/src/components/command-palette.tsx
+++ b/src/components/command-palette.tsx
@@ -60,7 +60,13 @@ type StaticAction = {
};
type FlatRow =
- | { kind: "entity"; key: string; data: EntitySearchResultData; run: () => void; workspaceLabel?: string }
+ | {
+ kind: "entity";
+ key: string;
+ data: EntitySearchResultData;
+ run: () => void;
+ workspaceLabel?: string;
+ }
| { kind: "action"; key: string; action: StaticAction }
| { kind: "create"; key: string; run: () => void; query: string };
@@ -148,14 +154,13 @@ export function CommandPalette() {
Icon: Plus,
run: () => {
setOpen(false);
- // Trigger the existing QuickCreate via its data-attribute hook.
- // Synthesizes a click on a hidden button so the overlay opens.
- const synthetic = document.createElement("button");
- synthetic.setAttribute("data-quick-create", "");
- synthetic.style.display = "none";
- document.body.appendChild(synthetic);
- synthetic.click();
- synthetic.remove();
+ // This action explicitly promises an issue, so bypass the
+ // pathname-aware default (issue detail normally opens Comment).
+ window.dispatchEvent(
+ new CustomEvent("forge:quick-create", {
+ detail: { mode: "issue" },
+ }),
+ );
},
},
{
@@ -299,7 +304,15 @@ export function CommandPalette() {
// `target.targetType` and use the target's own workspace slug when
// present (so cross-workspace pins still navigate to the right tenant).
const dispatchPin = (
- p: { target: { targetType: string; id: string; slug?: string; profileKey?: string; workspaceSlug?: string } | null },
+ p: {
+ target: {
+ targetType: string;
+ id: string;
+ slug?: string;
+ profileKey?: string;
+ workspaceSlug?: string;
+ } | null;
+ },
fallbackSlug: string,
) => {
setOpen(false);
@@ -533,15 +546,11 @@ export function CommandPalette() {
// Filtered actions matching the query.
const q = debouncedQuery.toLowerCase();
- const matchingActions = staticActions.filter((a) =>
- a.label.toLowerCase().includes(q),
- );
+ const matchingActions = staticActions.filter((a) => a.label.toLowerCase().includes(q));
if (matchingActions.length > 0) {
out.push({
title: "Actions",
- rows: matchingActions.map(
- (a): FlatRow => ({ kind: "action", key: a.id, action: a }),
- ),
+ rows: matchingActions.map((a): FlatRow => ({ kind: "action", key: a.id, action: a })),
});
}
@@ -598,9 +607,7 @@ export function CommandPalette() {
if (staticActions.length > 0) {
out.push({
title: "Actions",
- rows: staticActions.map(
- (a): FlatRow => ({ kind: "action", key: a.id, action: a }),
- ),
+ rows: staticActions.map((a): FlatRow => ({ kind: "action", key: a.id, action: a })),
});
}
@@ -714,7 +721,7 @@ export function CommandPalette() {
kbdHint={selected ? "↵" : undefined}
/>
{row.workspaceLabel && (
-
+
{row.workspaceLabel}
)}
@@ -791,7 +798,7 @@ export function CommandPalette() {
))}
{emptySections.length === 0 && (
-
+
Start typing to search.
)}
@@ -875,4 +882,3 @@ function ActionRow({
);
}
-
diff --git a/src/components/issue-board.tsx b/src/components/issue-board.tsx
index c2c5436c..3b4f5c08 100644
--- a/src/components/issue-board.tsx
+++ b/src/components/issue-board.tsx
@@ -134,10 +134,12 @@ function BoardColumn({
projectId?: string;
columnFilter: Parameters[0];
}) {
- const { data, hasNextPage, isFetchingNextPage, fetchNextPage } =
- trpc.issue.list.useInfiniteQuery(columnFilter, {
+ const { data, hasNextPage, isFetchingNextPage, fetchNextPage } = trpc.issue.list.useInfiniteQuery(
+ columnFilter,
+ {
getNextPageParam: (last) => last.nextCursor,
- });
+ },
+ );
const items = data?.pages.flatMap((p) => p.items) ?? [];
return (
@@ -149,10 +151,8 @@ function BoardColumn({
{items.length}
{hasNextPage ? "+" : ""}
- {/* Quick-add into this column. Opens the shared new-issue flow
- (prefilled to the project when the board is project-scoped).
- Status prefill isn't supported by quick-create yet, so the new
- issue lands in the default status. */}
+ {/* Quick-add into this column. The shared capture flow preserves
+ both the project scope and this column's status. */}
)}
@@ -686,11 +726,8 @@ export function IssueList({
{group.issues.length}
{/* Per-group quick-add (status + project groupings only).
- Fires the shared new-issue flow; the `forge:quick-create`
- event detail only supports a `projectId` prefill (the
- group's project when grouping by project, else the list's
- own scope), NOT a `statusId`, so the new issue lands in the
- workspace default status. */}
+ The shared capture flow preserves the originating project
+ and status when either grouping supplies one. */}
{(groupBy === "status" || groupBy === "project") && (
)}
@@ -809,13 +849,19 @@ export function IssueList({
)}