docs: migrate legacy specs and add product context (#2964)

This commit is contained in:
Carlos Florêncio
2026-08-24 11:09:03 +00:00
committed by GitHub
parent c071f6a5e3
commit 832734bb67
2136 changed files with 33965 additions and 23035 deletions
+1 -1
View File
@@ -178,7 +178,7 @@ Standalone agentctl is launched in its own process group so terminal Ctrl+C is h
- **Two session paths bypass the task service and must guard themselves.** (1) Handlers that resolve an execution by a bare in-memory lookup (`GetExecutionBySessionID`, `*BySessionID`) skip the `GetOrEnsure*` chokepoint where the lifecycle check runs — call `service.AuthorizeSessionAccess` (see `ProcessHandlers.denySessionAccess`) or `lifecycleMgr.CheckSessionAccess` first. (2) The orchestrator reads sessions through its own repo handle, so it inherits nothing from the task service; session-keyed entry points there call `authorizeSession` / `authorizeTask`, wired by `SetSessionAccessChecker` / `SetTaskAccessChecker`. An entry point that accepts **both** a task and a session ID must use `authorizeTaskSessionPair`, never `authorizeSession` alone: the caller can satisfy a session-only check with one of their own sessions while pointing `taskID` at someone else's task, which the method then uses for its task-scoped work. Put the guard **first** in the method, before any repo/executor/agent-manager use — `TestSessionKeyedEntryPointsGuardBeforeDependencies` runs each entry point with nil dependencies and fails on a panic if a guard is placed too late. - **Two session paths bypass the task service and must guard themselves.** (1) Handlers that resolve an execution by a bare in-memory lookup (`GetExecutionBySessionID`, `*BySessionID`) skip the `GetOrEnsure*` chokepoint where the lifecycle check runs — call `service.AuthorizeSessionAccess` (see `ProcessHandlers.denySessionAccess`) or `lifecycleMgr.CheckSessionAccess` first. (2) The orchestrator reads sessions through its own repo handle, so it inherits nothing from the task service; session-keyed entry points there call `authorizeSession` / `authorizeTask`, wired by `SetSessionAccessChecker` / `SetTaskAccessChecker`. An entry point that accepts **both** a task and a session ID must use `authorizeTaskSessionPair`, never `authorizeSession` alone: the caller can satisfy a session-only check with one of their own sessions while pointing `taskID` at someone else's task, which the method then uses for its task-scoped work. Put the guard **first** in the method, before any repo/executor/agent-manager use — `TestSessionKeyedEntryPointsGuardBeforeDependencies` runs each entry point with nil dependencies and fails on a panic if a guard is placed too late.
- **Dispatched WS actions have a gateway backstop.** `Client.authorizeAction` (`internal/gateway/websocket/dispatch_scope.go`) checks any payload carrying `task_id` or `session_id` against the client's identity *before* dispatch, so a newly added action is scoped by default rather than only when its author remembers. Handler-level scoping is still required (defense in depth, and it covers non-WS callers) — do not delete a service-layer check because the backstop exists. The backstop reads `task_id`, `session_id` and `task_environment_id`, plus `id` on **top-level `task.<verb>` actions only** (`task.state`, `task.move`, `task.get`, … — `task.` and exactly one more segment). Keep using those names for task-scoped actions, because an action that invents a new name for the same kind of resource silently opts out. (`user_shell.stop` was exactly that: it keys off `task_environment_id` with `task_id` optional, so a `task_id`-only backstop missed it. `task.state` and `task.move` were the second instance: they name the task `id`, so the backstop parsed no refs and let one user mutate any other user's task — hence the `id` rule.) Deeper `task.*` namespaces are deliberately excluded: in `task.plan.revision.get` and `task.review.finding.update` the `id` names a revision or a finding, so those actions must carry `task_id` when they are task-scoped. Also avoid permissive types (`any`, `json.RawMessage`) for those fields: a payload that fails to unmarshal into the ref struct is treated as naming nothing. - **Dispatched WS actions have a gateway backstop.** `Client.authorizeAction` (`internal/gateway/websocket/dispatch_scope.go`) checks any payload carrying `task_id` or `session_id` against the client's identity *before* dispatch, so a newly added action is scoped by default rather than only when its author remembers. Handler-level scoping is still required (defense in depth, and it covers non-WS callers) — do not delete a service-layer check because the backstop exists. The backstop reads `task_id`, `session_id` and `task_environment_id`, plus `id` on **top-level `task.<verb>` actions only** (`task.state`, `task.move`, `task.get`, … — `task.` and exactly one more segment). Keep using those names for task-scoped actions, because an action that invents a new name for the same kind of resource silently opts out. (`user_shell.stop` was exactly that: it keys off `task_environment_id` with `task_id` optional, so a `task_id`-only backstop missed it. `task.state` and `task.move` were the second instance: they name the task `id`, so the backstop parsed no refs and let one user mutate any other user's task — hence the `id` rule.) Deeper `task.*` namespaces are deliberately excluded: in `task.plan.revision.get` and `task.review.finding.update` the `id` names a revision or a finding, so those actions must carry `task_id` when they are task-scoped. Also avoid permissive types (`any`, `json.RawMessage`) for those fields: a payload that fails to unmarshal into the ref struct is treated as naming nothing.
- **WS**: clients carry their identity; dispatched actions and subscriptions are scoped; workspace-carrying events route via `Hub.BroadcastToWorkspace`. A new `hub.Broadcast` (global) call site needs a `//ws:global` justification comment. Subscription actions (`*.subscribe`) `return` before reaching the dispatch backstop (`Client.authorizeAction`), so scoping a new subscribe topic means adding a hook to `SubscriptionAccessPolicy` (`internal/gateway/websocket/access.go`) *and* calling it explicitly inside that action's own handler — the backstop's `scopedActionRefs` cannot reach it. `run.subscribe`'s `Subscriptions.Run` hook (added for WO-02, resolving the run's workspace via `runs.GetRunWorkspaceID`) is the worked example. - **WS**: clients carry their identity; dispatched actions and subscriptions are scoped; workspace-carrying events route via `Hub.BroadcastToWorkspace`. A new `hub.Broadcast` (global) call site needs a `//ws:global` justification comment. Subscription actions (`*.subscribe`) `return` before reaching the dispatch backstop (`Client.authorizeAction`), so scoping a new subscribe topic means adding a hook to `SubscriptionAccessPolicy` (`internal/gateway/websocket/access.go`) *and* calling it explicitly inside that action's own handler — the backstop's `scopedActionRefs` cannot reach it. `run.subscribe`'s `Subscriptions.Run` hook (added for WO-02, resolving the run's workspace via `runs.GetRunWorkspaceID`) is the worked example.
- **Task overview vs. session detail:** Keep cross-task state in the bounded, persisted `TaskStatusSummary` projection and `task.status_summary.updated`; keep unbounded transcript and execution data session-scoped. The [bounded task-status spec](../../docs/specs/platform/bounded-task-status-delivery.md) and [accepted ADR](../../docs/decisions/2026-08-01-separate-task-summary-session-stream-traffic.md) define the shared contract. - **Task overview vs. session detail:** Keep cross-task state in the bounded, persisted `TaskStatusSummary` projection and `task.status_summary.updated`; keep unbounded transcript and execution data session-scoped. The [bounded task-status spec](../../docs/specs/platform/requirements/bounded-task-status-delivery.md) and [accepted ADR](../../docs/decisions/2026-08-01-separate-task-summary-session-stream-traffic.md) define the shared contract.
- **Port-proxy subtree capability:** with auth enforced, `/port-proxy/:sessionId/:port/*` needs a credential on *every* request, but browser subresource fetches cannot always carry one (the `<link rel="manifest">` fetch never sends cookies; sandboxed iframes and `?token=`-opened previews drop them elsewhere). `PortProxyHandler` mints a short-lived (15 min, sliding), path-scoped, HMAC-signed capability after the document authenticates and propagates it as a cookie scoped to the proxy subtree plus a `kandev_cap` query parameter appended to rewritten asset URLs. `requireConnectionAuth` (`internal/gateway/websocket/access.go`) accepts either form only for the exact session:port it was minted for and restores the issuing identity, so `CheckSessionAccess` still gates on the real owner. Synthetic identities (auth disabled) skip minting — proxied bodies stay byte-identical to the pre-auth behavior. - **Port-proxy subtree capability:** with auth enforced, `/port-proxy/:sessionId/:port/*` needs a credential on *every* request, but browser subresource fetches cannot always carry one (the `<link rel="manifest">` fetch never sends cookies; sandboxed iframes and `?token=`-opened previews drop them elsewhere). `PortProxyHandler` mints a short-lived (15 min, sliding), path-scoped, HMAC-signed capability after the document authenticates and propagates it as a cookie scoped to the proxy subtree plus a `kandev_cap` query parameter appended to rewritten asset URLs. `requireConnectionAuth` (`internal/gateway/websocket/access.go`) accepts either form only for the exact session:port it was minted for and restores the issuing identity, so `CheckSessionAccess` still gates on the real owner. Synthetic identities (auth disabled) skip minting — proxied bodies stay byte-identical to the pre-auth behavior.
- **Self-authenticating webhooks** (automation and office channels) and `/health`, `/api/v1/features`, `/api/v1/app-state` stay public — the allowlist lives in `auth/httpmw/middleware.go` with a pinning test. Plugin webhooks are not allowlisted: `auth/httpmw` structurally defers only GET/POST relay paths because it cannot read manifests, and `internal/plugins.Controller.webhook` enforces each manifest's `access` policy. - **Self-authenticating webhooks** (automation and office channels) and `/health`, `/api/v1/features`, `/api/v1/app-state` stay public — the allowlist lives in `auth/httpmw/middleware.go` with a pinning test. Plugin webhooks are not allowlisted: `auth/httpmw` structurally defers only GET/POST relay paths because it cannot read manifests, and `internal/plugins.Controller.webhook` enforces each manifest's `access` policy.
+1 -1
View File
@@ -59,7 +59,7 @@ const steerSetupHoldMillis = 30_000
// text of its own, so a mid-turn steer delivered while it runs can only be // text of its own, so a mid-turn steer delivered while it runs can only be
// answered by the steer's own successor turn. This reproduces the "folded" // answered by the steer's own successor turn. This reproduces the "folded"
// outcome from the mid-turn steering spec's outcome taxonomy // outcome from the mid-turn steering spec's outcome taxonomy
// (docs/specs/platform/mid-turn-steering.md): the predecessor settles without // (docs/specs/platform/requirements/mid-turn-steering.md): the predecessor settles without
// having produced an answer, and the operator sees a single, combined reply. // having produced an answer, and the operator sees a single, combined reply.
func scenarioSteerFoldSetup(e *emitter) { func scenarioSteerFoldSetup(e *emitter) {
waitForDelay(e.ctx, steerSetupHoldMillis) waitForDelay(e.ctx, steerSetupHoldMillis)
@@ -59,7 +59,7 @@ type sshSessionState struct {
// Each session owns its own *ssh.Client (no shared pool). One SSH connection // Each session owns its own *ssh.Client (no shared pool). One SSH connection
// per session keeps teardown simple — closing the executor instance closes // per session keeps teardown simple — closing the executor instance closes
// the client — at the cost of an extra TCP+handshake per session on the same // the client — at the cost of an extra TCP+handshake per session on the same
// host. See docs/specs/ssh-executor/spec.md for the full design. // host. See docs/specs/executors/requirements/ssh-executor.md for the full design.
type SSHExecutor struct { type SSHExecutor struct {
agentctlResolver *AgentctlResolver agentctlResolver *AgentctlResolver
secretStore secrets.SecretStore secretStore secrets.SecretStore
@@ -349,7 +349,7 @@ func (c *Config) ToSharedConfig() *shared.Config {
// //
// Delivery is opportunistic: whether the agent folds the prompt into the running // Delivery is opportunistic: whether the agent folds the prompt into the running
// turn or runs it as the next turn is the agent's decision and is not advertised // turn or runs it as the next turn is the agent's decision and is not advertised
// over the protocol. See docs/specs/platform/mid-turn-steering.md. // over the protocol. See docs/specs/platform/requirements/mid-turn-steering.md.
type SteerablePrompter interface { type SteerablePrompter interface {
SupportsSteering() bool SupportsSteering() bool
PromptSteer( PromptSteer(
@@ -51,7 +51,7 @@ func (a *Adapter) SupportsSteering() bool {
// Delivery is opportunistic. Whether the agent folds this prompt into the // Delivery is opportunistic. Whether the agent folds this prompt into the
// running turn or runs it as the next turn is the agent's decision and is not // running turn or runs it as the next turn is the agent's decision and is not
// advertised over the protocol, so both outcomes must be correct. See // advertised over the protocol, so both outcomes must be correct. See
// docs/specs/platform/mid-turn-steering.md. // docs/specs/platform/requirements/mid-turn-steering.md.
func (a *Adapter) PromptSteer( func (a *Adapter) PromptSteer(
ctx context.Context, ctx context.Context,
message string, message string,
@@ -27,7 +27,7 @@ const promptQueueingMetaKey = "promptQueueing"
// the bridge, is not advertised over ACP, and its version is not observable // the bridge, is not advertised over ACP, and its version is not observable
// here. Mid-turn delivery is therefore opportunistic, and a caller must be // here. Mid-turn delivery is therefore opportunistic, and a caller must be
// correct whether the agent folds the prompt or runs it as the next turn. See // correct whether the agent folds the prompt or runs it as the next turn. See
// docs/specs/platform/mid-turn-steering.md. // docs/specs/platform/requirements/mid-turn-steering.md.
// //
// Every malformed shape fails closed to false: absent `_meta`, absent // Every malformed shape fails closed to false: absent `_meta`, absent
// namespace, a namespace that is not an object, a missing key, or a value that // namespace, a namespace that is not an object, a missing key, or a value that
@@ -294,7 +294,7 @@ type AgentEvent struct {
// prompt while another prompt for the same session is still in flight. It is // prompt while another prompt for the same session is still in flight. It is
// the negotiated precondition for prompt handoff and mid-turn steering, and // the negotiated precondition for prompt handoff and mid-turn steering, and
// does not by itself promise the agent will fold that prompt into the running // does not by itself promise the agent will fold that prompt into the running
// turn. See docs/specs/platform/mid-turn-steering.md. // turn. See docs/specs/platform/requirements/mid-turn-steering.md.
SupportsPromptQueueing bool `json:"supports_prompt_queueing"` SupportsPromptQueueing bool `json:"supports_prompt_queueing"`
// AuthMethods lists authentication methods from ACP initialize. // AuthMethods lists authentication methods from ACP initialize.
@@ -541,7 +541,7 @@ type ConfigOptionValue struct {
// per-turn total — see normalizeCodexPromptUsage in // per-turn total — see normalizeCodexPromptUsage in
// server/adapter/transport/acp/dialect_codex.go). Rows flagged estimated // server/adapter/transport/acp/dialect_codex.go). Rows flagged estimated
// still count toward budget totals at face value per // still count toward budget totals at face value per
// docs/specs/office/costs.md. // docs/specs/office/requirements/costs.md.
type PromptUsage struct { type PromptUsage struct {
InputTokens int64 `json:"input_tokens"` InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"` OutputTokens int64 `json:"output_tokens"`
@@ -128,7 +128,7 @@ func (h *Handlers) logout(c *gin.Context) {
// deliberately left alone: on a host serving a default-port instance (or a // deliberately left alone: on a host serving a default-port instance (or a
// not-yet-upgraded one), it is that instance's LIVE session cookie, not an // not-yet-upgraded one), it is that instance's LIVE session cookie, not an
// inert legacy jar — expiring it would log the other instance out (see // inert legacy jar — expiring it would log the other instance out (see
// docs/specs/fix-multi-instance-cookie-isolation/spec.md: the upgraded // docs/specs/auth/requirements/fix-multi-instance-cookie-isolation.md: the upgraded
// instance does not proactively delete the legacy cookie). // instance does not proactively delete the legacy cookie).
clearSessionCookie(c, h.svc.CookieNameForRequest(c.Request)) clearSessionCookie(c, h.svc.CookieNameForRequest(c.Request))
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
@@ -271,7 +271,7 @@ func TestEnabledModeAllowlistMatrix(t *testing.T) {
{name: "office without bearer", method: http.MethodGet, path: "/api/v1/office/tasks/t1", blocked: true}, {name: "office without bearer", method: http.MethodGet, path: "/api/v1/office/tasks/t1", blocked: true},
{ {
// AC-14 (docs/specs/health-endpoint-version/spec.md): /health // AC-14 (docs/specs/platform/requirements/health-endpoint-version.md): /health
// gained a version field, but /api/v1/system/info is not on the // gained a version field, but /api/v1/system/info is not on the
// allowlist and must stay rejected — the change must not widen it. // allowlist and must stay rejected — the change must not widen it.
name: "system info", method: http.MethodGet, path: "/api/v1/system/info", blocked: true, name: "system info", method: http.MethodGet, path: "/api/v1/system/info", blocked: true,
+1 -1
View File
@@ -3,7 +3,7 @@ package automation
import "gopkg.in/yaml.v3" import "gopkg.in/yaml.v3"
// exportDocumentVersion and exportDocumentType are the fixed values of the export // exportDocumentVersion and exportDocumentType are the fixed values of the export
// document's `version` and `type` keys. See docs/specs/automations-yaml-export/spec.md // document's `version` and `type` keys. See docs/specs/office/requirements/automations-yaml-export.md
// § Data model. // § Data model.
const ( const (
exportDocumentVersion = 1 exportDocumentVersion = 1
@@ -7,7 +7,7 @@ import (
// fieldDisposition records what AC-22 calls a field's "disposition": either exported // fieldDisposition records what AC-22 calls a field's "disposition": either exported
// under a named YAML key, or excluded for a stated reason. See // under a named YAML key, or excluded for a stated reason. See
// docs/specs/automations-yaml-export/spec.md § Round-trip completeness, the field // docs/specs/office/requirements/automations-yaml-export.md § Round-trip completeness, the field
// disposition table under "Data model". // disposition table under "Data model".
// //
// This is the anti-`reports_to` guard: Office's config export declared and emitted an // This is the anti-`reports_to` guard: Office's config export declared and emitted an
@@ -12,7 +12,7 @@ import (
) )
// ExportHandler serves the two read-only YAML/zip automation export // ExportHandler serves the two read-only YAML/zip automation export
// endpoints (see docs/specs/automations-yaml-export/spec.md § API surface). // endpoints (see docs/specs/office/requirements/automations-yaml-export.md § API surface).
type ExportHandler struct { type ExportHandler struct {
svc *Service svc *Service
log *logger.Logger log *logger.Logger
@@ -11,7 +11,7 @@ import (
) )
// yamlNewlineChars is YAML 1.2's line-break set, per // yamlNewlineChars is YAML 1.2's line-break set, per
// docs/specs/automations-yaml-export/spec.md's Definitions section: // docs/specs/office/requirements/automations-yaml-export.md's Definitions section:
// {U+000A, U+000D, U+0085, U+2028, U+2029}. AC-17 and AC-46 are stated against this // {U+000A, U+000D, U+0085, U+2028, U+2029}. AC-17 and AC-46 are stated against this
// set, not against "\n" alone — a CR-only, NEL-only, LS-only, or PS-only prompt still // set, not against "\n" alone — a CR-only, NEL-only, LS-only, or PS-only prompt still
// forces a non-literal emission and must still warn under AC-17. // forces a non-literal emission and must still warn under AC-17.
+1 -1
View File
@@ -101,7 +101,7 @@ type Automation struct {
// Derived in SQL (`execution_mode = 'task'`) rather than by reading the // Derived in SQL (`execution_mode = 'task'`) rather than by reading the
// column into a field, so the raw mode never enters the Go model and // column into a field, so the raw mode never enters the Go model and
// cannot grow a firing-path branch: this is a migration-window notice, // cannot grow a firing-path branch: this is a migration-window notice,
// not a second destination. See docs/specs/office/automations-settings.md // not a second destination. See docs/specs/office/requirements/automations-settings.md
// § Migration. // § Migration.
LegacyBoardCard bool `json:"legacy_board_card" db:"legacy_board_card"` LegacyBoardCard bool `json:"legacy_board_card" db:"legacy_board_card"`
+1 -1
View File
@@ -147,7 +147,7 @@ const (
// automation used to put a card on the board", for a migration notice that // automation used to put a card on the board", for a migration notice that
// closes once. Projecting the mode itself would hand every future caller a // closes once. Projecting the mode itself would hand every future caller a
// mode to branch on, and the whole point of withdrawing it is that no firing // mode to branch on, and the whole point of withdrawing it is that no firing
// path has one. See docs/specs/office/automations-settings.md § Migration. // path has one. See docs/specs/office/requirements/automations-settings.md § Migration.
const automationColumns = `id, workspace_id, name, description, workflow_id, workflow_step_id, const automationColumns = `id, workspace_id, name, description, workflow_id, workflow_step_id,
agent_profile_id, executor_profile_id, prompt, task_title_template, agent_profile_id, executor_profile_id, prompt, task_title_template,
enabled, max_concurrent_runs, webhook_secret, last_triggered_at, created_at, updated_at, enabled, max_concurrent_runs, webhook_secret, last_triggered_at, created_at, updated_at,
@@ -108,7 +108,7 @@ func TestBuildAgentProfileMatcher_SkipsDisabledProfile(t *testing.T) {
// TestBuildAgentProfileMatcher_PreservesDisabledExistingBinding is the // TestBuildAgentProfileMatcher_PreservesDisabledExistingBinding is the
// reconciliation regression: a workflow step already bound to a profile that // reconciliation regression: a workflow step already bound to a profile that
// gets disabled after the fact must keep that binding on the next sync, per // gets disabled after the fact must keep that binding on the next sync, per
// docs/specs/agents/profile-disable.md ("existing sessions/workflows keep // docs/specs/agents/requirements/profile-disable.md ("existing sessions/workflows keep
// their profile"). Excluding disabled profiles from *candidate selection* // their profile"). Excluding disabled profiles from *candidate selection*
// must not also strip a binding that was already in place. // must not also strip a binding that was already in place.
func TestBuildAgentProfileMatcher_PreservesDisabledExistingBinding(t *testing.T) { func TestBuildAgentProfileMatcher_PreservesDisabledExistingBinding(t *testing.T) {
@@ -62,7 +62,7 @@ type clarificationResponseClaim struct {
} }
// Resolver implements ResolveBundle // Resolver implements ResolveBundle
// (docs/specs/external-question-answering/spec.md, "Resolution semantics"), // (docs/specs/integrations/requirements/external-question-answering.md, "Resolution semantics"),
// the single service-layer operation the REST endpoints and the // the single service-layer operation the REST endpoints and the
// answer_question_kandev MCP tool both call. It resolves a bundle's identity // answer_question_kandev MCP tool both call. It resolves a bundle's identity
// and authorization (M5, A1-A9), validates the outcome (N6-N8b), claims it // and authorization (M5, A1-A9), validates the outcome (N6-N8b), claims it
@@ -4,7 +4,7 @@
// cookie scope — so multiple kandev instances on one host (same IP, // cookie scope — so multiple kandev instances on one host (same IP,
// different ports) share one cookie jar. Suffixing cookie names with the // different ports) share one cookie jar. Suffixing cookie names with the
// request's port lets each instance read and write only its own cookies // request's port lets each instance read and write only its own cookies
// (see docs/specs/fix-multi-instance-cookie-isolation/spec.md). // (see docs/specs/auth/requirements/fix-multi-instance-cookie-isolation.md).
package httpcookie package httpcookie
import ( import (
+1 -1
View File
@@ -230,7 +230,7 @@ type IssueWatch struct {
PollIntervalSeconds int `json:"pollIntervalSeconds" db:"poll_interval_seconds"` PollIntervalSeconds int `json:"pollIntervalSeconds" db:"poll_interval_seconds"`
// MaxInflightTasks caps how many open watcher-created tasks this watch can // MaxInflightTasks caps how many open watcher-created tasks this watch can
// hold at once. nil = uncapped. Values <= 0 are rejected at the API layer. // hold at once. nil = uncapped. Values <= 0 are rejected at the API layer.
// See docs/specs/throttle-watcher-fanout/spec.md for the open-task definition. // See docs/specs/tasks/system-design/wip-limit-pull-system.md for the open-task definition.
MaxInflightTasks *int `json:"maxInflightTasks,omitempty" db:"max_inflight_tasks"` MaxInflightTasks *int `json:"maxInflightTasks,omitempty" db:"max_inflight_tasks"`
LastPolledAt *time.Time `json:"lastPolledAt,omitempty" db:"last_polled_at"` LastPolledAt *time.Time `json:"lastPolledAt,omitempty" db:"last_polled_at"`
// LastError / LastErrorAt are stamped when the dispatch pipeline self- // LastError / LastErrorAt are stamped when the dispatch pipeline self-
+1 -1
View File
@@ -75,7 +75,7 @@ const createTablesSQL = `
poll_interval_seconds INTEGER NOT NULL DEFAULT 300, poll_interval_seconds INTEGER NOT NULL DEFAULT 300,
-- Cap on concurrent open watcher-created tasks for this watch. -- Cap on concurrent open watcher-created tasks for this watch.
-- NULL = uncapped. Positive integer = cap. Values <= 0 are rejected at -- NULL = uncapped. Positive integer = cap. Values <= 0 are rejected at
-- the API layer. See docs/specs/throttle-watcher-fanout/. -- the API layer. See docs/specs/tasks/system-design/wip-limit-pull-system.md.
max_inflight_tasks INTEGER DEFAULT 5, max_inflight_tasks INTEGER DEFAULT 5,
last_polled_at DATETIME, last_polled_at DATETIME,
last_error TEXT NOT NULL DEFAULT '', last_error TEXT NOT NULL DEFAULT '',
+1 -1
View File
@@ -229,7 +229,7 @@ type IssueWatch struct {
PollIntervalSeconds int `json:"pollIntervalSeconds" db:"poll_interval_seconds"` PollIntervalSeconds int `json:"pollIntervalSeconds" db:"poll_interval_seconds"`
// MaxInflightTasks caps how many open watcher-created tasks this watch can // MaxInflightTasks caps how many open watcher-created tasks this watch can
// hold at once. nil = uncapped. Values <= 0 are rejected at the API layer. // hold at once. nil = uncapped. Values <= 0 are rejected at the API layer.
// See docs/specs/throttle-watcher-fanout/spec.md for the open-task definition. // See docs/specs/tasks/system-design/wip-limit-pull-system.md for the open-task definition.
MaxInflightTasks *int `json:"maxInflightTasks,omitempty" db:"max_inflight_tasks"` MaxInflightTasks *int `json:"maxInflightTasks,omitempty" db:"max_inflight_tasks"`
// SortBy sets the dispatch order for matched issues; empty = Linear default order. // SortBy sets the dispatch order for matched issues; empty = Linear default order.
SortBy IssueSortBy `json:"sortBy,omitempty" db:"sort_by"` SortBy IssueSortBy `json:"sortBy,omitempty" db:"sort_by"`
+1 -1
View File
@@ -72,7 +72,7 @@ const createTablesSQL = `
poll_interval_seconds INTEGER NOT NULL DEFAULT 300, poll_interval_seconds INTEGER NOT NULL DEFAULT 300,
-- Cap on concurrent open watcher-created tasks for this watch. -- Cap on concurrent open watcher-created tasks for this watch.
-- NULL = uncapped. Positive integer = cap. Values <= 0 are rejected at -- NULL = uncapped. Positive integer = cap. Values <= 0 are rejected at
-- the API layer. See docs/specs/throttle-watcher-fanout/. -- the API layer. See docs/specs/tasks/system-design/wip-limit-pull-system.md.
max_inflight_tasks INTEGER DEFAULT 5, max_inflight_tasks INTEGER DEFAULT 5,
-- Dispatch order for matched issues under the in-flight cap. -- Dispatch order for matched issues under the in-flight cap.
-- '' = Linear default order (updatedAt asc). See linear/issue_sort.go. -- '' = Linear default order (updatedAt asc). See linear/issue_sort.go.
@@ -18,7 +18,7 @@ const (
) )
// Budget period and action-on-exceed constants. See spec // Budget period and action-on-exceed constants. See spec
// docs/specs/office-costs/spec.md for semantics. // docs/specs/office/requirements/costs.md for semantics.
const ( const (
budgetPeriodMonthly = "monthly" budgetPeriodMonthly = "monthly"
@@ -191,7 +191,7 @@ func (s *CostService) EvaluateBudget(
// CheckPreExecutionBudget checks all applicable budget policies before // CheckPreExecutionBudget checks all applicable budget policies before
// launching an agent session. Returns (allowed, reason, error). Only // launching an agent session. Returns (allowed, reason, error). Only
// pause_agent and block_new_tasks return allowed=false (notify_only // pause_agent and block_new_tasks return allowed=false (notify_only
// alerts but does not block). See docs/specs/office-costs/spec.md. // alerts but does not block). See docs/specs/office/requirements/costs.md.
// Implements shared.BudgetChecker. // Implements shared.BudgetChecker.
func (s *CostService) CheckPreExecutionBudget( func (s *CostService) CheckPreExecutionBudget(
ctx context.Context, agentInstanceID, projectID, workspaceID string, ctx context.Context, agentInstanceID, projectID, workspaceID string,
@@ -184,7 +184,7 @@ func (c *Client) catalogVersionLocked() string {
// cold-cache-buffer parse path, where the buffer and its version are // cold-cache-buffer parse path, where the buffer and its version are
// captured together before parsing — so a background refresh landing // captured together before parsing — so a background refresh landing
// mid-call can never pair one catalogue's rates with a different // mid-call can never pair one catalogue's rates with a different
// catalogue's version identifier (docs/specs/office/costs.md). // catalogue's version identifier (docs/specs/office/requirements/costs.md).
func (c *Client) LookupForModelWithVersion(ctx context.Context, modelID string) (shared.ModelPricing, string, bool) { func (c *Client) LookupForModelWithVersion(ctx context.Context, modelID string) (shared.ModelPricing, string, bool) {
key, strategy := Normalize(modelID) key, strategy := Normalize(modelID)
if strategy != StrategyLookup { if strategy != StrategyLookup {
@@ -222,7 +222,7 @@ func (c *Client) LookupForModelWithVersion(ctx context.Context, modelID string)
// wholesale under c.mu — so a later, unrelated lookup for the same key would // wholesale under c.mu — so a later, unrelated lookup for the same key would
// then read old rates paired with the new catalogue version, the exact // then read old rates paired with the new catalogue version, the exact
// provenance lie LookupForModelWithVersion exists to prevent // provenance lie LookupForModelWithVersion exists to prevent
// (docs/specs/office/costs.md). Compares catalogGen rather than the RFC3339 // (docs/specs/office/requirements/costs.md). Compares catalogGen rather than the RFC3339
// version string: the string only has one-second resolution, so two installs // version string: the string only has one-second resolution, so two installs
// landing in the same wall-clock second would otherwise compare equal and // landing in the same wall-clock second would otherwise compare equal and
// defeat this guard. The returned (pricing, bufVersion) pair for THIS call is // defeat this guard. The returned (pricing, bufVersion) pair for THIS call is
@@ -3,7 +3,7 @@ package dashboard
import "testing" import "testing"
// TestDeriveTaskTimestamps locks down the Started/Completed sidebar // TestDeriveTaskTimestamps locks down the Started/Completed sidebar
// derivation described in docs/specs/office/tasks.md: startedAt is the // derivation described in docs/specs/office/requirements/tasks.md: startedAt is the
// earliest transition into in_progress, completedAt is the latest // earliest transition into in_progress, completedAt is the latest
// transition into done. A regression here silently reverts the Office // transition into done. A regression here silently reverts the Office
// task detail sidebar to permanently blank Started/Completed rows. // task detail sidebar to permanently blank Started/Completed rows.
+1 -1
View File
@@ -135,7 +135,7 @@ func (s BudgetScopeType) Valid() bool {
// before this field existed — never inferred. // before this field existed — never inferred.
type CostSource string type CostSource string
// Cost-source values. See docs/specs/office/costs.md. // Cost-source values. See docs/specs/office/requirements/costs.md.
const ( const (
// CostSourceProviderReported means the CLI forwarded an authoritative // CostSourceProviderReported means the CLI forwarded an authoritative
// USD amount (claude-acp's usage_update.cost.amount); no rates apply. // USD amount (claude-acp's usage_update.cost.amount); no rates apply.
@@ -287,7 +287,7 @@ var ValidProjectStatuses = map[ProjectStatus]bool{
} }
// CostContractVersion is the in-band activation point for the cache-split / // CostContractVersion is the in-band activation point for the cache-split /
// cost-provenance / turn-attribution columns (docs/specs/office/costs.md). // cost-provenance / turn-attribution columns (docs/specs/office/requirements/costs.md).
// The Rill cost extract has no schema versioning of its own, so a row // The Rill cost extract has no schema versioning of its own, so a row
// written under a prior contract is distinguished by comparing // written under a prior contract is distinguished by comparing
// cost_contract_version, not by a date an analyst has to be told out of // cost_contract_version, not by a date an analyst has to be told out of
@@ -332,7 +332,7 @@ const CostContractVersion int64 = 3
// turn. The JSON cost-list representation includes this field as null so API // turn. The JSON cost-list representation includes this field as null so API
// consumers can make the same distinction. NULL is never backfilled to 0; // consumers can make the same distinction. NULL is never backfilled to 0;
// TokensCachedIn keeps its original read+write sum semantics on every row so // TokensCachedIn keeps its original read+write sum semantics on every row so
// existing consumers of that column are unaffected. See docs/specs/office/costs.md. // existing consumers of that column are unaffected. See docs/specs/office/requirements/costs.md.
type CostEvent struct { type CostEvent struct {
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
SessionID string `json:"session_id" db:"session_id"` SessionID string `json:"session_id" db:"session_id"`
@@ -267,7 +267,7 @@ func (r *Repository) createCostTables() error {
// columns must stay byte-identical to — see base_migrations.go). NULL // columns must stay byte-identical to — see base_migrations.go). NULL
// means "not recorded" (legacy row, or an adapter that did not report // means "not recorded" (legacy row, or an adapter that did not report
// cache data); 0 would silently claim zero cache activity. See // cache data); 0 would silently claim zero cache activity. See
// docs/specs/office/costs.md. // docs/specs/office/requirements/costs.md.
_, err := r.db.Exec(` _, err := r.db.Exec(`
CREATE TABLE IF NOT EXISTS office_cost_events ( CREATE TABLE IF NOT EXISTS office_cost_events (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -40,7 +40,7 @@ func (r *Repository) runMigrations() {
// migrateCostEventContract adds the cache read/write split, turn // migrateCostEventContract adds the cache read/write split, turn
// attribution, and cost-provenance columns to office_cost_events for // attribution, and cost-provenance columns to office_cost_events for
// databases created before this contract (docs/specs/office/costs.md). Every // databases created before this contract (docs/specs/office/requirements/costs.md). Every
// ALTER is nullable with no DEFAULT: a legacy row must read NULL, never 0, // ALTER is nullable with no DEFAULT: a legacy row must read NULL, never 0,
// because a merged tokens_cached_in cannot be decomposed and an unversioned // because a merged tokens_cached_in cannot be decomposed and an unversioned
// "0" would be indistinguishable from "no cache activity". Keep this column // "0" would be indistinguishable from "no cache activity". Keep this column
@@ -11,7 +11,7 @@ import (
) )
// TestCostEventContractMigration_LegacyRowsGetNullColumns covers the // TestCostEventContractMigration_LegacyRowsGetNullColumns covers the
// legacy-column contract from docs/specs/office/costs.md: an office_cost_events // legacy-column contract from docs/specs/office/requirements/costs.md: an office_cost_events
// table that predates the cache-split / cost-provenance / turn-attribution // table that predates the cache-split / cost-provenance / turn-attribution
// columns gets them added as nullable, a pre-existing row is left with NULL // columns gets them added as nullable, a pre-existing row is left with NULL
// (never 0) in every new column, the partial unique index on // (never 0) in every new column, the partial unique index on
@@ -11,7 +11,7 @@ import (
// TestPostgresCostEventContractMigration is the PostgreSQL twin of // TestPostgresCostEventContractMigration is the PostgreSQL twin of
// TestCostEventContractMigration_LegacyRowsGetNullColumns // TestCostEventContractMigration_LegacyRowsGetNullColumns
// (docs/specs/office/costs.md, ADR 0027): a pre-migration office_cost_events // (docs/specs/office/requirements/costs.md, ADR 0027): a pre-migration office_cost_events
// table gets the cache-split / provenance / turn-attribution columns added // table gets the cache-split / provenance / turn-attribution columns added
// nullable, a legacy row is left NULL (never 0), the partial unique index // nullable, a legacy row is left NULL (never 0), the partial unique index
// exists, and the migration replays cleanly on a second boot. Skips unless // exists, and the migration replays cleanly on a second boot. Skips unless
@@ -26,7 +26,7 @@ import (
var ErrDuplicateUsageEvent = errors.New("duplicate usage_event_id") var ErrDuplicateUsageEvent = errors.New("duplicate usage_event_id")
// usageEventIndexName is the partial unique index enforcing at most one row // usageEventIndexName is the partial unique index enforcing at most one row
// per non-NULL usage_event_id (docs/specs/office/costs.md). // per non-NULL usage_event_id (docs/specs/office/requirements/costs.md).
const usageEventIndexName = "uniq_office_cost_usage_event" const usageEventIndexName = "uniq_office_cost_usage_event"
// sqliteUsageEventViolationMessage is the substring go-sqlite3 puts in a // sqliteUsageEventViolationMessage is the substring go-sqlite3 puts in a
@@ -53,7 +53,7 @@ func TestTaskMigrations_NewColumnsExist(t *testing.T) {
} }
// Verify session columns (cost_subcents stores hundredths of a cent // Verify session columns (cost_subcents stores hundredths of a cent
// per docs/specs/office-costs/spec.md — the column was renamed from // per docs/specs/office/requirements/costs.md — the column was renamed from
// cost_cents pre-release; no migration kept). // cost_cents pre-release; no migration kept).
sessionColumns := []string{"cost_subcents", "tokens_in", "tokens_out"} sessionColumns := []string{"cost_subcents", "tokens_in", "tokens_out"}
for _, col := range sessionColumns { for _, col := range sessionColumns {
@@ -22,7 +22,7 @@ import (
var ErrTaskNotFound = errors.New("task not found") var ErrTaskNotFound = errors.New("task not found")
// Automation runs never appear in a task list: they are hidden by their // Automation runs never appear in a task list: they are hidden by their
// provenance, not by ephemerality (docs/specs/office/automations-settings.md). // provenance, not by ephemerality (docs/specs/office/requirements/automations-settings.md).
// is_ephemeral keeps its original quick-chat meaning, so every list read here // is_ephemeral keeps its original quick-chat meaning, so every list read here
// pairs the two. // pairs the two.
const ( const (
@@ -758,7 +758,7 @@ func TestCountActionableTasksForAgent_AgentIsolation(t *testing.T) {
// Automation runs are hidden from the task list by their origin, not by // Automation runs are hidden from the task list by their origin, not by
// is_ephemeral: they are ordinary persistent tasks with their own destination // is_ephemeral: they are ordinary persistent tasks with their own destination
// (docs/specs/office/automations-settings.md). The quick chat alongside them // (docs/specs/office/requirements/automations-settings.md). The quick chat alongside them
// still behaves exactly as it did. // still behaves exactly as it did.
func TestOfficeTaskListsExcludeAutomationOriginTasks(t *testing.T) { func TestOfficeTaskListsExcludeAutomationOriginTasks(t *testing.T) {
repo := newSearchTestRepo(t) repo := newSearchTestRepo(t)
@@ -155,7 +155,7 @@ type ResolveOptions struct {
type healthIndex map[ProviderID]map[string]map[string]models.ProviderHealth type healthIndex map[ProviderID]map[string]map[string]models.ProviderHealth
// Resolve runs the resolution algorithm described in // Resolve runs the resolution algorithm described in
// docs/specs/office-provider-routing/plan.md §Phase 2. // docs/plans/office-execution-profile-routing/plan.md §Phase 2.
func (r *Resolver) Resolve( func (r *Resolver) Resolve(
ctx context.Context, ctx context.Context,
workspaceID string, workspaceID string,
@@ -15,7 +15,7 @@ import (
// This pair exists because handlePromptUsage previously had two silent // This pair exists because handlePromptUsage previously had two silent
// early returns (decode failure, task lookup failure) and a silent insert // early returns (decode failure, task lookup failure) and a silent insert
// failure — "if this writer silently stops, nothing today would notice" // failure — "if this writer silently stops, nothing today would notice"
// (docs/specs/office/costs.md). // (docs/specs/office/requirements/costs.md).
var ( var (
costEventsWrittenTotal = expvar.NewMap("cost_events_written_total") costEventsWrittenTotal = expvar.NewMap("cost_events_written_total")
costEventsDroppedTotal = expvar.NewMap("cost_events_dropped_total") costEventsDroppedTotal = expvar.NewMap("cost_events_dropped_total")
@@ -596,7 +596,7 @@ func (s *Service) handleAgentFailed(ctx context.Context, event *bus.Event) error
// Office failure path (v1): every agent error is terminal. The // Office failure path (v1): every agent error is terminal. The
// retry-by-classifier path lives behind HandleRunFailure for // retry-by-classifier path lives behind HandleRunFailure for
// rate-limit-retry callers; we deliberately do NOT call into it // rate-limit-retry callers; we deliberately do NOT call into it
// here. See docs/specs/office-agent-error-handling. // here. See docs/specs/office/requirements/runtime.md.
errMsg := enrichModelFailureMessage(run, data.ErrorMessage) errMsg := enrichModelFailureMessage(run, data.ErrorMessage)
if err := s.HandleAgentFailure(ctx, run, errMsg); err != nil { if err := s.HandleAgentFailure(ctx, run, errMsg); err != nil {
return err return err
@@ -684,7 +684,7 @@ func (s *Service) tryPostStartFallback(
// handlePromptUsage records a cost event from a session/prompt usage // handlePromptUsage records a cost event from a session/prompt usage
// update. Cost resolution follows the two-layer order from // update. Cost resolution follows the two-layer order from
// docs/specs/office/costs.md, and CostSource on the row records which // docs/specs/office/requirements/costs.md, and CostSource on the row records which
// layer actually produced the dollar amount (see resolveCostForUsage in // layer actually produced the dollar amount (see resolveCostForUsage in
// prompt_usage_cost.go — distinct from Estimated, a usage-authority flag): // prompt_usage_cost.go — distinct from Estimated, a usage-authority flag):
// //
@@ -77,7 +77,7 @@ func (s *Service) resolveCostForUsage(ctx context.Context, data PromptUsageData)
// one atomic snapshot when s.pricingLookup satisfies // one atomic snapshot when s.pricingLookup satisfies
// shared.PricingLookupWithVersion, so a concurrent background refresh can // shared.PricingLookupWithVersion, so a concurrent background refresh can
// never pair one catalogue's rates with a different catalogue's version // never pair one catalogue's rates with a different catalogue's version
// identifier on the stored row (docs/specs/office/costs.md). Falls back to two // identifier on the stored row (docs/specs/office/requirements/costs.md). Falls back to two
// separate calls — accepting that narrower race — only for a PricingLookup // separate calls — accepting that narrower race — only for a PricingLookup
// implementation (e.g. a test double) that doesn't support the atomic form; // implementation (e.g. a test double) that doesn't support the atomic form;
// CatalogVersion is optional there too, so a non-implementer simply reports // CatalogVersion is optional there too, so a non-implementer simply reports
@@ -10,7 +10,7 @@
// across CLIs, and don't depend on prompt instructions the agent // across CLIs, and don't depend on prompt instructions the agent
// may rotate. // may rotate.
// //
// See docs/specs/office-heartbeat-rework/spec.md §"Continuation // See docs/specs/office/requirements/scheduler.md §"Continuation
// summary contract" for the section schema. // summary contract" for the section schema.
package summary package summary
@@ -621,7 +621,7 @@ func seedRunSkillSnapshotHandler(repo *officesqlite.Repository, log *logger.Logg
// Used by the agent dashboard E2E spec to drive the costs section // Used by the agent dashboard E2E spec to drive the costs section
// (aggregate + per-run rollup) without launching an agent. TokensOut is a // (aggregate + per-run rollup) without launching an agent. TokensOut is a
// pointer so a test can omit it to seed the "never measured" NULL shape // pointer so a test can omit it to seed the "never measured" NULL shape
// (docs/specs/office/costs.md) instead of always writing a measured 0. // (docs/specs/office/requirements/costs.md) instead of always writing a measured 0.
type seedCostEventRequest struct { type seedCostEventRequest struct {
AgentProfileID string `json:"agent_profile_id"` AgentProfileID string `json:"agent_profile_id"`
TaskID string `json:"task_id"` TaskID string `json:"task_id"`
@@ -1689,7 +1689,7 @@ func (s *Service) handleRecoverableFailureLocked(ctx context.Context, data watch
// working" and the topbar spinner clears. Kanban / quick-chat tasks // working" and the topbar spinner clears. Kanban / quick-chat tasks
// keep the legacy WAITING_FOR_INPUT path so the user can resume via // keep the legacy WAITING_FOR_INPUT path so the user can resume via
// the Resume / Start fresh recovery buttons in the existing chat // the Resume / Start fresh recovery buttons in the existing chat
// surface. (See docs/specs/office-agent-error-handling.) // surface. (See docs/specs/office/requirements/runtime.md.)
nextState := models.TaskSessionStateWaitingForInput nextState := models.TaskSessionStateWaitingForInput
if s.isOfficeSession(ctx, data.SessionID) { if s.isOfficeSession(ctx, data.SessionID) {
nextState = models.TaskSessionStateFailed nextState = models.TaskSessionStateFailed
@@ -513,7 +513,7 @@ const McpModeTaskTitlePending = "task-title-pending"
// McpModeOffice restricts the MCP toolset for office (autonomous) agents to // McpModeOffice restricts the MCP toolset for office (autonomous) agents to
// interaction + plan tools. Office agents manage tasks via the kandev CLI // interaction + plan tools. Office agents manage tasks via the kandev CLI
// (exposed through agentctl + $KANDEV_CLI), not MCP — see // (exposed through agentctl + $KANDEV_CLI), not MCP — see
// docs/specs/office-agent-cli/spec.md. // docs/specs/office/system-design/agents-03.md.
const McpModeOffice = "office" const McpModeOffice = "office"
// LaunchOptions contains optional parameters for LaunchPreparedSession. // LaunchOptions contains optional parameters for LaunchPreparedSession.
@@ -117,7 +117,7 @@ type MessageCreator interface {
// (Task tool) invocation observed on a tool-call frame. It returns nothing — // (Task tool) invocation observed on a tool-call frame. It returns nothing —
// a repository failure never fails the enclosing message write, turn, or // a repository failure never fails the enclosing message write, turn, or
// agent stream (AC-27 in // agent stream (AC-27 in
// docs/specs/subagent-context-persistence/spec.md). Implemented by // docs/specs/agents/requirements/subagent-context-persistence.md). Implemented by
// taskservice.Service via an adapter; optional, so an installation that // taskservice.Service via an adapter; optional, so an installation that
// never wires it behaves exactly as before. // never wires it behaves exactly as before.
type SubagentContextRecorder interface { type SubagentContextRecorder interface {
@@ -1144,7 +1144,7 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s
// Office tasks restrict the MCP toolset: kanban tools (move/update/list // Office tasks restrict the MCP toolset: kanban tools (move/update/list
// task, etc.) are excluded because office agents call those via the // task, etc.) are excluded because office agents call those via the
// kandev CLI ($KANDEV_CLI). See docs/specs/office-agent-cli/spec.md. // kandev CLI ($KANDEV_CLI). See docs/specs/office/system-design/agents-03.md.
mcpMode := "" mcpMode := ""
if isOfficeTask { if isOfficeTask {
mcpMode = executor.McpModeOffice mcpMode = executor.McpModeOffice
@@ -212,7 +212,7 @@ func (s *Service) dispatchWatcherEvent(ctx context.Context, src WatcherSource, e
// Per-watcher throttle. Synchronous: we MUST acquire the slot before // Per-watcher throttle. Synchronous: we MUST acquire the slot before
// spawning the goroutine, otherwise a tight burst of bus events all // spawning the goroutine, otherwise a tight burst of bus events all
// read the same pre-creation DB count and overshoot the cap. See // read the same pre-creation DB count and overshoot the cap. See
// docs/specs/throttle-watcher-fanout/spec.md. // docs/specs/tasks/system-design/wip-limit-pull-system.md.
// //
// The slot covers the WHOLE dispatch pipeline, including the case where // The slot covers the WHOLE dispatch pipeline, including the case where
// Dispatch returns early because Reserve loses the dedup race (no task // Dispatch returns early because Reserve loses the dedup race (no task
@@ -535,7 +535,7 @@ func (w *pluginWorker) logOverflow(dropped string) {
// Logs and gives up (event is lost from the live-delivery path; a // Logs and gives up (event is lost from the live-delivery path; a
// subsequent health-poller error transition plus Flush is the recovery // subsequent health-poller error transition plus Flush is the recovery
// path for further events, per the at-least-once/error-buffer semantics in // path for further events, per the at-least-once/error-buffer semantics in
// docs/specs/plugins/spec.md) if every attempt fails. // docs/specs/plugins/requirements/plugins.md) if every attempt fails.
func (w *pluginWorker) deliverWithRetry(ctx context.Context, d Delivery) { func (w *pluginWorker) deliverWithRetry(ctx context.Context, d Delivery) {
var lastErr error var lastErr error
attempts := 1 + len(w.deps.retryDelays) attempts := 1 + len(w.deps.retryDelays)
@@ -23,7 +23,7 @@ type bufferedDelivery struct {
// ringBuffer holds events for one plugin while it is in the error state, so // ringBuffer holds events for one plugin while it is in the error state, so
// Deliverer.Flush can replay them in order after a health-poller recovery // Deliverer.Flush can replay them in order after a health-poller recovery
// transition. Capacity and TTL are the numbers in // transition. Capacity and TTL are the numbers in
// docs/specs/plugins/spec.md ("Event webhook delivery"): 100 events / 5 // docs/specs/plugins/requirements/plugins.md ("Event webhook delivery"): 100 events / 5
// minutes. The oldest entry is dropped on overflow; expired entries are // minutes. The oldest entry is dropped on overflow; expired entries are
// purged lazily on the next Add, Drain, or Len call. // purged lazily on the next Add, Drain, or Len call.
type ringBuffer struct { type ringBuffer struct {
+1 -1
View File
@@ -44,7 +44,7 @@ type SetAutoUpdateRequest struct {
// SyncResult is the body of a successful POST /api/plugins/sync (and the // SyncResult is the body of a successful POST /api/plugins/sync (and the
// return value of Service.Sync / Service.bootScan): what the filesystem // return value of Service.Sync / Service.bootScan): what the filesystem
// scan under the plugins directory found and did this run, per // scan under the plugins directory found and did this run, per
// docs/specs/plugins/spec.md ("Filesystem sideloading & sync"). // docs/specs/plugins/requirements/plugins.md ("Filesystem sideloading & sync").
type SyncResult struct { type SyncResult struct {
// Added lists the plugin ids of directory sideloads // Added lists the plugin ids of directory sideloads
// (<pluginsDir>/<id>/<version>/manifest.yaml found with no existing // (<pluginsDir>/<id>/<version>/manifest.yaml found with no existing
+1 -1
View File
@@ -96,7 +96,7 @@ var _ pluginsdk.Host = (*pluginHost)(nil)
// permissionDenied builds the gRPC error RemotePlugin/Host RPCs return for // permissionDenied builds the gRPC error RemotePlugin/Host RPCs return for
// an undeclared capability, matching the wire-level message from // an undeclared capability, matching the wire-level message from
// docs/specs/plugins/spec.md ("Permissions"): "capability '<name>' not // docs/specs/plugins/requirements/plugins.md ("Permissions"): "capability '<name>' not
// declared". // declared".
func permissionDenied(capability string) error { func permissionDenied(capability string) error {
return status.Errorf(codes.PermissionDenied, "capability '%s' not declared", capability) return status.Errorf(codes.PermissionDenied, "capability '%s' not declared", capability)
@@ -1,5 +1,5 @@
// Package manifest defines the plugin manifest data model and validation // Package manifest defines the plugin manifest data model and validation
// rules described in docs/specs/plugins/spec.md ("Data model / Plugin // rules described in docs/specs/plugins/requirements/plugins.md ("Data model / Plugin
// registration"). A manifest is the YAML document an operator supplies to // registration"). A manifest is the YAML document an operator supplies to
// POST /api/plugins/register. // POST /api/plugins/register.
package manifest package manifest
@@ -120,7 +120,7 @@ func buildShiftAlteredKeySet() map[string]bool {
} }
// Validate checks the manifest against the plugin registration rules // Validate checks the manifest against the plugin registration rules
// described in docs/specs/plugins/spec.md. It returns nil if the manifest // described in docs/specs/plugins/requirements/plugins.md. It returns nil if the manifest
// is well-formed, or a joined error describing every violation found. // is well-formed, or a joined error describing every violation found.
func (m *Manifest) Validate() error { func (m *Manifest) Validate() error {
var errs []error var errs []error
@@ -6,7 +6,7 @@
// The marketplace is a discovery layer only — it never installs, spawns, or // The marketplace is a discovery layer only — it never installs, spawns, or
// mutates a plugin. Installing a catalog entry reuses the existing // mutates a plugin. Installing a catalog entry reuses the existing
// POST /api/plugins/install pipeline with the entry's package_url. See // POST /api/plugins/install pipeline with the entry's package_url. See
// docs/specs/plugins/marketplace.md. // docs/specs/plugins/requirements/marketplace.md.
package marketplace package marketplace
import "time" import "time"
@@ -20,7 +20,7 @@ const (
) )
// InstallState is the derived state of a catalog entry relative to what is // InstallState is the derived state of a catalog entry relative to what is
// installed locally (docs/specs/plugins/marketplace.md → "State machine"). // installed locally (docs/specs/plugins/requirements/marketplace.md → "State machine").
type InstallState string type InstallState string
const ( const (
@@ -22,7 +22,7 @@ const tarballSuffix = ".tar.gz"
// Sync scans the plugins directory for filesystem-level changes an operator // Sync scans the plugins directory for filesystem-level changes an operator
// made outside the install/enable/disable/uninstall API — directory // made outside the install/enable/disable/uninstall API — directory
// sideloads and dropped tarballs — and reconciles the registry with what // sideloads and dropped tarballs — and reconciles the registry with what
// actually exists on disk, per docs/specs/plugins/spec.md ("Filesystem // actually exists on disk, per docs/specs/plugins/requirements/plugins.md ("Filesystem
// sideloading & sync"): // sideloading & sync"):
// //
// 1. Dir sideloads: an extracted <id>/<version>/manifest.yaml with no // 1. Dir sideloads: an extracted <id>/<version>/manifest.yaml with no
+1 -1
View File
@@ -1,7 +1,7 @@
// Package state provides a plugin-scoped key/value store backed by SQLite. // Package state provides a plugin-scoped key/value store backed by SQLite.
// Plugins read and write arbitrary JSON values under a (scope, scope_id, key) // Plugins read and write arbitrary JSON values under a (scope, scope_id, key)
// tuple, always filtered by plugin_id so a plugin can never read or write // tuple, always filtered by plugin_id so a plugin can never read or write
// another plugin's state (spec: docs/specs/plugins/spec.md, "plugin_state"). // another plugin's state (spec: docs/specs/plugins/requirements/plugins.md, "plugin_state").
package state package state
import ( import (
@@ -135,7 +135,7 @@ func TestStoreDeleteRemovesEntry(t *testing.T) {
} }
// TestStoreDeleteAllRemovesEveryRowForPlugin pins the Uninstall cleanup // TestStoreDeleteAllRemovesEveryRowForPlugin pins the Uninstall cleanup
// contract (docs/specs/plugins/spec.md "plugin_state"): a plugin's entire // contract (docs/specs/plugins/requirements/plugins.md "plugin_state"): a plugin's entire
// state footprint — across every scope and scope_id — must be removable in // state footprint — across every scope and scope_id — must be removable in
// one call, so a reinstalled or id-reused plugin never inherits stale state. // one call, so a reinstalled or id-reused plugin never inherits stale state.
func TestStoreDeleteAllRemovesEveryRowForPlugin(t *testing.T) { func TestStoreDeleteAllRemovesEveryRowForPlugin(t *testing.T) {
@@ -248,7 +248,7 @@ func TestStoreListReturnsOnlyMatchingScope(t *testing.T) {
} }
// TestStorePluginsCannotReadEachOthersState pins the spec invariant that // TestStorePluginsCannotReadEachOthersState pins the spec invariant that
// plugin state is always filtered by plugin_id (docs/specs/plugins/spec.md // plugin state is always filtered by plugin_id (docs/specs/plugins/requirements/plugins.md
// "Plugins cannot read others' state"). // "Plugins cannot read others' state").
func TestStorePluginsCannotReadEachOthersState(t *testing.T) { func TestStorePluginsCannotReadEachOthersState(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
+1 -1
View File
@@ -18,7 +18,7 @@ import (
) )
// Status values for a plugin registration, per the state machine in // Status values for a plugin registration, per the state machine in
// docs/specs/plugins/spec.md ("State machine"). // docs/specs/plugins/requirements/plugins.md ("State machine").
const ( const (
StatusRegistered = "registered" StatusRegistered = "registered"
StatusActive = "active" StatusActive = "active"
+1 -1
View File
@@ -15,7 +15,7 @@ import (
) )
// Status is a plugin's lifecycle state, per the state machine in // Status is a plugin's lifecycle state, per the state machine in
// docs/specs/plugins/spec.md ("State machine"): // docs/specs/plugins/requirements/plugins.md ("State machine"):
// //
// registered -> active -> disabled -> uninstalled // registered -> active -> disabled -> uninstalled
// registered|active|disabled --failure--> error // registered|active|disabled --failure--> error
+1 -1
View File
@@ -1,5 +1,5 @@
// Package review turns a task's working diff into anchored, advisory code-review // Package review turns a task's working diff into anchored, advisory code-review
// findings. See docs/specs/native-code-review/spec.md. // findings. See docs/specs/agents/requirements/native-code-review.md.
// //
// The package owns run orchestration only: persistence and event publication // The package owns run orchestration only: persistence and event publication
// live in internal/task/service (ReviewService), and the inference call is made // live in internal/task/service (ReviewService), and the inference call is made
@@ -3,7 +3,7 @@
// and exposes a RunQueueAdapter the workflow engine can use to // and exposes a RunQueueAdapter the workflow engine can use to
// enqueue runs without depending on the office package. // enqueue runs without depending on the office package.
// //
// Phase 3 of task-model-unification (see docs/specs/task-model-unification/plan.md // Phase 3 of task-model-unification (see docs/specs/tasks/system-design/model-unification.md
// sections B3.2 and B3.5) lifted this logic out of internal/office/service // sections B3.2 and B3.5) lifted this logic out of internal/office/service
// and added an event-driven claim signal so engine-emitted runs reach // and added an event-driven claim signal so engine-emitted runs reach
// the scheduler in a few ms instead of waiting up to one tick (5s). // the scheduler in a few ms instead of waiting up to one tick (5s).
+1 -1
View File
@@ -1,5 +1,5 @@
// Package cron owns the Phase 5 cron-driven trigger handlers (see // Package cron owns the Phase 5 cron-driven trigger handlers (see
// docs/specs/task-model-unification/plan.md, B5.1–B5.4). // docs/specs/tasks/system-design/model-unification.md, B5.1–B5.4).
// //
// Three handlers run on a shared tick loop: // Three handlers run on a shared tick loop:
// //
+1 -1
View File
@@ -2201,7 +2201,7 @@ type TaskReviewRun struct {
// TaskReviewFinding is one anchored, advisory review comment produced by a // TaskReviewFinding is one anchored, advisory review comment produced by a
// review run. It renders in the Changes/Review diff at File/StartLine..EndLine // review run. It renders in the Changes/Review diff at File/StartLine..EndLine
// of Repository, and carries FileDiffHash so a client can tell whether the // of Repository, and carries FileDiffHash so a client can tell whether the
// diff has moved under it (see ../../../../docs/specs/native-code-review/spec.md). // diff has moved under it (see ../../../../docs/specs/agents/requirements/native-code-review.md).
type TaskReviewFinding struct { type TaskReviewFinding struct {
ID string `json:"id"` ID string `json:"id"`
RunID string `json:"run_id"` RunID string `json:"run_id"`
@@ -4,7 +4,7 @@ import "time"
// SubagentContext is a durable, queryable row for one subagent (Task tool) // SubagentContext is a durable, queryable row for one subagent (Task tool)
// invocation observed by the orchestrator. See // invocation observed by the orchestrator. See
// docs/specs/subagent-context-persistence/spec.md. // docs/specs/agents/requirements/subagent-context-persistence.md.
// //
// Every nullable column is a pointer. This is not style: ToolUseCount in // Every nullable column is a pointer. This is not style: ToolUseCount in
// particular must distinguish a reported 0 from "not reported", and a // particular must distinguish a reported 0 from "not reported", and a
@@ -579,7 +579,7 @@ type PlanRepository interface {
// SubagentContextRepository persists the durable, queryable record of a // SubagentContextRepository persists the durable, queryable record of a
// subagent (Task tool) invocation. See // subagent (Task tool) invocation. See
// docs/specs/subagent-context-persistence/spec.md. // docs/specs/agents/requirements/subagent-context-persistence.md.
type SubagentContextRepository interface { type SubagentContextRepository interface {
// UpsertSubagentContext inserts or merges one subagent invocation row, // UpsertSubagentContext inserts or merges one subagent invocation row,
// keyed on (task_session_id, tool_call_id). A single atomic statement — // keyed on (task_session_id, tool_call_id). A single atomic statement —
@@ -735,7 +735,7 @@ func (r *Repository) initSessionSchema() error {
// initSubagentContextSchema creates task_session_subagents, the durable // initSubagentContextSchema creates task_session_subagents, the durable
// relational record of a subagent (Task tool) invocation. See // relational record of a subagent (Task tool) invocation. See
// docs/specs/subagent-context-persistence/spec.md. The three measurement // docs/specs/agents/requirements/subagent-context-persistence.md. The three measurement
// columns (total_tokens, tool_use_count, duration_ms) deliberately carry no // columns (total_tokens, tool_use_count, duration_ms) deliberately carry no
// DEFAULT: an unreported value must store NULL, never 0 (75% of observed // DEFAULT: an unreported value must store NULL, never 0 (75% of observed
// invocations report none of them). turn_id carries no FOREIGN KEY so a turn // invocations report none of them). turn_id carries no FOREIGN KEY so a turn
@@ -180,7 +180,7 @@ func scanClarificationBundleRows(rows *sql.Rows, isPostgres bool) ([]models.Clar
} }
// visibilityPredicate builds the L1c/L1d three-way disjunction (spec // visibilityPredicate builds the L1c/L1d three-way disjunction (spec
// docs/specs/external-question-answering/spec.md, "L1c"/"L1d"): the task's // docs/specs/integrations/requirements/external-question-answering.md, "L1c"/"L1d"): the task's
// workspace_id is empty, names no existing workspace row, or is in the L1a // workspace_id is empty, names no existing workspace row, or is in the L1a
// visible set. For an unscoped caller the predicate is satisfied // visible set. For an unscoped caller the predicate is satisfied
// unconditionally, so no clause is added at all. // unconditionally, so no clause is added at all.
@@ -11,7 +11,7 @@ import "testing"
// or a replayed one. The claim path itself has no statement of its own to // or a replayed one. The claim path itself has no statement of its own to
// assert against: CompleteActiveClarificationBundle is the only clarification // assert against: CompleteActiveClarificationBundle is the only clarification
// claim in the repository package, enforced by the compiler once the retired // claim in the repository package, enforced by the compiler once the retired
// repo/model files are gone (docs/specs/external-question-answering/spec.md, // repo/model files are gone (docs/specs/integrations/requirements/external-question-answering.md,
// P1). // P1).
func TestClarificationResolutionsTableNeverCreated(t *testing.T) { func TestClarificationResolutionsTableNeverCreated(t *testing.T) {
repo := newRepoForSessionTests(t) repo := newRepoForSessionTests(t)
@@ -2445,7 +2445,7 @@ func (r *Repository) GetActiveTaskInfoByAgentProfile(ctx context.Context, agentP
// let one nightly report block its profile's deletion forever. Those are // let one nightly report block its profile's deletion forever. Those are
// excluded, which makes them non-resumable if the profile goes: replying to // excluded, which makes them non-resumable if the profile goes: replying to
// an old run afterwards fails. That is the accepted trade — see // an old run afterwards fails. That is the accepted trade — see
// docs/specs/office/automations-settings.md. // docs/specs/office/requirements/automations-settings.md.
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT DISTINCT t.id, t.title, t.is_ephemeral SELECT DISTINCT t.id, t.title, t.is_ephemeral
FROM task_sessions ts FROM task_sessions ts
@@ -34,7 +34,7 @@ func (r *Repository) warnSubagentContextMigration(name string, err error) {
// "subagent_task" (AC-21), and publishes the two write-once kandev_meta // "subagent_task" (AC-21), and publishes the two write-once kandev_meta
// activation keys that let a consumer distinguish "zero fan-out" from // activation keys that let a consumer distinguish "zero fan-out" from
// "unmeasured, before capture began" (AC-24, AC-25). See // "unmeasured, before capture began" (AC-24, AC-25). See
// docs/specs/subagent-context-persistence/spec.md § Backfill. // docs/specs/agents/requirements/subagent-context-persistence.md § Backfill.
// //
// runMigrations() (and therefore this function) runs on every boot, not just // runMigrations() (and therefore this function) runs on every boot, not just
// the first. AC-24d (revision 4) requires the guard to be "both backfill keys // the first. AC-24d (revision 4) requires the guard to be "both backfill keys
@@ -4,7 +4,7 @@ package sqlite
// subagentPreciseTimestamp builds a SQL expression that evaluates to a value // subagentPreciseTimestamp builds a SQL expression that evaluates to a value
// safe to compare with plain >= / < against another such expression, at no // safe to compare with plain >= / < against another such expression, at no
// coarser than microsecond precision and monotonically across the whole // coarser than microsecond precision and monotonically across the whole
// range (AC-24e in docs/specs/subagent-context-persistence/spec.md). expr may // range (AC-24e in docs/specs/agents/requirements/subagent-context-persistence.md). expr may
// be a TIMESTAMP column reference or a bind-parameter placeholder carrying an // be a TIMESTAMP column reference or a bind-parameter placeholder carrying an
// RFC3339(Nano) string (an activation key from kandev_meta). // RFC3339(Nano) string (an activation key from kandev_meta).
// //
@@ -37,7 +37,7 @@ type taskScanColumn struct {
// Automation runs are hidden from the board and from task lists by their // Automation runs are hidden from the board and from task lists by their
// provenance, not by ephemerality: their tasks are ordinary persistent tasks // provenance, not by ephemerality: their tasks are ordinary persistent tasks
// that keep their worktree and stay repliable, they just have their own // that keep their worktree and stay repliable, they just have their own
// destination (docs/specs/office/automations-settings.md). is_ephemeral keeps // destination (docs/specs/office/requirements/automations-settings.md). is_ephemeral keeps
// its original quick-chat meaning, so every board read pairs the two. // its original quick-chat meaning, so every board read pairs the two.
const ( const (
andNotAutomationOrigin = ` AND COALESCE(origin, '') != '` + models.TaskOriginAutomationRun + `'` andNotAutomationOrigin = ` AND COALESCE(origin, '') != '` + models.TaskOriginAutomationRun + `'`
@@ -62,7 +62,7 @@ const subagentContextSourceLive = "live"
// invocation. It returns nothing: that is the mechanism by which AC-27 // invocation. It returns nothing: that is the mechanism by which AC-27
// holds — a writer with no error return cannot fail the enclosing message // holds — a writer with no error return cannot fail the enclosing message
// write, turn, or agent stream, and no future caller can accidentally // write, turn, or agent stream, and no future caller can accidentally
// propagate one. See docs/specs/subagent-context-persistence/spec.md. // propagate one. See docs/specs/agents/requirements/subagent-context-persistence.md.
func (s *Service) RecordSubagentContext(ctx context.Context, req RecordSubagentContextRequest) { func (s *Service) RecordSubagentContext(ctx context.Context, req RecordSubagentContextRequest) {
if s.subagentContexts == nil { if s.subagentContexts == nil {
return return
@@ -5,7 +5,7 @@ import "expvar"
// subagentContextTotal exposes writer-health counters for // subagentContextTotal exposes writer-health counters for
// RecordSubagentContext, under the existing expvar convention used by // RecordSubagentContext, under the existing expvar convention used by
// routing_* (internal/office/scheduler) and subproc_*. Counters only — see // routing_* (internal/office/scheduler) and subproc_*. Counters only — see
// AC-26 in docs/specs/subagent-context-persistence/spec.md. // AC-26 in docs/specs/agents/requirements/subagent-context-persistence.md.
// //
// Keys: attempted, persisted, skipped_no_identity, anomalous_value, failed, // Keys: attempted, persisted, skipped_no_identity, anomalous_value, failed,
// unknown_execution (Amendment 1, AC-31). // unknown_execution (Amendment 1, AC-31).
+1 -1
View File
@@ -193,7 +193,7 @@ func (h *HTTPHandlers) httpRevoke(c *gin.Context) {
// mapShareError translates a service error into the response body + status // mapShareError translates a service error into the response body + status
// code. Returns (nil, 0) when err is nil. The status table is part of the // code. Returns (nil, 0) when err is nil. The status table is part of the
// public contract; keep it in sync with docs/specs/public-share-links/spec.md. // public contract; keep it in sync with docs/specs/auth/requirements/public-share-links.md.
func mapShareError(err error) (*errorBody, int) { func mapShareError(err error) (*errorBody, int) {
if err == nil { if err == nil {
return nil, 0 return nil, 0
@@ -144,7 +144,7 @@ type SetSessionModeAction struct {
// RunCodeReviewAction starts a native code-review pass on step entry. // RunCodeReviewAction starts a native code-review pass on step entry.
// AgentProfileID is optional; empty means "use the configured code-review // AgentProfileID is optional; empty means "use the configured code-review
// utility agent". See docs/specs/native-code-review/spec.md. // utility agent". See docs/specs/agents/requirements/native-code-review.md.
type RunCodeReviewAction struct { type RunCodeReviewAction struct {
AgentProfileID string AgentProfileID string
} }
@@ -34,7 +34,7 @@ type AgentProfileResolver func(profileID string) *AgentProfilePortable
// during workflow-sync reconciliation); implementations should keep it when // during workflow-sync reconciliation); implementations should keep it when
// it still matches the descriptor even if the profile was since disabled - // it still matches the descriptor even if the profile was since disabled -
// disabling only hides a profile from new selection, it doesn't touch // disabling only hides a profile from new selection, it doesn't touch
// existing bindings (docs/specs/agents/profile-disable.md). // existing bindings (docs/specs/agents/requirements/profile-disable.md).
type AgentProfileMatcher func(agentName, model, mode, currentID string) string type AgentProfileMatcher func(agentName, model, mode, currentID string) string
// WorkflowPortable is a workflow without instance-specific fields (IDs, timestamps). // WorkflowPortable is a workflow without instance-specific fields (IDs, timestamps).
@@ -674,7 +674,7 @@ func TestApplySyncedWorkflows_WorkflowRebindWarningDoesNotFireOnFailedUpdate(t *
// updateSyncedWorkflow passes the step's existing AgentProfileID to the // updateSyncedWorkflow passes the step's existing AgentProfileID to the
// matcher as currentID. A real matcher (buildAgentProfileMatcher) uses that // matcher as currentID. A real matcher (buildAgentProfileMatcher) uses that
// to preserve a binding that was disabled after the fact instead of treating // to preserve a binding that was disabled after the fact instead of treating
// disablement as a rebind (docs/specs/agents/profile-disable.md). This test // disablement as a rebind (docs/specs/agents/requirements/profile-disable.md). This test
// stubs that same contract: the matcher echoes currentID back when given // stubs that same contract: the matcher echoes currentID back when given
// one, standing in for "the bound profile still matches, keep it". // one, standing in for "the bound profile still matches, keep it".
func TestApplySyncedWorkflows_StepMatcherReceivesCurrentBinding(t *testing.T) { func TestApplySyncedWorkflows_StepMatcherReceivesCurrentBinding(t *testing.T) {
+2 -2
View File
@@ -90,7 +90,7 @@ For rebasing or finishing PRs written against the old Next.js runtime, follow [`
Use subscription hooks only; the WS client auto-deduplicates. Use subscription hooks only; the WS client auto-deduplicates.
**Task overview vs. session detail:** Shared task rows read `Task.statusSummary` and `task.status_summary.updated`; rich streams stay session-detail-only. Extend the bounded projection per the [spec](../../docs/specs/platform/bounded-task-status-delivery.md) and [ADR](../../docs/decisions/2026-08-01-separate-task-summary-session-stream-traffic.md). **Task overview vs. session detail:** Shared task rows read `Task.statusSummary` and `task.status_summary.updated`; rich streams stay session-detail-only. Extend the bounded projection per the [spec](../../docs/specs/platform/requirements/bounded-task-status-delivery.md) and [ADR](../../docs/decisions/2026-08-01-separate-task-summary-session-stream-traffic.md).
**Branch-scoped task state:** For live worktree/session state plus `task_prs`, key by `(repository, checked-out branch)`, not task/repository alone. `branch_switched` invalidates prior status/commits; reject late results with a generation/identity guard and preserve siblings. Historical PRs affect Changes only when `repository_id` and normalized `head_branch` match; Review/PR history may still show them. Test single/multi-repo cases and desktop/mobile Changes behavior. **Branch-scoped task state:** For live worktree/session state plus `task_prs`, key by `(repository, checked-out branch)`, not task/repository alone. `branch_switched` invalidates prior status/commits; reject late results with a generation/identity guard and preserve siblings. Historical PRs affect Changes only when `repository_id` and normalized `head_branch` match; Review/PR history may still show them. Test single/multi-repo cases and desktop/mobile Changes behavior.
**HTTP/WS cache races:** When HTTP hydrates a cache also updated by WebSockets, guard responses with per-scope revision and request/workspace generation; discard or refresh stale responses and cover deferred responses. `useEnsureTaskSession` re-fires `session.ensure` when an open task page sees zero sessions after a prior ensure, so deleting the last session from that page spawns a replacement; test zero-session states through the backend/API instead. **HTTP/WS cache races:** When HTTP hydrates a cache also updated by WebSockets, guard responses with per-scope revision and request/workspace generation; discard or refresh stale responses and cover deferred responses. `useEnsureTaskSession` re-fires `session.ensure` when an open task page sees zero sessions after a prior ensure, so deleting the last session from that page spawns a replacement; test zero-session states through the backend/API instead.
@@ -246,7 +246,7 @@ mandatory reason — reasonless or stale entries are errors. For zh-tw/zh-hk run
`i18n:check` also gates key/catalog drift, `<Trans>` indices, inline plurals, `i18n:check` also gates key/catalog drift, `<Trans>` indices, inline plurals,
module-scope `t()`, em dashes, and the **pseudo-locale** check. Needs **Node 24**. module-scope `t()`, em dashes, and the **pseudo-locale** check. Needs **Node 24**.
Guide: [`docs/i18n.md`](../../docs/i18n.md); spec Guide: [`docs/i18n.md`](../../docs/i18n.md); spec
[`docs/specs/platform/i18n.md`](../../docs/specs/platform/i18n.md). [`docs/specs/platform/requirements/i18n.md`](../../docs/specs/platform/requirements/i18n.md).
## Markdown safety ## Markdown safety
@@ -287,7 +287,7 @@ function InheritedTierHint({
// AC-17 / AC-17a / AC-17b: names the wake reasons whose policy shadows the // AC-17 / AC-17a / AC-17b: names the wake reasons whose policy shadows the
// tier displayed above, whichever level supplied it. The effective map is // tier displayed above, whichever level supplied it. The effective map is
// the agent's own tier_per_reason when it overrides the workspace policy // the agent's own tier_per_reason when it overrides the workspace policy
// (an override replaces the map entirely, per docs/specs/office/routing.md) // (an override replaces the map entirely, per docs/specs/office/requirements/routing.md)
// and the workspace map otherwise; keys with an empty value do not shadow // and the workspace map otherwise; keys with an empty value do not shadow
// anything and are excluded. // anything and are excluded.
function effectiveWakeReasonMap( function effectiveWakeReasonMap(
@@ -47,7 +47,7 @@ export function formatShortDate(iso: string): string {
* Renders an int64 subcents (hundredths of a cent) value as USD. The * Renders an int64 subcents (hundredths of a cent) value as USD. The
* backend ships every cost figure as a subcents integer so we don't * backend ships every cost figure as a subcents integer so we don't
* carry float drift; the formatter just divides by 10000 at the edge. * carry float drift; the formatter just divides by 10000 at the edge.
* See docs/specs/office-costs/spec.md for the unit contract. * See docs/specs/office/requirements/costs.md for the unit contract.
*/ */
export function formatSubcents(subcents: number): string { export function formatSubcents(subcents: number): string {
if (!Number.isFinite(subcents)) return "$0.00"; if (!Number.isFinite(subcents)) return "$0.00";
@@ -33,7 +33,7 @@ type Props = {
// RoleTierCard renders the per-role tier policy table on the workspace // RoleTierCard renders the per-role tier policy table on the workspace
// routing settings page: the third of four precedence levels, below a // routing settings page: the third of four precedence levels, below a
// wake-reason policy and a per-agent override, above the workspace default. // wake-reason policy and a per-agent override, above the workspace default.
// See docs/specs/office-agent-tier-routing/spec.md. // See docs/specs/office/requirements/office-agent-tier-routing.md.
export function RoleTierCard({ roles, config, value, onChange, disabled }: Props) { export function RoleTierCard({ roles, config, value, onChange, disabled }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const handleRowChange = (role: AgentRole, tier: Tier | typeof USE_WORKSPACE_DEFAULT) => { const handleRowChange = (role: AgentRole, tier: Tier | typeof USE_WORKSPACE_DEFAULT) => {
@@ -44,7 +44,7 @@ export function rememberWorkspaceSelectionById(id: string, kind: "office" | "kan
* that instance's LIVE selection cookie, and for upgraded ported instances it * that instance's LIVE selection cookie, and for upgraded ported instances it
* is the validated migration fallback until each writes its own scoped value. * is the validated migration fallback until each writes its own scoped value.
* Scrubbing it from one instance would change the other instances' next boot * Scrubbing it from one instance would change the other instances' next boot
* (see docs/specs/fix-multi-instance-cookie-isolation/spec.md: the upgraded * (see docs/specs/auth/requirements/fix-multi-instance-cookie-isolation.md: the upgraded
* instance does not proactively delete the legacy cookie). * instance does not proactively delete the legacy cookie).
*/ */
function writeWorkspaceCookie(name: string, value: string): void { function writeWorkspaceCookie(name: string, value: string): void {
@@ -22,7 +22,7 @@ import { settingsActionClassName } from "@/components/settings/settings-control"
/** /**
* Operator UI to browse, install, enable, disable, uninstall, and update kandev * Operator UI to browse, install, enable, disable, uninstall, and update kandev
* plugins (docs/specs/plugins/marketplace.md). Gated on the `plugins` feature * plugins (docs/specs/plugins/requirements/marketplace.md). Gated on the `plugins` feature
* flag by the page-level default export. * flag by the page-level default export.
*/ */
export function PluginsSettings() { export function PluginsSettings() {
@@ -23,7 +23,7 @@ export type ConditionalTodoPanelAction = "add" | "remove" | "none";
* Details conditional panel, there is no per-instance identity to reconcile * Details conditional panel, there is no per-instance identity to reconcile
* and no closed-for-session suppression: the `showTodoListPanel` preference * and no closed-for-session suppression: the `showTodoListPanel` preference
* is the single authoritative on/off switch (see * is the single authoritative on/off switch (see
* docs/specs/ui/agent-todo-list-panel.md — "true, unconditional visibility * docs/specs/ui/requirements/agent-todo-list-panel.md — "true, unconditional visibility
* gate"). The "only pin when not empty" sub-option (`onlyPinWhenNotEmpty`) * gate"). The "only pin when not empty" sub-option (`onlyPinWhenNotEmpty`)
* gates only the automatic add: while it is on, an absent panel is not added * gates only the automatic add: while it is on, an absent panel is not added
* until the active session's todo list is non-empty (`todoListNotEmpty`). * until the active session's todo list is non-empty (`todoListNotEmpty`).
@@ -33,7 +33,7 @@ function formatDate(dateStr: string): string {
} }
// formatCurrency converts subcents (hundredths of a cent — the office // formatCurrency converts subcents (hundredths of a cent — the office
// cost storage unit per docs/specs/office-costs/spec.md) to a USD string. // cost storage unit per docs/specs/office/requirements/costs.md) to a USD string.
function formatCurrency(subcents: number): string { function formatCurrency(subcents: number): string {
return new Intl.NumberFormat(undefined, { return new Intl.NumberFormat(undefined, {
style: "currency", style: "currency",
@@ -4,7 +4,7 @@ import { AutomationsPage } from "../pages/automations-page";
/** /**
* E2E coverage for the github_pr_merged ("Pull request merged") trigger type. * E2E coverage for the github_pr_merged ("Pull request merged") trigger type.
* *
* Spec: docs/specs/automations-pr-merged-trigger/spec.md * Spec: docs/specs/office/requirements/automations-pr-merged-trigger.md
* Decisions pinned here: * Decisions pinned here:
* - Picker shows "Pull request merged" under GitHub group, immediately after "New pull requests" * - Picker shows "Pull request merged" under GitHub group, immediately after "New pull requests"
* - Config round-trip: all_repos, repos, base_branches survive save/reopen * - Config round-trip: all_repos, repos, base_branches survive save/reopen
@@ -10,7 +10,7 @@ import { registerSeparateQueueRows } from "../../helpers/message-queue-settings"
registerSeparateQueueRows(test); registerSeparateQueueRows(test);
// End-to-end coverage for the mid-turn steering composer contract // End-to-end coverage for the mid-turn steering composer contract
// (docs/specs/platform/mid-turn-steering.md). CONTRIBUTING.md requires Playwright // (docs/specs/platform/requirements/mid-turn-steering.md). CONTRIBUTING.md requires Playwright
// coverage for UI changes; this exercises the flag-gated decision and the real // coverage for UI changes; this exercises the flag-gated decision and the real
// steer dispatch path through the mock agent, which advertises promptQueueing. // steer dispatch path through the mock agent, which advertises promptQueueing.
// //
@@ -153,7 +153,7 @@ test.describe.serial("Claude mid-turn steering experiment", () => {
}); });
// The two outcomes below replay the mid-turn steering spec's "folded" vs // The two outcomes below replay the mid-turn steering spec's "folded" vs
// "deferred" outcome taxonomy (docs/specs/platform/mid-turn-steering.md) // "deferred" outcome taxonomy (docs/specs/platform/requirements/mid-turn-steering.md)
// via the dedicated mock-agent setup scenarios: steer-fold-setup never // via the dedicated mock-agent setup scenarios: steer-fold-setup never
// answers on its own (task 08's mock-replay acceptance for "folded" — // answers on its own (task 08's mock-replay acceptance for "folded" —
// pinned deterministically at the mock level by // pinned deterministically at the mock level by
@@ -8,7 +8,7 @@ import { test, expect } from "../../fixtures/test-base";
* externalization sweep: any plain-ASCII user-facing text under `pseudo` is a * externalization sweep: any plain-ASCII user-facing text under `pseudo` is a
* literal that was never routed through `t()`. * literal that was never routed through `t()`.
* *
* See docs/specs/platform/i18n.md and docs/i18n.md. * See docs/specs/platform/requirements/i18n.md and docs/i18n.md.
*/ */
const APPEARANCE_URL = "/settings/preferences/appearance"; const APPEARANCE_URL = "/settings/preferences/appearance";
@@ -20,7 +20,7 @@ async function expandIntegrationsSection(testPage: Page) {
await expect(integrationsToggle).toHaveAttribute("aria-expanded", "true"); await expect(integrationsToggle).toHaveAttribute("aria-expanded", "true");
} }
// Covers docs/specs/integrations/enable-disable-toggle.md's nav-visibility // Covers docs/specs/integrations/requirements/enable-disable-toggle.md's nav-visibility
// scenarios: with "Hide disabled integrations from left panel navigation" // scenarios: with "Hide disabled integrations from left panel navigation"
// off (the default), a disabled-but-configured integration still shows in // off (the default), a disabled-but-configured integration still shows in
// the sidebar; turning the setting on hides it; turning it back off (or // the sidebar; turning the setting on hides it; turning it back off (or
@@ -1,6 +1,6 @@
import { test, expect } from "../../fixtures/test-base"; import { test, expect } from "../../fixtures/test-base";
// Covers docs/specs/integrations/enable-disable-toggle.md's per-workspace // Covers docs/specs/integrations/requirements/enable-disable-toggle.md's per-workspace
// scoping: the enable toggle belongs to the workspace whose settings page it // scoping: the enable toggle belongs to the workspace whose settings page it
// sits on, so disabling an integration in one workspace leaves every other // sits on, so disabling an integration in one workspace leaves every other
// workspace's toggle alone. It shipped install-wide first, which silenced an // workspace's toggle alone. It shipped install-wide first, which silenced an
@@ -1,6 +1,6 @@
import { test, expect } from "../../fixtures/test-base"; import { test, expect } from "../../fixtures/test-base";
// Covers docs/specs/integrations/enable-disable-toggle.md's index-page slider // Covers docs/specs/integrations/requirements/enable-disable-toggle.md's index-page slider
// scenarios: every integration row has a working slider, toggling it never // scenarios: every integration row has a working slider, toggling it never
// navigates, and the toggle stays in sync with that integration's own // navigates, and the toggle stays in sync with that integration's own
// settings page (the shared per-workspace `useXEnabled` state). // settings page (the shared per-workspace `useXEnabled` state).
@@ -3,7 +3,7 @@ import { test, expect } from "../../fixtures/office-fixture";
/** /**
* E2E coverage for the office task approval gate (in_review → done is * E2E coverage for the office task approval gate (in_review → done is
* blocked until every approver has a current "approved" decision). Spec: * blocked until every approver has a current "approved" decision). Spec:
* docs/specs/office-task-approval-flow/spec.md * docs/specs/office/requirements/inbox.md
* *
* Approach: * Approach:
* - Use `officeApi.createAgent` to add an approver to the workspace. * - Use `officeApi.createAgent` to add an approver to the workspace.
@@ -4,7 +4,7 @@ import { test, expect } from "../../fixtures/office-fixture";
* E2E coverage for the office dashboard "Agents" panel: per-agent cards * E2E coverage for the office dashboard "Agents" panel: per-agent cards
* rendered always (even with zero activity), driven reactively by the * rendered always (even with zero activity), driven reactively by the
* existing WS pipeline (`session.state_changed`, `office.task.updated`, * existing WS pipeline (`session.state_changed`, `office.task.updated`,
* `office.agent.updated`). Spec: docs/specs/office-dashboard-agent-cards. * `office.agent.updated`). Spec: docs/specs/office/requirements/dashboard.md.
* *
* The lifecycle harness is rich enough to drive these scenarios without a * The lifecycle harness is rich enough to drive these scenarios without a
* real executor: `apiClient.seedTaskSession` writes directly to the DB * real executor: `apiClient.seedTaskSession` writes directly to the DB
@@ -4,7 +4,7 @@ import { balancedExecutionProfileRouting } from "../../helpers/office-routing";
/** /**
* Phase 7 spec #2 — provider fallback under quota_limited. * Phase 7 spec #2 — provider fallback under quota_limited.
* *
* Scenario (per docs/specs/office-provider-routing/spec.md): * Scenario (per docs/specs/office/requirements/routing.md):
* 1. Workspace routing enabled with order `claude-acp → codex-acp`, * 1. Workspace routing enabled with order `claude-acp → codex-acp`,
* tier `balanced`. * tier `balanced`.
* 2. Backend is launched with `KANDEV_PROVIDER_FAILURES=claude-acp: * 2. Backend is launched with `KANDEV_PROVIDER_FAILURES=claude-acp:
@@ -4,7 +4,7 @@ import { test, expect } from "../../fixtures/office-fixture";
* E2E coverage for the office task session lifecycle (per-(task, agent) * E2E coverage for the office task session lifecycle (per-(task, agent)
* persistent sessions, RUNNING ↔ IDLE state cycling, COMPLETED only on * persistent sessions, RUNNING ↔ IDLE state cycling, COMPLETED only on
* participation removal). Spec: * participation removal). Spec:
* docs/specs/office-task-session-lifecycle/spec.md * docs/specs/office/requirements/tasks.md
* *
* Harness limitations: * Harness limitations:
* - The /api/v1/_test/task-sessions route only INSERTS new rows; it does * - The /api/v1/_test/task-sessions route only INSERTS new rows; it does
@@ -4,7 +4,7 @@ import path from "node:path";
import { test, expect } from "../../fixtures/test-base"; import { test, expect } from "../../fixtures/test-base";
import { OfficeApiClient } from "../../helpers/office-api-client"; import { OfficeApiClient } from "../../helpers/office-api-client";
// Verifies the spec-aligned skill injection (docs/specs/office/agents.md): // Verifies the spec-aligned skill injection (docs/specs/office/requirements/agents.md):
// when a skill is assigned to an agent profile, launching a session writes // when a skill is assigned to an agent profile, launching a session writes
// it to <worktree>/<projectSkillDir>/kandev-<slug>/SKILL.md and appends // it to <worktree>/<projectSkillDir>/kandev-<slug>/SKILL.md and appends
// the kandev-* glob to .git/info/exclude. // the kandev-* glob to .git/info/exclude.
@@ -1,6 +1,6 @@
/** /**
* E2E: `PluginComposerCapability` against the real native composers * E2E: `PluginComposerCapability` against the real native composers
* (docs/specs/plugins/voice-extraction-host.md). * (docs/specs/plugins/requirements/voice-extraction-host.md).
* *
* These are the prerequisites the extracted Voice Mode plugin stands on, so * These are the prerequisites the extracted Voice Mode plugin stands on, so
* everything here goes through a genuinely registered plugin slot and then * everything here goes through a genuinely registered plugin slot and then
@@ -1,6 +1,6 @@
/** /**
* Pixel 5 coverage for `PluginComposerCapability` * Pixel 5 coverage for `PluginComposerCapability`
* (docs/specs/plugins/voice-extraction-host.md, "Mobile Design Contract"). * (docs/specs/plugins/requirements/voice-extraction-host.md, "Mobile Design Contract").
* *
* Capability parity, not layout parity: the same action, the same insertion * Capability parity, not layout parity: the same action, the same insertion
* and the same native submit have to work by touch, from the shipped mobile * and the same native submit have to work by touch, from the shipped mobile
+1 -1
View File
@@ -114,7 +114,7 @@ function pluginExecutablePath(installPath: string): string {
* Stages a filesystem sideload: extracts the same fixture package the * Stages a filesystem sideload: extracts the same fixture package the
* upload tests use directly into `<pluginsDir>/<id>/<version>/`, with no * upload tests use directly into `<pluginsDir>/<id>/<version>/`, with no
* `{id}.yml` record — the on-disk shape `Service.Sync`'s directory-sideload * `{id}.yml` record — the on-disk shape `Service.Sync`'s directory-sideload
* step looks for (docs/specs/plugins/spec.md "Filesystem sideloading & * step looks for (docs/specs/plugins/requirements/plugins.md "Filesystem sideloading &
* sync"). `checksums.txt` lands in the directory alongside the rest of the * sync"). `checksums.txt` lands in the directory alongside the rest of the
* package; the sideload path ignores it (only the tarball-install path * package; the sideload path ignores it (only the tarball-install path
* verifies checksums). * verifies checksums).
@@ -1,6 +1,6 @@
import { test, expect } from "../../fixtures/test-base"; import { test, expect } from "../../fixtures/test-base";
// Covers docs/specs/agents/hide-disabled-profiles-nav.md's nav-visibility // Covers docs/specs/agents/requirements/hide-disabled-profiles-nav.md's nav-visibility
// scenarios: with "Hide disabled agent profiles from left panel navigation" // scenarios: with "Hide disabled agent profiles from left panel navigation"
// off (the default), a disabled profile still shows in the Settings left // off (the default), a disabled profile still shows in the Settings left
// panel's Agents tree; turning the setting on hides it; re-enabling the // panel's Agents tree; turning the setting on hides it; re-enabling the

Some files were not shown because too many files have changed in this diff Show More