Files
mcpctl/.taskmaster/docs/prd-agentic-teams.md
Michal 797df2299d docs(taskmaster): PRD + task graph for agentic teams foundation
Converts docs/agentic-teams.md §16 steps 1-3 into a trackable PRD and 48
appended tasks (IDs 115-162) in the master tag. Steps 4-8 land as 5 low
priority placeholders wired onto the spine, pending the §15 open questions.

The PRD is grounded in the current codebase rather than the design doc's
indicative sketches, which changed the shape of the work:

- RBAC `tasks` is already owned by the inference queue, and bindings are
  normalized at parse time, so existing RbacDefinition rows literally hold
  "tasks". Taking the name for work items needs a one-shot guarded backfill
  to `inference-tasks` (task 116).
- deploy/entrypoint.sh uses `prisma db push`, so migration SQL never runs in
  production. Invariants are service-layer, not CHECK constraints.
- Ephemeral pods do not exist: restartPolicy is hardcoded Always and
  ContainerSpec has no volume/workdir fields (tasks 140-142).
- `exec` is cheap: KubernetesOrchestrator already implements exec/attach over
  k8s.Exec/k8s.Attach; only an API + CLI surface is missing (tasks 144-145).
- No git provider client exists in product code (task 148).
- ChatService caps its tool loop at 12 iterations, so the AgentRuntime runs a
  headless coding agent in-pod rather than extending chat (tasks 146-147).

Names the git-repo entity CodeRepo (CLI `repos`) to avoid colliding with the
repository/ data-access layer; AgentProject carries an optional llmId so the
brain-ownership question can be settled either way without a migration.

Generated task_*.md files are left untracked, matching how this repo has
treated them since the first commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-08 09:58:20 +01:00

37 KiB
Raw Blame History

PRD: Agentic Teams — Foundation

Overview

mcpctl today orchestrates MCP servers, projects, prompts, skills, and chat agents. It does not orchestrate work. This PRD adds the foundation that lets human developers and autonomous agents be peers in the same project: a versioned vision layer (Epic → epictask → task), git repositories attached to projects, agents as many-to-many project members, ephemeral per-session pods where agents do real work, a swappable agent runtime, a task lifecycle with QA/Security/SRE gates, and a single human accept gate that fires a merge through the git provider's API.

The design record is docs/agentic-teams.md. This PRD converts §16 steps 1–3 of that document into an implementation spec. Steps 4–8 are named as deferred work at the end, deliberately without detail — they depend on open design questions (docs/agentic-teams.md §15) that are not closed.

Problem

Two failure classes motivate this work.

Nothing tracks work. There is no Epic, EpicTask, Task, Repository, or AgentProject model in src/db/prisma/schema.prisma, and no repository, service, route, or CLI code for any of them. Agents can chat and call tools, but there is no unit of work to assign, no lifecycle to advance, no branch or PR to attach, and no gate for a human to accept.

Completed tasks do not add up to the vision. From the design doc: you have a grand vision, you split it into tasks, you complete all the tasks, and you are left with a stick with wheels instead of a car. The root cause is not execution — it is that decomposition is lossy, tasks are graded mechanically ("compiles / lint / renders") rather than against intent, and integration is nobody's job. The fix is an authoritative, versioned Epic that every task can walk up to, so "what am I serving, and what does done-for-this look like" is always answerable.

A third, practical problem shapes the ordering: the design document's "don't rebuild, delegate" principle is correct in spirit, but several primitives it assumes do not exist in mcpctl yet. Those are enumerated under Execution Substrate Changes and must land before any agent can work a task.

Scope & Non-Goals

In scope (design doc §16 steps 1–3):

  1. Epic / EpicTask / Task / CodeRepo entities + AgentProject membership, with project linkage, mcpd CRUD, CLI verbs, RBAC, backup, docs, and tests.
  2. An AgentRuntime adapter interface and a first implementation running a headless coding agent inside an ephemeral per-session pod. Prove: pod spawn → clone repos → work a task branch → push → report.
  3. Task lifecycle with QA / Security / SRE stages, PR linkage, the human accept gate, and merge through the git provider API, with post-merge failure reopening the task.

Explicitly out of scope — these are named in Deferred below and get placeholder tasks only: the Tier-2 design/product critic and Epic-comparison loop; collaboration modes (standup, joint exec sessions as a workflow); the confidence signal and escalation dashboard; per-persona memory and the coverage ledger; script artifacts and the baseline scorecard.

Not being built at all: a permission engine. mcpctl enforces coarse gates (who may transition a task, what RBAC a session needs); fine-grained per-tool approval stays inside the agent runtime.

Decisions Already Made

Decision Choice Rationale
Work-item RBAC name Work items take tasks; the inference queue moves to inference-tasks Matches the design doc's vocabulary. The inference-tasks alias already exists, so the URL prefix /api/v1/inference-tasks is unchanged
Roadmap-node spelling Model EpicTask, resource epictasks, aliases epictask, et Design doc §15 Q2; one word, matches the doc's own usage
Git-repo resource name Model CodeRepo, resource repos, aliases repo, repository, repositories Avoids repository.repository.ts and the collision with the data-access-layer term used throughout src/mcpd/src/repositories/
Epic revisions Epic joins the existing ResourceRevision system as resourceType: 'epic' The Epic is the auditable source of truth for "is this done"; scope changes must be visible. ResourceType is a string union by design, so this is a non-migration change
Agent membership New AgentProject junction; Agent.projectId retained and kept in sync for one release Design doc §4. ProjectMember was deliberately dropped in migration 20260223120000_remove_project_members; this is a narrower reversal — agent membership only, not human membership, which stays RBAC-driven
Invariants Enforced in the service layer, not CHECK constraints See Deployment constraint below
Agent brain Home brain (Agent.llmId) for bare chat; the project's llmProvider/llmModel when the agent acts inside a project; optional per-membership override on AgentProject.llmId Design doc §15 Q1 leans this way. This is the one open question that blocks the data model — the AgentProject.llmId column exists so the decision can be reversed without a migration

Deployment constraint (affects every schema task)

deploy/entrypoint.sh provisions the database with prisma db push --accept-data-loss, not prisma migrate deploy. Hand-written SQL in src/db/prisma/migrations/ therefore never runs in production. The single existing CHECK constraint, ChatThread_agent_xor_project (migration 20260717234728_add_project_chat_threads), is absent from any db push-provisioned database and is enforced only by ChatService.

Consequence: every invariant introduced here must be either expressible in the Prisma schema (unique constraints, non-null columns, enums, foreign keys) or enforced in the service layer with a test. Do not write CHECK constraints and assume they hold.

Core Concepts

The vision layer

Epic (the vision — project-scoped, versioned, revision-tracked)
  └── EpicTask  — a roadmap node; chains to other epictasks (dependency graph)
        └── Task — the unit of work, with its own lifecycle

An Epic holds the agreed done-criteria written at vision time, its scope-change history, and accepted-tradeoff decisions. An Epic is done only when 100% of the vision is delivered, or the user explicitly accepted the tradeoffs — recorded so "done" is honest and auditable.

An EpicTask is a roadmap node. EpicTasks chain to one another, so visualising them yields a dependency graph rather than a flat sequence. Each carries the decomposed subset of the Epic's acceptance criteria, so per-node completeness is computable.

A Task is the unit of work, owned by an EpicTask. Because Task → EpicTask → Epic, every task can resolve what it is serving by walking up the chain.

Two-level verification

Level Checks Who When
Tier 1 — correctness / security / ops Builds, runs, passes tests, stays secure, and the deployment-as-code is right for this project's real infrastructure Developer + QA + Security + SRE personas Inside the task's own lifecycle, before Done
Tier 2 — vision fidelity Does the assembled result match the vision Design/product critic At the Epic level — deferred, not in this PRD

Tier 1 stages are gates inside the task loop. Their feedback bounces the task back to InProgress. They are not post-submit reviewers.

The SRE/Release stage reviews the deployment-as-code — is it targeting the right infrastructure, by the right method, with the right tooling. It reviews the deployment definition; it does not deploy. It exists because a large share of failures come from implicit operational rules a developer LLM does not know. Concrete example: this cluster runs default-deny network policies, so any new workload needs explicit egress and ingress rules. A developer LLM watching a connection fail will deep-dive the network-debugging path when the real cause is a missing egress policy silently dropping traffic. The SRE persona, holding per-project infra knowledge, flags that in review.

Agents as people

An agent exists independently of any project — a persistent identity with a name, description, system prompt, and a home brain. It joins zero, one, or many projects. Per-project role and scope hang off the membership junction, not off the agent.

Pod per session

Each agent session gets its own ephemeral pod. The pod is the workspace: an init container clones the project's repositories, the session is attached to project prompts, MCP tools, and scoped secrets, and the pod dies at session end. Agents do not share a live filesystem with each other — handoffs happen via git (branch + diff + verdict pinned to a commit). A single human may share one agent's environment via exec.

Schema Changes

All new models follow existing house conventions: id String @id @default(cuid()), version Int @default(1) as an optimistic-concurrency counter bumped unconditionally in the repository layer (see agent.repository.ts updateData.version = { increment: 1 }), plus createdAt / updatedAt.

Epic

model Epic {
  id                String   @id @default(cuid())
  name              String
  description       String   @default("")
  // The agreed statement of the vision. Body of the Epic.
  content           String   @db.Text
  // Checkable outcomes + intent/quality attributes that define 100% delivery.
  // Array of { id, text, kind: 'outcome' | 'quality', met: boolean }.
  doneCriteria      Json     @default("[]")
  // Accepted-tradeoff records: { what, why, who, deScopedBy, at }.
  acceptedTradeoffs Json     @default("[]")
  projectId         String
  status            EpicStatus @default(open)
  semver            String   @default("0.1.0")
  currentRevisionId String?
  ownerId           String
  version           Int      @default(1)
  createdAt         DateTime @default(now())
  updatedAt         DateTime @updatedAt

  project   Project   @relation(fields: [projectId], references: [id], onDelete: Cascade)
  owner     User      @relation(fields: [ownerId], references: [id], onDelete: Cascade)
  epicTasks EpicTask[]

  @@unique([name, projectId])
  @@index([projectId])
  @@index([name])
}

enum EpicStatus {
  open
  delivered              // 100% of doneCriteria met
  delivered_with_tradeoffs
  cancelled
}

EpicTask

model EpicTask {
  id           String   @id @default(cuid())
  name         String
  description  String   @default("")
  epicId       String
  // Decomposed subset of the Epic's doneCriteria this node is responsible for.
  doneCriteria Json     @default("[]")
  status       EpicTaskStatus @default(todo)
  ownerId      String
  version      Int      @default(1)
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt

  epic      Epic     @relation(fields: [epicId], references: [id], onDelete: Cascade)
  owner     User     @relation(fields: [ownerId], references: [id], onDelete: Cascade)
  tasks     Task[]
  dependsOn EpicTaskEdge[] @relation("EpicTaskEdgeFrom")
  blocks    EpicTaskEdge[] @relation("EpicTaskEdgeTo")

  @@unique([name, epicId])
  @@index([epicId])
}

// Chaining between roadmap nodes. Modelled as an explicit edge table rather
// than a self-referencing array so the graph can be walked in both directions
// and edges can later carry a type.
model EpicTaskEdge {
  id        String   @id @default(cuid())
  fromId    String   // the dependent node
  toId      String   // the prerequisite node
  createdAt DateTime @default(now())

  from EpicTask @relation("EpicTaskEdgeFrom", fields: [fromId], references: [id], onDelete: Cascade)
  to   EpicTask @relation("EpicTaskEdgeTo",   fields: [toId],   references: [id], onDelete: Cascade)

  @@unique([fromId, toId])
  @@index([fromId])
  @@index([toId])
}

enum EpicTaskStatus {
  todo
  in_progress
  done
  cancelled
}

Cycle prevention in the edge graph is a service-layer check (see Deployment constraint): before creating an edge, walk to's transitive dependencies and reject if from is reachable.

Task

model Task {
  id           String   @id @default(cuid())
  name         String
  title        String
  description  String   @default("") @db.Text
  epicTaskId   String
  repoId       String?
  // The agent currently responsible. Null while unassigned.
  assigneeId   String?
  state        TaskState @default(todo)
  // Drives how much Tier-1 verification is engaged: trivial work may pass on a
  // light self-check; risky work engages QA / Security / SRE.
  risk         TaskRisk  @default(normal)
  // Git linkage, populated when the agent pushes.
  branch       String?
  headCommit   String?
  prUrl        String?
  prNumber     Int?
  // Free-form reviewer comment attached at the accept gate.
  reviewNote   String?  @db.Text
  ownerId      String
  version      Int      @default(1)
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt

  epicTask  EpicTask     @relation(fields: [epicTaskId], references: [id], onDelete: Cascade)
  repo      CodeRepo?    @relation(fields: [repoId],     references: [id], onDelete: SetNull)
  assignee  Agent?       @relation(fields: [assigneeId], references: [id], onDelete: SetNull)
  owner     User         @relation(fields: [ownerId],    references: [id], onDelete: Cascade)
  verdicts  TaskVerdict[]
  sessions  AgentSession[]
  events    TaskEvent[]

  @@unique([name, epicTaskId])
  @@index([epicTaskId])
  @@index([state])
  @@index([assigneeId])
}

enum TaskState {
  todo
  in_progress
  building          // compile, run, smoke
  self_testing      // developer's own tests
  qa_review
  security_review
  sre_review
  done              // leaves the developer's loop
  awaiting_review   // human accept gate
  merged
  reopened          // post-merge failure
  cancelled
}

enum TaskRisk {
  trivial   // docs, formatting — light self-check may suffice
  normal
  high      // auth, secrets, migrations, cross-contract
}

// A Tier-1 persona's verdict, pinned to the commit it judged. Immutable —
// this is the provenance record the design doc calls "QA verified 3f2ab".
model TaskVerdict {
  id        String   @id @default(cuid())
  taskId    String
  stage     TaskState        // qa_review | security_review | sre_review
  agentId   String?
  commit    String
  passed    Boolean
  summary   String   @db.Text
  // Structured findings: [{ severity, message, path?, line? }]
  findings  Json     @default("[]")
  createdAt DateTime @default(now())

  task  Task   @relation(fields: [taskId],  references: [id], onDelete: Cascade)
  agent Agent? @relation(fields: [agentId], references: [id], onDelete: SetNull)

  @@index([taskId])
  @@index([taskId, stage])
}

// Append-only transition log. Every state change writes one row.
model TaskEvent {
  id        String   @id @default(cuid())
  taskId    String
  fromState TaskState?
  toState   TaskState
  actorType String   // 'user' | 'agent' | 'system'
  actorId   String?
  note      String?  @db.Text
  createdAt DateTime @default(now())

  task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)

  @@index([taskId, createdAt])
}

CodeRepo

model CodeRepo {
  id            String   @id @default(cuid())
  name          String
  projectId     String
  // Clone URL. https for token auth, ssh for key auth.
  url           String
  defaultBranch String   @default("main")
  // Relative path under the session workspace root. Defaults to `name`.
  path          String   @default("")
  // Git provider, for PR/merge API calls. 'gitea' is the only driver in v1.
  provider      String   @default("gitea")
  // Secret holding the provider credential. Resolved through SecretService at
  // session-provision time — never stored in plaintext on the row.
  credSecretId  String?
  credSecretKey String?
  ownerId       String
  version       Int      @default(1)
  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt

  project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
  cred    Secret? @relation(fields: [credSecretId], references: [id])
  owner   User    @relation(fields: [ownerId], references: [id], onDelete: Cascade)
  tasks   Task[]

  @@unique([name, projectId])
  @@index([projectId])
}

AgentProject

Mirrors ProjectServer (schema.prisma): explicit join model with its own cuid id, createdAt, cascade on both sides, @@unique([a, b]). Indexes on both columns follow PersonalityPrompt, because both reverse lookups are needed ("agents of project", "projects of agent").

model AgentProject {
  id        String   @id @default(cuid())
  agentId   String
  projectId String
  // Per-project persona role. TEXT, not an enum, so adding a role later is a
  // non-migration change (same reasoning as ResourceProposal.resourceType).
  // v1 values: 'developer' | 'qa' | 'security' | 'sre'.
  role      String   @default("developer")
  // Optional per-membership brain override. Present so the open question in
  // docs/agentic-teams.md §15 Q1 can be answered either way without a migration.
  llmId     String?
  createdAt DateTime @default(now())

  agent   Agent   @relation(fields: [agentId],   references: [id], onDelete: Cascade)
  project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
  llm     Llm?    @relation(fields: [llmId],     references: [id], onDelete: SetNull)

  @@unique([agentId, projectId])
  @@index([agentId])
  @@index([projectId])
}

Agent.projectId is retained and written in sync with the junction for one release, so existing prepareContext / findByProjectId paths keep working. A follow-up removes it.

AgentSession

model AgentSession {
  id          String   @id @default(cuid())
  agentId     String
  projectId   String
  taskId      String?
  // Orchestrator handle — pod name on k8s, container id on docker.
  containerId String?
  state       SessionState @default(pending)
  image       String
  // Reaped when now() > expiresAt. Swept by the same style of GC loop as the
  // inference-task sweep in main.ts.
  expiresAt   DateTime
  lastError   String?  @db.Text
  ownerId     String
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
  endedAt     DateTime?

  agent   Agent   @relation(fields: [agentId],   references: [id], onDelete: Cascade)
  project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
  task    Task?   @relation(fields: [taskId],    references: [id], onDelete: SetNull)
  owner   User    @relation(fields: [ownerId],   references: [id], onDelete: Cascade)

  @@index([state])
  @@index([taskId])
  @@index([agentId])
}

enum SessionState {
  pending
  provisioning
  running
  ended
  failed
}

Back-relations on existing models

Prisma requires both sides of every relation. The blocks above show only the owning side; the following fields must be added to existing models or prisma generate fails:

Model Fields to add
Project epics Epic[], codeRepos CodeRepo[], agentMemberships AgentProject[], sessions AgentSession[]
User ownedEpics Epic[], ownedEpicTasks EpicTask[], ownedTasks Task[], ownedRepos CodeRepo[], ownedSessions AgentSession[]
Agent projectMemberships AgentProject[], assignedTasks Task[], verdicts TaskVerdict[], sessions AgentSession[]
Llm agentProjects AgentProject[]
Secret codeRepos CodeRepo[]

Agent.projectId and its project Project? relation stay as-is during the transition release, so Project carries both agents Agent[] (existing) and agentMemberships AgentProject[] (new).

Test helper

src/db/tests/helpers.ts clearAllTables must gain deleteMany calls for every new model — globals with nullable foreign keys do not cascade.

API Changes

All routes sit under /api/v1, follow the existing Fastify + zod-in-the-service pattern, and resolve project / agent name strings to ids at the route layer (as routes/skills.ts does).

New endpoints

# Vision layer
GET|POST       /api/v1/epics                       ?project=<name>
GET|PUT|DELETE /api/v1/epics/:id
GET            /api/v1/epics/:id/roadmap           → epictask graph + per-node completeness
POST           /api/v1/epics/:id/tradeoffs         → append an accepted-tradeoff record

GET|POST       /api/v1/epictasks                   ?epic=<name>&project=<name>
GET|PUT|DELETE /api/v1/epictasks/:id
POST|DELETE    /api/v1/epictasks/:id/edges         → { toId }  add/remove a dependency edge

# Work items
GET|POST       /api/v1/tasks                       ?project=&epictask=&state=&assignee=
GET|PUT|DELETE /api/v1/tasks/:id
POST           /api/v1/tasks/:id/transition        → { toState, note? }
POST           /api/v1/tasks/:id/verdict           → record a QA/Security/SRE verdict
POST           /api/v1/tasks/:id/accept            → { note? }  human accept gate → merge
POST           /api/v1/tasks/:id/reject            → { note }   → back to in_progress
GET            /api/v1/tasks/:id/events

# Repositories
GET|POST       /api/v1/repos                       ?project=<name>
GET|PUT|DELETE /api/v1/repos/:id

# Agent membership
GET            /api/v1/agents/:name/projects
POST|DELETE    /api/v1/agents/:name/projects/:project   → { role?, llm? }
GET            /api/v1/projects/:name/agents

# Sessions & exec
GET|POST       /api/v1/sessions                    ?project=&agent=&task=
GET|DELETE     /api/v1/sessions/:id
POST           /api/v1/sessions/:id/exec           → one-shot exec, returns { exitCode, stdout, stderr }
GET            /api/v1/sessions/:id/attach         → WebSocket, bidirectional stdio

# Git provider webhook
POST           /api/v1/webhooks/git/:provider      → post-merge failure reopens the task

Changed endpoints

/api/v1/inference-tasks/* is unchanged in shape. Only its RBAC resource name changes (see below).

RBAC Changes

The tasks namespace split

src/mcpd/src/validation/rbac-definition.schema.ts currently declares tasks in RBAC_RESOURCES and aliases task, inference-task, and inference-tasks all onto it — with the comment "the only 'task' we have is inference for now". That is no longer true.

Required changes:

  1. RBAC_RESOURCES gains epics, epictasks, tasks (now meaning work items), repos, sessions, and inference-tasks.
  2. RESOURCE_ALIASES maps task → tasks, epic → epics, epictask → epictasks, et → epictasks, repo → repos, repository → repos, repositories → repos, session → sessions, and inference-task → inference-tasks. The entry 'inference-tasks': 'tasks' is removed.
  3. mapUrlToPermission in src/mcpd/src/main.ts gains epics, epictasks, tasks, repos, sessions, and maps /api/v1/inference-tasks to inference-tasks. Non-CRUD verbs need explicit cases above the map: POST /tasks/:id/accept|reject|transition → run:tasks:<name>, POST /sessions/:id/exec and GET /sessions/:id/attach → run:sessions:<name>.
  4. nameResolvers (main.ts) gains epics, epictasks, tasks, repos, sessions so name-scoped grants match when the URL carries a cuid.

One-shot binding backfill

ResourceBindingSchema transforms resource through normalizeResource at parse time and stores the normalized string, so existing RbacDefinition.roleBindings rows already contain the literal "tasks" meaning inference. After the split, those grants would silently become work-item grants.

A one-shot startup migration must rewrite resource: "tasks" → "inference-tasks" across all RbacDefinition rows. Model the walk on migrateAdminRole in src/mcpd/src/main.ts (it iterates rbacRepo.findAll() and rewrites roleBindings in place).

It must be guarded so it runs exactly once — a blanket rewrite on every boot would clobber legitimate new work-item grants. Use the existing system-settings pattern: a boolean key in the mcpctl-system-settings Secret (SYSTEM_SETTINGS_SECRET in src/mcpd/src/bootstrap/system-settings.ts), read before the walk and written after. Wildcard bindings (resource: '*') are untouched — they already cover both.

Document the rename in docs/inference-tasks.md.

CLI Changes

Two project rules govern every resource added here:

  • Everything settable via apply -f must also be a create flag.
  • mcpctl get RESOURCE NAME -o yaml must round-trip through mcpctl apply -f.

New commands

mcpctl get epics|epictasks|tasks|repos|sessions [--project NAME]
mcpctl describe epic|epictask|task|repo|session NAME
mcpctl create epic|epictask|task|repo ...
mcpctl edit epic|epictask|task|repo NAME
mcpctl delete epic|epictask|task|repo NAME

mcpctl describe epic NAME            # roadmap: epictask graph + per-node completeness
mcpctl tasks accept NAME [--note ..] # human accept gate → merge via provider API
mcpctl tasks reject NAME --note ..   # → back to in_progress
mcpctl exec --project NAME AGENT -- bash

mcpctl exec semantics (design doc §10): exec drops the human into the working environment — the cloned repos, branches, tools, and deps. It does not attach to the agent's reasoning loop. The agent remains a separate process the human can still talk to.

Files to touch

Concern File
Resource aliases src/cli/src/commands/shared.ts RESOURCE_ALIASES
get columns + yaml kind src/cli/src/commands/get.ts — row interfaces, getColumnsForResource, RESOURCE_KIND, toApplyDocs strip blocks
describe formatters src/cli/src/commands/describe.ts — clean sections and tables, not key-value dumps
create subcommands src/cli/src/commands/create.ts
apply -f src/cli/src/commands/apply.ts — spec schemas, ApplyConfigSchema keys, KIND_TO_RESOURCE, dry-run counts, apply loops
edit src/cli/src/commands/edit.ts validResources
Scoped fetch src/cli/src/index.ts fetchResource / fetchSingleResource for --project scoping
Completions scripts/generate-completions.ts CANONICAL_RESOURCES + ALIAS_ENTRIES, then pnpm completions:generate

Completions are generated, never hand-edited; src/cli/tests/completions.test.ts runs the generator with --check and fails on drift.

Execution Substrate Changes

These are the primitives the design document assumes but mcpctl does not have. They gate step 2.

1. Ephemeral pods

ContainerSpec in src/mcpd/src/services/orchestrator.ts has only image, name, command, env, ports, limits, labels, and network. generatePodSpec in src/mcpd/src/services/k8s/manifest-generator.ts hardcodes restartPolicy: 'Always', and no code anywhere uses the Kubernetes Job API.

ContainerSpec must gain:

  • restartPolicy?: 'Always' | 'Never' | 'OnFailure'
  • workdir?: string
  • volumes?: Array<{ name: string; mountPath: string; kind: 'emptyDir' }> — the session workspace
  • initContainers?: Array<{ name; image; command; env?; volumeMounts? }> — the repo-clone step

Both backends implement them: KubernetesOrchestrator and DockerContainerManager.

2. Session provisioning

A SessionService that: creates the AgentSession row; resolves the project's CodeRepo list and their credentials through SecretService (resolution failure is fatal for the attempt, matching InstanceService.attemptStart); builds a spec with an init container that clones each repo to its path under the workspace volume and checks out the task branch; starts the pod; and transitions the row to running.

Reaping runs as a GC sweep in the same style as the existing inference-task sweep in src/mcpd/src/main.ts: sessions past expiresAt are stopped, removed, and marked ended. Note these loops assume a single mcpd replica — there is no leader election, and this inherits that.

3. Exec surface

The hard part already exists. McpOrchestrator declares execInContainer, execInteractive, and attachInteractive; KubernetesOrchestrator implements the latter two over k8s.Exec and k8s.Attach, and src/mcpd/src/services/transport/persistent-stdio.ts uses them in production today. This work is an API and CLI surface over a proven primitive: a one-shot exec route, a WebSocket attach route with terminal resize and proper exit-code propagation, and the mcpctl exec client.

4. Git provider client

No Gitea or GitHub API client, PR creation, or webhook handling exists in product code. PRs are made today by the root-level pr.sh (curl against the Gitea API) or by an agent calling the gitea-mcp-server MCP tool.

A GitProvider interface in mcpd with a Gitea driver: createPullRequest, getPullRequest, getCombinedStatus, mergePullRequest. Credentials resolve from the CodeRepo's Secret. pr.sh is the reference for the endpoint shapes (POST /api/v1/repos/:owner/:repo/pulls). A webhook receiver validates the provider signature and reopens the task on post-merge failure.

AgentRuntime Interface

mcpctl builds an interface, not a runtime. ChatService is not a candidate — its tool loop is capped at MAX_ITERATIONS = 12 (clamped 1..50), which is a soft-DoS guard appropriate for chat and far too small for a coding task.

export interface AgentRuntime {
  startSession(opts: RuntimeSessionOpts): Promise<RuntimeSession>;
  send(sessionId: string, message: string): Promise<void>;
  status(sessionId: string): Promise<RuntimeStatus>;
  result(sessionId: string): Promise<RuntimeResult>;   // branch, head commit, summary
  endSession(sessionId: string): Promise<void>;
}

Implementation #1 runs a battle-tested headless coding agent inside the session pod. It already solves per-tool permission approval, git and branch mechanics, subagents, streaming, and MCP client behaviour — all things the design document explicitly declines to rebuild. The persona is delivered as prompt files in the container, which suits a file-aware coding runtime.

A second adapter is explicitly deferred; the interface exists so it stays possible.

Task Lifecycle

todo → in_progress → building → self_testing
         → qa_review        (feedback → in_progress)
         → security_review  (feedback → in_progress)
         → sre_review       (feedback → in_progress)
         → done → awaiting_review
                    accept → merged
                    reject → in_progress
       merged --post-merge failure (webhook)--> reopened

Verification effort scales with Task.risk: trivial work may pass on a light self-check; high engages all three review stages. The stages decide their own depth.

Transitions are guarded by a table of legal (from, to) pairs and applied as compare-and-swap — updateMany with the expected state in the where clause, then check the affected-row count. This is the same idiom InferenceTaskRepository uses so two racing claimants cannot both win. Every transition appends a TaskEvent.

The human approval gate is not a per-tool tri-state. It is a task-level yes/no plus a comment on the resolved outcome, and accepting it fires the merge. The human should not have to go to git.

Testing Strategy

Unit — src/db/tests/

  • Schema tests per model (pattern: skill-schema.test.ts): create, unique constraints, cascade behaviour on project/epic/task delete, AgentProject uniqueness.
  • clearAllTables in helpers.ts covers every new model.

Unit — src/mcpd/tests/

  • Service tests: Epic done-criteria roll-up; EpicTask edge cycle rejection; task transition table including every illegal pair; compare-and-swap under concurrent transition.
  • Route tests per resource (pattern: prompt-routes.test.ts, agent-routes.test.ts).
  • RBAC: the tasks → inference-tasks backfill runs once and is idempotent on a second boot; a pre-existing view:tasks grant still authorizes /api/v1/inference-tasks afterwards and does not authorize /api/v1/tasks; name-scoped grants resolve for all new resources.
  • Orchestrator: generatePodSpec emits restartPolicy, workdir, volumes, and init containers.
  • Git provider: Gitea driver against a mocked HTTP server.

Unit — src/cli/tests/

  • get -o yaml → apply -f round-trip for every new resource.
  • create flag coverage: every field settable in apply has a matching flag.
  • completions.test.ts assertions for the new resource words.

Smoke — src/mcplocal/tests/smoke/

Every feature ships smoke tests. Follow the CRUD pattern in personality.smoke.test.ts: a Date.now().toString(36) name suffix, a healthz() preflight that skips rather than fails when the daemon is unreachable, mcpctl --direct shelling, and explicit afterAll cleanup.

  • epic.smoke.test.ts — Epic → EpicTask → Task create/list/describe/delete against live mcpd.
  • repo.smoke.test.ts — CodeRepo CRUD, credential reference resolves.
  • session.smoke.test.ts — provision a session pod against the real cluster, confirm the repo is cloned, exec a command, confirm the pod is reaped.
  • task-lifecycle.smoke.test.ts — drive a task through the full transition chain including a rejected accept-gate bounce.

When a smoke test fails, fix the environment (deploy, backend, RBAC) — never weaken the assertion.

Deferred

Named here so the boundary is explicit. Each gets a single placeholder task with no detail, to be expanded once the corresponding open questions in docs/agentic-teams.md §15 are closed.

  • Tier-2 vision critic and Epic-comparison. A review-only, non-executing design/product persona that judges the assembled result against the Epic in its native medium, producing reopen / new-task / exec outcomes. Blocked on §15 Q13 (does this need render/screenshot infrastructure, or can a design-aware persona judge from a structured artifact?).
  • Collaboration modes. On-demand async standup (mcpctl standup --project NAME) and exec as a workflow rather than a command. Blocked on §15 Q4 and Q5.
  • Confidence signal and escalation dashboard. Evidence-anchored confidence (retrieve K similar past tasks with their outcomes, ask how confident we are this resolves cleanly), thresholds driving autonomy, and a single cross-project human decision queue. Blocked on §15 Q12 (web / TUI / CLI?). Retrieval quality is the crux — weak retrieval quietly poisons the model.
  • Memory and the coverage ledger. Global per-agent memory plus per-project knowledge, and a commit-pinned coverage ledger enabling delta-verification. Non-negotiable prerequisite: invalidation when a covered region changes, or the ledger becomes false confidence. Keep the trust boundary — the ledger is platform-verified machine fact; agent prose notes are opinion and never the gate.
  • Script artifacts and the baseline scorecard. Reusable executable script artifacts at task, project, and agent scope, plus a provisioned baseline-rules scorecard. Blocked on §15 Q15 and Q16. Watch the incentive integrity risk: reward reused tools, not tool proliferation.

Implementation Phases

Phase A — foundation (blocks everything)

  1. RBAC tasks namespace split, alias repoint, and the one-shot guarded binding backfill.
  2. Prisma models Epic, EpicTask, EpicTaskEdge, Task, TaskVerdict, TaskEvent, CodeRepo, AgentSession + enums + migration + clearAllTables.
  3. AgentProject junction, with Agent.projectId kept in sync for one release.

Phase B — mcpd CRUD 4. Epic resource: repository, service, zod validation, routes, main.ts wiring, revision opt-in. 5. EpicTask resource plus the edge graph and cycle rejection. 6. Task resource plus the transition table and compare-and-swap transitions. 7. CodeRepo resource with credential resolution. 8. RBAC wiring for all new resources: mapUrlToPermission, nameResolvers, non-CRUD verb cases. 9. Backup and YAML kinds for the new resources — BACKUP_KINDS, APPLY_ORDER, kindMap, the export loop, serializeResource, and main.ts kindFromSegment / importResource / deleteResource. Fix the pre-existing gap where skill is missing from the latter two.

Phase C — CLI 10. get columns and describe formatters for all new resources. 11. create, apply -f, edit, delete, with the yaml round-trip test. 12. Shell completions and their guard test. 13. Roadmap view: describe epic rendering the epictask graph with per-node completeness.

Phase D — execution substrate 14. Extend ContainerSpec and both orchestrator backends with restart policy, workdir, volumes, and init containers. 15. SessionService: provisioning, repo-clone init container, credential resolution, TTL and GC. 16. AgentRuntime interface plus adapter #1 running a headless coding agent in-pod. 17. Exec surface: one-shot exec route, WebSocket attach, and mcpctl exec.

Phase E — lifecycle and PR 18. Task lifecycle state machine, transition API, and TaskEvent log. 19. GitProvider interface plus the Gitea driver. 20. PR linkage and the human accept gate firing merge through the provider API. 21. QA / Security / SRE stage verdicts, risk-scaled, pinned to a commit. 22. Post-merge failure webhook reopening the task.

Phase F — cross-cutting 23. Documentation: docs/epics.md, docs/tasks.md, docs/repos.md, docs/sessions.md, the README resources table, and the CLAUDE.md resource cheatsheet. 24. Smoke tests for every new resource plus the session and lifecycle flows.

Phase G — deferred placeholders 25. Tier-2 vision critic and Epic-comparison loop. 26. Collaboration modes: async standup and exec joint sessions. 27. Confidence signal and escalation dashboard. 28. Per-agent memory and the coverage ledger. 29. Script artifacts and the baseline scorecard.