feat(office): autonomous agent management layer (#914)

This commit is contained in:
Carlos Florêncio
2026-05-16 20:07:57 +01:00
committed by GitHub
parent 4a517427ba
commit 0b2085688b
1433 changed files with 170986 additions and 3303 deletions
+123
View File
@@ -0,0 +1,123 @@
---
name: pr-poller
description: Poll a GitHub PR until CI checks and automated bot reviews (CodeRabbit, Greptile, Claude, cubic) reach terminal state, then return a compact structured report. Use as the polling/state-gathering half of a PR-fixup loop — the parent agent does the actual code fixes and comment replies.
tools: Bash
model: sonnet
---
# PR Poller
Pure-polling subagent. Burn the bash output here so the parent's context stays clean. Do NOT edit code, push commits, or reply to comments — those are the parent's job.
## Inputs
The parent will tell you the PR number (or rely on `gh pr view` against the current branch). If neither is available, return a report with `error=...` and stop.
## Output contract — print exactly this shape and nothing else
```
=== pr-poller report ===
pr=<number> branch=<name>
ci_failed:
- name=<check_name> run_id=<id> conclusion=<failure|cancelled|timed_out> url=<details_url>
- … (omit the entire ci_failed: line if none)
ci_passed: <count>
ci_pending: <comma-separated names, or "none">
bots:
coderabbit: <done|rate_limited|pending|timeout> comments=<N>
greptile: <done|pending|timeout> reviews=<N>
claude: <done|pending|timeout> reviews=<N> path=<app|fork|none>
cubic: <done|pending|timeout> reviews=<N>
unresolved_review_threads: <N>
issue_comments_from_bots: <N>
recommendation: <one sentence — what the parent should do next>
=== end ===
```
Free-form notes are forbidden outside the markers. The parent parses this verbatim. If something unexpected happens, surface it through `recommendation:` (one sentence).
## Procedure
1. **Resolve PR.** `gh pr view --json number,url,headRefName,baseRefName` (or `gh pr view <num> --json …` if the parent passed a number). Capture `number` and `headRefName`.
2. **Wrap any heavy `gh` call with `scripts/run-quiet`** so its raw output does not enter your own context. You only care about the parsed result:
```bash
scripts/run-quiet gh-checks -- gh pr checks <num>
```
For JSON queries use `--jq` directly; those are short and can run unwrapped. `gh run view --log-failed` is the big one to wrap — but the parent uses that, not you.
3. **Poll loop, 30 s cadence, 20 min cap (40 rounds).** Each round, in parallel:
a. **CI status:**
```bash
gh pr checks <num> --json name,status,conclusion,detailsUrl
```
- `status` ∈ `{queued, in_progress, completed}`
- `completed` + `conclusion=success` → passing
- `completed` + `conclusion∈{failure,cancelled,timed_out}` → failed
- anything else → pending
b. **Bot reviews** (terminal conditions):
- **CodeRabbit** (`coderabbitai[bot]`, posts issue comments):
```bash
gh pr view <num> --json comments --jq '.comments[] | select(.author.login == "coderabbitai")'
```
- `done` if any comment body contains `<!-- walkthrough_start -->`
- `rate_limited` if any comment body contains `<!-- rate limited by coderabbit.ai -->`
- else `pending`
- **Greptile** (`greptile-apps[bot]`, posts via reviews API):
```bash
gh api repos/:owner/:repo/pulls/<num>/reviews --jq '.[] | select(.user.login == "greptile-apps[bot]")'
```
- `done` if any matching review exists, else `pending`
- **Claude** — two delivery paths, accept either:
- same-repo: `gh api repos/:owner/:repo/pulls/<num>/reviews --jq '.[] | select(.user.login == "claude[bot]")'` → `done`, `path=app`
- fork: `gh pr view <num> --json comments --jq '.comments[] | select(.author.login == "github-actions" and ((.body | startswith("**Claude finished ")) or (.body | startswith("## Code Review"))))'` → `done`, `path=fork`
- also stop waiting if `gh pr checks` shows the `claude-review` check completed (any conclusion) → use whichever signal arrives first
- else `pending`, `path=none`
- **cubic** (`cubic-dev-ai[bot]`):
```bash
gh api repos/:owner/:repo/pulls/<num>/reviews --jq '.[] | select(.user.login == "cubic-dev-ai[bot]")'
```
- `done` if a matching review exists OR if `gh pr checks` shows the `cubic · AI code reviewer` check completed
- else `pending`
c. **Exit conditions:**
- All CI checks completed AND every bot is in a terminal state (`done` / `rate_limited` / `timeout`) → exit loop.
- Round 40 reached (≈20 min) → mark any still-pending CI checks under `ci_pending:` and any still-pending bots as `timeout`, then exit loop.
4. **Count unresolved review threads** via GraphQL (single call, not per-round):
```bash
gh api graphql -f query='
query($owner:String!,$repo:String!,$num:Int!){
repository(owner:$owner,name:$repo){
pullRequest(number:$num){
reviewThreads(first:100){ nodes { isResolved } }
}
}
}' -f owner=":owner" -f repo=":repo" -F num=<num> --jq '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false)] | length'
```
5. **Count bot issue comments** (CodeRabbit walkthrough, fork-Claude findings, etc.):
```bash
gh pr view <num> --json comments --jq '[.comments[] | select(.author.login | IN("coderabbitai","github-actions"))] | length'
```
6. **Emit the report.** Fill in the shape above exactly. The `recommendation:` line is one short sentence chosen from this menu, picking the first that applies:
- `"CI failed — parent should read failing logs and fix."` if `ci_failed` is non-empty
- `"All checks green; parent should triage <N> unresolved review threads."` if `unresolved_review_threads > 0`
- `"All checks green and no unresolved comments — parent may close out."` otherwise
- `"Polling timed out with pending items; parent should decide whether to keep waiting."` if any axis hit the cap
## What you do NOT do
- Read source code or edit files (no `Read` / `Edit` / `Write` tools — you only have `Bash`)
- Reply to comments, react with 👍, or resolve threads
- Push commits or trigger workflows
- Fetch full CI logs (`gh run view --log-failed`) — that's the parent's job, on demand, per failed run
Your single deliverable is the report block. Return it and exit.
+9 -5
View File
@@ -34,12 +34,16 @@ Run the full verification pipeline for the monorepo, then fix any issues found.
- `make -C apps/backend fmt`
- `cd apps && pnpm format`
3. **Verify in parallel** where possible:
- `make -C apps/backend test lint`
- `cd apps && pnpm --filter @kandev/web typecheck && pnpm --filter @kandev/web lint`
3. **Verify** — always wrap heavy commands with `scripts/run-quiet <tag> -- <cmd>`. The wrapper redirects all output to a unique `/tmp/kandev-run.<tag>.<random>.log`, prints `exit=0 log=...` on success or an auto-grepped failure summary on error, and exits with the underlying command's exit code. Random suffix ⇒ safe under parallel invocations.
```bash
scripts/run-quiet backend-test -- make -C apps/backend test lint
scripts/run-quiet web-lint -- bash -c 'cd apps && pnpm --filter @kandev/web lint'
```
- If both print `exit=0`: done, nothing more to read.
- If non-zero: the failure summary is already in your context. `Read` only specific line ranges of the printed log path if you need more context — never `cat` the whole log.
4. **Fix issues** — do NOT just report them:
- Read each failing file at the reported line number
- Read each failing file at the reported line number (use the `Read` tool with `offset`/`limit`, not `cat` of the whole file)
- Fix the root cause (don't suppress warnings or add ignores)
- Common fixes:
- **Type errors**: fix the type, add a missing import, or correct the function signature
@@ -49,7 +53,7 @@ Run the full verification pipeline for the monorepo, then fix any issues found.
- **Lint — unused imports**: remove them
- **Lint — duplicate strings**: extract to a constant
- **Test failures**: read the test, understand the assertion, fix the code (not the test) unless the test is outdated
- After fixing, re-run only the failed command to confirm the fix
- After fixing, re-run **only** the failed command via `scripts/run-quiet` to confirm the fix. Do not re-run the whole pipeline on every iteration.
5. **Repeat** steps 3-4 until all commands pass. If a fix introduces new issues, address those too.
+30
View File
@@ -0,0 +1,30 @@
---
name: add-integration
description: Add a new third-party integration (Jira/Linear-style) — per-workspace credentials, 90s auth-health poller, settings page, link/import buttons. Use when scaffolding a new external service integration.
---
# Adding a new third-party integration
Jira and Linear are the model: per-workspace credentials, a 90s auth-health poller, a settings page with status banner + reconnect CTA, link/import buttons that gate on availability. New integrations should **reuse the shared shapes** rather than copying either.
## Backend (`apps/backend/internal/<name>/`)
- Mirror the package layout: `service.go`, `store.go`, `client.go`, `provider.go`, `handlers.go`, `models.go`, `poller.go`. Expose `Provide(writer, reader *sqlx.DB, secrets SecretStore, eventBus bus.EventBus, log *logger.Logger) (*Service, func() error, error)`. Pass `nil` for `eventBus` when the integration doesn't publish events; both Jira and Linear take and use it for issue-watch publishing.
- Use `internal/integrations/secretadapter` instead of writing your own upsert wrapper around `secrets.SecretStore`. The adapter satisfies any per-integration `SecretStore` interface shaped as `{Reveal, Set, Delete, Exists}`.
- Use `internal/integrations/healthpoll` for the auth-health loop. Implement the `Prober` interface (`ListConfiguredWorkspaces` + `RecordAuthHealth`) on a small adapter and let `healthpoll.New("name", prober, log)` own Start/Stop/ticker. Keep integration-specific loops (JQL polling, webhook reconciliation, etc.) separate, like jira's issue-watch loop.
- Wire the service via a per-domain `init<Name>Service(...)` helper in `cmd/kandev/services.go`, not inline in `provideServices`.
- Ship a `mock_client.go` + `mock_controller.go` next to the real client. `Provide` branches on `KANDEV_MOCK_<NAME>=true` and returns the in-memory client; `RegisterMockRoutes(router, svc, log)` mounts `/api/v1/<name>/mock/*` only when the service was built with the mock. The e2e backend fixture sets the env var so Playwright tests drive the mock via `apiClient.mock<Name>*()` helpers — see jira/linear for the layout.
## Frontend
- Hooks live under `hooks/domains/<name>/`, **not** `components/<name>/`.
- Use `hooks/domains/integrations/use-integration-availability.ts` and `use-integration-enabled.ts` — each integration's `useXAvailable` / `useXEnabled` should be a one-line wrapper passing the storage key + sync event + config-fetch function.
- Settings page reuses `<IntegrationAuthStatusBanner>` (`components/integrations/auth-status-banner.tsx`).
- "Auth required / reconnect" UI reuses `<IntegrationAuthErrorMessage>` (`components/integrations/auth-error-message.tsx`) — supply the integration's display name, regex check, and reconnect href.
- Link / import popovers reuse `<ValidatedPopover>` (`components/integrations/validated-popover.tsx`) — supply the icon, label, key regex, fetch function, and success callback.
## Where Jira and Linear deliberately diverge
- **Issue model:** Jira uses transitions + JQL; Linear uses state IDs + structured filters. Don't merge these schemas — the upstream APIs are genuinely different.
- **Watch filter persistence:** Jira stores the JQL string verbatim; Linear stores the structured `SearchFilter` as JSON in `filter_json` (Linear has no JQL equivalent). The orchestrator emits `NewJiraIssueEvent` / `NewLinearIssueEvent` respectively and dedups by issue key (Jira) vs identifier (Linear).
- **Health column extras:** Linear's `linear_configs` row carries an `org_slug` captured from successful probes; Jira's row does not.
+17 -7
View File
@@ -54,20 +54,30 @@ ci: add PR title linting workflow
1. **Understand changes:** Run `git status` and `git diff` to understand all changes. Review recent commits with `git log --oneline -10` to match project style.
2. **Check pre-commit hooks:** Run:
2. **Ensure pre-commit hooks are wired up.** This must work in worktrees too, where `.git/` is a file (not a directory) and the real hooks path is shared with the main repo via `core.hooksPath`. Use `git rev-parse --git-path` so the check resolves correctly regardless:
```bash
pre-commit --version 2>/dev/null && echo "INSTALLED" || echo "NOT_INSTALLED"
# Is the framework on PATH?
pre-commit --version >/dev/null 2>&1 && echo "INSTALLED" || echo "NOT_INSTALLED"
# Is the hook actually wired into git's hook system?
HOOK_PATH=$(git rev-parse --git-path hooks/pre-commit)
test -f "$HOOK_PATH" && grep -q "pre-commit" "$HOOK_PATH" && echo "ACTIVE" || echo "INACTIVE"
```
- If **not installed**, warn the user: _"⚠️ pre-commit is not installed. Install it with `pip install pre-commit && pre-commit install` for automatic formatting and lint checks on every commit."_
- If installed, check hooks are active:
- If **NOT_INSTALLED**, tell the user once: _"⚠️ pre-commit is not on PATH. Install it with `pip install pre-commit` so format/lint runs on every commit."_ Then continue (don't block).
- If installed but **INACTIVE**, **install it yourself** — the project ships `.pre-commit-config.yaml` and `make doctor` is a no-op-on-already-installed wrapper around the same command:
```bash
test -f .git/hooks/pre-commit && grep -q "pre-commit" .git/hooks/pre-commit && echo "ACTIVE" || echo "INACTIVE"
pre-commit install -t pre-commit -t commit-msg --overwrite
```
- If **inactive**, warn the user: _"⚠️ pre-commit hooks are not active. Run `pre-commit install` to enable them."_
- **Do not block** — continue with the remaining steps regardless.
Mention that you wired it up. Subsequent commits will run hooks automatically.
- If both checks pass, no output needed.
Why this matters: a missing hook lets lint regressions slip past local commits and only surface in CI (e.g. funlen / cognitive complexity on backend Go code). The hook catches them in <1s at commit time. See `Makefile`'s `doctor` target for the idempotent install command.
3. **Run verify (MANDATORY — do NOT skip):** Delegate to the `verify` subagent to run the full verification pipeline (rebase, format, typecheck, test, lint). It will fix any issues it finds. **Wait for it to complete before proceeding.** Do NOT proceed to step 4 until verify passes. If verify cannot fix the failures, stop and surface the errors to the user — do not commit. Do NOT substitute this with a partial check (e.g. running only the changed package's tests).
4. **Stage files:** Stage relevant files (prefer specific files over `git add -A`).
- **Splitting commits with new files:** When introducing a brand-new file alongside the file that uses it, stage them together. The Go lint pre-commit hook stashes *unstaged* changes before linting but keeps *untracked* files in the working tree — so a new helper committed alone, while its (still-unstaged) caller sits in the working tree, lints as `unused` and rejects the commit.
5. **Commit:** Write a commit message following the format above. If changes span multiple concerns, consider separate commits.
+117
View File
@@ -0,0 +1,117 @@
---
name: debug-instance
description: Debug a running kandev development instance. Use when the user reports a bug, unexpected behavior, or asks to investigate an issue while kandev is running via `make dev`. Fetches backend logs, analyzes errors, and optionally inspects the UI via an existing Playwright browser session.
allowed-tools: Bash(curl:*) Bash(jq:*) Bash(playwright-cli:*)
---
# Debug Running Kandev Instance
Diagnose issues in a running kandev development instance by fetching structured logs and optionally inspecting the UI via an existing Playwright browser session.
## Immediately: Create Task Pipeline
As soon as this skill is invoked, create a task list to track progress:
1. **Detect running instance** — probe health endpoint, determine ports
2. **Fetch & analyze backend logs** — get debug export, summarize errors
3. **Browser inspection** (if UI-related) — check for existing playwright session, inspect DOM/console
4. **Diagnose & report** — correlate findings, present root cause hypothesis
Mark each task as `in_progress` when starting and `completed` when done. Skip task 3 if the issue is purely backend.
---
## Phase 1 — Detect Running Instance
1. Probe the default backend port:
```bash
curl -sf http://localhost:38429/api/v1/system/health
```
2. If that fails, try ports 38430–38435 (kandev auto-assigns nearby ports if default is busy).
3. If still not found, ask the user which port kandev is running on.
4. Derive the web port: typically `backend_port - 1000` (e.g., 38429 → 37429).
## Phase 2 — Fetch & Analyze Logs
**IMPORTANT**: The debug export returns raw JSON. If output appears schema-converted (types instead of values), the response is being compressed by a proxy tool — use `curl` directly or check tool configuration.
1. Fetch the debug export:
```bash
curl -s http://localhost:<backend_port>/api/v1/system/debug/export | jq .
```
2. Summarize findings:
- **Metadata**: uptime, version, goroutine count, memory usage
- **Errors**: count of error-level entries, most recent errors with stack traces
- **Warnings**: count and notable patterns
3. Filter by level if needed:
```bash
curl -s "http://localhost:<backend_port>/api/v1/system/debug/export?level=error" | jq .logs
```
4. Correlate errors with the user's reported issue.
## Phase 3 — Browser Interaction (conditional)
Only enter this phase if:
- The issue is UI-related
- The user explicitly asks to inspect the browser
- Log analysis alone is insufficient
### Check for existing Playwright session
```bash
playwright-cli list
```
- **If a session exists**: use it directly. Do NOT open a new browser.
- **If no session exists** and browser inspection is needed:
```bash
playwright-cli open http://localhost:<web_port> --headed
```
### Browser debugging commands
```bash
# Page state
playwright-cli snapshot
# Console logs (frontend errors)
playwright-cli console
playwright-cli console error
# Network activity
playwright-cli network
# Execute JS to extract frontend log buffer
playwright-cli eval "JSON.stringify(window.__kandevLogBuffer?.snapshot?.() ?? [])"
# Navigate to a specific page
playwright-cli goto http://localhost:<web_port>/some/path
# Inspect specific elements
playwright-cli snapshot "#main"
playwright-cli snapshot --depth=4
```
### Correlate frontend + backend
After gathering browser console errors or frontend log buffer entries, cross-reference with backend error timestamps to identify the full request lifecycle.
## Phase 4 — Diagnose & Report
1. Present a structured summary:
- **Issue**: what was reported
- **Evidence**: relevant log entries, console errors, DOM state
- **Root cause hypothesis**: most likely cause based on evidence
- **Suggested fix**: what code to change (with file paths)
2. If the fix is clear, offer to implement it (delegate to `/fix` or `/tdd`).
---
## Rules
- **Always probe health first** — never assume ports.
- **Always check `playwright-cli list` before opening a browser** — reuse existing sessions.
- **Present log summary before browser inspection** — logs are faster and often sufficient.
- **Don't leave browsers open** — if you opened one, close it when done (unless user wants to keep it).
- **Filter aggressively** — 2000 log entries is a lot; use `?level=error` or grep for keywords.
- **Create the task pipeline immediately** — track progress from the start.
+14 -5
View File
@@ -21,9 +21,11 @@ Write E2E tests using TDD (Red-Green-Refactor). Always run the tests you create
apps/web/e2e/
├── fixtures/
│ ├── backend.ts # Worker-scoped backend + frontend process
│ └── test-base.ts # Extended fixture (apiClient, seedData, testPage)
│ ├── test-base.ts # Extended fixture (apiClient, seedData, testPage)
│ └── office-fixture.ts # Office fixtures (officeApi, officeSeed with workspace+agent)
├── helpers/
│ └── api-client.ts # HTTP client for seeding data (read for available methods)
│ ├── api-client.ts # HTTP client for seeding data (read for available methods)
│ └── office-api-client.ts # Office-specific API client (onboarding, issues, agents)
├── pages/ # Page objects (read for available pages and methods)
└── tests/ # Spec files (*.spec.ts), grouped by feature
├── task/ # Task creation, deletion, archiving, environment, subtasks
@@ -50,7 +52,13 @@ cd apps && pnpm --filter @kandev/web e2e -- tests/task/my-test.spec.ts # single
cd apps && pnpm --filter @kandev/web e2e -- --grep "task creation" # by name
```
Prerequisites: `make build-backend build-web` (Make targets do this automatically).
**CRITICAL: E2E tests run against the production build** (`.next/standalone/`), not dev mode. After any frontend code change, you **must** rebuild before running tests:
```bash
make build-web # ~30s, required after every frontend change
```
Without this, tests run against stale code and failures are misleading. `make build-backend` is also required after Go changes. `make test-e2e` handles both automatically.
## Writing a test
@@ -194,8 +202,9 @@ Tests are grouped by feature area in subdirectories under `tests/`. When creatin
When a test fails:
1. **Read the error output** — the Playwright error message, expected vs. actual, and which locator timed out
2. **Read the failure screenshot** from `e2e/test-results/` — see what the page actually rendered
3. **Attach to the failure** for deeper debugging using `playwright-cli`:
2. **Read `error-context.md`** from `test-results/<test-name>/` — contains a YAML DOM snapshot showing exactly what was rendered. Search for expected elements, check if the page is in the right state (e.g., simple mode vs advanced mode)
3. **Read the failure screenshot** from `e2e/test-results/` — see what the page actually rendered
4. **Attach to the failure** for deeper debugging using `playwright-cli`:
```bash
cd apps && PLAYWRIGHT_HTML_OPEN=never pnpm --filter @kandev/web e2e -- tests/path.spec.ts --debug=cli &
# Wait for "Debugging Instructions" with session name
+29 -5
View File
@@ -14,6 +14,17 @@ Systematic bug fixing: reproduce the problem, find the root cause, apply a minim
- **`/verify`** — Run after fixing to ensure nothing else broke.
- **`/record`** — Record architectural decisions or insights discovered during the fix.
## What a fix produces (and what it doesn't)
The artifacts for a bug fix are:
- A regression test that fails before the fix and passes after.
- The minimal code change.
- A clear commit message capturing the root cause.
- **An ADR (via `/record decision`) IF the fix encoded a new project-wide convention** (e.g., "GC code must fail-closed", "all repo deletes must be transactional"). Most fixes don't need one.
- **An update to the related feature spec IF the bug exposed a requirement gap** (see Phase 5).
A bug fix does **not** produce a spec. Specs describe product features; bugs are corrections to existing features and are tracked in tests + commits.
---
## Before anything else: create the pipeline
@@ -24,7 +35,7 @@ Create these tasks immediately (use your task/todo tracking tool if available):
2. **Find the root cause** — Trace the code path, narrow the scope, state the cause clearly
3. **Fix with TDD** — Minimal fix with regression test, no surrounding refactors
4. **Verify** — Run full verification, check for similar patterns elsewhere
5. **Record** — Save any architectural decisions or insights for future sessions
5. **Record** — Save any architectural decisions or insights, AND update the related feature spec if the bug exposed a requirement gap
Then start with task 1. Mark each task in_progress when you begin it and completed when you finish it. Do not skip ahead — fixing without reproducing leads to patches that don't address the real problem. Fixing without understanding the root cause leads to whack-a-mole.
@@ -100,11 +111,24 @@ Mark task 4 as completed.
Mark task 5 as in_progress.
Check if the fix revealed any insights worth recording for future sessions:
Two questions, in order:
1. If the root cause exposed an architectural gap or non-obvious constraint, run `/record decision`
2. If you discovered a pattern that would help debug similar issues, save it
3. If nothing worth recording, skip this phase
**1. Did the fix expose a requirement gap in an existing spec?**
A bug can mean the spec was wrong, ambiguous, or silent about the scenario that broke. Ask: "If someone re-implemented this feature from the spec alone, would they reproduce this bug?" If yes, the spec is incomplete.
- Find the related spec under `docs/specs/<slug>/spec.md` (check `docs/specs/INDEX.md`).
- Update it to cover the missing requirement — usually a new line under **What** and/or a new **GIVEN/WHEN/THEN** scenario. Keep it observable and behavior-focused; don't paste the root cause or the fix.
- If no spec exists yet but should (this category of behavior is feature-shaped and load-bearing), flag it to the user — don't create one unilaterally during a fix.
- If the bug was in infra, tooling, or behavior not covered by any feature spec, skip — there is nothing to update.
**Do NOT create a new spec for the bug fix itself.** Bugs aren't features.
**2. Did the fix encode a new project-wide convention?**
If the root cause exposed an architectural gap, a non-obvious constraint, or a rule that should bind future code (e.g., "GC code must fail-closed", "all bulk deletes must be transactional"), run `/record decision` to capture it as an ADR.
If neither question applies, skip this phase.
Mark task 5 as completed.
+147
View File
@@ -0,0 +1,147 @@
---
name: plan
description: Create an implementation plan (plan.md) from a feature spec. Explores the codebase, designs the approach, and produces a structured plan with backend, frontend, tests, and E2E sections. Use after writing a spec and before implementing.
---
# Create Implementation Plan
Translate a feature spec into a concrete, phased implementation plan saved as `plan.md` alongside the spec.
## Input
- The feature spec (`docs/specs/<slug>/spec.md`) — read it first
- The codebase — explore relevant areas before designing
## Output
`docs/specs/<slug>/plan.md` — a structured plan the implementing agent can follow directly
---
## Steps
### 1. Read the spec
Read `docs/specs/<slug>/spec.md` in full. Identify:
- The observable behaviors (What section)
- The scenarios — each is a potential test case
- Any out-of-scope items (don't plan for these)
### 2. Explore the codebase
Search in parallel for all integration points the spec touches:
- Relevant models, repos, services, handlers
- Similar existing features to reuse as patterns
- Frontend state slices, hooks, and components in the area
- Existing tests in the area (to understand the testing patterns)
Use `docs/decisions/INDEX.md` to check for relevant architectural decisions.
### 3. Ask before designing (if needed)
If the spec leaves implementation choices open, ask — one question at a time. Do not assume. Examples of things to ask:
- Which table/model owns new data?
- Is a new API endpoint needed or does an existing one extend?
- Should this be behind a feature flag?
Stop asking when you have enough to write the plan.
### 4. Write plan.md
Save to `docs/specs/<slug>/plan.md`. Use this structure:
```markdown
---
spec: <slug>
created: YYYY-MM-DD
status: draft
---
# Implementation Plan: <Feature Name>
## Overview
2-4 sentences. What changes, in what order, and why that order.
---
## Backend
### <Area 1 — e.g., Schema Changes>
For each change: file path, exact struct/function/SQL, reason.
### <Area 2 — e.g., Service Layer>
...
### <Area N>
...
---
## Frontend
> Skip this section if the spec has no user-facing changes.
### <Component / Page>
File path, what changes, why.
### API client
What new calls are needed and where they go.
### State
Store slice / hook changes.
---
## Tests
Every plan MUST include this section. For each testable behavior in the spec, list:
- **What:** the behavior under test (maps to a spec scenario)
- **File:** where the test goes (`*_test.go` or `*.test.ts`)
- **How:** table-driven unit test / integration test with real DB / mock service
At minimum, include:
- One unit test per new function with non-trivial logic
- One integration test that exercises the full path (handler → service → repo)
- One test per edge case called out in the spec scenarios
---
## E2E Tests
> Skip this section only if the spec has zero user-visible UI changes.
For each user-facing scenario in the spec:
- **Scenario:** restate the GIVEN/WHEN/THEN from the spec
- **File:** `apps/web/e2e/<area>/<name>.spec.ts`
- **What to verify:** the observable outcome (URL change, element visible, toast shown)
---
## Implementation Waves
Group all work above into waves. Parallelism rules:
- Backend packages: can run in parallel
- Frontend (Next.js): single build — only one task at a time
- E2E: after all backend + frontend changes are done
```
Wave 1 (parallel): [backend task A, backend task B]
Wave 2: [frontend changes]
Wave 3: [E2E tests]
```
For small features (≤3 tasks total), waves are optional — list sequentially.
---
## Open Questions
(Delete when empty.)
```
### Style rules
- **Be specific.** Name exact file paths, function signatures, SQL column names. The implementing agent should not need to re-explore the codebase.
- **No speculation.** Only plan what the spec requires. Do not add "nice to have" items.
- **Tests are not optional.** Every plan must have a Tests section. E2E is required whenever there are UI changes.
- **Frontend is not optional.** If the spec has any user-visible behavior, the plan must have a Frontend section.
- **Keep it proportional.** A small spec gets a 1-page plan. A large spec may need 3-4 pages. Do not pad.
+78 -105
View File
@@ -11,7 +11,8 @@ Wait for CI and code review to complete on a pull request, fix any failures or v
## Available skills and subagents
- **`verify` subagent** — Run the full verification pipeline (format, typecheck, test, lint) before pushing fixes.
- **`pr-poller` subagent (Sonnet)** — Polls CI checks and the 4 review bots until terminal, returns a compact structured report. Replaces the old steps 1-3 and the post-push re-check (step 6).
- **`verify` subagent (Sonnet)** — Run the full verification pipeline (format, typecheck, test, lint) before pushing fixes.
- **`/e2e`** — Read for debugging guidance when E2E tests fail in CI. Covers test patterns, run commands, failure triage, and local reproduction.
- **`/commit`** — Use for staging and committing fixes with Conventional Commits format.
@@ -26,14 +27,13 @@ The first thing you do — before fetching PR state, before reading logs, before
Create these tasks immediately (use your task/todo tracking tool if available):
1. **Gather PR state** — Fetch checks, comments, and review status
2. **Wait for CI checks** — Poll until all checks resolve
3. **Wait for automated reviews** — Poll for CodeRabbit, Greptile, Claude, and cubic
4. **Fix failing CI checks** — Read logs, fix issues, run E2E tests locally if needed
5. **Triage review comments** — Classify each comment as valid, already addressed, nitpick, or wrong
6. **Address each comment** — Fix or reply with reasoning
7. **Verify, commit, and push** — Run verification pipeline, commit fixes, push
8. **Summary** — Report what was done: CI fixes, comments addressed/skipped, pushed commit
1. **Delegate to `pr-poller`** — Subagent gathers CI + bot review state, returns a compact report
2. **Fix failing CI checks** — Read failing run logs (via `scripts/run-quiet gh-run -- gh run view ...`), fix issues, run E2E tests locally if needed
3. **Triage review comments** — Classify each comment as valid, already addressed, nitpick, or wrong
4. **Address each comment** — Fix or reply with reasoning, resolve threads
5. **Verify, commit, push** — Delegate to `verify` subagent; commit fixes; push
6. **Re-check** — Delegate to `pr-poller` again. If new failures, loop back to task 2
7. **Summary** — Report what was done
Then start with task 1. Mark each task in_progress when you begin it and completed when you finish it.
@@ -41,97 +41,50 @@ Then start with task 1. Mark each task in_progress when you begin it and complet
## Steps
### 1. Gather PR state
### 1. Delegate state-gathering to `pr-poller`
Mark task 1 as in_progress.
Get the PR number from context or the user. Fetch the current state:
Invoke the `pr-poller` subagent with the PR number (or let it resolve via `gh pr view` against the current branch). The subagent:
- Fetches the current CI/bot/comment state once
- Polls (30s cadence, **20 min cap**) until every CI check and every bot (CodeRabbit, Greptile, Claude, cubic) reaches a terminal state
- Counts unresolved review threads and bot issue comments
- Returns a structured report between `=== pr-poller report ===` and `=== end ===` markers
```bash
gh pr checks <number>
gh pr view <number> --json comments
gh api repos/:owner/:repo/pulls/<number>/comments
```
**Parse the report.** The fields you care about:
- `ci_failed` — list of `{name, run_id, conclusion, url}`. Empty list ⇒ CI is green.
- `ci_pending` — anything still running when the 20-min cap hit. Decide whether to re-invoke `pr-poller` after a short delay, or proceed with what you have and re-check at step 6.
- `bots.<name>` — `done` / `rate_limited` / `pending` / `timeout`. Anything in `done` or `rate_limited` has had its chance; treat the rest as missing data, not a blocker.
- `unresolved_review_threads` and `issue_comments_from_bots` — drive steps 3-4. If both are 0 and `ci_failed` is empty, skip to step 5 (still run verify + push if you have fixes from earlier).
**Do not fetch poll output yourself** — that is what burns context. The report is the only thing that enters your context.
Mark task 1 as completed.
### 2. Wait for CI checks
### 2. Fix failing CI checks
Mark task 2 as in_progress.
If any checks are still running (`pending` / `queued` / `in_progress`), poll until they all resolve:
For each entry in the report's `ci_failed:` list:
```bash
gh pr checks <number>
```
- Poll every **30 seconds**
- Cap at **10 minutes** (20 polls). If still running after 10 min, report which checks are stuck and proceed.
- Once done, note which checks passed and which failed.
Mark task 2 as completed.
### 3. Wait for automated reviews
Mark task 3 as in_progress.
Check if CodeRabbit, Greptile, Claude, and cubic have posted or are generating reviews.
**Bot usernames** (`gh pr view --json comments` uses GraphQL and returns `author.login` **without** the `[bot]` suffix; `gh api /.../reviews` and `/.../issues/<n>/comments` use REST and return `user.login` **with** the suffix — filters below use whichever form the invoked endpoint returns):
- CodeRabbit: `coderabbitai[bot]`
- Greptile: `greptile-apps[bot]`
- Claude: `claude[bot]` on same-repo PRs (posts a real review with inline comments via the Claude GitHub App), or `github-actions[bot]` on fork PRs (posts findings as issue comments via `GITHUB_TOKEN`; identify by body markers — tracker starts with `**Claude finished `, findings comment starts with `## Code Review`).
- cubic (cubic.dev): `cubic-dev-ai[bot]` (posts reviews via the GitHub review API, similar to Greptile; has its own `cubic · AI code reviewer` check).
**CodeRabbit — stop waiting if:**
- A comment contains `<!-- rate limited by coderabbit.ai -->` — rate-limited, won't review.
- A comment contains `<!-- walkthrough_start -->` — review complete.
**Greptile — stop waiting if:**
- A review from `greptile-apps[bot]` exists (posts via the GitHub review API, not issue comments).
**Claude — stop waiting if any of these holds:**
- A review from `claude[bot]` exists (same-repo PR, App-authenticated path — has inline review comments).
- An issue comment from `github-actions[bot]` whose body starts with `**Claude finished ` or `## Code Review` exists (fork PR, `GITHUB_TOKEN` fallback path — findings are issue comments, no GitHub Review object).
- The `claude-review` check in `gh pr checks` has completed (regardless of conclusion).
**cubic — stop waiting if any of these holds:**
- A review from `cubic-dev-ai[bot]` exists (posts via the GitHub review API).
- The `cubic · AI code reviewer` check in `gh pr checks` has completed (regardless of conclusion).
**Keep polling if:**
- A bot hasn't commented yet AND `gh pr checks` shows its check is still `pending`.
Poll every **30 seconds**, cap at **10 minutes**. Fetch both issue comments and reviews each poll:
```bash
# CodeRabbit posts issue comments
gh pr view <number> --json comments --jq '.comments[] | select(.author.login == "coderabbitai") | {author: .author.login, body: .body}'
# Greptile posts reviews (with inline review comments)
gh api repos/:owner/:repo/pulls/<number>/reviews --jq '.[] | select(.user.login == "greptile-apps[bot]") | {user: .user.login, state: .state}'
# Claude — same-repo PRs: App-authenticated review + inline comments
gh api repos/:owner/:repo/pulls/<number>/reviews --jq '.[] | select(.user.login == "claude[bot]") | {user: .user.login, state: .state}'
# Claude — fork PRs: issue comments from github-actions[bot] (match by body marker)
gh pr view <number> --json comments --jq '.comments[] | select(.author.login == "github-actions" and ((.body | startswith("**Claude finished ")) or (.body | startswith("## Code Review")))) | {author: .author.login, body_start: (.body[0:80])}'
# cubic posts reviews (with inline review comments)
gh api repos/:owner/:repo/pulls/<number>/reviews --jq '.[] | select(.user.login == "cubic-dev-ai[bot]") | {user: .user.login, state: .state}'
```
Mark task 3 as completed.
### 4. Fix failing CI checks
Mark task 4 as in_progress.
If any CI checks failed:
1. Identify the failed runs from the `gh pr checks` output (the URL column contains the run URL)
2. Fetch the failed logs:
1. Use the `run_id` from the report (the poller already extracted these — don't re-run `gh pr checks`).
2. Fetch the failed logs via `scripts/run-quiet` — `gh run view --log-failed` dumps thousands of lines and will blow your context if it goes straight to stdout. The wrapper redirects to `/tmp/kandev-run.gh-run.<random>.log` and auto-greps for the relevant error lines:
```bash
gh run view <run-id> --log-failed
scripts/run-quiet gh-run -- gh run view <run-id> --log-failed
```
3. Read the relevant source files at the failing lines
If the printed summary is enough, stop. Only `Read` specific line ranges from the printed log path if you need surrounding context.
3. Read the relevant source files at the failing lines (use `Read` with `offset`/`limit`, not `cat`)
4. Fix the issues (lint errors, test failures, type errors, etc.)
**If the failure looks unfamiliar or the cause isn't obvious from the log, check CI history on the branch before diving into the code:**
```bash
gh run list --branch <branch> --workflow "<workflow name>" --limit 10 --json conclusion,headSha,createdAt,databaseId
```
On long-lived PRs that get rebased/squashed, prior SHAs on the same branch often passed the same workflow. A `passing → failing` boundary tells you the regression is isolated to the most recent rework — diff against the last passing SHA (`git diff <last-passing-sha>..HEAD`) instead of against `main` to narrow the search dramatically.
**E2E test failures require special handling:**
If any failing check is an E2E test (Playwright):
@@ -139,24 +92,28 @@ If any failing check is an E2E test (Playwright):
1. Read the `/e2e` skill (`SKILL.md`) for debugging guidance, test patterns, and run commands
2. Follow the "Debugging failures" section — read error output, check failure screenshots in `e2e/test-results/`, classify the failure (test logic, frontend, backend)
3. Fix the root cause. **Never increase timeouts to fix flaky tests** — find the real issue
4. Confirm fixes pass locally before pushing:
4. Confirm fixes pass locally before pushing. Wrap with `scripts/run-quiet`:
```bash
make build-backend build-web
cd apps && pnpm --filter @kandev/web e2e -- tests/path/to/failing.spec.ts
scripts/run-quiet build -- make build-backend build-web
scripts/run-quiet e2e -- bash -c 'cd apps && pnpm --filter @kandev/web e2e -- tests/path/to/failing.spec.ts'
```
Run the specific failing test file(s), not the full suite. Only proceed to step 7 after the test passes locally.
Run the specific failing test file(s), not the full suite. Only proceed to step 5 after the test passes locally.
Mark task 4 as completed.
Mark task 2 as completed.
### 5. Triage review comments
### 3. Triage review comments
Mark task 5 as in_progress.
Mark task 3 as in_progress.
Fetch all review comments — human reviewers, CodeRabbit, Greptile, Claude, and cubic:
Use the report's `unresolved_review_threads` and `issue_comments_from_bots` counts to know whether there's anything to triage. If both are 0, mark this step completed and move on.
Otherwise, fetch the actual comment bodies on demand — one bot or one set at a time, not all at once:
```bash
gh pr view <number> --json reviews,comments
# Inline review threads (humans, Greptile, Claude same-repo, cubic):
gh api repos/:owner/:repo/pulls/<number>/comments
# Issue comments (CodeRabbit walkthrough, Claude fork findings):
gh pr view <number> --json comments
```
**Verify before implementing.** Do not blindly accept review feedback — evaluate each comment technically:
@@ -180,11 +137,11 @@ Then classify:
- It's technically incorrect for this stack
- It conflicts with architectural decisions
Mark task 5 as completed.
Mark task 3 as completed.
### 6. Address each comment
### 4. Address each comment
Mark task 6 as in_progress.
Mark task 4 as in_progress.
Every comment must get a response — either a fix or a reply explaining why it was skipped.
@@ -193,7 +150,7 @@ Every comment must get a response — either a fix or a reply explaining why it
- **Never post a single summary issue comment in place of individual thread replies.** A top-level summary comment leaves every inline thread unresolved and unanswered; reviewers have to hunt for your response across the diff. The only acceptable use of a summary comment is as an *addition* to per-thread replies, not a substitute.
- **Every unresolved review thread on the PR must receive a direct reply and be resolved**, even if that means 20+ thread interactions. Looping over threads programmatically is fine (and expected); batching into one summary is not.
- **Reply to the comment that started the thread**, not a random later one. Get the first-comment ID from the GraphQL `reviewThreads(first: 100) { nodes { comments(first: 1) { nodes { databaseId } } } }` query.
- **Do not mark task 6 completed until every previously-unresolved review thread is either resolved or has an explicit reason documented in a reply.** If you finish the pass and the `isResolved == false` set is still non-empty, you are not done.
- **Do not mark task 4 completed until every previously-unresolved review thread is either resolved or has an explicit reason documented in a reply.** If you finish the pass and the `isResolved == false` set is still non-empty, you are not done.
**Important: issue comments vs review comments use different APIs:**
- **Review comments** (inline, from `gh api repos/:owner/:repo/pulls/<number>/comments`) — reply via `/pulls/<number>/comments/<comment_id>/replies`, react via `/pulls/comments/<comment_id>/reactions`
@@ -248,11 +205,11 @@ Then resolve using the thread `id`:
gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "<thread_node_id>"}) { thread { isResolved } } }'
```
Mark task 6 as completed.
Mark task 4 as completed.
### 7. Verify, commit, and push
### 5. Verify, commit, and push
Mark task 7 as in_progress.
Mark task 5 as in_progress.
1. Delegate to the **`verify` sub-agent** to run the full verification pipeline (format, typecheck, test, lint). It will fix any issues it finds. Wait for it to complete.
@@ -268,16 +225,32 @@ Mark task 7 as in_progress.
git push
```
Mark task 7 as completed.
Mark task 5 as completed.
### 8. Summary
### 6. Re-check via `pr-poller`
Mark task 8 as in_progress.
Mark task 6 as in_progress.
After the push, CI restarts and bots may re-review. Delegate to `pr-poller` again — same subagent, same contract, same 20-min cap. Parse the new report:
- If `ci_failed:` is empty AND `unresolved_review_threads: 0` AND `issue_comments_from_bots: 0` (no new bot comments to address) → mark task 6 completed and proceed to summary.
- If new CI failures appeared from the latest commit → loop back to task 2 and reset task 2-5 to `in_progress` as needed.
- If new review comments appeared after the push → loop back to task 3.
- If the poller hit its cap (`recommendation:` mentions "timed out") → surface the remaining pending items to the user and stop.
Cap re-check loops at **3 iterations** to prevent runaway sessions. After 3, surface the remaining state to the user and stop.
Mark task 6 as completed.
### 7. Summary
Mark task 7 as in_progress.
Report what was done:
- CI checks: which failed and how they were fixed
- Comments addressed (with thumbs up)
- Comments skipped and why
- Link to the pushed commit
- Re-check iteration count
Mark task 8 as completed.
Mark task 7 as completed.
+107
View File
@@ -0,0 +1,107 @@
---
name: pull-upstream
description: Pull latest changes from the public kandev repo, rebase onto them, and fix any conflicts or issues.
---
# Pull Upstream
Pull latest changes from the public kandev repo, rebase onto them, and fix any conflicts or issues.
## Context
This repo (`kdlbs/kandev-orchestrate`) is a private fork of `kdlbs/kandev` (public).
```
origin → git@github.com:kdlbs/kandev-orchestrate.git (private, our code)
upstream → git@github.com:kdlbs/kandev.git (public, community changes)
```
The public repo's `main` receives bug fixes, features, and community PRs independently.
## Steps
### 1. Fetch upstream
```bash
git fetch upstream
```
### 2. Check what's new
```bash
git log --oneline main..upstream/main
```
If empty, nothing to do. Report and stop.
### 3. Update main
Fast-forward `main` to match upstream, then push:
```bash
git checkout main
git merge --ff-only upstream/main
git push origin main
```
### 4. Rebase feature branch
Switch back to the feature branch and rebase onto updated main:
```bash
git checkout feature/orchestrate
git rebase main
```
### 5. Handle conflicts
If the rebase has conflicts:
1. For each conflicting file, read both sides and resolve correctly
2. Our office feature code takes priority over upstream when the same area is modified
3. Upstream changes to shared infrastructure (agent lifecycle, MCP, task system, workflow engine) should be preserved — integrate both sides
4. After resolving each file: `git add <file>`
5. Continue: `git rebase --continue`
6. Repeat until rebase completes
Common conflict zones:
- `cmd/kandev/main.go` — we wire office services here
- `cmd/kandev/helpers.go` — we register office routes here
- `internal/mcp/server/server.go` — we added ModeOffice
- `internal/mcp/handlers/handlers.go` — we added office MCP tools
- `apps/web/lib/state/` — we added office store slices
- `docs/specs/INDEX.md` — both sides add spec entries
- `CLAUDE.md` — may have parallel edits
### 6. Verify
Run the full verification pipeline. Use subshells so the second command
doesn't resolve `cd apps` relative to the first command's cwd (which
would look for a non-existent `apps/backend/apps/`):
```bash
(cd apps/backend && go build ./... && make test && make lint)
(cd apps && pnpm --filter @kandev/web lint)
```
Fix any issues:
- **Import conflicts**: upstream may have renamed/moved packages (e.g. `agentctl/server/mcp` → `mcp/server`)
- **Interface changes**: upstream may have changed interfaces we implement
- **Test failures**: upstream tests may depend on state we changed
- **Lint errors**: upstream may have added stricter rules
### 7. Push
Force-push the rebased feature branch:
```bash
git push --force-with-lease
```
### 8. Report
Summarize:
- How many upstream commits were pulled
- Any conflicts resolved and how
- Any fixes applied post-rebase
- Build/test/lint status
+12 -26
View File
@@ -1,11 +1,11 @@
---
name: record
description: Record an architectural decision (ADR) or save an implementation plan. Use after making significant design choices or completing features.
description: Keep docs/decisions/ ADRs and docs/specs/ specs in sync with the work happening in the conversation. AUTO-INVOKE proactively the moment the user asks for any change that will alter architecture or observable product behavior — new feature, cross-cutting refactor, dependency swap, data-model or public-API change, new pattern, or a bug fix that changes documented behavior or reveals a spec gap. Also invoke on explicit triggers: "record this", "create an ADR", "document this decision", "update the spec", "ADR for X". Run BEFORE coding when the decision is upfront, or AFTER landing when the right call only became clear during implementation. SKIP for typo/lint fixes, refactors that preserve behavior within an existing pattern, and obvious uncontested choices.
---
# Record Knowledge
Record architectural decisions and implementation plans for future reference.
Record architectural decisions for future reference, and keep related feature specs in sync.
## Record a decision
@@ -14,6 +14,7 @@ When a significant architectural or design choice is made, create an ADR:
1. Read `docs/decisions/INDEX.md` to find the next number
2. Create `docs/decisions/NNNN-short-title.md` using the template below
3. Update `docs/decisions/INDEX.md` with the new entry
4. **Reconcile specs** — see "Update or create a spec" below
### ADR template
@@ -50,30 +51,15 @@ What else was considered and why it was rejected.
- Bug fixes, refactors within the same pattern, simple features
- Anything where the choice is obvious and uncontested
## Save a plan
## Update or create a spec
After implementing a feature, save the design as a permanent record:
ADRs capture *why* a decision was made. Specs capture *what* a feature does and why it exists. After recording an ADR, reconcile the affected spec — specs are the canonical product record kept in git, so they must stay accurate.
1. Read `docs/plans/INDEX.md` to check for existing plan
2. Create `docs/plans/YYYY-MM-feature-name.md` using the template below
3. Update `docs/plans/INDEX.md` with the new entry
1. Read `docs/specs/INDEX.md` and identify any spec whose scope the decision touches (e.g., a routing decision affects `office-provider-routing/spec.md`).
2. For each affected spec:
- **If the decision changes observable behavior, scope, or scenarios:** update `docs/specs/<slug>/spec.md` so the "What" and "Why" sections reflect the new direction. Add a `Decision: ADR-NNNN` reference where relevant.
- **If the decision is purely internal (implementation choice with no spec-visible change):** no spec edit needed — the ADR alone is sufficient.
3. If the decision introduces a new product feature that has no spec yet, invoke `/spec` to create one rather than writing it ad-hoc here.
4. If no spec applies (pure infra/process decision, like this knowledge system itself), skip — note in the ADR that no spec is needed.
### Plan template
```markdown
# Feature/Plan Title
**Date:** YYYY-MM-DD
**Status:** proposed | approved | implemented | abandoned
**PR:** #NNN
**Decision:** ADR-NNNN (if applicable)
## Problem
What problem this solves.
## Design
File paths, interfaces, data flow. Mermaid diagrams where useful.
## Implementation Notes
Post-implementation: what changed from the plan, gotchas, things the next person should know.
```
Do not duplicate ADR content inside the spec. Specs reference ADRs; they don't restate them.
+40
View File
@@ -0,0 +1,40 @@
---
name: release
description: Kandev release & versioning conventions — single SemVer across npm, Homebrew, GitHub release. Use when cutting a release, debugging release artifacts, or answering questions about version channels.
---
# Release & Versioning
Kandev uses a **single SemVer** `X.Y.Z` shared across all distribution channels.
## Version targets
- `apps/cli/package.json` version → `X.Y.Z`
- npm main package: `kandev@X.Y.Z`
- npm runtime packages: `@kdlbs/runtime-{platform}@X.Y.Z` (5 platforms; declared as `optionalDependencies` in main package)
- Git tag: `vX.Y.Z` (three-part; legacy `vM.m` tags normalize to `M.m.0`)
- Homebrew formula: `kdlbs/homebrew-kandev` `Formula/kandev.rb` `version "X.Y.Z"`
- GitHub release: `vX.Y.Z` with platform tarballs `kandev-{platform}.tar.gz` + `.sha256`
**npm and Homebrew are sibling channels**, not chained. Both consume the same GitHub release artifacts; neither depends on the other.
## Release flow
Entirely in CI via `.github/workflows/release.yml`, triggered by a maintainer from the GitHub Actions UI:
1. Maintainer clicks "Run workflow" → picks `bump` (patch/minor/major) → optional `dry_run`.
2. `prepare` job bumps version + regenerates CHANGELOG, opens release PR, squash-merges, tags `vX.Y.Z`.
3. `build-web` + `build-cli` + `build-bundles` (5 platforms) build the release artifacts.
4. `publish-release` creates the GitHub release with platform tarballs + sha256 + auto-generated notes.
5. `publish-npm` publishes 5 `@kdlbs/runtime-*` packages + main `kandev` package to npmjs.
6. `update-homebrew-tap` pushes updated `Formula/kandev.rb` to `kdlbs/homebrew-kandev` via SSH deploy key.
There is no local release script — the entire flow runs in GHA.
## Runtime resolution
In `apps/cli/src/runtime.ts`, the CLI locates its bundled runtime via:
1. `KANDEV_BUNDLE_DIR` env var (set by Homebrew wrapper, used by tests).
2. Installed `@kdlbs/runtime-{platform}` npm package via `require.resolve()`.
3. `--runtime-version <tag>` cache fallback (debug only — downloads from GitHub).
+117 -19
View File
@@ -1,36 +1,69 @@
---
name: spec
description: Write a feature spec — the "what & why" of a kandev product feature, before coding. Use when the user says "let's spec X" or starts a new product feature.
description: Write a feature spec — the "what & why" of a kandev product feature, before coding. Use ONLY for a product-feature surface (user-visible capability the app supports). Do NOT use for bug fixes, incident postmortems, refactors that preserve behavior, or infra-only work — those get ADRs (if a new convention emerged) and/or regression tests, not specs. Use when the user says "let's spec X" or starts a new product feature.
---
# Writing a Spec
A spec captures **what** a feature does and **why**, before deciding **how**.
## Bar
The bar a spec must clear: **a fresh agent given only this spec (no source code) should be able to either reimplement the feature OR test the system for conformance.**
That means every requirement must be unambiguous. Where the feature has persistent state, the data model is documented. Where it exposes a contract (HTTP/WS/Go interface), the contract is documented. Where it has a multi-step lifecycle, the state machine is documented. If reading the spec leaves an implementer guessing, the spec is incomplete.
## Gate: is this actually a feature?
Before doing anything else, check that the topic is feature-shaped. A spec is appropriate ONLY when **all** of these are true:
- It describes a **product-feature surface** — a capability a user (human or office agent) can invoke.
- The "What" section can be written as observable behaviors the feature must support, not as a problem statement or a fix.
- The artifact will be a **living document** that evolves with the feature, not a one-shot record of a decision or incident.
If any of these are false, STOP and route to the right artifact:
| Situation | Use this instead |
|---|---|
| Bug fix, incident postmortem | `/fix` — plus an ADR via `/record decision` if the fix encoded a new convention. No spec. |
| Architecture or convention decision | `/record decision` — produces an ADR under `docs/decisions/`. No spec. |
| Refactor that preserves behavior | Commit + (optional) ADR. No spec. |
| Infra / tooling / build / CI change | Commit + (optional) ADR. No spec. |
| Cluster of related sub-features under one umbrella | One spec for the umbrella feature, not one per sub-feature. |
A "spec" that opens with a "Problem statement" or a "Root cause" section is not a spec. Specs describe what the feature **does for users**, not what went wrong or what we decided.
## What a spec is (and isn't)
A spec **is**:
- The user-visible behavior of one feature
- A short, testable definition that the team agrees on before writing code
- A testable definition that the team agrees on before writing code
- The source of truth for "is this feature done?"
- Self-contained enough that an implementer or conformance tester needs nothing else
A spec **is not**:
- An architecture or design document
- An architecture or design document for *how* it's built (only contracts, not internals)
- A task list
- A retrospective of work already done
- A record of a bug, incident, or postmortem (those are ADRs and regression tests)
## Where it lives
```
docs/specs/<feature-slug>/
└── spec.md
docs/specs/<umbrella>/<feature-slug>.md # nested under an umbrella when applicable
docs/specs/<feature-slug>/spec.md # standalone feature folder
```
- Slug is kebab-case, descriptive: `kanban-task-queue`, `host-utility-agentctl`. Avoid sequential numbering (`feature-1`, `feature-2`); numbers that are part of a technology name (`http2-proxy`, `oauth2-integration`) are fine.
- One folder per feature.
- Umbrellas group related features into one product surface (e.g. `office/`, `tasks/`, `integrations/`, `agents/`, `workspaces/`, `costs/`).
- Slug is kebab-case, descriptive: `agents.md`, `provider-routing.md`. Avoid sequential numbering.
- One file per feature surface. Sub-features that don't have their own observable contract live as sections inside the parent.
## Template
Required sections: `Why`, `What`, `Scenarios`, `Out of scope`.
Optional sections: include only when the feature has the corresponding concern. A small UI feature may need none of them; a stateful subsystem will need most.
```markdown
---
status: draft # draft | approved | building | shipped | archived
@@ -44,12 +77,46 @@ owner: <name>
1-3 sentences. The user problem and who feels it. No solution yet.
## What
- Bullet list of must-have behaviors.
- Use SHALL/MUST sparingly — only for hard requirements.
- Bullet list of must-have behaviors, written as observable outcomes.
- Reserve SHALL/MUST for hard requirements.
## Data model <-- include when the feature has persistent state
Entities, fields (with types), relationships. Where data lives (table name or
store). Constraints (uniqueness, foreign keys, nullability). One paragraph or
short table per entity. Diagrams only when multi-entity relationships aren't
obvious from prose.
## API surface <-- include when the feature exposes a contract
HTTP routes / WS event types / Go interfaces / CLI commands, with request
and response shapes. Names, paths, methods, payloads — not implementation.
Where an existing contract is reused, link to it instead of repeating.
## State machine <-- include when the feature has a multi-step lifecycle
States, transitions, the trigger for each transition, and which actor causes
each. A short list is fine for ≤6 states; a mermaid diagram or table for more.
## Permissions <-- include when authorization matters
Who (role / agent / external caller) can read, write, or invoke each action.
Cross-link to the agent-roles spec if the rule is "role X can do Y".
## Failure modes <-- include when failures are observable to the user or affect data
What happens when each external dependency fails or invariant is violated.
Whether the action retries, fails closed, falls back, or surfaces an error.
This is where fail-closed/safe-deletion/retry conventions get encoded.
## Persistence guarantees <-- include when restart behavior matters
What survives a kandev restart, what doesn't, and why. Covers worktrees,
sessions, queued work, in-flight runs, and any caches the user can observe.
## Scenarios
- **GIVEN** <state>, **WHEN** <action>, **THEN** <observable outcome>
The Scenarios section IS the conformance test surface. Write each scenario
so an agent could turn it directly into a test case — observable preconditions,
single triggering action, single observable outcome. Cover the golden path
plus every edge case that changes the design or has different observable
behavior than the golden path.
## Out of scope
- What this feature deliberately is not doing.
@@ -68,11 +135,41 @@ Bullet list of must-have behaviors, written as **observable outcomes**. Reserve
Good: "Stopped sessions resume into the last active turn."
Bad: "Add a `/sessions/:id/resume` POST endpoint that restores the ACP session."
### Data model
Include this section when the feature has any persistent state — DB tables, files on disk, state in a cache the user can observe. Name the entity, list its fields with types, note constraints (PK, FK, unique, nullable). Where the data lives (which table, which store) is part of the contract because conformance tests need to reach it.
```
worktree
id string PK
session_id string FK -> task_sessions.id (cascade delete)
worktree_path string abs path on disk, "" allowed only while creating
status enum active | merged | deleted
created_at timestamp
deleted_at timestamp nullable
```
### API surface
List the contracts an external caller or another module relies on. HTTP routes (method, path, body, response codes), WS event types (name, payload), Go interfaces with signatures, CLI commands with flags. Don't describe internals — only what the boundary looks like. When an existing contract is reused, link to it.
### State machine
For features with a multi-step lifecycle (task states, session states, run states, worktree states), enumerate the states, the transitions between them, the trigger for each transition, and which actor causes each. Capture which transitions require approval or block on external signals.
### Permissions
When the feature has any authorization, document who can do what. "Workspace admins can delete projects; project members can create tasks; runner agents cannot create new agents." Cross-link to `agents/` or `workspaces/` specs when the rule is a role-derived rule.
### Failure modes
Document the observable failure behavior for every external dependency the feature touches (DB, network, filesystem, external API) and every invariant it relies on. State whether the action retries, fails closed, falls back to a degraded path, or surfaces an error to the user. This is where conventions like "GC fails closed" or "approval requests retry on transient errors" become contract.
### Persistence guarantees
What survives a kandev process restart? Worktrees on disk, queued runs, in-flight ACP sessions, dashboard subscriptions. State explicitly what does NOT survive so implementers don't accidentally make it durable. Include any TTLs, retention policies, or cleanup grace periods.
### Scenarios
At least one `GIVEN`/`WHEN`/`THEN` for the golden path. Add edge cases **only when they change the design** — not for every error path. Each scenario should be observable from outside the system (UI state, API response, log line) so QA can verify it.
GIVEN/WHEN/THEN, observable from outside the system (UI state, API response, log line, DB row). Each scenario maps to one conformance test. Cover the golden path AND each edge case that changes the design.
```markdown
- **GIVEN** a stopped session with a pending tool call, **WHEN** the user clicks Resume, **THEN** the agent re-runs the tool call and continues the turn.
- **GIVEN** a worktree directory absent from `task_session_worktrees` and older than 24h, **WHEN** the office GC sweeps, **THEN** the directory is deleted and a log line `removing orphaned worktree directory` is emitted.
- **GIVEN** the worktree inventory query returns an error, **WHEN** the office GC sweeps, **THEN** no directories are deleted and a warning is logged.
```
### Out of scope
@@ -83,18 +180,19 @@ Park unresolved decisions here while drafting. Each one blocks the spec from bei
## Right-sizing
The spec should be proportional to the feature. A small feature gets a 20-line spec; a large one rarely needs more than a page. If a spec is growing past one screen:
The spec should be proportional to the feature. A small UI feature gets a 30-line spec with only Why/What/Scenarios/Out of scope. A stateful subsystem with persistence and a contract surface needs Data model + API surface + State machine + Failure modes + Persistence guarantees — and that's fine, because each of those is required to implement or test the feature correctly.
- Split into multiple specs (one feature per slug)
- Drop "how" content — a spec describes behavior, not implementation
- Drop scenarios that don't change the design
Signs a spec is the wrong size:
- **Too thin:** an implementer can't write the code without asking questions a conformance test would also need answered. Add the missing contract section.
- **Too thick:** content is restating what's in the code or the ADR. Cross-link instead of duplicating.
- **Too broad:** one spec covers multiple features that have different actors, contracts, or lifecycles. Split.
A padded spec is worse than a short one — it hides the requirements behind ceremony.
A padded spec is worse than a short one — it hides the requirements behind ceremony. But an under-specified spec fails the bar at the top of this file.
## Style notes
- **Symbols in code font.** File paths, packages, types: `internal/agent/lifecycle`, `TaskSession`.
- **Cross-link, don't duplicate.** Reference ADRs (`../../decisions/NNNN-...md`) and architecture docs rather than restating them.
- **Specs rarely need diagrams.** A user-flow mermaid is acceptable when it clarifies a multi-step interaction. Architectural diagrams do not belong here.
- **Symbols in code font.** File paths, packages, types, table names: `internal/agent/lifecycle`, `TaskSession`, `task_session_worktrees`.
- **Cross-link, don't duplicate.** Reference ADRs (`../../decisions/NNNN-...md`) and other specs rather than restating their content.
- **Specs rarely need diagrams.** A user-flow mermaid is acceptable when it clarifies a multi-step interaction. Architectural diagrams do not belong here — those go in ADRs.
- **Present tense, active voice.** "The agent resumes the turn" — not "the turn will be resumed by the agent".
- **Concrete over abstract.** "Wakeups fire at most once per 60s per agent" is testable; "wakeups are rate-limited appropriately" is not.
+6 -61
View File
@@ -5,68 +5,13 @@ description: Run format, typecheck, test, and lint across the monorepo. Use afte
# Verify
Run the full verification pipeline for the monorepo, then fix any issues found.
Delegate to the **`verify` subagent** to run the full verification pipeline (rebase, format, typecheck, test, lint) and fix any issues it finds. The subagent runs on Sonnet, which is cheaper than the main session and well-suited to the mechanical run-parse-fix loop.
## Steps
## What to do
**Create a task for each step below and mark them as completed as you go.** This pipeline must run in order — formatting before linting prevents formatter-induced lint failures.
Invoke the `verify` subagent in a single call. Wait for it to complete and surface the result.
### 1. Rebase on main
- If verify passes cleanly: report success.
- If verify cannot fix all failures: surface the remaining errors to the user — do not proceed with downstream actions (commit, push, PR) that depended on a green verify.
Ensures CI parity — CI merges main into the PR branch.
```bash
git fetch origin main --quiet
```
- If the current branch is `main`, skip this step.
- If the branch is stacked on another feature branch (not main), skip this step.
Detect: `git rev-parse --abbrev-ref --symbolic-full-name @{upstream} 2>/dev/null` — if the upstream is NOT `origin/main` (e.g., another feature branch), skip rebase.
- Otherwise, rebase:
```bash
git rebase origin/main
```
- If the rebase has conflicts, resolve them:
1. For each conflicted file, read the file and understand both sides of the conflict
2. Resolve by keeping the correct combination of both changes (don't just pick one side)
3. `git add <resolved-file>` then `git rebase --continue`
4. Repeat for each conflicting commit until the rebase completes
5. If a conflict is too ambiguous to resolve confidently, abort (`git rebase --abort`) and ask the user for guidance
### 2. Format
Run formatters first — they may change line lengths which affects linter results.
- `make -C apps/backend fmt`
- `cd apps && pnpm format`
### 3. Verify in parallel
Run these in parallel where possible:
- `make -C apps/backend test lint`
- `cd apps && pnpm --filter @kandev/web typecheck && pnpm --filter @kandev/web lint`
### 4. Fix issues
Do NOT just report issues — fix them:
- Read each failing file at the reported line number
- Fix the root cause (don't suppress warnings or add ignores)
- Common fixes:
- **Type errors**: fix the type, add a missing import, or correct the function signature
- **Lint — function too long**: extract a helper function or sub-component
- **Lint — file too long**: split the file into smaller, cohesive files grouped by responsibility (e.g., separate types, helpers, constants, or sub-domains into their own files)
- **Lint — cyclomatic/cognitive complexity**: simplify conditionals, extract early returns, break into smaller functions
- **Lint — unused imports**: remove them
- **Lint — duplicate strings**: extract to a constant
- **Test failures**: read the test, understand the assertion, fix the code (not the test) unless the test is outdated
- After fixing, re-run only the failed command to confirm the fix
### 5. Repeat
Repeat steps 3-4 until all commands pass. If a fix introduces new issues, address those too.
### 6. Done
All pass cleanly: rebase, fmt, typecheck, test, lint. Mark all tasks as completed.
Do NOT run the verification commands yourself in the main session — that defeats the cost saving. The subagent's prompt already contains the full procedure (see `.claude/agents/verify.md`).
+15 -7
View File
@@ -32,7 +32,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -57,8 +57,9 @@ jobs:
- name: Build
run: go build -v ./cmd/kandev
- name: Check gofmt
- name: Resolve base SHA
shell: bash
working-directory: ${{ github.workspace }}
run: |
set -euo pipefail
@@ -72,6 +73,14 @@ jobs:
BASE_SHA="$(git rev-list --max-parents=0 HEAD)"
fi
echo "BASE_SHA=${BASE_SHA}" >> "$GITHUB_ENV"
echo "Base SHA: ${BASE_SHA}"
- name: Check gofmt
shell: bash
run: |
set -euo pipefail
CHANGED_GO_FILES="$(
git diff --name-only --diff-filter=ACMRT "${BASE_SHA}"..."${{ github.sha }}" -- apps/backend \
| grep -E '^apps/backend/.*\.go$' \
@@ -94,9 +103,8 @@ jobs:
uses: golangci/golangci-lint-action@v9
with:
version: v2.9
args: ./...
args: ./... --new-from-rev=${{ env.BASE_SHA }}
working-directory: apps/backend
only-new-issues: true
- name: Run tests
run: |
@@ -110,7 +118,7 @@ jobs:
fromJSONFile: apps/backend/test-results.json
- name: Upload test artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
if: always()
with:
name: test-results
@@ -134,7 +142,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
@@ -158,4 +166,4 @@ jobs:
# a self-hosting Go helper (see testmain_test.go) so they run on
# every platform.
- name: Test windows-sensitive packages
run: go test -race -v ./internal/agentctl/server/process/... ./internal/agentctl/client/launcher/...
run: go test -race -v ./internal/agentctl/server/process/... ./internal/agent/runtime/agentctl/launcher/...
+35 -30
View File
@@ -38,16 +38,16 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version-file: apps/backend/go.mod
cache-dependency-path: apps/backend/go.sum
- name: Set up pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v5
with:
version: "9.15.9"
@@ -70,7 +70,7 @@ jobs:
run: make build-backend-linux-helpers
- name: Upload backend build
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: e2e-backend-build
path: apps/backend/bin/
@@ -82,7 +82,7 @@ jobs:
run: tar -cf apps/web/next-build.tar -C apps/web .next
- name: Upload web build
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: e2e-web-build
path: apps/web/next-build.tar
@@ -100,10 +100,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v5
with:
version: "9.15.9"
@@ -121,7 +121,7 @@ jobs:
run: npx playwright install chromium --with-deps
- name: Download backend build
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: e2e-backend-build
path: apps/backend/bin/
@@ -130,7 +130,7 @@ jobs:
run: chmod +x apps/backend/bin/*
- name: Download web build
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: e2e-web-build
path: apps/web/
@@ -149,7 +149,7 @@ jobs:
- name: Upload blob report
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: blob-report-${{ matrix.shard }}
path: apps/web/e2e/blob-report/
@@ -157,32 +157,37 @@ jobs:
- name: Upload test results
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: test-results-${{ matrix.shard }}
path: apps/web/e2e/test-results/
retention-days: 7
# Real-Docker E2E: exercises the local_docker executor end-to-end. ubuntu-latest
# ships with Docker preinstalled, so no service container is required. The
# spec count is small (currently 1 active + 2 fixme) so we don't shard.
# Real-Docker E2E: exercises the local_docker executor end-to-end.
# ubuntu-latest ships with Docker preinstalled, so no service container is
# required. Container-bound tests are slow (~2 min each), so we shard across
# 6 runners — Playwright's --shard splits the project's tests into N groups.
# Cleans up any leftover kandev-managed containers at the end so a leaky
# process doesn't poison the next run on the same self-hosted runner.
e2e-docker:
name: E2E Docker
name: E2E Docker Shard ${{ matrix.shard }}/6
needs: build
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6]
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Verify Docker is available
run: docker info
- name: Set up pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v5
with:
version: "9.15.9"
@@ -200,7 +205,7 @@ jobs:
run: npx playwright install chromium --with-deps
- name: Download backend build
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: e2e-backend-build
path: apps/backend/bin/
@@ -209,7 +214,7 @@ jobs:
run: chmod +x apps/backend/bin/*
- name: Download web build
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: e2e-web-build
path: apps/web/
@@ -218,11 +223,11 @@ jobs:
working-directory: apps/web
run: tar -xf next-build.tar && rm next-build.tar
- name: Run Docker E2E tests
- name: Run Docker E2E tests (shard ${{ matrix.shard }}/6)
working-directory: apps/web
env:
KANDEV_E2E_DOCKER: "1"
run: npx playwright test --config e2e/playwright.config.ts --project=docker
run: npx playwright test --config e2e/playwright.config.ts --project=docker --shard=${{ matrix.shard }}/6
- name: Cleanup leftover kandev containers
if: always()
@@ -233,17 +238,17 @@ jobs:
# sharded chromium/mobile-chrome runs.
- name: Upload Docker blob report
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: blob-report-docker
name: blob-report-docker-${{ matrix.shard }}
path: apps/web/e2e/blob-report/
retention-days: 3
- name: Upload Docker E2E results
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: docker-e2e-test-results
name: docker-e2e-test-results-${{ matrix.shard }}
path: apps/web/e2e/test-results/
retention-days: 7
@@ -256,10 +261,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v5
with:
version: "9.15.9"
@@ -273,7 +278,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Download blob reports
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
path: apps/web/e2e/blob-reports
pattern: blob-report-*
@@ -284,7 +289,7 @@ jobs:
run: npx playwright merge-reports --reporter=html ./e2e/blob-reports
- name: Upload merged report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: playwright-report
path: apps/web/playwright-report/
+2 -2
View File
@@ -44,12 +44,12 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v5
with:
version: "9.15.9"
+5 -5
View File
@@ -26,7 +26,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
@@ -35,7 +35,7 @@ jobs:
go-version-file: apps/backend/go.mod
cache-dependency-path: apps/backend/go.sum
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v5
with:
version: "9.15.9"
@@ -77,7 +77,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
# pull_request_target checks out base by default; explicitly use PR head.
# Safe: the preview CLI only reads files to build artifacts and posts comments.
@@ -92,7 +92,7 @@ jobs:
go-version-file: apps/backend/go.mod
cache-dependency-path: apps/backend/go.sum
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v5
with:
version: "9.15.9"
@@ -156,7 +156,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
+19 -19
View File
@@ -53,12 +53,12 @@ jobs:
version: ${{ steps.compute.outputs.version }}
tag: ${{ steps.compute.outputs.tag }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v5
with:
version: "9.15.9"
@@ -246,10 +246,10 @@ jobs:
if: ${{ !inputs.dry_run }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.tag }}
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v5
with:
version: "9.15.9"
- uses: actions/setup-node@v6
@@ -263,7 +263,7 @@ jobs:
run: pnpm -C apps --filter @kandev/web build
- name: Package web bundle
run: bash scripts/release/package-web.sh
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v5
with:
name: web-bundle
path: dist/web
@@ -275,10 +275,10 @@ jobs:
if: ${{ !inputs.dry_run }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.tag }}
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v5
with:
version: "9.15.9"
- uses: actions/setup-node@v6
@@ -288,7 +288,7 @@ jobs:
run: pnpm -C apps install --frozen-lockfile
- name: Build and bundle CLI
run: bash scripts/release/package-cli.sh
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v5
with:
name: cli-bundle
path: dist/kandev/cli
@@ -328,18 +328,18 @@ jobs:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.tag }}
- uses: actions/setup-go@v6
with:
go-version-file: apps/backend/go.mod
cache-dependency-path: apps/backend/go.sum
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v5
with:
name: web-bundle
path: dist/web
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v5
with:
name: cli-bundle
path: dist/kandev/cli
@@ -391,7 +391,7 @@ jobs:
tar -czf "kandev-${{ matrix.platform }}.tar.gz" kandev
shasum -a 256 "kandev-${{ matrix.platform }}.tar.gz" > "kandev-${{ matrix.platform }}.tar.gz.sha256"
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v5
with:
name: bundle-${{ matrix.platform }}
path: |
@@ -407,7 +407,7 @@ jobs:
contents: read
packages: write # push to GHCR
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.tag }}
@@ -467,7 +467,7 @@ jobs:
env:
STAGING_IMAGE: ghcr.io/kdlbs/kandev:universal-staging-${{ needs.prepare.outputs.tag }}-${{ github.run_id }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.tag }}
@@ -553,7 +553,7 @@ jobs:
permissions:
contents: write # softprops/action-gh-release creates the release
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.tag }}
fetch-depth: 0
@@ -566,7 +566,7 @@ jobs:
env:
OUTPUT: RELEASE_NOTES.md
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v5
with:
pattern: bundle-*
path: dist/release-assets
@@ -609,11 +609,11 @@ jobs:
id-token: write # Required for OIDC trusted publishing
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.tag }}
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v5
with:
version: "9.15.9"
@@ -639,7 +639,7 @@ jobs:
if: ${{ !inputs.dry_run }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.tag }}
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
env:
STAGING_IMAGE: ghcr.io/kdlbs/kandev:universal-staging-weekly-${{ github.run_id }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Compute date tag
id: date
+7
View File
@@ -110,3 +110,10 @@ normalized-*.jsonl
# playwright-cli snapshots/console logs
.playwright-cli/
/.claude/scheduled_tasks.lock
# implementation plans (reviewed locally, not committed)
docs/specs/*/plan.md
docs/plans/
# Local worktrees (not the backend-managed tree under ~/.kandev)
/.worktrees/
+62 -60
View File
@@ -6,7 +6,7 @@
```
apps/
├── backend/ # Go backend (orchestrator, lifecycle, agentctl, WS gateway)
├── backend/ # Go backend (orchestrator, agent runtime, agentctl, WS gateway)
├── web/ # Next.js frontend (SSR + WS + Zustand)
├── cli/ # CLI tool (TypeScript)
├── landing/ # Landing page
@@ -21,6 +21,7 @@ apps/
- **UI**: Shadcn components via `@kandev/ui`
- **GitHub repo**: `https://github.com/kdlbs/kandev`
- **Container image**: `ghcr.io/kdlbs/kandev` (GitHub Container Registry)
- **Dev mock providers**: In `KANDEV_MOCK_AGENT=true` dev mode the base `mock-agent` is enabled alongside the real CLIs. To exercise the office provider-routing UI without installing real CLIs, set `KANDEV_MOCK_PROVIDERS=claude-acp,codex-acp,opencode-acp` (or any subset of `RoutableProviderIDs`) when launching `make dev` — those canonicals are then replaced with MockAgent aliases.
---
@@ -36,7 +37,10 @@ apps/backend/
│ └── mock-agent/ # Mock agent for testing
├── internal/
│ ├── agent/
│ │ ├── lifecycle/ # Agent instance management (see below)
│ │ ├── runtime/ # Agent runtime: single seam for Launch/Resume/Stop/observe
│ │ │ ├── lifecycle/ # Agent instance management (moved from agent/lifecycle)
│ │ │ ├── agentctl/ # HTTP client for talking to agentctl (moved from agentctl/client)
│ │ │ └── routingerr/ # Provider error classifier + sanitizer + ProviderProber registry
│ │ ├── agents/ # Agent type implementations
│ │ ├── controller/ # Agent control operations
│ │ ├── credentials/ # Agent credential management
@@ -50,7 +54,6 @@ apps/backend/
│ │ ├── mcpconfig/ # MCP server configuration
│ │ └── remoteauth/ # Remote auth catalog and method IDs for remote executors/UI
│ ├── agentctl/
│ │ ├── client/ # HTTP client for talking to agentctl
│ │ └── server/ # agentctl HTTP server
│ │ ├── acp/ # ACP protocol implementation
│ │ ├── adapter/ # Protocol adapters + transport/ (ACP, Codex, OpenCode, Copilot, Amp)
@@ -77,15 +80,31 @@ apps/backend/
│ │ ├── models/ # Task, Session, Executor, Message models
│ │ ├── repository/ # Database access (SQLite)
│ │ └── service/ # Task business logic
│ ├── analytics/ # Usage analytics
│ ├── clarification/ # Agent clarification handling
│ ├── common/ # Shared utilities
│ ├── db/ # Database initialization
│ ├── debug/ # Debug tooling
│ ├── editors/ # Editor integration
│ ├── office/ # Autonomous agent management (Office feature)
│ │ ├── agents/ # Agent instance CRUD + auth guards
│ │ ├── approvals/ # Approval requests and decisions
│ │ ├── channels/ # External integration channels (webhooks)
│ │ ├── config/ # Config sync (DB ↔ filesystem)
│ │ ├── configloader/ # Filesystem config reader/writer
│ │ ├── costs/ # Cost tracking and budget policies
│ │ ├── dashboard/ # Dashboard API, issues, activity, live runs
│ │ ├── infra/ # GC, reconciliation
│ │ ├── labels/ # Task labels
│ │ ├── onboarding/ # Workspace onboarding wizard API
│ │ ├── projects/ # Project management
│ │ ├── repository/ # Office SQLite persistence
│ │ ├── runtime/ # Agent run context, capabilities, and runtime action surface
│ │ ├── routines/ # Scheduled recurring tasks
│ │ ├── routing/ # Provider routing: resolver, validators, catalogue, backoff, agent-overrides
│ │ ├── scheduler/ # Wakeup scheduler (duplicate of service scheduler features)
│ │ ├── service/ # Core office service (wakeups, event subscribers, execution policy)
│ │ ├── shared/ # Shared interfaces and activity logging
│ │ ├── skills/ # Skill injection and materialization
│ │ └── workspaces/ # Workspace deletion handler
│ ├── events/ # Event bus for internal pub/sub
│ ├── gateway/ # WebSocket gateway
│ ├── github/ # GitHub API integration (PRs, reviews, webhooks)
│ ├── common/ # Shared utilities, config, logger
│ ├── integration/ # External integrations
│ ├── integrations/ # Shared shapes for third-party integrations
│ │ ├── healthpoll/ # Reusable 90s auth-health Poller (used by jira, linear)
@@ -109,7 +128,7 @@ apps/backend/
│ ├── user/ # User management
│ ├── utility/ # Shared utility functions
│ ├── workflow/ # Workflow engine
│ │ ├── engine/ # Typed state-machine engine (trigger evaluation, action callbacks, transition store)
│ │ ├── engine/ # Typed state-machine engine
│ │ ├── models/ # Workflow step, template, and history models
│ │ ├── repository/ # Workflow persistence (SQLite)
│ │ └── service/ # Workflow CRUD and step resolution
@@ -133,7 +152,11 @@ apps/backend/
- `RequiresApproval` on actions: transitions requiring review gating are skipped
- Idempotent by `OperationID`; session-scoped data bag via `MachineState.Data`
**Lifecycle Manager** (`internal/agent/lifecycle/`) manages agent instances:
**Agent Runtime** (`internal/agent/runtime/`) is the single seam for launching, resuming, stopping, and observing agent executions. ADR 0004 introduced this in Phase 1 of task-model-unification. The public surface is `runtime.Runtime` (`runtime.go`); a thin facade (`facade.go`) delegates to a `Backend` (satisfied by `*lifecycle.Manager`).
**Convention:** only `internal/agent/runtime/` (and code that pre-dates Phase 1 migration) may import `runtime/lifecycle` or `runtime/agentctl` directly. New consumers — workflow engine actions, cron-driven trigger handlers, future task-tier callers — should depend on `runtime.Runtime`. Existing call sites are migrated through later phases of task-model-unification.
**Lifecycle Manager** (`internal/agent/runtime/lifecycle/`) manages agent instances under the runtime:
- `Manager` (`manager.go`, `manager_*.go`) - central coordinator for agent lifecycle
- `ExecutorBackend` interface (`executor_backend.go`) - abstracts execution environment (Docker, Standalone, Sprites, Remote Docker)
- `ExecutionStore` (`execution_store.go`) - thread-safe in-memory execution tracking
@@ -142,6 +165,8 @@ apps/backend/
- `process_runner.go` - agent process launch and management
- `profile_resolver.go` - resolves agent profiles/settings
**agentctl client** (`internal/agent/runtime/agentctl/`) is the HTTP/WS client used by the lifecycle manager to talk to a running agentctl instance. It is a runtime-tier package and should not be imported outside `internal/agent/runtime/`.
**agentctl** is an HTTP server that:
- Runs inside Docker containers or as standalone process
- Manages agent subprocess via stdin/stdout (ACP protocol)
@@ -173,7 +198,7 @@ Client (WS) ← Orchestrator ← Lifecycle Manager ←──── stream update
**Worktrees:** `internal/worktree/Manager` provides workspace isolation. Each session can have its own worktree (branch) to prevent conflicts between concurrent agents.
**Executor default scripts:** Default prepare scripts are in `internal/agent/lifecycle/default_scripts.go`; `internal/scriptengine/` handles placeholder resolution.
**Executor default scripts:** Default prepare scripts are in `internal/agent/runtime/lifecycle/default_scripts.go`; `internal/scriptengine/` handles placeholder resolution.
---
@@ -240,6 +265,7 @@ lib/state/
│ ├── session-runtime/ # shell, processes, git, context
│ ├── workspace/ # workspaces, repos, branches
│ ├── settings/ # executors, agents, editors, prompts (incl. userSettings)
│ ├── office/ # office agents, skills, projects, dashboard, issues
│ ├── comments/ # code review diff comments
│ ├── github/ # GitHub PRs, reviews
│ └── ui/ # preview, connection, active state, sidebar views
@@ -249,6 +275,7 @@ hooks/domains/{kanban,session,workspace,settings,comments,github}/ # Domain-org
lib/api/domains/ # API clients
├── kanban-api, session-api, workspace-api, settings-api, process-api
├── plan-api, queue-api, workflow-api, stats-api, github-api
├── office-api, office-extended-api, tree-api
├── user-shell-api, debug-api, secrets-api, sprites-api, vscode-api
├── health-api, utility-api
```
@@ -274,35 +301,9 @@ lib/api/domains/ # API clients
Commits to `main` **must** follow [Conventional Commits](https://www.conventionalcommits.org/) (`type: description`). PRs are squash-merged — the PR title becomes the commit, validated by CI. Changelog is auto-generated from these via git-cliff (`cliff.toml`). See `.agents/skills/commit/SKILL.md` for allowed types and examples.
### Release & Versioning Convention
### Release & Versioning
Kandev uses a **single SemVer** `X.Y.Z` shared across all distribution channels:
- `apps/cli/package.json` version → `X.Y.Z`
- npm main package: `kandev@X.Y.Z`
- npm runtime packages: `@kdlbs/runtime-{platform}@X.Y.Z` (5 platforms; declared as `optionalDependencies` in main package)
- Git tag: `vX.Y.Z` (three-part; legacy `vM.m` tags normalize to `M.m.0`)
- Homebrew formula: `kdlbs/homebrew-kandev` `Formula/kandev.rb` `version "X.Y.Z"`
- GitHub release: `vX.Y.Z` with platform tarballs `kandev-{platform}.tar.gz` + `.sha256`
**npm and Homebrew are sibling channels**, not chained. Both consume the same GitHub release artifacts; neither depends on the other.
**Release flow** (entirely in CI via `.github/workflows/release.yml`, triggered by maintainer from GitHub Actions UI):
1. Maintainer clicks "Run workflow" → picks `bump` (patch/minor/major) → optional `dry_run`.
2. `prepare` job bumps version + regenerates CHANGELOG, opens release PR, squash-merges, tags `vX.Y.Z`.
3. `build-web` + `build-cli` + `build-bundles` (5 platforms) build the release artifacts.
4. `publish-release` creates the GitHub release with platform tarballs + sha256 + auto-generated notes.
5. `publish-npm` publishes 5 `@kdlbs/runtime-*` packages + main `kandev` package to npmjs.
6. `update-homebrew-tap` pushes updated `Formula/kandev.rb` to `kdlbs/homebrew-kandev` via SSH deploy key.
There is no local release script — the entire flow runs in GHA.
**Runtime resolution** (in `apps/cli/src/runtime.ts`):
1. `KANDEV_BUNDLE_DIR` env var (set by Homebrew wrapper, used by tests).
2. Installed `@kdlbs/runtime-{platform}` npm package via `require.resolve()`.
3. `--runtime-version <tag>` cache fallback (debug only — downloads from GitHub).
Kandev uses a **single SemVer** `X.Y.Z` across npm, Homebrew, and GitHub release; release flow runs entirely in CI via `.github/workflows/release.yml`. Full details in the `/release` skill — load it when cutting a release or debugging release artifacts.
### Code Quality (enforced by linters)
@@ -330,14 +331,23 @@ Every code change must include tests for new or changed logic. Backend: `*_test.
### Knowledge
- **Specs:** Feature specs live in `docs/specs/<slug>/spec.md` — the "what & why" of a feature, written before coding. Optional siblings: `plan.md` (how), `notes.md` (post-ship). Use `/spec` to write or update a spec. See `docs/specs/INDEX.md`.
- **Decisions:** Architecture decisions are recorded in `docs/decisions/`. Read `docs/decisions/INDEX.md` for an overview. When making significant architectural choices, create a new ADR via `/record decision`.
- **Plans:** Retrospective implementation plans live in `docs/plans/` (created via `/record plan`). A forward-looking plan tied to a spec goes at `docs/specs/<slug>/plan.md`.
- **Plans:** Implementation plans are generated from specs via `/plan` and saved to `docs/specs/<slug>/plan.md`. Plans are gitignored (working files, not committed) — only specs and ADRs are tracked.
### Backend
- Provider pattern for DI; stderr for logs, stdout for ACP only
- Pass context through chains; event bus for cross-component comm
- **Execution access:** Workspace-oriented handlers (files, shell, inference, ports, vscode, LSP) MUST use `GetOrEnsureExecution(ctx, sessionID)` — it recovers from backend restarts by creating executions on-demand. Only use `GetExecutionBySessionID` for operations that require a running agent process (prompt, cancel, mode).
- **Task lifecycle events:** Any code path that mutates a task row must publish via the event bus (`task.created` / `task.updated` / `task.deleted`) — either by going through `Service.CreateTask` / `UpdateTask` / `DeleteTask` / `ArchiveTask`, or by calling `publishTaskEvent` (or one of the `Publish*` helpers in `service_events.go`) directly. Walking `repository.TaskRepository` straight bypasses event publishing and breaks WS-driven UI like the All-Workflows kanban view. `HandoffService`'s cascade methods learned this the hard way — they now require a `TaskEventPublisher` wired via `SetTaskEventPublisher`. New cascade / bulk / cleanup paths must follow the same pattern.
- **Testing:** Prefer `testing/synctest` (Go 1.24+) over `time.Sleep` for time-dependent tests. Use `synctest.Test` to wrap tests with tickers or timeouts — it advances fake time instantly when all goroutines are idle. When `synctest` is not feasible (e.g., tests spawning external processes like `git`), use channel-based synchronization (`<-started`, non-blocking `select`) instead of sleep-based waits. Reserve `time.Sleep` only for integration tests that need real subprocess execution time.
### Backups
- On every SQLite boot, `persistence.Provide` reads `kandev_meta.kandev_version`. If the stored version differs from the binary version (or any user tables exist but no version is recorded), it takes a `VACUUM INTO` snapshot into `<data-dir>/backups/` before running migrations.
- Retention: 2 backups kept (newest two by mtime); older ones are pruned after the snapshot succeeds.
- Postgres: backup is skipped with a log line. Use `pg_dump` for Postgres backups.
- Boot aborts if the backup fails -- the pool is closed and `Provide` returns an error.
- After all repos complete `initSchema`, `cmd/kandev/storage.go:recordSchemaVersion` writes the current binary version into `kandev_meta` (non-fatal; a failure just means the next boot will take a fresh snapshot).
- Migration logging: `db.MigrateLogger.Apply(name, stmt)` -- success logs Info, "already exists"/"duplicate column name" is silently swallowed, anything else logs Warn but never returns an error (preserving the existing swallow-error contract).
### Frontend
- **Data:** SSR fetch → hydrate → read store. Never fetch in components
- **UI Components:**
@@ -354,31 +364,23 @@ Every code change must include tests for new or changed logic. Backend: `*_test.
### Plan Implementation
- After implementing a plan, run `make fmt` first to format code, then run `make typecheck test lint` to verify the changes. Formatting must come first because formatters may split lines, which can trigger complexity linter warnings.
### Observability
- In dev mode (`KANDEV_MOCK_AGENT=true` or `debug.pprofEnabled`), `/debug/vars` exposes the stdlib expvar handler. Office provider-routing metrics live under `routing_*` (route attempts, fallbacks, parked runs, provider degraded/recovered counters). The metrics are also still emitted as structured `routing.metric.*` zap logs for human debugging.
### GitHub Operations
Skills use `gh` CLI by default. If a `gh` command fails (not installed, not authenticated, etc.), use whatever GitHub tools are available in the environment (MCP GitHub tools, API tools, etc.) to accomplish the same operation. The goal is the same — the tool may differ.
### Adding a new third-party integration
### Third-party integrations
Jira and Linear are the model: per-workspace credentials, a 90s auth-health poller, a settings page with status banner + reconnect CTA, link/import buttons that gate on availability. New integrations should reuse the shared shapes rather than copying either.
Jira and Linear are the model (per-workspace credentials, 90s auth-health poller via `internal/integrations/healthpoll`, settings page with status banner). New integrations should **reuse the shared shapes** rather than copying either. Full layout, file conventions, and Jira-vs-Linear divergence notes in the `/add-integration` skill — load it when scaffolding a new integration.
**Backend** (`apps/backend/internal/<name>/`):
- Mirror the package layout: `service.go`, `store.go`, `client.go`, `provider.go`, `handlers.go`, `models.go`, `poller.go`. Expose `Provide(writer, reader *sqlx.DB, secrets SecretStore, eventBus bus.EventBus, log *logger.Logger) (*Service, func() error, error)`. Pass `nil` for `eventBus` when the integration doesn't publish events; both Jira and Linear take and use it for issue-watch publishing.
- Use `internal/integrations/secretadapter` instead of writing your own upsert wrapper around `secrets.SecretStore`. The adapter satisfies any per-integration `SecretStore` interface shaped as `{Reveal, Set, Delete, Exists}`.
- Use `internal/integrations/healthpoll` for the auth-health loop. Implement the `Prober` interface (`ListConfiguredWorkspaces` + `RecordAuthHealth`) on a small adapter and let `healthpoll.New("name", prober, log)` own Start/Stop/ticker. Keep integration-specific loops (JQL polling, webhook reconciliation, etc.) separate, like jira's issue-watch loop.
- Wire the service via a per-domain `init<Name>Service(...)` helper in `cmd/kandev/services.go`, not inline in `provideServices`.
- Ship a `mock_client.go` + `mock_controller.go` next to the real client. `Provide` branches on `KANDEV_MOCK_<NAME>=true` and returns the in-memory client; `RegisterMockRoutes(router, svc, log)` mounts `/api/v1/<name>/mock/*` only when the service was built with the mock. The e2e backend fixture sets the env var so Playwright tests drive the mock via `apiClient.mock<Name>*()` helpers — see jira/linear for the layout.
### Runtime profiles (prod / dev / e2e)
**Frontend**:
- Hooks live under `hooks/domains/<name>/`, **not** `components/<name>/`.
- Use `hooks/domains/integrations/use-integration-availability.ts` and `use-integration-enabled.ts` — each integration's `useXAvailable` / `useXEnabled` should be a one-line wrapper passing the storage key + sync event + config-fetch function.
- Settings page reuses `<IntegrationAuthStatusBanner>` (`components/integrations/auth-status-banner.tsx`).
- "Auth required / reconnect" UI reuses `<IntegrationAuthErrorMessage>` (`components/integrations/auth-error-message.tsx`) — supply the integration's display name, regex check, and reconnect href.
- Link / import popovers reuse `<ValidatedPopover>` (`components/integrations/validated-popover.tsx`) — supply the icon, label, key regex, fetch function, and success callback.
**`profiles.yaml` at the repo root** is the single source of truth for env-driven runtime defaults — feature flags, mock providers (agent / GitHub / Jira / Linear), debug switches, and e2e tuning knobs. The backend embeds it (`//go:embed` via `apps/backend/internal/profiles/`) and at startup calls `profiles.ApplyProfile()` to write the matching profile's env vars onto its own process, *only when each var is not already set* — so launchers, shells, and per-spec overrides still win.
**Where Jira and Linear deliberately diverge:**
- Issue model: Jira uses transitions + JQL; Linear uses state IDs + structured filters. Don't merge these schemas — the upstream APIs are genuinely different.
- Watch filter persistence: Jira stores the JQL string verbatim; Linear stores the structured `SearchFilter` as JSON in `filter_json` (Linear has no JQL equivalent). The orchestrator emits `NewJiraIssueEvent` / `NewLinearIssueEvent` respectively and dedups by issue key (Jira) vs identifier (Linear).
- Health column extras: Linear's `linear_configs` row carries an `org_slug` captured from successful probes; Jira's row does not.
Profile selection: `KANDEV_E2E_MOCK=true` → `e2e`, `KANDEV_DEBUG_DEV_MODE=true` → `dev`, otherwise `prod`. `apps/cli/src/dev.ts` and `apps/web/e2e/fixtures/backend.ts` set only the selector — they no longer hardcode the underlying values.
To flip a feature on for every user: change its `prod:` to `"true"` in `profiles.yaml`. To add a new feature flag: 1 line in `profiles.yaml` + 1 `FeaturesConfig` field + the gate at the call site + the frontend additions (`FeatureFlags` type, `useFeature` checks, `notFound()` from a server-side layout). Full pattern in `docs/decisions/0007-runtime-feature-flags.md`.
---
@@ -388,4 +390,4 @@ This file is read by AI coding agents (Claude Code via `CLAUDE.md` symlink, Code
---
**Last Updated**: 2026-05-03
**Last Updated**: 2026-05-16
+31 -1
View File
@@ -58,6 +58,7 @@ help:
@echo " dev-backend Run backend in development mode (port 38429)"
@echo " dev-web Run web app in development mode (port 37429)"
@echo " dev Note: Uses apps/cli launcher (auto ports)"
@echo " doctor Idempotently wire up pre-commit hooks (runs automatically before dev)"
@echo ""
@echo "Production Commands:"
@echo " start Install deps, build, and start backend + web in production mode"
@@ -120,8 +121,37 @@ else
endif
endif
# Idempotent: wires the pre-commit framework into git's hook system
# (.pre-commit-config.yaml at the repo root drives it). Runs as a
# dependency of `dev` so every fresh clone / worktree picks up the
# format + lint hooks the first time a contributor starts the dev
# server, without anyone having to remember a setup step.
#
# Silent unless pre-commit is missing — in that case it prints a
# one-line note instead of failing, because `make dev` should not
# block on an optional tool. CI still runs the real linters.
#
# Quirks handled:
# - pre-commit refuses to install when core.hooksPath is set, even
# when it's set to the default location (a leftover from a stale
# setup). Detect that exact case and unset it before installing.
# - core.hooksPath is shared between the main repo and every
# worktree, so a single install covers all of them.
.PHONY: doctor
doctor:
@if ! command -v pre-commit >/dev/null 2>&1; then \
echo "⚠ pre-commit not found on PATH — install with 'pip install pre-commit' to enable hook-based format/lint on commit."; \
exit 0; \
fi; \
default_hooks="$$(git rev-parse --git-common-dir)/hooks"; \
current="$$(git config --get core.hooksPath 2>/dev/null || true)"; \
if [ -n "$$current" ] && [ "$$current" = "$$default_hooks" ]; then \
git config --unset core.hooksPath 2>/dev/null || true; \
fi; \
pre-commit install -t pre-commit -t commit-msg --overwrite >/dev/null 2>&1 || true
.PHONY: dev
dev:
dev: doctor
ifneq ($(HOST_IS_LINUX_AMD64),1)
@echo "Building Linux agentctl for remote executors..."
@$(MAKE) -C $(BACKEND_DIR) build-agentctl-linux
+4
View File
@@ -1,5 +1,9 @@
version: "2"
run:
build-tags:
- fts5
linters:
enable:
- funlen
+9 -9
View File
@@ -91,7 +91,7 @@ build-acpdbg:
build: build-agentctl build-agentctl-linux build-mock-agent build-acpdbg build-winjob
@echo "Building $(BINARY_NAME)..."
-@$(MKDIR) $(BUILD_DIR)
$(CGO_PREFIX) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)$(EXE) ./cmd/kandev
$(CGO_PREFIX) $(GO) build $(GOFLAGS) -tags fts5 -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)$(EXE) ./cmd/kandev
## Build all binaries (unified + individual services + agentctl)
build-all: build build-agentctl
@@ -141,7 +141,7 @@ start-debug: build
## Run tests
test:
@echo "Running tests..."
$(CGO_PREFIX) $(GO) test ./...
$(CGO_PREFIX) $(GO) test -tags fts5 ./...
## Run only the Windows-sensitive packages — mirrors the test-windows job in
## .github/workflows/backend-tests.yml. The broader suite uses Unix-only
@@ -151,7 +151,7 @@ test:
## runtime.GOOS guards in source.
test-windows:
@echo "Running Windows-sensitive packages..."
$(CGO_PREFIX) $(GO) test -v ./internal/agentctl/server/process/... ./internal/agentctl/client/launcher/...
$(CGO_PREFIX) $(GO) test -tags fts5 -v ./internal/agentctl/server/process/... ./internal/agent/runtime/agentctl/launcher/...
## Run E2E tests (real agents, costs money — manual only)
## Set agent commands via env vars: E2E_CLAUDE_CODE_COMMAND, E2E_AMP_COMMAND
@@ -165,12 +165,12 @@ test-e2e:
test-sprites-e2e: build-agentctl-linux
@echo "Running sprites e2e test..."
KANDEV_AGENTCTL_LINUX_BINARY=$(CURDIR)/$(BUILD_DIR)/agentctl-linux-amd64 \
$(GO) test -v -count=1 -tags sprites_e2e -run TestSpritesE2E -timeout 10m ./internal/agent/lifecycle/
$(GO) test -v -count=1 -tags sprites_e2e -run TestSpritesE2E -timeout 10m ./internal/agent/runtime/lifecycle/
## Run tests with coverage
test-coverage:
@echo "Running tests with coverage..."
$(CGO_PREFIX) $(GO) test -v -coverprofile=coverage.out ./...
$(CGO_PREFIX) $(GO) test -tags fts5 -v -coverprofile=coverage.out ./...
$(GO) tool cover -html=coverage.out -o coverage.html
## Run linter (requires golangci-lint)
@@ -202,10 +202,10 @@ deps:
build-cross:
@echo "Building for multiple platforms..."
-@$(MKDIR) $(BUILD_DIR)
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./cmd/kandev
CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 ./cmd/kandev
CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./cmd/kandev
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./cmd/kandev
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 $(GO) build -tags fts5 -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./cmd/kandev
CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 $(GO) build -tags fts5 -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 ./cmd/kandev
CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 $(GO) build -tags fts5 -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./cmd/kandev
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 $(GO) build -tags fts5 -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./cmd/kandev
## Show help
help:
+125
View File
@@ -0,0 +1,125 @@
package main
import (
"encoding/json"
"fmt"
"net/url"
"os"
)
// Shared subcommand literals so goconst doesn't flag duplicates across
// the per-noun handler files. `subcmdList` was added with the first
// CLI rollout; `subcmdCreate` was added when the office mutation
// surfaces (agents / routines / docs / task) landed; `subcmdGet` was
// added when the office costs CLI joined memory + task as `get`-style
// readers.
const (
subcmdList = "list"
subcmdCreate = "create"
subcmdGet = "get"
)
// runKandevCLI dispatches the kandev subcommand to the appropriate handler.
// Returns an exit code (0 = success, non-zero = error).
func runKandevCLI(args []string) int {
if len(args) == 0 {
printUsage()
return 1
}
switch args[0] {
case "task":
// Singular `task` group (get/update/create) stays for back
// compat with skills authored before the plural rollout.
// New skills should prefer `tasks` which covers list/move/
// archive/message/conversation as well.
return runTaskCmd(args[1:])
case "tasks":
return runTasksCmd(args[1:])
case "comment":
return runCommentCmd(args[1:])
case "agents":
return runAgentsCmd(args[1:])
case "memory":
return runMemoryCmd(args[1:])
case "checkout":
return runCheckoutCmd(args[1:])
case "label":
return runLabelCmd(args[1:])
case "doc":
return runDocCmd(args[1:])
case "routines":
return runRoutinesCmd(args[1:])
case "approvals":
return runApprovalsCmd(args[1:])
case "budget":
return runBudgetCmd(args[1:])
default:
cliError("unknown command: %s", args[0])
return 1
}
}
func printUsage() {
fmt.Fprintln(os.Stderr, "Usage: agentctl kandev <command> [flags]")
fmt.Fprintln(os.Stderr,
"Commands: task, tasks, comment, agents, memory, checkout, label, doc, routines, approvals, budget")
}
// cliError writes a JSON error object to stderr.
func cliError(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
data, _ := json.Marshal(map[string]string{"error": msg})
fmt.Fprintln(os.Stderr, string(data))
}
// cliOutput writes JSON data to stdout.
func cliOutput(data []byte) {
_, _ = os.Stdout.Write(data)
// Ensure trailing newline for shell friendliness.
if len(data) == 0 || data[len(data)-1] != '\n' {
_, _ = os.Stdout.Write([]byte("\n"))
}
}
// handleResponse checks the HTTP status and writes output or error accordingly.
// Returns 0 on 2xx status, 1 otherwise.
func handleResponse(body []byte, status int, err error) int {
if err != nil {
cliError("%v", err)
return 1
}
if status >= 200 && status < 300 {
cliOutput(body)
return 0
}
// Non-2xx: write body to stderr as the error.
fmt.Fprintln(os.Stderr, string(body))
return 1
}
// getWithParams performs a GET request with query parameters. It handles
// client creation, required env var check, path building, and response output.
func getWithParams(basePath, requiredEnvName, requiredEnvVal string, params map[string]string) int {
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
if requiredEnvVal == "" {
cliError("%s must be set", requiredEnvName)
return 1
}
values := url.Values{}
for k, v := range params {
if v == "" {
continue
}
values.Set(k, v)
}
q := ""
if encoded := values.Encode(); encoded != "" {
q = "?" + encoded
}
body, status, doErr := client.do("GET", basePath+q, nil)
return handleResponse(body, status, doErr)
}
+178
View File
@@ -0,0 +1,178 @@
package main
import (
"flag"
"fmt"
"net/http"
"os"
)
func runAgentsCmd(args []string) int {
if len(args) == 0 {
cliError("usage: agentctl kandev agents <list|create|update|delete> [flags]")
return 1
}
switch args[0] {
case subcmdList:
return agentsList(args[1:])
case subcmdCreate:
return agentsCreate(args[1:])
case "update":
return agentsUpdate(args[1:])
case "delete":
return agentsDelete(args[1:])
default:
cliError("unknown agents subcommand: %s", args[0])
return 1
}
}
// agentsList lists agents in the workspace with optional role and status filters.
func agentsList(args []string) int {
fs := flag.NewFlagSet("agents list", flag.ContinueOnError)
roleFlag := fs.String("role", "", "Filter by role")
statusFlag := fs.String("status", "", "Filter by status")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
wsID := os.Getenv("KANDEV_WORKSPACE_ID")
path := fmt.Sprintf("/api/v1/office/workspaces/%s/agents", wsID)
return getWithParams(path, "KANDEV_WORKSPACE_ID", wsID, map[string]string{
"role": *roleFlag,
"status": *statusFlag,
})
}
// agentsCreate hires a new agent for the current workspace. The
// office governance layer decides whether the agent is created
// immediately or queued behind a hire_agent approval — `agents
// list` after the call surfaces the actual state.
func agentsCreate(args []string) int {
fs := flag.NewFlagSet("agents create", flag.ContinueOnError)
name := fs.String("name", "", "Agent name (required)")
role := fs.String("role", "", "Agent role (required): ceo | worker | specialist | assistant | reviewer")
icon := fs.String("icon", "", "Optional avatar icon slug")
reportsTo := fs.String("reports-to", "", "Agent ID this new agent reports to")
budgetCents := fs.Int("budget-monthly-cents", 0, "Monthly budget in cents (0 = unlimited)")
maxSessions := fs.Int("max-concurrent-sessions", 0, "Maximum concurrent sessions (0 = default)")
reason := fs.String("reason", "", "Justification surfaced on the hire_agent approval, when one is required")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
if *name == "" || *role == "" {
cliError("--name and --role are required")
return 1
}
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
wsID := os.Getenv("KANDEV_WORKSPACE_ID")
if wsID == "" {
cliError("KANDEV_WORKSPACE_ID must be set")
return 1
}
payload := map[string]any{
"name": *name,
"role": *role,
}
if *icon != "" {
payload["icon"] = *icon
}
if *reportsTo != "" {
payload["reports_to"] = *reportsTo
}
if *budgetCents > 0 {
payload["budget_monthly_cents"] = *budgetCents
}
if *maxSessions > 0 {
payload["max_concurrent_sessions"] = *maxSessions
}
if *reason != "" {
payload["reason"] = *reason
}
body, status, err := client.do(http.MethodPost,
fmt.Sprintf("/api/v1/office/workspaces/%s/agents", wsID), payload)
return handleResponse(body, status, err)
}
// agentsUpdate edits an existing agent profile. Mutually optional
// flags — at least one must be set; the backend's PATCH semantics
// only apply fields that are present in the payload.
func agentsUpdate(args []string) int {
fs := flag.NewFlagSet("agents update", flag.ContinueOnError)
id := fs.String("id", "", "Agent ID (required)")
name := fs.String("name", "", "New name")
icon := fs.String("icon", "", "New icon slug")
budgetCents := fs.Int("budget-monthly-cents", -1, "Monthly budget in cents (-1 = unchanged)")
maxSessions := fs.Int("max-concurrent-sessions", -1, "Max concurrent sessions (-1 = unchanged)")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
if *id == "" {
cliError("--id is required")
return 1
}
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
payload := map[string]any{}
if *name != "" {
payload["name"] = *name
}
if *icon != "" {
payload["icon"] = *icon
}
if *budgetCents >= 0 {
payload["budget_monthly_cents"] = *budgetCents
}
if *maxSessions >= 0 {
payload["max_concurrent_sessions"] = *maxSessions
}
if len(payload) == 0 {
cliError("at least one update flag required (--name, --icon, --budget-monthly-cents, --max-concurrent-sessions)")
return 1
}
body, status, err := client.do(http.MethodPatch,
fmt.Sprintf("/api/v1/office/agents/%s", *id), payload)
return handleResponse(body, status, err)
}
// agentsDelete removes an agent from the workspace. Fails server-
// side when the caller lacks `can_delete_agents` or when the agent
// is mid-session.
func agentsDelete(args []string) int {
fs := flag.NewFlagSet("agents delete", flag.ContinueOnError)
id := fs.String("id", "", "Agent ID (required)")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
if *id == "" {
cliError("--id is required")
return 1
}
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
body, status, err := client.do(http.MethodDelete,
fmt.Sprintf("/api/v1/office/agents/%s", *id), nil)
return handleResponse(body, status, err)
}
@@ -0,0 +1,81 @@
package main
import (
"flag"
"fmt"
"net/http"
"os"
)
// runApprovalsCmd dispatches `agentctl kandev approvals <subcmd>`.
// Approvals gate sensitive office mutations (hiring, budget grants,
// etc.) and the CEO uses this group to clear the inbox.
func runApprovalsCmd(args []string) int {
if len(args) == 0 {
cliError("usage: agentctl kandev approvals <list|decide> [flags]")
return 1
}
switch args[0] {
case subcmdList:
return approvalsList(args[1:])
case "decide":
return approvalsDecide(args[1:])
default:
cliError("unknown approvals subcommand: %s", args[0])
return 1
}
}
func approvalsList(args []string) int {
fs := flag.NewFlagSet("approvals list", flag.ContinueOnError)
status := fs.String("status", "", "Filter by status (pending, approved, rejected)")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
wsID := os.Getenv("KANDEV_WORKSPACE_ID")
path := fmt.Sprintf("/api/v1/office/workspaces/%s/approvals", wsID)
return getWithParams(path, "KANDEV_WORKSPACE_ID", wsID, map[string]string{
"status": *status,
})
}
// approvalsDecide flips an approval to approved or rejected. The
// caller is responsible for confirming the policy fit (the backend
// checks RBAC; the CEO is allowed by default).
func approvalsDecide(args []string) int {
fs := flag.NewFlagSet("approvals decide", flag.ContinueOnError)
id := fs.String("id", "", "Approval ID (required)")
decision := fs.String("decision", "", "approve | reject (required)")
note := fs.String("note", "", "Optional decision note (visible on the approval row)")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
if *id == "" || *decision == "" {
cliError("--id and --decision are required")
return 1
}
status := ""
switch *decision {
case "approve", "approved":
status = "approved"
case "reject", "rejected":
status = "rejected"
default:
cliError("--decision must be 'approve' or 'reject'")
return 1
}
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
body, code, err := client.do(http.MethodPost,
fmt.Sprintf("/api/v1/office/approvals/%s/decide", *id),
map[string]any{
"status": status,
"decision_note": *note,
})
return handleResponse(body, code, err)
}
@@ -0,0 +1,49 @@
package main
import (
"flag"
"fmt"
"os"
)
// runBudgetCmd dispatches `agentctl kandev budget <subcmd>`. The
// CEO uses this to check workspace + per-agent spend before
// expensive operations (hiring, model upgrades, etc.).
func runBudgetCmd(args []string) int {
if len(args) == 0 {
cliError("usage: agentctl kandev budget <get> [flags]")
return 1
}
switch args[0] {
case subcmdGet:
return budgetGet(args[1:])
default:
cliError("unknown budget subcommand: %s", args[0])
return 1
}
}
// budgetGet returns the workspace cost summary. When --agent-id is
// supplied, the per-agent endpoint is hit instead so the caller
// sees just that agent's slice.
func budgetGet(args []string) int {
fs := flag.NewFlagSet("budget get", flag.ContinueOnError)
agentID := fs.String("agent-id", "", "Restrict to a single agent's spend")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
wsID := os.Getenv("KANDEV_WORKSPACE_ID")
if wsID == "" {
cliError("KANDEV_WORKSPACE_ID must be set")
return 1
}
if *agentID == "" {
return getWithParams(
fmt.Sprintf("/api/v1/office/workspaces/%s/costs/summary", wsID),
"KANDEV_WORKSPACE_ID", wsID, nil)
}
return getWithParams(
fmt.Sprintf("/api/v1/office/workspaces/%s/costs/by-agent", wsID),
"KANDEV_WORKSPACE_ID", wsID, map[string]string{"agent_id": *agentID})
}
@@ -0,0 +1,61 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"net/http"
)
// runCheckoutCmd attempts to claim a task for the current agent.
// Returns a clear message on 409 (already claimed by another agent).
func runCheckoutCmd(args []string) int {
fs := flag.NewFlagSet("checkout", flag.ContinueOnError)
taskFlag := fs.String("task", "", "Task ID (defaults to $KANDEV_TASK_ID)")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
taskID := resolveTaskID(*taskFlag, client.taskID)
if taskID == "" {
cliError("task ID required: use --task or set KANDEV_TASK_ID")
return 1
}
payload := map[string]string{
"agent_id": client.agentID,
}
body, status, doErr := client.do(http.MethodPost,
fmt.Sprintf("/api/v1/office/tasks/%s/checkout", taskID), payload)
if doErr != nil {
cliError("%v", doErr)
return 1
}
if status == http.StatusConflict {
return handleCheckoutConflict(body)
}
return handleResponse(body, status, nil)
}
// handleCheckoutConflict parses a 409 response and outputs a clear error.
func handleCheckoutConflict(body []byte) int {
var resp map[string]any
if err := json.Unmarshal(body, &resp); err == nil {
if msg, ok := resp["error"].(string); ok {
cliError("task already claimed: %s", msg)
return 1
}
}
cliError("task already claimed by another agent")
return 1
}
@@ -0,0 +1,90 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
// kandevClient is a thin HTTP client for the kandev office API.
// It reads configuration from environment variables set by the kandev backend
// when launching agent containers or processes.
type kandevClient struct {
apiURL string // KANDEV_API_URL
apiKey string // KANDEV_API_KEY
runID string // KANDEV_RUN_ID
agentID string // KANDEV_AGENT_ID
taskID string // KANDEV_TASK_ID (default for --id/--task flags)
workspaceID string // KANDEV_WORKSPACE_ID
http *http.Client
}
// newKandevClient creates a client from environment variables.
// Returns an error if required variables (KANDEV_API_URL, KANDEV_API_KEY) are missing.
func newKandevClient() (*kandevClient, error) {
apiURL := os.Getenv("KANDEV_API_URL")
apiKey := os.Getenv("KANDEV_API_KEY")
if apiURL == "" || apiKey == "" {
return nil, fmt.Errorf("KANDEV_API_URL and KANDEV_API_KEY must be set")
}
return &kandevClient{
apiURL: apiURL,
apiKey: apiKey,
runID: os.Getenv("KANDEV_RUN_ID"),
agentID: os.Getenv("KANDEV_AGENT_ID"),
taskID: os.Getenv("KANDEV_TASK_ID"),
workspaceID: os.Getenv("KANDEV_WORKSPACE_ID"),
http: &http.Client{Timeout: 30 * time.Second},
}, nil
}
// isMutating returns true for methods that modify server state.
func isMutating(method string) bool {
switch method {
case http.MethodPost, http.MethodPatch, http.MethodPut, http.MethodDelete:
return true
}
return false
}
// do sends an HTTP request to the kandev API and returns the response body,
// status code, and any error. It sets Authorization and, for mutating requests,
// X-Kandev-Run-Id headers automatically.
func (c *kandevClient) do(method, path string, body any) ([]byte, int, error) {
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, 0, fmt.Errorf("marshal request body: %w", err)
}
bodyReader = bytes.NewReader(data)
}
url := c.apiURL + path
req, err := http.NewRequest(method, url, bodyReader)
if err != nil {
return nil, 0, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
if isMutating(method) && c.runID != "" {
req.Header.Set("X-Kandev-Run-Id", c.runID)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("http request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, fmt.Errorf("read response: %w", err)
}
return respBody, resp.StatusCode, nil
}
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"flag"
"fmt"
"io"
"net/http"
"os"
"strconv"
)
func runCommentCmd(args []string) int {
if len(args) == 0 {
cliError("usage: agentctl kandev comment <add|list> [flags]")
return 1
}
switch args[0] {
case "add":
return commentAdd(args[1:])
case subcmdList:
return commentList(args[1:])
default:
cliError("unknown comment subcommand: %s", args[0])
return 1
}
}
// commentAdd posts a comment on a task. If --body is "-", reads from stdin.
func commentAdd(args []string) int {
fs := flag.NewFlagSet("comment add", flag.ContinueOnError)
taskFlag := fs.String("task", "", "Task ID (defaults to $KANDEV_TASK_ID)")
bodyFlag := fs.String("body", "", "Comment body (use \"-\" for stdin)")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
taskID := resolveTaskID(*taskFlag, client.taskID)
if taskID == "" {
cliError("task ID required: use --task or set KANDEV_TASK_ID")
return 1
}
body, err := resolveBody(*bodyFlag)
if err != nil {
cliError("read body: %v", err)
return 1
}
if body == "" {
cliError("--body is required")
return 1
}
payload := map[string]string{
"body": body,
"author_type": "agent",
"author_id": client.agentID,
}
respBody, status, doErr := client.do(http.MethodPost,
fmt.Sprintf("/api/v1/office/tasks/%s/comments", taskID), payload)
return handleResponse(respBody, status, doErr)
}
// commentList retrieves comments for a task with optional limit.
func commentList(args []string) int {
fs := flag.NewFlagSet("comment list", flag.ContinueOnError)
taskFlag := fs.String("task", "", "Task ID (defaults to $KANDEV_TASK_ID)")
limitFlag := fs.Int("limit", 0, "Max number of comments (0 = all)")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
taskID := resolveTaskID(*taskFlag, client.taskID)
if taskID == "" {
cliError("task ID required: use --task or set KANDEV_TASK_ID")
return 1
}
path := fmt.Sprintf("/api/v1/office/tasks/%s/comments", taskID)
if *limitFlag > 0 {
path += "?limit=" + strconv.Itoa(*limitFlag)
}
body, status, doErr := client.do(http.MethodGet, path, nil)
return handleResponse(body, status, doErr)
}
// resolveBody returns the body string, or reads from stdin when value is "-".
func resolveBody(value string) (string, error) {
if value != "-" {
return value, nil
}
data, err := io.ReadAll(os.Stdin)
if err != nil {
return "", fmt.Errorf("read stdin: %w", err)
}
return string(data), nil
}
+183
View File
@@ -0,0 +1,183 @@
package main
import (
"flag"
"fmt"
"io"
"mime"
"net/http"
"os"
"path/filepath"
)
func runDocCmd(args []string) int {
if len(args) == 0 {
cliError("usage: agentctl kandev doc <create|read|list|upload> [flags]")
return 1
}
switch args[0] {
case subcmdCreate:
return docCreate(args[1:])
case "read":
return docRead(args[1:])
case subcmdList:
return docList(args[1:])
case "upload":
return docUpload(args[1:])
default:
cliError("unknown doc subcommand: %s", args[0])
return 1
}
}
// docCreate creates or updates a document for a task.
// Usage: kandev doc create <task-id> <key> --type <type> --title <title> --content <content>
func docCreate(args []string) int {
// Go's flag.FlagSet.Parse stops at the first non-flag argument, so the
// documented "positional THEN flags" form would silently leave --type /
// --title / --content at their defaults. Peel off the two required
// positionals first and parse the remainder as flags.
if len(args) < 2 {
cliError("usage: kandev doc create <task-id> <key> [flags]")
return 1
}
taskID := args[0]
key := args[1]
fs := flag.NewFlagSet("doc create", flag.ContinueOnError)
docType := fs.String("type", "custom", "Document type (plan, spec, notes, review, attachment, custom)")
title := fs.String("title", "", "Document title")
content := fs.String("content", "", "Document content (markdown). If empty, reads from stdin.")
if err := fs.Parse(args[2:]); err != nil {
cliError("parse flags: %v", err)
return 1
}
docContent := *content
if docContent == "" {
data, err := io.ReadAll(os.Stdin)
if err != nil {
cliError("read stdin: %v", err)
return 1
}
docContent = string(data)
}
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
payload := map[string]interface{}{
"type": *docType,
"title": *title,
"content": docContent,
}
body, status, doErr := client.do(http.MethodPut,
fmt.Sprintf("/api/v1/tasks/%s/documents/%s", taskID, key), payload)
return handleResponse(body, status, doErr)
}
// docRead fetches and prints the content of a document.
// Usage: kandev doc read <task-id> <key>
func docRead(args []string) int {
fs := flag.NewFlagSet("doc read", flag.ContinueOnError)
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
positional := fs.Args()
if len(positional) < 2 {
cliError("usage: kandev doc read <task-id> <key>")
return 1
}
taskID := positional[0]
key := positional[1]
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
body, status, doErr := client.do(http.MethodGet,
fmt.Sprintf("/api/v1/tasks/%s/documents/%s", taskID, key), nil)
return handleResponse(body, status, doErr)
}
// docList lists all documents for a task.
// Usage: kandev doc list <task-id>
func docList(args []string) int {
fs := flag.NewFlagSet("doc list", flag.ContinueOnError)
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
positional := fs.Args()
if len(positional) < 1 {
cliError("usage: kandev doc list <task-id>")
return 1
}
taskID := positional[0]
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
body, status, doErr := client.do(http.MethodGet,
fmt.Sprintf("/api/v1/tasks/%s/documents", taskID), nil)
return handleResponse(body, status, doErr)
}
// docUpload uploads a file as an attachment document.
// Usage: kandev doc upload <task-id> <key> <filepath>
func docUpload(args []string) int {
fs := flag.NewFlagSet("doc upload", flag.ContinueOnError)
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
positional := fs.Args()
if len(positional) < 3 {
cliError("usage: kandev doc upload <task-id> <key> <filepath>")
return 1
}
taskID := positional[0]
key := positional[1]
filePath := positional[2]
data, err := os.ReadFile(filePath)
if err != nil {
cliError("read file %q: %v", filePath, err)
return 1
}
filename := filepath.Base(filePath)
mimeType := mime.TypeByExtension(filepath.Ext(filename))
if mimeType == "" {
mimeType = "application/octet-stream"
}
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
payload := map[string]interface{}{
"filename": filename,
"mime_type": mimeType,
"data": data,
}
body, status, doErr := client.do(http.MethodPost,
fmt.Sprintf("/api/v1/tasks/%s/documents/%s/upload", taskID, key), payload)
return handleResponse(body, status, doErr)
}
+131
View File
@@ -0,0 +1,131 @@
package main
import (
"flag"
"fmt"
"net/http"
"net/url"
)
// runLabelCmd dispatches label sub-commands: add, remove, list.
func runLabelCmd(args []string) int {
if len(args) == 0 {
cliError("usage: agentctl kandev label <add|remove|list> [flags]")
return 1
}
switch args[0] {
case "add":
return labelAdd(args[1:])
case "remove":
return labelRemove(args[1:])
case subcmdList:
return labelList(args[1:])
default:
cliError("unknown label subcommand: %s", args[0])
return 1
}
}
// labelAdd adds a named label to a task.
func labelAdd(args []string) int {
fs := flag.NewFlagSet("label add", flag.ContinueOnError)
taskFlag := fs.String("task", "", "Task ID (defaults to $KANDEV_TASK_ID)")
nameFlag := fs.String("name", "", "Label name (required)")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
taskID := resolveTaskID(*taskFlag, client.taskID)
if taskID == "" {
cliError("task ID required: use --task or set KANDEV_TASK_ID")
return 1
}
if *nameFlag == "" {
cliError("--name is required")
return 1
}
if client.workspaceID == "" {
cliError("KANDEV_WORKSPACE_ID must be set")
return 1
}
payload := map[string]string{"name": *nameFlag}
path := fmt.Sprintf("/api/v1/office/workspaces/%s/tasks/%s/labels",
client.workspaceID, taskID)
body, status, doErr := client.do(http.MethodPost, path, payload)
return handleResponse(body, status, doErr)
}
// labelRemove removes a named label from a task.
func labelRemove(args []string) int {
fs := flag.NewFlagSet("label remove", flag.ContinueOnError)
taskFlag := fs.String("task", "", "Task ID (defaults to $KANDEV_TASK_ID)")
nameFlag := fs.String("name", "", "Label name (required)")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
taskID := resolveTaskID(*taskFlag, client.taskID)
if taskID == "" {
cliError("task ID required: use --task or set KANDEV_TASK_ID")
return 1
}
if *nameFlag == "" {
cliError("--name is required")
return 1
}
if client.workspaceID == "" {
cliError("KANDEV_WORKSPACE_ID must be set")
return 1
}
path := fmt.Sprintf("/api/v1/office/workspaces/%s/tasks/%s/labels/%s",
client.workspaceID, taskID, url.PathEscape(*nameFlag))
body, status, doErr := client.do(http.MethodDelete, path, nil)
return handleResponse(body, status, doErr)
}
// labelList lists all labels attached to a task.
func labelList(args []string) int {
fs := flag.NewFlagSet("label list", flag.ContinueOnError)
taskFlag := fs.String("task", "", "Task ID (defaults to $KANDEV_TASK_ID)")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
taskID := resolveTaskID(*taskFlag, client.taskID)
if taskID == "" {
cliError("task ID required: use --task or set KANDEV_TASK_ID")
return 1
}
if client.workspaceID == "" {
cliError("KANDEV_WORKSPACE_ID must be set")
return 1
}
path := fmt.Sprintf("/api/v1/office/workspaces/%s/tasks/%s/labels",
client.workspaceID, taskID)
body, status, doErr := client.do(http.MethodGet, path, nil)
return handleResponse(body, status, doErr)
}
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"flag"
"fmt"
"net/http"
"os"
)
func runMemoryCmd(args []string) int {
if len(args) == 0 {
cliError("usage: agentctl kandev memory <get|set|summary> [flags]")
return 1
}
switch args[0] {
case subcmdGet:
return memoryGet(args[1:])
case "set":
return memorySet(args[1:])
case "summary":
return memorySummary(args[1:])
default:
cliError("unknown memory subcommand: %s", args[0])
return 1
}
}
// memoryGet retrieves memory entries for the current agent, with optional
// layer and key filters.
func memoryGet(args []string) int {
fs := flag.NewFlagSet("memory get", flag.ContinueOnError)
layerFlag := fs.String("layer", "", "Filter by layer")
keyFlag := fs.String("key", "", "Filter by key")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
agentID := os.Getenv("KANDEV_AGENT_ID")
path := fmt.Sprintf("/api/v1/office/agents/%s/memory", agentID)
return getWithParams(path, "KANDEV_AGENT_ID", agentID, map[string]string{
"layer": *layerFlag,
"key": *keyFlag,
})
}
// memorySet upserts a memory entry for the current agent.
func memorySet(args []string) int {
fs := flag.NewFlagSet("memory set", flag.ContinueOnError)
layerFlag := fs.String("layer", "", "Memory layer (required)")
keyFlag := fs.String("key", "", "Memory key (required)")
contentFlag := fs.String("content", "", "Memory content (required)")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
if *layerFlag == "" || *keyFlag == "" || *contentFlag == "" {
cliError("--layer, --key, and --content are all required")
return 1
}
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
if client.agentID == "" {
cliError("KANDEV_AGENT_ID must be set")
return 1
}
payload := map[string]any{
"entries": []map[string]string{
{
"layer": *layerFlag,
"key": *keyFlag,
"content": *contentFlag,
},
},
}
path := fmt.Sprintf("/api/v1/office/agents/%s/memory", client.agentID)
body, status, doErr := client.do(http.MethodPut, path, payload)
return handleResponse(body, status, doErr)
}
// memorySummary retrieves a summary of the agent's memory entries.
func memorySummary(args []string) int {
fs := flag.NewFlagSet("memory summary", flag.ContinueOnError)
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
if client.agentID == "" {
cliError("KANDEV_AGENT_ID must be set")
return 1
}
path := fmt.Sprintf("/api/v1/office/agents/%s/memory/summary", client.agentID)
body, status, doErr := client.do(http.MethodGet, path, nil)
return handleResponse(body, status, doErr)
}
@@ -0,0 +1,174 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"net/http"
"os"
)
// runRoutinesCmd dispatches `agentctl kandev routines <subcmd>`.
// Office routines schedule recurring work via cron or webhook; the
// CEO uses this group to install standups, weekly reports, etc.
func runRoutinesCmd(args []string) int {
if len(args) == 0 {
cliError("usage: agentctl kandev routines <list|create|pause|resume|delete> [flags]")
return 1
}
switch args[0] {
case subcmdList:
return routinesList(args[1:])
case subcmdCreate:
return routinesCreate(args[1:])
case "pause":
return routinesSetStatus(args[1:], "paused")
case "resume":
return routinesSetStatus(args[1:], "active")
case "delete":
return routinesDelete(args[1:])
default:
cliError("unknown routines subcommand: %s", args[0])
return 1
}
}
func routinesList(args []string) int {
fs := flag.NewFlagSet("routines list", flag.ContinueOnError)
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
wsID := os.Getenv("KANDEV_WORKSPACE_ID")
path := fmt.Sprintf("/api/v1/office/workspaces/%s/routines", wsID)
return getWithParams(path, "KANDEV_WORKSPACE_ID", wsID, nil)
}
// routinesCreate provisions a routine + cron trigger in two
// requests. The trigger is created only when --cron is set; webhook
// triggers can be added later via the UI or future subcommand.
//
// Why two calls: the backend keeps the routine and its trigger in
// separate tables so a routine can carry multiple triggers (cron +
// webhook) without redesigning the create endpoint. The cost of two
// round-trips is acceptable for an interactive CEO action.
func routinesCreate(args []string) int {
fs := flag.NewFlagSet("routines create", flag.ContinueOnError)
name := fs.String("name", "", "Routine name (required)")
taskTitle := fs.String("task-title", "", "Title for tasks created by each fire (required)")
taskDesc := fs.String("task-description", "", "Description for tasks created by each fire")
assignee := fs.String("assignee", "", "Agent ID that receives the created task (required)")
cron := fs.String("cron", "", "Cron expression for the fire trigger (e.g. '0 9 * * MON-FRI')")
timezone := fs.String("timezone", "UTC", "Timezone for the cron trigger")
concurrency := fs.String("concurrency", "coalesce_if_active", "Concurrency policy")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
if *name == "" || *taskTitle == "" || *assignee == "" {
cliError("--name, --task-title and --assignee are required")
return 1
}
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
wsID := os.Getenv("KANDEV_WORKSPACE_ID")
if wsID == "" {
cliError("KANDEV_WORKSPACE_ID must be set")
return 1
}
template := map[string]string{"title": *taskTitle}
if *taskDesc != "" {
template["description"] = *taskDesc
}
tplJSON, _ := json.Marshal(template)
body, status, err := client.do(http.MethodPost,
fmt.Sprintf("/api/v1/office/workspaces/%s/routines", wsID),
map[string]any{
"name": *name,
"task_template": string(tplJSON),
"assignee_agent_profile_id": *assignee,
"concurrency_policy": *concurrency,
})
if err != nil || status >= 300 {
return handleResponse(body, status, err)
}
if *cron == "" {
cliOutput(body)
return 0
}
// Extract the routine id so the trigger call knows where to attach.
var created struct {
ID string `json:"id"`
Routine struct {
ID string `json:"id"`
} `json:"routine"`
}
_ = json.Unmarshal(body, &created)
routineID := created.ID
if routineID == "" {
routineID = created.Routine.ID
}
if routineID == "" {
cliError("created routine but response missing id (cannot attach cron trigger): %s", string(body))
return 1
}
trBody, trStatus, trErr := client.do(http.MethodPost,
fmt.Sprintf("/api/v1/office/routines/%s/triggers", routineID),
map[string]any{
"kind": "cron",
"cron_expression": *cron,
"timezone": *timezone,
})
return handleResponse(trBody, trStatus, trErr)
}
func routinesSetStatus(args []string, status string) int {
fs := flag.NewFlagSet("routines status", flag.ContinueOnError)
id := fs.String("id", "", "Routine ID (required)")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
if *id == "" {
cliError("--id is required")
return 1
}
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
body, code, err := client.do(http.MethodPatch,
fmt.Sprintf("/api/v1/office/routines/%s", *id),
map[string]any{"status": status})
return handleResponse(body, code, err)
}
func routinesDelete(args []string) int {
fs := flag.NewFlagSet("routines delete", flag.ContinueOnError)
id := fs.String("id", "", "Routine ID (required)")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
if *id == "" {
cliError("--id is required")
return 1
}
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
body, code, err := client.do(http.MethodDelete,
fmt.Sprintf("/api/v1/office/routines/%s", *id), nil)
return handleResponse(body, code, err)
}
+147
View File
@@ -0,0 +1,147 @@
package main
import (
"flag"
"fmt"
"net/http"
"strings"
)
func runTaskCmd(args []string) int {
if len(args) == 0 {
cliError("usage: agentctl kandev task <get|update|create> [flags]")
return 1
}
switch args[0] {
case subcmdGet:
return taskGet(args[1:])
case "update":
return taskUpdate(args[1:])
case subcmdCreate:
return taskCreate(args[1:])
default:
cliError("unknown task subcommand: %s", args[0])
return 1
}
}
// taskGet fetches a task by ID. Defaults to $KANDEV_TASK_ID when --id is omitted.
func taskGet(args []string) int {
fs := flag.NewFlagSet("task get", flag.ContinueOnError)
id := fs.String("id", "", "Task ID (defaults to $KANDEV_TASK_ID)")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
taskID := resolveTaskID(*id, client.taskID)
if taskID == "" {
cliError("task ID required: use --id or set KANDEV_TASK_ID")
return 1
}
body, status, err := client.do(http.MethodGet,
fmt.Sprintf("/api/v1/office/tasks/%s", taskID), nil)
return handleResponse(body, status, err)
}
// taskUpdate patches a task with optional status and comment fields.
func taskUpdate(args []string) int {
fs := flag.NewFlagSet("task update", flag.ContinueOnError)
id := fs.String("id", "", "Task ID (defaults to $KANDEV_TASK_ID)")
status := fs.String("status", "", "New status")
comment := fs.String("comment", "", "Comment to add")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
taskID := resolveTaskID(*id, client.taskID)
if taskID == "" {
cliError("task ID required: use --id or set KANDEV_TASK_ID")
return 1
}
payload := make(map[string]string)
if *status != "" {
payload["status"] = *status
}
if *comment != "" {
payload["comment"] = *comment
}
if len(payload) == 0 {
cliError("nothing to update: pass --status and/or --comment")
return 1
}
body, statusCode, err := client.do(http.MethodPatch,
fmt.Sprintf("/api/v1/office/tasks/%s", taskID), payload)
return handleResponse(body, statusCode, err)
}
// taskCreate creates a new task via the kanban task API.
func taskCreate(args []string) int {
fs := flag.NewFlagSet("task create", flag.ContinueOnError)
title := fs.String("title", "", "Task title (required)")
parent := fs.String("parent", "", "Parent task ID")
assignee := fs.String("assignee", "", "Assignee agent ID")
priority := fs.String("priority", "", "Priority value")
blockedBy := fs.String("blocked-by", "", "Comma-separated task IDs that must complete before this task")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
if *title == "" {
cliError("--title is required")
return 1
}
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
payload := map[string]interface{}{"title": *title}
if *parent != "" {
payload["parent_id"] = *parent
}
if *assignee != "" {
payload["assignee"] = *assignee
}
if *priority != "" {
payload["priority"] = *priority
}
if *blockedBy != "" {
ids := strings.Split(*blockedBy, ",")
for i, id := range ids {
ids[i] = strings.TrimSpace(id)
}
payload["blocked_by"] = ids
}
body, status, err := client.do(http.MethodPost, "/api/v1/tasks", payload)
return handleResponse(body, status, err)
}
// resolveTaskID returns the explicit ID if provided, otherwise falls back to
// the default (from KANDEV_TASK_ID env var).
func resolveTaskID(explicit, defaultID string) string {
if explicit != "" {
return explicit
}
return defaultID
}
+165
View File
@@ -0,0 +1,165 @@
package main
import (
"flag"
"fmt"
"net/http"
"os"
)
// runTasksCmd dispatches `agentctl kandev tasks <subcmd>`. The
// singular `agentctl kandev task <subcmd>` group (get/update/create)
// remains the back-compat path; this plural group adds the verbs
// the office runtime needs without breaking older scripts.
func runTasksCmd(args []string) int {
if len(args) == 0 {
cliError("usage: agentctl kandev tasks <list|move|archive|message|conversation> [flags]")
return 1
}
switch args[0] {
case subcmdList:
return tasksList(args[1:])
case "move":
return tasksMove(args[1:])
case "archive":
return tasksArchive(args[1:])
case "message":
return tasksMessage(args[1:])
case "conversation":
return tasksConversation(args[1:])
default:
cliError("unknown tasks subcommand: %s", args[0])
return 1
}
}
// tasksList returns workspace tasks, optionally filtered by status
// or assignee. Hits the office dashboard list endpoint so the
// returned shape matches what the Tasks page sees.
func tasksList(args []string) int {
fs := flag.NewFlagSet("tasks list", flag.ContinueOnError)
status := fs.String("status", "", "Filter by status (todo, in_progress, done, …)")
assignee := fs.String("assignee", "", "Filter by assignee agent ID")
project := fs.String("project", "", "Filter by project ID")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
wsID := os.Getenv("KANDEV_WORKSPACE_ID")
path := fmt.Sprintf("/api/v1/office/workspaces/%s/tasks", wsID)
return getWithParams(path, "KANDEV_WORKSPACE_ID", wsID, map[string]string{
"status": *status,
"assignee": *assignee,
"project": *project,
})
}
// tasksMove transitions a task to a different workflow step.
// `--prompt` queues a handoff message that the receiving step will
// see as the first user comment.
func tasksMove(args []string) int {
fs := flag.NewFlagSet("tasks move", flag.ContinueOnError)
id := fs.String("id", "", "Task ID (required; defaults to $KANDEV_TASK_ID)")
step := fs.String("step", "", "Destination workflow step ID (required)")
prompt := fs.String("prompt", "", "Optional handoff prompt queued for the destination step")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
taskID := resolveTaskID(*id, client.taskID)
if taskID == "" || *step == "" {
cliError("--id (or KANDEV_TASK_ID) and --step are required")
return 1
}
payload := map[string]any{"workflow_step_id": *step}
if *prompt != "" {
payload["prompt"] = *prompt
}
body, status, err := client.do(http.MethodPost,
fmt.Sprintf("/api/v1/tasks/%s/move", taskID), payload)
return handleResponse(body, status, err)
}
// tasksArchive archives a task. Idempotent: archiving an already-
// archived task is a 200/204 no-op.
func tasksArchive(args []string) int {
fs := flag.NewFlagSet("tasks archive", flag.ContinueOnError)
id := fs.String("id", "", "Task ID (required; defaults to $KANDEV_TASK_ID)")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
taskID := resolveTaskID(*id, client.taskID)
if taskID == "" {
cliError("--id (or KANDEV_TASK_ID) required")
return 1
}
body, status, err := client.do(http.MethodPost,
fmt.Sprintf("/api/v1/tasks/%s/archive", taskID), nil)
return handleResponse(body, status, err)
}
// tasksMessage posts a comment to a task as the current agent.
// Different from `comment add` (which writes as the user) — this
// shape lets the CEO drop a coordinator note on a worker's task
// without spawning a new subtask.
func tasksMessage(args []string) int {
fs := flag.NewFlagSet("tasks message", flag.ContinueOnError)
id := fs.String("id", "", "Task ID (required; defaults to $KANDEV_TASK_ID)")
prompt := fs.String("prompt", "", "Message body (required)")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
taskID := resolveTaskID(*id, client.taskID)
if taskID == "" || *prompt == "" {
cliError("--id (or KANDEV_TASK_ID) and --prompt are required")
return 1
}
payload := map[string]any{
"body": *prompt,
"author_type": "agent",
}
body, status, err := client.do(http.MethodPost,
fmt.Sprintf("/api/v1/office/tasks/%s/comments", taskID), payload)
return handleResponse(body, status, err)
}
// tasksConversation lists comments on a task. Useful for catching
// up after a wake when the prepended-summary slice is empty.
func tasksConversation(args []string) int {
fs := flag.NewFlagSet("tasks conversation", flag.ContinueOnError)
id := fs.String("id", "", "Task ID (required; defaults to $KANDEV_TASK_ID)")
if err := fs.Parse(args); err != nil {
cliError("parse flags: %v", err)
return 1
}
client, err := newKandevClient()
if err != nil {
cliError("%v", err)
return 1
}
taskID := resolveTaskID(*id, client.taskID)
if taskID == "" {
cliError("--id (or KANDEV_TASK_ID) required")
return 1
}
body, status, err := client.do(http.MethodGet,
fmt.Sprintf("/api/v1/office/tasks/%s/comments", taskID), nil)
return handleResponse(body, status, err)
}
+609
View File
@@ -0,0 +1,609 @@
package main
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
)
// capturedRequest stores details from the mock server for assertion.
type capturedRequest struct {
Method string
Path string
Query string
Body string
Header http.Header
}
// setupMockServer creates an httptest server that records the request and
// responds with the given status and body.
func setupMockServer(t *testing.T, status int, respBody string) (*httptest.Server, *capturedRequest) {
t.Helper()
captured := &capturedRequest{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured.Method = r.Method
captured.Path = r.URL.Path
captured.Query = r.URL.RawQuery
captured.Header = r.Header.Clone()
body, _ := io.ReadAll(r.Body)
captured.Body = string(body)
w.WriteHeader(status)
_, _ = w.Write([]byte(respBody))
}))
t.Cleanup(srv.Close)
return srv, captured
}
// setEnvVars sets the required env vars for tests and returns a cleanup function.
func setEnvVars(t *testing.T, srv *httptest.Server) {
t.Helper()
t.Setenv("KANDEV_API_URL", srv.URL)
t.Setenv("KANDEV_API_KEY", "test-key-123")
t.Setenv("KANDEV_RUN_ID", "run-456")
t.Setenv("KANDEV_AGENT_ID", "agent-789")
t.Setenv("KANDEV_TASK_ID", "task-abc")
t.Setenv("KANDEV_WORKSPACE_ID", "ws-def")
}
// --- Task Tests ---
func TestTaskGet_CallsCorrectEndpoint(t *testing.T) {
srv, captured := setupMockServer(t, 200, `{"task":{"id":"task-abc"}}`)
setEnvVars(t, srv)
code := runKandevCLI([]string{"task", "get"})
if code != 0 {
t.Fatalf("expected exit 0, got %d", code)
}
if captured.Method != "GET" {
t.Errorf("expected GET, got %s", captured.Method)
}
if captured.Path != "/api/v1/office/tasks/task-abc" {
t.Errorf("unexpected path: %s", captured.Path)
}
assertAuthHeader(t, captured)
}
func TestTaskGet_ExplicitID(t *testing.T) {
srv, captured := setupMockServer(t, 200, `{"task":{"id":"explicit-1"}}`)
setEnvVars(t, srv)
code := runKandevCLI([]string{"task", "get", "--id", "explicit-1"})
if code != 0 {
t.Fatalf("expected exit 0, got %d", code)
}
if captured.Path != "/api/v1/office/tasks/explicit-1" {
t.Errorf("unexpected path: %s", captured.Path)
}
}
func TestTaskUpdate_SendsPatchWithRunIDHeader(t *testing.T) {
srv, captured := setupMockServer(t, 200, `{"ok":true}`)
setEnvVars(t, srv)
code := runKandevCLI([]string{
"task", "update", "--status", "done", "--comment", "finished",
})
if code != 0 {
t.Fatalf("expected exit 0, got %d", code)
}
if captured.Method != "PATCH" {
t.Errorf("expected PATCH, got %s", captured.Method)
}
if captured.Path != "/api/v1/office/tasks/task-abc" {
t.Errorf("unexpected path: %s", captured.Path)
}
assertAuthHeader(t, captured)
assertRunIDHeader(t, captured, "run-456")
var body map[string]string
if err := json.Unmarshal([]byte(captured.Body), &body); err != nil {
t.Fatalf("unmarshal body: %v", err)
}
if body["status"] != "done" {
t.Errorf("expected status=done, got %s", body["status"])
}
if body["comment"] != "finished" {
t.Errorf("expected comment=finished, got %s", body["comment"])
}
}
// TestTaskUpdate_EmptyPayload_ReturnsError pins the guard added in
// `taskUpdate` after the cubic-dev-ai review: invoking `task update`
// with neither `--status` nor `--comment` would otherwise send an
// empty PATCH, which is never the intent. The fix returns exit 1
// before contacting the server; the assertion below would have caught
// a regression to the old "send empty body" behaviour.
func TestTaskUpdate_EmptyPayload_ReturnsError(t *testing.T) {
srv, captured := setupMockServer(t, 200, `{}`)
setEnvVars(t, srv)
code := runKandevCLI([]string{"task", "update"})
if code == 0 {
t.Fatal("expected non-zero exit when no update fields provided, got 0")
}
if captured.Method != "" {
t.Errorf("server must not be contacted on empty payload; got %s %s", captured.Method, captured.Path)
}
}
func TestTaskCreate_PostsToTasksEndpoint(t *testing.T) {
srv, captured := setupMockServer(t, 201, `{"task":{"id":"new-1"}}`)
setEnvVars(t, srv)
code := runKandevCLI([]string{
"task", "create", "--title", "New task", "--parent", "parent-1", "--assignee", "agent-2",
})
if code != 0 {
t.Fatalf("expected exit 0, got %d", code)
}
if captured.Method != "POST" {
t.Errorf("expected POST, got %s", captured.Method)
}
if captured.Path != "/api/v1/tasks" {
t.Errorf("unexpected path: %s", captured.Path)
}
var body map[string]string
if err := json.Unmarshal([]byte(captured.Body), &body); err != nil {
t.Fatalf("unmarshal body: %v", err)
}
if body["title"] != "New task" {
t.Errorf("expected title='New task', got %s", body["title"])
}
if body["parent_id"] != "parent-1" {
t.Errorf("expected parent_id='parent-1', got %s", body["parent_id"])
}
}
func TestTaskCreate_BlockedBy(t *testing.T) {
srv, captured := setupMockServer(t, 201, `{"task":{"id":"new-2"}}`)
setEnvVars(t, srv)
code := runKandevCLI([]string{
"task", "create",
"--title", "Staged task",
"--blocked-by", "task-1,task-2",
})
if code != 0 {
t.Fatalf("expected exit 0, got %d", code)
}
var body map[string]interface{}
if err := json.Unmarshal([]byte(captured.Body), &body); err != nil {
t.Fatalf("unmarshal body: %v", err)
}
blockedBy, ok := body["blocked_by"].([]interface{})
if !ok {
t.Fatalf("expected blocked_by to be []interface{}, got %T: %v", body["blocked_by"], body["blocked_by"])
}
if len(blockedBy) != 2 {
t.Fatalf("expected 2 blocked_by entries, got %d", len(blockedBy))
}
if blockedBy[0] != "task-1" || blockedBy[1] != "task-2" {
t.Errorf("unexpected blocked_by values: %v", blockedBy)
}
}
func TestTaskCreate_BlockedByTrimsSpaces(t *testing.T) {
srv, captured := setupMockServer(t, 201, `{"task":{"id":"new-3"}}`)
setEnvVars(t, srv)
code := runKandevCLI([]string{
"task", "create",
"--title", "Space task",
"--blocked-by", "task-a, task-b , task-c",
})
if code != 0 {
t.Fatalf("expected exit 0, got %d", code)
}
var body map[string]interface{}
if err := json.Unmarshal([]byte(captured.Body), &body); err != nil {
t.Fatalf("unmarshal body: %v", err)
}
blockedBy, ok := body["blocked_by"].([]interface{})
if !ok {
t.Fatalf("expected blocked_by to be []interface{}, got %T", body["blocked_by"])
}
if len(blockedBy) != 3 {
t.Fatalf("expected 3 entries, got %d", len(blockedBy))
}
if blockedBy[0] != "task-a" || blockedBy[1] != "task-b" || blockedBy[2] != "task-c" {
t.Errorf("unexpected blocked_by values: %v", blockedBy)
}
}
func TestTaskCreate_NoExecutionPolicyOrBlockedBy_OmitsFields(t *testing.T) {
srv, captured := setupMockServer(t, 201, `{"task":{"id":"new-4"}}`)
setEnvVars(t, srv)
code := runKandevCLI([]string{"task", "create", "--title", "Plain task"})
if code != 0 {
t.Fatalf("expected exit 0, got %d", code)
}
var body map[string]interface{}
if err := json.Unmarshal([]byte(captured.Body), &body); err != nil {
t.Fatalf("unmarshal body: %v", err)
}
if _, exists := body["execution_policy"]; exists {
t.Error("execution_policy should not be present when flag is not set")
}
if _, exists := body["blocked_by"]; exists {
t.Error("blocked_by should not be present when flag is not set")
}
}
// --- Comment Tests ---
func TestCommentAdd_PostsComment(t *testing.T) {
srv, captured := setupMockServer(t, 201, `{"ok":true}`)
setEnvVars(t, srv)
code := runKandevCLI([]string{
"comment", "add", "--body", "This is a test comment",
})
if code != 0 {
t.Fatalf("expected exit 0, got %d", code)
}
if captured.Method != "POST" {
t.Errorf("expected POST, got %s", captured.Method)
}
if captured.Path != "/api/v1/office/tasks/task-abc/comments" {
t.Errorf("unexpected path: %s", captured.Path)
}
var body map[string]string
if err := json.Unmarshal([]byte(captured.Body), &body); err != nil {
t.Fatalf("unmarshal body: %v", err)
}
if body["body"] != "This is a test comment" {
t.Errorf("unexpected body: %s", body["body"])
}
if body["author_id"] != "agent-789" {
t.Errorf("expected author_id=agent-789, got %s", body["author_id"])
}
}
func TestCommentAdd_StdinMode(t *testing.T) {
srv, captured := setupMockServer(t, 201, `{"ok":true}`)
setEnvVars(t, srv)
// Replace stdin with a pipe containing test data.
oldStdin := os.Stdin
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
os.Stdin = r
t.Cleanup(func() { os.Stdin = oldStdin })
_, _ = w.WriteString("multi-line\ncomment from stdin")
_ = w.Close()
code := runKandevCLI([]string{"comment", "add", "--body", "-"})
if code != 0 {
t.Fatalf("expected exit 0, got %d", code)
}
var body map[string]string
if err := json.Unmarshal([]byte(captured.Body), &body); err != nil {
t.Fatalf("unmarshal body: %v", err)
}
if body["body"] != "multi-line\ncomment from stdin" {
t.Errorf("unexpected body: %q", body["body"])
}
}
func TestCommentList_GetsCommentsWithLimit(t *testing.T) {
srv, captured := setupMockServer(t, 200, `{"comments":[]}`)
setEnvVars(t, srv)
code := runKandevCLI([]string{"comment", "list", "--limit", "5"})
if code != 0 {
t.Fatalf("expected exit 0, got %d", code)
}
if captured.Method != "GET" {
t.Errorf("expected GET, got %s", captured.Method)
}
if !strings.Contains(captured.Query, "limit=5") {
t.Errorf("expected limit=5 in query, got %s", captured.Query)
}
}
// --- Agents Tests ---
func TestAgentsList_FiltersRoleAndStatus(t *testing.T) {
srv, captured := setupMockServer(t, 200, `{"agents":[]}`)
setEnvVars(t, srv)
code := runKandevCLI([]string{"agents", "list", "--role", "worker", "--status", "idle"})
if code != 0 {
t.Fatalf("expected exit 0, got %d", code)
}
if captured.Path != "/api/v1/office/workspaces/ws-def/agents" {
t.Errorf("unexpected path: %s", captured.Path)
}
if !strings.Contains(captured.Query, "role=worker") {
t.Errorf("expected role=worker in query, got %s", captured.Query)
}
if !strings.Contains(captured.Query, "status=idle") {
t.Errorf("expected status=idle in query, got %s", captured.Query)
}
}
// TestAgentsList_SpecialCharsInFilterAreURLEncoded pins the cubic-dev-ai
// fix to `getWithParams`: the earlier raw string concat (`q += k + "="
// + v`) would have produced a malformed URL for any value containing
// `&`, `=`, or spaces. The new code routes through `url.Values.Encode()`.
// `TestAgentsList_FiltersRoleAndStatus` only exercises plain ASCII and
// would pass identically against the old buggy code; this test fails
// without the encoding fix because the captured query would contain a
// raw `&` that splits the value into a second parameter.
func TestAgentsList_SpecialCharsInFilterAreURLEncoded(t *testing.T) {
srv, captured := setupMockServer(t, 200, `{"agents":[]}`)
setEnvVars(t, srv)
code := runKandevCLI([]string{"agents", "list", "--role", "a&b=c", "--status", "x y"})
if code != 0 {
t.Fatalf("expected exit 0, got %d", code)
}
// Standard query parsing must round-trip the values intact. If the
// raw `&` from "a&b=c" leaked into the URL it would become a second
// parameter named "b" with value "c" and `role` would be just "a".
parsed, err := url.ParseQuery(captured.Query)
if err != nil {
t.Fatalf("captured query is not a valid query string: %v (raw=%q)", err, captured.Query)
}
if got := parsed.Get("role"); got != "a&b=c" {
t.Errorf("role round-trip: got %q, want %q (raw query=%q)", got, "a&b=c", captured.Query)
}
if got := parsed.Get("status"); got != "x y" {
t.Errorf("status round-trip: got %q, want %q (raw query=%q)", got, "x y", captured.Query)
}
}
// --- Doc Tests ---
// TestDocCreate_FlagsAfterPositionals pins the cubic-dev-ai fix to
// `docCreate`'s argument parsing. The documented usage is
// `kandev doc create <task-id> <key> --type ... --title ... --content ...`,
// but Go's `flag.FlagSet.Parse` stops at the first non-flag argument —
// so the original implementation that called `fs.Parse(args)` left every
// flag at its default value. The fix peels the two required positionals
// off first and then parses `args[2:]`.
//
// A regression to the old behaviour would land defaults in the PATCH
// body (e.g. `type` = "custom" instead of "plan"); this test would catch
// it.
func TestDocCreate_FlagsAfterPositionals(t *testing.T) {
srv, captured := setupMockServer(t, 200, `{"ok":true}`)
setEnvVars(t, srv)
code := runKandevCLI([]string{
"doc", "create", "task-abc", "plan",
"--type", "plan", "--title", "My plan", "--content", "body",
})
if code != 0 {
t.Fatalf("expected exit 0, got %d", code)
}
if captured.Method != "PUT" {
t.Errorf("expected PUT, got %s", captured.Method)
}
if captured.Path != "/api/v1/tasks/task-abc/documents/plan" {
t.Errorf("unexpected path: %s", captured.Path)
}
var body map[string]any
if err := json.Unmarshal([]byte(captured.Body), &body); err != nil {
t.Fatalf("unmarshal body: %v", err)
}
if body["type"] != "plan" {
t.Errorf("expected type=plan (flag parsed correctly), got %v — regression to flag.Parse stopping at the first positional", body["type"])
}
if body["title"] != "My plan" {
t.Errorf("expected title='My plan', got %v", body["title"])
}
if body["content"] != "body" {
t.Errorf("expected content='body', got %v", body["content"])
}
}
// --- Memory Tests ---
func TestMemorySet_UpsertsEntry(t *testing.T) {
srv, captured := setupMockServer(t, 200, `{"ok":true}`)
setEnvVars(t, srv)
code := runKandevCLI([]string{
"memory", "set", "--layer", "facts", "--key", "test-key", "--content", "test-value",
})
if code != 0 {
t.Fatalf("expected exit 0, got %d", code)
}
if captured.Method != "PUT" {
t.Errorf("expected PUT, got %s", captured.Method)
}
if captured.Path != "/api/v1/office/agents/agent-789/memory" {
t.Errorf("unexpected path: %s", captured.Path)
}
var body map[string]any
if err := json.Unmarshal([]byte(captured.Body), &body); err != nil {
t.Fatalf("unmarshal body: %v", err)
}
entries, ok := body["entries"].([]any)
if !ok || len(entries) != 1 {
t.Fatalf("expected 1 entry, got %v", body["entries"])
}
entry := entries[0].(map[string]any)
if entry["layer"] != "facts" || entry["key"] != "test-key" || entry["content"] != "test-value" {
t.Errorf("unexpected entry: %v", entry)
}
}
func TestMemoryGet_QueriesByLayer(t *testing.T) {
srv, captured := setupMockServer(t, 200, `{"memory":[]}`)
setEnvVars(t, srv)
code := runKandevCLI([]string{"memory", "get", "--layer", "facts"})
if code != 0 {
t.Fatalf("expected exit 0, got %d", code)
}
if captured.Path != "/api/v1/office/agents/agent-789/memory" {
t.Errorf("unexpected path: %s", captured.Path)
}
if !strings.Contains(captured.Query, "layer=facts") {
t.Errorf("expected layer=facts in query, got %s", captured.Query)
}
}
func TestMemorySummary_CallsCorrectEndpoint(t *testing.T) {
srv, captured := setupMockServer(t, 200, `{"count":5}`)
setEnvVars(t, srv)
code := runKandevCLI([]string{"memory", "summary"})
if code != 0 {
t.Fatalf("expected exit 0, got %d", code)
}
if captured.Path != "/api/v1/office/agents/agent-789/memory/summary" {
t.Errorf("unexpected path: %s", captured.Path)
}
}
// --- Checkout Tests ---
func TestCheckout_CallsEndpoint(t *testing.T) {
srv, captured := setupMockServer(t, 200, `{"ok":true}`)
setEnvVars(t, srv)
code := runKandevCLI([]string{"checkout"})
if code != 0 {
t.Fatalf("expected exit 0, got %d", code)
}
if captured.Method != "POST" {
t.Errorf("expected POST, got %s", captured.Method)
}
if captured.Path != "/api/v1/office/tasks/task-abc/checkout" {
t.Errorf("unexpected path: %s", captured.Path)
}
}
func TestCheckout_Handles409Conflict(t *testing.T) {
srv, _ := setupMockServer(t, 409, `{"error":"already checked out by agent-other"}`)
setEnvVars(t, srv)
code := runKandevCLI([]string{"checkout"})
if code != 1 {
t.Fatalf("expected exit 1 on conflict, got %d", code)
}
}
// --- Error Tests ---
func TestMissingEnv_ReturnsClearError(t *testing.T) {
// Unset required env vars.
t.Setenv("KANDEV_API_URL", "")
t.Setenv("KANDEV_API_KEY", "")
code := runKandevCLI([]string{"task", "get", "--id", "some-task"})
if code != 1 {
t.Fatalf("expected exit 1, got %d", code)
}
}
func TestDefaultTaskID_UsesEnvVar(t *testing.T) {
srv, captured := setupMockServer(t, 200, `{"task":{"id":"task-abc"}}`)
setEnvVars(t, srv)
// Do not pass --id, should use KANDEV_TASK_ID.
code := runKandevCLI([]string{"task", "get"})
if code != 0 {
t.Fatalf("expected exit 0, got %d", code)
}
if captured.Path != "/api/v1/office/tasks/task-abc" {
t.Errorf("expected task-abc in path, got %s", captured.Path)
}
}
func TestUnknownCommand_ReturnsError(t *testing.T) {
code := runKandevCLI([]string{"invalid"})
if code != 1 {
t.Fatalf("expected exit 1, got %d", code)
}
}
func TestNoArgs_ReturnsError(t *testing.T) {
code := runKandevCLI(nil)
if code != 1 {
t.Fatalf("expected exit 1, got %d", code)
}
}
func TestNon2xxResponse_ReturnsError(t *testing.T) {
srv, _ := setupMockServer(t, 500, `{"error":"internal server error"}`)
setEnvVars(t, srv)
code := runKandevCLI([]string{"task", "get"})
if code != 1 {
t.Fatalf("expected exit 1 on 500, got %d", code)
}
}
func TestRunIDHeader_NotSetOnGet(t *testing.T) {
srv, captured := setupMockServer(t, 200, `{"task":{}}`)
setEnvVars(t, srv)
runKandevCLI([]string{"task", "get"})
if captured.Header.Get("X-Kandev-Run-Id") != "" {
t.Error("X-Kandev-Run-Id should not be set on GET requests")
}
}
func TestMissingAgentID_ForMemory(t *testing.T) {
srv, _ := setupMockServer(t, 200, `{}`)
setEnvVars(t, srv)
t.Setenv("KANDEV_AGENT_ID", "")
code := runKandevCLI([]string{"memory", "get"})
if code != 1 {
t.Fatalf("expected exit 1, got %d", code)
}
}
func TestMissingWorkspaceID_ForAgents(t *testing.T) {
srv, _ := setupMockServer(t, 200, `{}`)
setEnvVars(t, srv)
t.Setenv("KANDEV_WORKSPACE_ID", "")
code := runKandevCLI([]string{"agents", "list"})
if code != 1 {
t.Fatalf("expected exit 1, got %d", code)
}
}
// --- Helpers ---
func assertAuthHeader(t *testing.T, captured *capturedRequest) {
t.Helper()
auth := captured.Header.Get("Authorization")
if auth != "Bearer test-key-123" {
t.Errorf("expected Bearer test-key-123, got %s", auth)
}
}
func assertRunIDHeader(t *testing.T, captured *capturedRequest, expected string) {
t.Helper()
runID := captured.Header.Get("X-Kandev-Run-Id")
if runID != expected {
t.Errorf("expected X-Kandev-Run-Id=%s, got %s", expected, runID)
}
}
+7
View File
@@ -39,6 +39,13 @@ var (
)
func main() {
// Dispatch kandev CLI subcommands before flag parsing or server startup.
// When invoked as "agentctl kandev <cmd>", this runs the CLI and exits
// without starting the HTTP server.
if len(os.Args) > 1 && os.Args[1] == "kandev" {
os.Exit(runKandevCLI(os.Args[2:]))
}
flag.Parse()
// Load configuration
+32 -42
View File
@@ -8,9 +8,9 @@ import (
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/lifecycle"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/agentctl/client"
"github.com/kandev/kandev/internal/agent/runtime/agentctl"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/clarification"
"github.com/kandev/kandev/internal/common/logger"
@@ -109,6 +109,17 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch
RepoName: req.RepoName,
}
if req.RouteOverride != nil {
launchReq.RouteOverride = &lifecycle.RouteOverride{
ProviderID: req.RouteOverride.ProviderID,
Model: req.RouteOverride.Model,
Tier: req.RouteOverride.Tier,
Mode: req.RouteOverride.Mode,
Flags: req.RouteOverride.Flags,
Env: req.RouteOverride.Env,
}
}
// Multi-repo: forward the explicit repo list when the orchestrator built one.
if len(req.Repositories) > 0 {
specs := make([]lifecycle.RepoLaunchSpec, 0, len(req.Repositories))
@@ -175,6 +186,7 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch
WorktreeID: worktreeID,
WorktreePath: worktreePath,
WorktreeBranch: worktreeBranch,
WorkspacePath: execution.WorkspacePath,
Metadata: execution.Metadata,
PrepareResult: execution.PrepareResult,
Worktrees: worktrees,
@@ -215,6 +227,11 @@ func (a *lifecycleAdapter) ShouldApplyPreferredShell(executorType string) bool {
return a.mgr.ShouldApplyPreferredShell(executorType)
}
// SetExecutionEnv updates per-run env vars in an existing execution.
func (a *lifecycleAdapter) SetExecutionEnv(ctx context.Context, agentExecutionID string, env map[string]string) error {
return a.mgr.SetExecutionEnv(ctx, agentExecutionID, env)
}
// SetMcpMode changes the MCP tool mode on an existing execution's agentctl instance.
func (a *lifecycleAdapter) SetMcpMode(ctx context.Context, executionID string, mode string) error {
return a.mgr.SetMcpMode(ctx, executionID, mode)
@@ -446,6 +463,19 @@ func (a *lifecycleAdapter) GetGitStatus(ctx context.Context, sessionID string) (
return agentClient.GetGitStatus(ctx)
}
// GetGitStatusFresh retrieves a fresh (non-cached) git status for a session.
func (a *lifecycleAdapter) GetGitStatusFresh(ctx context.Context, sessionID string) (*client.GitStatusResult, error) {
execution, ok := a.mgr.GetExecutionBySessionID(sessionID)
if !ok {
return nil, nil
}
agentClient := execution.GetAgentCtlClient()
if agentClient == nil {
return nil, nil
}
return agentClient.GetGitStatusFresh(ctx)
}
// WaitForAgentctlReady waits for the agentctl HTTP server to be ready for a session.
func (a *lifecycleAdapter) WaitForAgentctlReady(ctx context.Context, sessionID string) error {
return a.mgr.WaitForAgentctlReadyForSession(ctx, sessionID)
@@ -773,43 +803,3 @@ func (a *messageCreatorAdapter) CreateThinkingMessageStreaming(ctx context.Conte
func (a *messageCreatorAdapter) AppendThinkingMessage(ctx context.Context, messageID, additionalContent string) error {
return a.svc.AppendThinkingContent(ctx, messageID, additionalContent)
}
// turnServiceAdapter adapts the task service to the orchestrator.TurnService interface
type turnServiceAdapter struct {
svc *taskservice.Service
}
func (a *turnServiceAdapter) StartTurn(ctx context.Context, sessionID string) (*models.Turn, error) {
return a.svc.StartTurn(ctx, sessionID)
}
func (a *turnServiceAdapter) CompleteTurn(ctx context.Context, turnID string) error {
return a.svc.CompleteTurn(ctx, turnID)
}
func (a *turnServiceAdapter) GetActiveTurn(ctx context.Context, sessionID string) (*models.Turn, error) {
return a.svc.GetActiveTurn(ctx, sessionID)
}
func (a *turnServiceAdapter) AbandonOpenTurns(ctx context.Context, sessionID string) error {
return a.svc.AbandonOpenTurns(ctx, sessionID)
}
func newTurnServiceAdapter(svc *taskservice.Service) *turnServiceAdapter {
return &turnServiceAdapter{svc: svc}
}
// taskSessionCheckerAdapter adapts the task repository for github.TaskSessionChecker.
type taskSessionCheckerAdapter struct {
repo interface {
ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error)
}
}
func (a *taskSessionCheckerAdapter) HasTaskSessions(ctx context.Context, taskID string) (bool, error) {
sessions, err := a.repo.ListTaskSessions(ctx, taskID)
if err != nil {
return false, err
}
return len(sessions) > 0, nil
}
+313
View File
@@ -0,0 +1,313 @@
package main
import (
"context"
"database/sql"
"fmt"
"github.com/kandev/kandev/internal/github"
officeconfig "github.com/kandev/kandev/internal/office/config"
"github.com/kandev/kandev/internal/office/configloader"
officedashboard "github.com/kandev/kandev/internal/office/dashboard"
officeengineadapters "github.com/kandev/kandev/internal/office/engine_adapters"
officeonboarding "github.com/kandev/kandev/internal/office/onboarding"
officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite"
officeroutines "github.com/kandev/kandev/internal/office/routines"
officeservice "github.com/kandev/kandev/internal/office/service"
officewakeup "github.com/kandev/kandev/internal/office/wakeup"
"github.com/kandev/kandev/internal/task/models"
tasksqlite "github.com/kandev/kandev/internal/task/repository/sqlite"
taskservice "github.com/kandev/kandev/internal/task/service"
)
// taskWorkspaceCreatorAdapter adapts the task service to the office
// WorkspaceCreator interface for dual workspace creation.
type taskWorkspaceCreatorAdapter struct {
taskSvc *taskservice.Service
}
func (a *taskWorkspaceCreatorAdapter) CreateWorkspace(ctx context.Context, name, description string) error {
_, err := a.taskSvc.CreateWorkspace(ctx, &taskservice.CreateWorkspaceRequest{
Name: name,
Description: description,
})
return err
}
func (a *taskWorkspaceCreatorAdapter) FindWorkspaceIDByName(ctx context.Context, name string) (string, error) {
workspaces, err := a.taskSvc.ListWorkspaces(ctx)
if err != nil {
return "", err
}
for _, ws := range workspaces {
if ws.Name == name {
return ws.ID, nil
}
}
return "", nil
}
func (a *taskWorkspaceCreatorAdapter) ListWorkspaceNames(ctx context.Context) ([]string, error) {
workspaces, err := a.taskSvc.ListWorkspaces(ctx)
if err != nil {
return nil, err
}
names := make([]string, len(workspaces))
for i, ws := range workspaces {
names[i] = ws.Name
}
return names, nil
}
// taskPRListerAdapter adapts *github.Service to the office TaskPRLister
// interface so the children-completed payload can carry per-child PR URLs.
// Returns an empty map (no PRs) when the github service is nil.
type taskPRListerAdapter struct {
gh *github.Service
}
func (a *taskPRListerAdapter) ListTaskPRsByTaskIDs(
ctx context.Context, taskIDs []string,
) (map[string][]officeservice.TaskPRLink, error) {
out := make(map[string][]officeservice.TaskPRLink)
if a.gh == nil || len(taskIDs) == 0 {
return out, nil
}
prs, err := a.gh.ListTaskPRsByTaskIDs(ctx, taskIDs)
if err != nil {
return nil, err
}
for taskID, list := range prs {
links := make([]officeservice.TaskPRLink, 0, len(list))
for _, pr := range list {
if pr == nil {
continue
}
links = append(links, officeservice.TaskPRLink{
URL: pr.PRURL,
Title: pr.PRTitle,
Number: pr.PRNumber,
State: pr.State,
})
}
if len(links) > 0 {
out[taskID] = links
}
}
return out, nil
}
// childTaskCreatorAdapter bridges *taskservice.Service to the
// engine_adapters.ChildTaskCreator interface. The two structs use
// identical fields but live in different packages — this adapter copies
// the fields field-by-field to keep the dependency graph acyclic.
type childTaskCreatorAdapter struct {
taskSvc *taskservice.Service
}
func (a *childTaskCreatorAdapter) CreateChildTask(
ctx context.Context, parent *models.Task, spec officeengineadapters.ChildTaskCreateSpec,
) (string, error) {
return a.taskSvc.CreateChildTask(ctx, parent, taskservice.ChildTaskSpec{
Title: spec.Title,
Description: spec.Description,
WorkflowID: spec.WorkflowID,
StepID: spec.StepID,
AgentProfileID: spec.AgentProfileID,
})
}
// taskCreatorAdapter adapts the task service to the office TaskCreator interface.
type taskCreatorAdapter struct {
taskSvc *taskservice.Service
}
func (a *taskCreatorAdapter) CreateOfficeTask(ctx context.Context, workspaceID, projectID, assigneeAgentID, title, description string) (string, error) {
task, err := a.taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{ //nolint:exhaustruct
WorkspaceID: workspaceID,
Title: title,
Description: description,
ProjectID: projectID,
AssigneeAgentProfileID: assigneeAgentID,
Origin: models.TaskOriginOnboarding,
})
if err != nil {
return "", err
}
return task.ID, nil
}
// CreateOfficeTaskInWorkflow creates an office task pinned to a specific
// workflow id, bypassing the workspace's default office_workflow_id. Used
// for the standing coordination task that lives on the dedicated
// Coordination workflow.
func (a *taskCreatorAdapter) CreateOfficeTaskInWorkflow(
ctx context.Context, workspaceID, projectID, assigneeAgentID, workflowID, title, description string,
) (string, error) {
task, err := a.taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{ //nolint:exhaustruct
WorkspaceID: workspaceID,
WorkflowID: workflowID,
Title: title,
Description: description,
ProjectID: projectID,
AssigneeAgentProfileID: assigneeAgentID,
Origin: models.TaskOriginOnboarding,
})
if err != nil {
return "", err
}
return task.ID, nil
}
func (a *taskCreatorAdapter) CreateOfficeSubtask(
ctx context.Context,
parentTaskID, assigneeAgentID, title, description string,
) (string, error) {
parent, err := a.taskSvc.GetTask(ctx, parentTaskID)
if err != nil {
return "", err
}
return a.taskSvc.CreateChildTask(ctx, parent, taskservice.ChildTaskSpec{
Title: title,
Description: description,
AgentProfileID: assigneeAgentID,
})
}
// routineWakeupAdapter bridges the routines package's WakeupEnqueuer
// interface to the concrete office sqlite repo + wakeup dispatcher.
// Defined here (not in the routines package) so the routines package
// stays free of office/repository/sqlite imports — keeping the
// dependency direction routines → wakeup (via interface) and never the
// reverse.
type routineWakeupAdapter struct {
repo *officesqlite.Repository
dispatcher *officewakeup.Dispatcher
}
func (a *routineWakeupAdapter) CreateWakeupRequest(
ctx context.Context, req *officeroutines.WakeupRequest,
) error {
row := &officesqlite.WakeupRequest{
ID: req.ID,
AgentProfileID: req.AgentProfileID,
Source: req.Source,
Reason: req.Reason,
Payload: req.Payload,
RequestedAt: req.RequestedAt,
}
if req.IdempotencyKey != "" {
row.IdempotencyKey = sql.NullString{String: req.IdempotencyKey, Valid: true}
}
return a.repo.CreateWakeupRequest(ctx, row)
}
func (a *routineWakeupAdapter) Dispatch(ctx context.Context, requestID string) error {
return a.dispatcher.Dispatch(ctx, requestID)
}
// configSyncerAdapter bridges config.ConfigService to the onboarding.ConfigSyncer
// interface by converting config.ImportResult to onboarding.ApplyResult.
type configSyncerAdapter struct {
svc *officeconfig.ConfigService
}
func (a *configSyncerAdapter) ApplyIncoming(ctx context.Context, workspaceID string) (*officeonboarding.ApplyResult, error) {
result, err := a.svc.ApplyIncoming(ctx, workspaceID)
if err != nil {
return nil, err
}
return &officeonboarding.ApplyResult{
CreatedCount: result.CreatedCount,
UpdatedCount: result.UpdatedCount,
}, nil
}
// workflowEnsurerAdapter wraps the task repo to satisfy onboarding.WorkflowEnsurer.
type workflowEnsurerAdapter struct {
repo *tasksqlite.Repository
}
func (a *workflowEnsurerAdapter) EnsureOfficeWorkflow(ctx context.Context, workspaceID string) (string, error) {
return a.repo.EnsureOfficeWorkflow(ctx, workspaceID)
}
func (a *workflowEnsurerAdapter) EnsureOfficeDefaultWorkflow(ctx context.Context, workspaceID string) (string, error) {
return a.repo.EnsureOfficeDefaultWorkflow(ctx, workspaceID)
}
func (a *workflowEnsurerAdapter) EnsureRoutineWorkflow(ctx context.Context, workspaceID string) (string, error) {
return a.repo.EnsureRoutineWorkflow(ctx, workspaceID)
}
// workspaceSettingsProviderAdapter implements dashboard.SettingsProvider using
// the filesystem-based config loader and writer.
type workspaceSettingsProviderAdapter struct {
loader *configloader.ConfigLoader
writer *configloader.FileWriter
}
func (a *workspaceSettingsProviderAdapter) GetSettings(workspaceName string) (*officedashboard.WorkspaceSettings, error) {
ws, err := a.loader.GetWorkspace(workspaceName)
if err != nil {
// Workspace not found in config loader — return defaults.
return &officedashboard.WorkspaceSettings{
Name: workspaceName,
PermissionHandlingMode: "human",
RecoveryLookbackHours: 24,
}, nil
}
mode := string(ws.Settings.PermissionHandlingMode)
if mode == "" {
mode = "human"
}
lookback := ws.Settings.RecoveryLookbackHours
if lookback == 0 {
lookback = 24
}
return &officedashboard.WorkspaceSettings{
Name: ws.Settings.Name,
Description: ws.Settings.Description,
PermissionHandlingMode: mode,
RecoveryLookbackHours: lookback,
}, nil
}
func (a *workspaceSettingsProviderAdapter) UpdatePermissionHandlingMode(workspaceName, mode string) error {
ws, err := a.loader.GetWorkspace(workspaceName)
if err != nil {
// Workspace not in loader — write a minimal kandev.yml.
ws = &configloader.WorkspaceConfig{
Name: workspaceName,
Settings: configloader.WorkspaceSettings{
Name: workspaceName,
},
}
}
settings := ws.Settings
settings.PermissionHandlingMode = configloader.PermissionHandlingMode(mode)
data, err := configloader.MarshalSettings(settings)
if err != nil {
return fmt.Errorf("marshal settings: %w", err)
}
return a.writer.WriteRawSettings(workspaceName, data)
}
func (a *workspaceSettingsProviderAdapter) UpdateRecoveryLookbackHours(workspaceName string, hours int) error {
ws, err := a.loader.GetWorkspace(workspaceName)
if err != nil {
ws = &configloader.WorkspaceConfig{
Name: workspaceName,
Settings: configloader.WorkspaceSettings{
Name: workspaceName,
},
}
}
settings := ws.Settings
settings.RecoveryLookbackHours = hours
data, err := configloader.MarshalSettings(settings)
if err != nil {
return fmt.Errorf("marshal settings: %w", err)
}
return a.writer.WriteRawSettings(workspaceName, data)
}
+13 -4
View File
@@ -4,19 +4,25 @@ import (
"context"
"time"
agentctlclient "github.com/kandev/kandev/internal/agentctl/client"
"github.com/kandev/kandev/internal/agentctl/client/launcher"
agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl"
"github.com/kandev/kandev/internal/agent/runtime/agentctl/launcher"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
// agentctlLauncherResult holds the outputs of provideAgentctlLauncher.
type agentctlLauncherResult struct {
cleanup func() error
binaryPath string
}
// provideAgentctlLauncher starts the agentctl launcher for standalone runtime.
// agentctl is a core service that always runs - it's used by the Standalone runtime
// for agent execution on the host machine.
// If the configured port is unavailable, the launcher may fall back to an OS-assigned
// port. In that case, cfg.Agent.StandalonePort is updated to reflect the actual port.
func provideAgentctlLauncher(ctx context.Context, cfg *config.Config, log *logger.Logger) (func() error, error) {
func provideAgentctlLauncher(ctx context.Context, cfg *config.Config, log *logger.Logger) (*agentctlLauncherResult, error) {
l, cleanup, err := launcher.Provide(ctx, launcher.Config{
Host: cfg.Agent.StandaloneHost,
Port: cfg.Agent.StandalonePort,
@@ -33,7 +39,10 @@ func provideAgentctlLauncher(ctx context.Context, cfg *config.Config, log *logge
}
// Store the per-launch auth token so downstream clients can authenticate
cfg.Agent.StandaloneAuthToken = l.AuthToken()
return cleanup, nil
return &agentctlLauncherResult{
cleanup: cleanup,
binaryPath: l.BinaryPath(),
}, nil
}
// waitForAgentctlControlHealthy waits for the agentctl control server to be healthy.
+7 -2
View File
@@ -8,11 +8,11 @@ import (
"github.com/kandev/kandev/internal/agent/credentials"
agentexecutor "github.com/kandev/kandev/internal/agent/executor"
"github.com/kandev/kandev/internal/agent/lifecycle"
"github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/agent/registry"
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
settingsstore "github.com/kandev/kandev/internal/agent/settings/store"
agentctl "github.com/kandev/kandev/internal/agentctl/client"
"github.com/kandev/kandev/internal/agentctl/server/process"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
@@ -108,6 +108,11 @@ func provideLifecycleManager(
preparerRegistry.Register(agentexecutor.NameSprites, lifecycle.NewSpritesPreparer(log))
lifecycleMgr.SetPreparerRegistry(preparerRegistry)
lifecycleMgr.SetSecretStore(secretStore)
// Wire the agent_profiles reader so the launch-prep skill deploy hook
// (ADR 0005 Wave A) can resolve full profile rows including the office
// enrichment fields. Without a wired SkillDeployer this is a no-op,
// but the reader still lets future Wave-B/C consumers light up.
lifecycleMgr.SetAgentProfileReader(agentSettingsRepo)
// MCP handler is set later in main.go after MCP handlers are registered
// via lifecycleMgr.SetMCPHandler(gateway.Dispatcher)
+257
View File
@@ -0,0 +1,257 @@
package main
import (
"context"
"fmt"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
officeenginedispatcher "github.com/kandev/kandev/internal/office/engine_dispatcher"
officemodels "github.com/kandev/kandev/internal/office/models"
officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite"
officeroutines "github.com/kandev/kandev/internal/office/routines"
schedulercron "github.com/kandev/kandev/internal/scheduler/cron"
tasksqlite "github.com/kandev/kandev/internal/task/repository/sqlite"
workflowrepo "github.com/kandev/kandev/internal/workflow/repository"
)
// startCronScheduler builds and starts the Phase 5 shared cron loop.
// All three handlers (heartbeat, budget, routines) ride a single
// goroutine so the backend has one cron driver for all
// task-model-unification timers.
//
// Wiring is best-effort: if a collaborator is missing the matching
// handler degrades to a no-op rather than failing startup. This keeps
// development environments runnable even when, for example, no office
// service is configured.
func startCronScheduler(
ctx context.Context,
repos *Repositories,
dispatcher *officeenginedispatcher.Dispatcher,
routineSvc *officeroutines.RoutineService,
log *logger.Logger,
) {
heartbeat := buildHeartbeatHandler(repos, dispatcher, log)
budget := buildBudgetHandler(repos, dispatcher, log)
routines := schedulercron.NewRoutinesHandler(routineSvc, nil, log)
loop := schedulercron.NewLoop(schedulercron.DefaultTickInterval, log,
heartbeat, budget, routines)
go loop.Start(ctx)
log.Info("phase 5 cron loop started",
zap.Duration("interval", schedulercron.DefaultTickInterval))
}
func buildHeartbeatHandler(
repos *Repositories,
dispatcher *officeenginedispatcher.Dispatcher,
log *logger.Logger,
) *schedulercron.HeartbeatHandler {
return schedulercron.NewHeartbeatHandler(
&heartbeatStepLister{wf: repos.Workflow},
&heartbeatTaskLister{tasks: repos.Task},
&heartbeatAgentRuntime{office: repos.Office},
dispatcher,
nil,
log,
)
}
func buildBudgetHandler(
repos *Repositories,
dispatcher *officeenginedispatcher.Dispatcher,
log *logger.Logger,
) *schedulercron.BudgetHandler {
return schedulercron.NewBudgetHandler(
&budgetEvaluator{repo: repos.Office, tasks: repos.Task},
&budgetTaskScope{repo: repos.Office},
dispatcher,
nil,
log,
)
}
// heartbeatStepLister adapts the workflow repository to
// cron.HeartbeatStepLister. It scans every step's events JSON for the
// on_heartbeat key — Phase 6 tightens this with a SQL LIKE pre-filter
// once template authoring lands and we know the volume of steps that
// will carry the trigger. For now scanning is fine: kanban steps don't
// carry on_heartbeat so the parser quickly bails on each row.
type heartbeatStepLister struct {
wf *workflowrepo.Repository
}
func (l *heartbeatStepLister) ListHeartbeatSteps(ctx context.Context) ([]schedulercron.HeartbeatStepInfo, error) {
if l.wf == nil {
return nil, nil
}
rows, err := l.wf.ListAllStepEventsJSON(ctx)
if err != nil {
return nil, fmt.Errorf("list workflow step events: %w", err)
}
out := make([]schedulercron.HeartbeatStepInfo, 0, len(rows))
for _, r := range rows {
if !schedulercron.HasHeartbeatTrigger(r.EventsJSON) {
continue
}
out = append(out, schedulercron.HeartbeatStepInfo{
StepID: r.StepID,
WorkflowID: r.WorkflowID,
CadenceSeconds: schedulercron.ParseCadenceFromEvents(r.EventsJSON),
})
}
return out, nil
}
// heartbeatTaskLister adapts the task repository to
// cron.HeartbeatTaskLister.
type heartbeatTaskLister struct {
tasks *tasksqlite.Repository
}
func (l *heartbeatTaskLister) ListActiveTasksAtStep(
ctx context.Context, stepID string,
) ([]schedulercron.HeartbeatTaskInfo, error) {
if l.tasks == nil {
return nil, nil
}
tasks, err := l.tasks.ListTasksByWorkflowStep(ctx, stepID)
if err != nil {
return nil, err
}
out := make([]schedulercron.HeartbeatTaskInfo, 0, len(tasks))
for _, t := range tasks {
if t.AssigneeAgentProfileID == "" {
continue
}
out = append(out, schedulercron.HeartbeatTaskInfo{
TaskID: t.ID,
WorkflowStepID: stepID,
AssigneeAgentProfileID: t.AssigneeAgentProfileID,
})
}
return out, nil
}
// heartbeatAgentRuntime adapts the office agent + runtime repos to
// cron.HeartbeatAgentRuntime. The gate combines two checks:
//
// 1. AgentInstance.Status — paused/stopped agents never receive
// heartbeats. (idle and working both pass; the runs scheduler's
// own checkout layer handles concurrency.)
// 2. CooldownSec since LastRunFinishedAt — when a run finished
// recently the gate suppresses heartbeats so the cooldown is
// respected end-to-end (heartbeat ≠ scheduler).
type heartbeatAgentRuntime struct {
office *officesqlite.Repository
}
func (r *heartbeatAgentRuntime) AllowFire(
ctx context.Context, agentID string, now time.Time,
) (bool, error) {
if r.office == nil || agentID == "" {
return false, nil
}
agent, err := r.office.GetAgentInstance(ctx, agentID)
if err != nil {
return false, err
}
if agent == nil {
return false, nil
}
switch agent.Status {
case officemodels.AgentStatusPaused, officemodels.AgentStatusStopped:
return false, nil
}
if agent.LastRunFinishedAt != nil && agent.CooldownSec > 0 {
gateUntil := agent.LastRunFinishedAt.Add(time.Duration(agent.CooldownSec) * time.Second)
if now.Before(gateUntil) {
return false, nil
}
}
return true, nil
}
// budgetEvaluator adapts the office repository to
// cron.BudgetEvaluator. It walks every workspace, lists policies, and
// computes spend per policy using the same projections the office
// CostService uses for pre-execution checks.
type budgetEvaluator struct {
repo *officesqlite.Repository
tasks *tasksqlite.Repository
}
func (e *budgetEvaluator) EvaluatePolicies(ctx context.Context) ([]schedulercron.BudgetCheckResult, error) {
if e.repo == nil || e.tasks == nil {
return nil, nil
}
workspaces, err := e.tasks.ListWorkspaces(ctx)
if err != nil {
return nil, fmt.Errorf("list workspaces: %w", err)
}
var out []schedulercron.BudgetCheckResult
for _, ws := range workspaces {
policies, err := e.repo.ListBudgetPolicies(ctx, ws.ID)
if err != nil {
return nil, fmt.Errorf("list budget policies (%s): %w", ws.ID, err)
}
for _, p := range policies {
spent, err := e.spendForPolicy(ctx, p, ws.ID)
if err != nil {
return nil, err
}
out = append(out, schedulercron.BudgetCheckResult{
WorkspaceID: ws.ID,
ScopeType: p.ScopeType,
ScopeID: p.ScopeID,
SpentSubcents: spent,
LimitSubcents: p.LimitSubcents,
Period: p.Period,
})
}
}
return out, nil
}
// spendForPolicy applies the period filter ("monthly" resets at the 1st
// UTC; "total" or anything else returns the lifetime sum).
func (e *budgetEvaluator) spendForPolicy(
ctx context.Context, p *officemodels.BudgetPolicy, workspaceID string,
) (int64, error) {
var since time.Time
if p.Period == "monthly" {
now := time.Now().UTC()
since = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
}
switch p.ScopeType {
case "agent":
return e.repo.GetCostForAgentSince(ctx, p.ScopeID, since)
case "project":
return e.repo.GetCostForProjectSince(ctx, p.ScopeID, since)
case "workspace":
return e.repo.SumCostsSince(ctx, workspaceID, since)
}
return 0, nil
}
// budgetTaskScope adapts the office repository to
// cron.BudgetTaskScope. It returns the workspace's coordination task
// for every scope today. Phase 6 lands the coordination task and a
// dedicated lookup by role; until then the resolver returns empty
// for workspaces without one configured, which the budget handler
// interprets as "no fire, mark dedup so we don't churn".
type budgetTaskScope struct {
repo *officesqlite.Repository
}
func (s *budgetTaskScope) ResolveAlertTaskID(
_ context.Context, _, _, _ string,
) (string, error) {
// TODO(phase 6): look up the workspace's coordination task by the
// agent role configured on it (CEO / standing). For now the
// handler returns no task — the budget cron is quiet by design
// until Phase 6 lands the on_budget_alert wiring on the
// coordination workflow.
return "", nil
}
+31
View File
@@ -43,6 +43,37 @@ func handleE2EReset(repo *sqliterepo.Repository, taskSvc *taskservice.Service, l
ctx := c.Request.Context()
// Wipe routing state so the office-routing-* specs don't leak
// degraded health rows / route attempts / parked runs between
// each other. Office tables live in the same SQLite db so the
// task repo's connection can hit them. Tables are no-ops when
// the office routing feature isn't enabled.
for _, q := range []string{
`DELETE FROM office_run_route_attempts WHERE run_id IN (SELECT id FROM runs WHERE agent_profile_id IN (SELECT id FROM agent_profiles WHERE workspace_id = ?))`,
`DELETE FROM runs WHERE agent_profile_id IN (SELECT id FROM agent_profiles WHERE workspace_id = ?)`,
`DELETE FROM office_provider_health WHERE workspace_id = ?`,
`DELETE FROM office_workspace_routing WHERE workspace_id = ?`,
} {
if _, err := repo.DB().ExecContext(ctx, q, workspaceID); err != nil {
// Best-effort: log + continue. Some routing tables may
// not exist when the feature is gated off.
log.Warn("e2e reset: routing cleanup failed", zap.String("sql", q), zap.Error(err))
}
}
// Reset every agent's routing override to the inherit-markers
// shape onboarding writes. Without this, an agent-override test
// leaves the CEO pinned to a single provider, which derails
// subsequent workspace-level routing specs that expect the
// resolver to walk the full provider_order.
if _, err := repo.DB().ExecContext(ctx, `
UPDATE agent_profiles
SET settings = '{"routing":{"provider_order_source":"inherit","tier_source":"inherit"}}'
WHERE workspace_id = ?
`, workspaceID); err != nil {
log.Warn("e2e reset: agent settings reset failed", zap.Error(err))
}
// Route through the task service (rather than a raw SQL DELETE) so
// each delete spawns the async cleanup goroutine that stops the
// agentctl instance and releases its port. Without this, instances
+17 -1
View File
@@ -8,8 +8,8 @@ import (
agentcontroller "github.com/kandev/kandev/internal/agent/controller"
agenthandlers "github.com/kandev/kandev/internal/agent/handlers"
"github.com/kandev/kandev/internal/agent/lifecycle"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/common/scripts"
"github.com/kandev/kandev/internal/events"
@@ -199,6 +199,8 @@ func provideGateway(
go gateway.Hub.Run(ctx)
gateways.RegisterTaskNotifications(ctx, eventBus, gateway.Hub, log)
gateways.RegisterUserNotifications(ctx, eventBus, gateway.Hub, log)
gateways.RegisterOfficeNotifications(ctx, eventBus, gateway.Hub, log)
gateways.RegisterRunNotifications(ctx, eventBus, gateway.Hub, log)
// Route session focus/subscription transitions from the hub into the
// lifecycle manager so it can push poll-mode changes to agentctl.
@@ -245,6 +247,20 @@ func provideGateway(
if err != nil {
log.Error("Failed to subscribe to task session notifications", zap.Error(err))
}
_, err = eventBus.Subscribe(events.OfficeInboxItem, func(ctx context.Context, event *bus.Event) error {
data, ok := event.Data.(map[string]interface{})
if !ok {
return nil
}
itemType, _ := data["type"].(string)
title, _ := data["title"].(string)
notificationSvc.HandleInboxItem(ctx, itemType, title)
return nil
})
if err != nil {
log.Error("Failed to subscribe to office inbox notifications", zap.Error(err))
}
}
return gateway, notificationSvc, notificationCtrl, nil
+123 -9
View File
@@ -19,17 +19,19 @@ import (
"github.com/kandev/kandev/internal/agent/docker"
agenthandlers "github.com/kandev/kandev/internal/agent/handlers"
"github.com/kandev/kandev/internal/agent/hostutility"
"github.com/kandev/kandev/internal/agent/lifecycle"
"github.com/kandev/kandev/internal/agent/loginpty"
"github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/agent/registry"
client "github.com/kandev/kandev/internal/agent/runtime/agentctl"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller"
agentsettingshandlers "github.com/kandev/kandev/internal/agent/settings/handlers"
"github.com/kandev/kandev/internal/agentctl/client"
settingsstore "github.com/kandev/kandev/internal/agent/settings/store"
"github.com/kandev/kandev/internal/agentctl/tracing"
analyticshandlers "github.com/kandev/kandev/internal/analytics/handlers"
analyticsrepository "github.com/kandev/kandev/internal/analytics/repository"
"github.com/kandev/kandev/internal/clarification"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/common/ports"
debughandlers "github.com/kandev/kandev/internal/debug"
@@ -46,6 +48,10 @@ import (
mcpserver "github.com/kandev/kandev/internal/mcp/server"
notificationcontroller "github.com/kandev/kandev/internal/notifications/controller"
notificationhandlers "github.com/kandev/kandev/internal/notifications/handlers"
"github.com/kandev/kandev/internal/office"
officeagents "github.com/kandev/kandev/internal/office/agents"
officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite"
officetestharness "github.com/kandev/kandev/internal/office/testharness"
"github.com/kandev/kandev/internal/orchestrator"
promptcontroller "github.com/kandev/kandev/internal/prompts/controller"
prompthandlers "github.com/kandev/kandev/internal/prompts/handlers"
@@ -63,6 +69,7 @@ import (
utilityhandlers "github.com/kandev/kandev/internal/utility/handlers"
workflowcontroller "github.com/kandev/kandev/internal/workflow/controller"
workflowhandlers "github.com/kandev/kandev/internal/workflow/handlers"
"github.com/kandev/kandev/internal/worktree"
ws "github.com/kandev/kandev/pkg/websocket"
)
@@ -409,6 +416,7 @@ type routeParams struct {
gateway *gateways.Gateway
taskSvc *taskservice.Service
taskRepo *sqliterepo.Repository
officeRepo *officesqlite.Repository
analyticsRepo analyticsrepository.Repository
orchestratorSvc *orchestrator.Service
lifecycleMgr *lifecycle.Manager
@@ -416,6 +424,7 @@ type routeParams struct {
eventBus bus.EventBus
services *Services
agentSettingsController *agentsettingscontroller.Controller
agentSettingsRepo settingsstore.Repository
agentList taskhandlers.AgentLister
agentRegistry *registry.Registry
userCtrl *usercontroller.Controller
@@ -431,8 +440,9 @@ type routeParams struct {
repoCloner *repoclone.Cloner
version string
webInternalURL string
pprofEnabled bool
devMode bool
httpPort int
features config.FeaturesConfig
log *logger.Logger
}
@@ -444,14 +454,80 @@ func registerRoutes(p routeParams) {
clarificationCanceller := clarification.NewCanceller(clarificationStore, p.taskRepo, p.eventBus, p.log)
p.orchestratorSvc.SetClarificationCanceller(clarificationCanceller)
p.gateway.SetupRoutes(p.router)
registerTaskRoutes(p, planService)
registerSecondaryRoutes(p, workflowCtrl, clarificationStore, planService)
// Wire pending clarification requests into the office inbox.
if p.services.OfficeSvcs != nil && p.services.OfficeSvcs.Dashboard != nil {
p.services.OfficeSvcs.Dashboard.SetPermissionLister(clarificationStore)
}
// Office task-handoffs phase 4 + 5 — single HandoffService instance
// shared by the MCP path (office agents) and the HTTP path (Kanban
// subtask UI). Constructed here so registerTaskRoutes can wire it
// into TaskHandlers.SetHandoffService and registerMCPAndDebugRoutes
// reuses the same instance via SetHandoffService on mcpHandlers.
handoffDocSvc := taskservice.NewDocumentService(p.taskRepo, p.log)
handoffSvc := taskservice.NewHandoffService(p.taskRepo, p.taskRepo, handoffDocSvc,
p.officeRepo, p.officeRepo, p.log)
// Phase 6 wirings — materializer hook + disk cleaner. The
// SessionWorktreeReader and WorkspaceCleaner interfaces are both
// satisfied by adapters that delegate to existing services.
handoffSvc.SetSessionReader(p.taskRepo)
if p.lifecycleMgr != nil {
if wtMgr := p.lifecycleMgr.WorktreeManager(); wtMgr != nil {
handoffSvc.SetWorkspaceCleaner(worktree.NewHandoffCleaner(wtMgr, p.log))
}
}
handoffSvc.SetRunCanceller(p.orchestratorSvc)
// Cascade archive/delete must re-publish task.updated / task.deleted
// events; HandoffService walks the repo directly and bypasses the
// Service wrappers that normally publish these. Without this wiring
// the kanban board doesn't react to subtree archive/delete until a
// full reload.
handoffSvc.SetTaskEventPublisher(p.taskSvc)
// Cascade archive/delete must tear down runtime resources
// (container, sandbox, worktree, executor_running rows) for every
// task in the tree. Without this wiring the agent gets stopped via
// runCanceller but its container leaks because the cascade bypasses
// Service.ArchiveTask's runAsyncTaskCleanup branch.
handoffSvc.SetTaskResourceCleaner(p.taskSvc)
p.orchestratorSvc.SetWorkspaceMaterializer(handoffSvc)
// Phase 8 prompt enrichment — wire the office scheduler's
// TaskContextProvider so every run prompt rendered by the active
// office/service/BuildPrompt sees Related-tasks / Documents
// available / Workspace sections.
if p.services.OrchScheduler != nil {
p.services.OrchScheduler.SetTaskContextProvider(handoffSvc)
}
p.gateway.SetupRoutes(p.router)
registerTaskRoutes(p, planService, handoffSvc)
registerSecondaryRoutes(p, workflowCtrl, clarificationStore, planService, handoffSvc)
// /health is a readiness probe, not a liveness probe. It only
// returns 200 after main has flipped the package-level `ready`
// flag — which happens after route registration, agent-registry
// seeding, the HTTP listener accepting connections, and (when
// KANDEV_E2E_MOCK is set) the testharness routes being mounted.
// Before that, return 503 so callers (including the e2e fixture's
// waitForHealth) keep polling instead of racing ahead and hitting
// 404s on routes that aren't wired yet.
p.router.GET("/health", func(c *gin.Context) {
if !ready.Load() {
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "starting", "service": "kandev"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok", "service": "kandev", "mode": "websocket+http"})
})
// /api/v1/features is a public, unauthenticated read of the runtime
// feature-flag map. The frontend SSR-fetches it once per page render to
// decide whether to mount Office (and any future flagged feature). The
// `json:` tags on FeaturesConfig drive serialization, so adding a new
// field to the struct is enough — no edit here required.
// See docs/decisions/0007-runtime-feature-flags.md.
p.router.GET("/api/v1/features", func(c *gin.Context) {
c.JSON(http.StatusOK, p.features)
})
if p.webInternalURL != "" {
target, err := url.Parse(p.webInternalURL)
if err != nil {
@@ -539,10 +615,13 @@ func resolveRepositoryIDForSubpath(ctx context.Context, taskRepo *sqliterepo.Rep
}
// registerTaskRoutes registers all task-related HTTP and WebSocket routes.
func registerTaskRoutes(p routeParams, planService *taskservice.PlanService) {
func registerTaskRoutes(p routeParams, planService *taskservice.PlanService, handoffSvc *taskservice.HandoffService) {
taskhandlers.RegisterWorkspaceRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log)
taskhandlers.RegisterWorkflowRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.services.Workflow, p.log)
taskH := taskhandlers.RegisterTaskRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.orchestratorSvc, p.taskRepo, planService, p.log)
if handoffSvc != nil {
taskH.SetHandoffService(handoffSvc)
}
if p.services.GitHub != nil {
ghSvc := p.services.GitHub
taskH.SetOnTaskCreatedWithPR(func(ctx context.Context, taskID, sessionID, prURL, branch string) {
@@ -574,6 +653,7 @@ func registerSecondaryRoutes(
workflowCtrl *workflowcontroller.Controller,
clarificationStore *clarification.Store,
planService *taskservice.PlanService,
handoffSvc *taskservice.HandoffService,
) {
workflowhandlers.RegisterRoutes(p.router, p.gateway.Dispatcher, workflowCtrl, p.log)
p.log.Info("Registered Workflow handlers (HTTP + WebSocket)")
@@ -661,9 +741,34 @@ func registerSecondaryRoutes(
p.log.Debug("Registered Improve Kandev handlers (HTTP)")
}
registerMCPAndDebugRoutes(p, workflowCtrl, clarificationStore, planService)
registerMCPAndDebugRoutes(p, workflowCtrl, clarificationStore, planService, handoffSvc)
registerE2EResetRoutes(p.router, p.taskRepo, p.taskSvc, p.log)
if officetestharness.Enabled() {
var officeAgentSvc *officeagents.AgentService
if p.services.OfficeSvcs != nil {
officeAgentSvc = p.services.OfficeSvcs.Agents
}
officetestharness.RegisterRoutes(
p.router,
p.taskRepo,
p.officeRepo,
p.agentSettingsRepo,
officeAgentSvc,
p.eventBus,
p.log,
)
p.log.Info("E2E mock routes enabled at /api/v1/_test/* — DO NOT enable in production")
}
// Register office routes
if p.services.OfficeSvcs != nil {
api := p.router.Group("/api/v1/office")
api.Use(officeagents.AgentAuthMiddleware(p.services.OfficeSvcs.Agents))
office.RegisterAllRoutes(api, p.services.OfficeSvcs, p.log)
p.log.Debug("Registered Office handlers (HTTP)")
}
}
func dockerTaskTitleProvider(taskRepo *sqliterepo.Repository, log *logger.Logger) docker.TaskTitleProvider {
@@ -728,6 +833,7 @@ func registerMCPAndDebugRoutes(
wfCtrl *workflowcontroller.Controller,
clarificationStore *clarification.Store,
planService *taskservice.PlanService,
handoffSvc *taskservice.HandoffService,
) {
mcpHandlers := mcphandlers.NewHandlers(
p.taskSvc, wfCtrl,
@@ -736,6 +842,13 @@ func registerMCPAndDebugRoutes(
// Wire config-mode dependencies for agent-native configuration
mcpHandlers.SetConfigDeps(p.services.Workflow, p.agentSettingsController, p.mcpConfigSvc)
// Reuse the cross-task handoff service constructed in registerRoutes —
// the same instance backs the MCP path and the HTTP Kanban path so
// workspace-group state stays consistent across both surfaces.
if handoffSvc != nil {
mcpHandlers.SetHandoffService(handoffSvc)
}
mcpHandlers.RegisterHandlers(p.gateway.Dispatcher)
p.log.Debug("Registered MCP handlers (WebSocket)")
@@ -749,7 +862,8 @@ func registerMCPAndDebugRoutes(
debughandlers.RegisterRoutes(p.router, p.log)
p.log.Debug("Registered Debug handlers (HTTP)")
if p.pprofEnabled {
if p.devMode {
debughandlers.RegisterExportRoute(p.router, p.version, Commit, p.log)
debughandlers.RegisterPprofRoutes(p.router, p.log)
debughandlers.RegisterMemoryRoute(p.router, p.log)
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -12,8 +12,8 @@ import (
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/lifecycle"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
+3
View File
@@ -112,6 +112,9 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
Jira: jiraSvc,
Linear: linearSvc,
Slack: slackSvc,
// Office is constructed later in initOfficeServices once all
// of its dependencies (config loader, task integrations, etc.) are available.
Office: nil,
// Notification service is initialized after gateway is available.
Notification: nil,
}, agentSettingsController, nil
+99 -36
View File
@@ -3,6 +3,9 @@ package main
import (
"fmt"
"github.com/jmoiron/sqlx"
"go.uber.org/zap"
analyticsrepository "github.com/kandev/kandev/internal/analytics/repository"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
@@ -16,13 +19,14 @@ import (
settingsstore "github.com/kandev/kandev/internal/agent/settings/store"
editorstore "github.com/kandev/kandev/internal/editors/store"
notificationstore "github.com/kandev/kandev/internal/notifications/store"
"github.com/kandev/kandev/internal/office"
promptstore "github.com/kandev/kandev/internal/prompts/store"
userstore "github.com/kandev/kandev/internal/user/store"
)
func provideRepositories(cfg *config.Config, log *logger.Logger) (*db.Pool, *Repositories, []func() error, error) {
cleanups := make([]func() error, 0, 6)
pool, cleanup, err := persistence.Provide(cfg, log)
func provideRepositories(cfg *config.Config, log *logger.Logger, version string) (*db.Pool, *Repositories, []func() error, error) {
cleanups := make([]func() error, 0, 12)
pool, cleanup, err := persistence.Provide(cfg, log, version)
if err != nil {
return nil, nil, nil, err
}
@@ -31,7 +35,7 @@ func provideRepositories(cfg *config.Config, log *logger.Logger) (*db.Pool, *Rep
writer := pool.Writer()
reader := pool.Reader()
taskRepoImpl, cleanup, err := repository.Provide(writer, reader)
taskRepoImpl, cleanup, err := repository.Provide(writer, reader, log)
if err != nil {
return nil, nil, nil, err
}
@@ -39,7 +43,7 @@ func provideRepositories(cfg *config.Config, log *logger.Logger) (*db.Pool, *Rep
// Workflow repo must be initialized before analytics repo because
// analytics creates indexes on the workflow_steps table.
workflowRepo, err := workflowrepository.NewWithDB(writer, reader)
workflowRepo, err := workflowrepository.NewWithDB(writer, reader, log)
if err != nil {
return nil, nil, nil, err
}
@@ -50,65 +54,124 @@ func provideRepositories(cfg *config.Config, log *logger.Logger) (*db.Pool, *Rep
}
cleanups = append(cleanups, cleanup)
agentSettingsRepo, cleanup, err := settingsstore.Provide(writer, reader)
agentSettingsRepo, cleanup, err := settingsstore.Provide(writer, reader, log)
if err != nil {
return nil, nil, nil, err
}
cleanups = append(cleanups, cleanup)
userRepo, cleanup, err := userstore.Provide(writer, reader)
supportRepos, supportCleanups, err := provideSupportRepos(writer, reader)
if err != nil {
return nil, nil, nil, err
}
cleanups = append(cleanups, cleanup)
cleanups = append(cleanups, supportCleanups...)
notificationRepo, cleanup, err := notificationstore.Provide(writer, reader)
officeRepo, officeCleanup, err := office.Provide(writer, reader, log)
if err != nil {
return nil, nil, nil, err
return nil, nil, nil, fmt.Errorf("office repo: %w", err)
}
cleanups = append(cleanups, cleanup)
cleanups = append(cleanups, officeCleanup)
editorRepo, cleanup, err := editorstore.Provide(writer, reader)
if err != nil {
return nil, nil, nil, err
}
cleanups = append(cleanups, cleanup)
promptRepo, cleanup, err := promptstore.Provide(writer, reader)
if err != nil {
return nil, nil, nil, err
}
cleanups = append(cleanups, cleanup)
utilityRepo, cleanup, err := utilitystore.Provide(writer, reader)
if err != nil {
return nil, nil, nil, err
}
cleanups = append(cleanups, cleanup)
// Initialize master key and secrets store
masterKeyProvider, err := secrets.NewMasterKeyProvider(cfg.ResolvedDataDir())
if err != nil {
return nil, nil, nil, fmt.Errorf("master key: %w", err)
}
secretStore, cleanup, err := secrets.Provide(writer, reader, masterKeyProvider)
if err != nil {
return nil, nil, nil, fmt.Errorf("secret store: %w", err)
}
cleanups = append(cleanups, cleanup)
// All repositories have finished their initSchema calls. Record the
// current binary version so the next boot can detect upgrades correctly.
recordSchemaVersion(writer, cfg.Database.Driver, version, log)
repos := &Repositories{
Task: taskRepoImpl,
Analytics: analyticsRepo,
AgentSettings: agentSettingsRepo,
User: userRepo,
Notification: notificationRepo,
Editor: editorRepo,
Prompts: promptRepo,
Utility: utilityRepo,
User: supportRepos.user,
Notification: supportRepos.notification,
Editor: supportRepos.editor,
Prompts: supportRepos.prompts,
Utility: supportRepos.utility,
Workflow: workflowRepo,
Secrets: secretStore,
Office: officeRepo,
}
return pool, repos, cleanups, nil
}
// supportRepositorySet groups the lighter-weight support repositories
// that share a common (writer, reader) wire-up pattern.
type supportRepositorySet struct {
user userstore.Repository
notification notificationstore.Repository
editor editorstore.Repository
prompts promptstore.Repository
utility utilitystore.Repository
}
// provideSupportRepos wires up user, notification, editor, prompt, and utility
// repositories. Extracted from provideRepositories to keep its statement count
// within the funlen limit.
func provideSupportRepos(writer, reader *sqlx.DB) (supportRepositorySet, []func() error, error) {
var cleanups []func() error
var repos supportRepositorySet
userRepo, cleanup, err := userstore.Provide(writer, reader)
if err != nil {
return repos, nil, err
}
cleanups = append(cleanups, cleanup)
repos.user = userRepo
notificationRepo, cleanup, err := notificationstore.Provide(writer, reader)
if err != nil {
return repos, nil, err
}
cleanups = append(cleanups, cleanup)
repos.notification = notificationRepo
editorRepo, cleanup, err := editorstore.Provide(writer, reader)
if err != nil {
return repos, nil, err
}
cleanups = append(cleanups, cleanup)
repos.editor = editorRepo
promptRepo, cleanup, err := promptstore.Provide(writer, reader)
if err != nil {
return repos, nil, err
}
cleanups = append(cleanups, cleanup)
repos.prompts = promptRepo
utilityRepo, cleanup, err := utilitystore.Provide(writer, reader)
if err != nil {
return repos, nil, err
}
cleanups = append(cleanups, cleanup)
repos.utility = utilityRepo
return repos, cleanups, nil
}
// recordSchemaVersion writes the current binary version into kandev_meta so the
// next boot can detect upgrades. A failure here is non-fatal: the stored
// version stays at the previous value and the next boot will take a fresh
// backup (idempotent). Skipped for postgres, which has no kandev_meta table.
func recordSchemaVersion(writer *sqlx.DB, driver, version string, log *logger.Logger) {
if version == "" || driver == "postgres" {
return
}
if err := persistence.WriteVersion(writer, version); err != nil {
if log != nil {
log.Warn("failed to record schema version", zap.Error(err))
}
return
}
if log != nil {
log.Info("schema version recorded", zap.String("version", version))
}
}
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"context"
"github.com/kandev/kandev/internal/task/models"
taskservice "github.com/kandev/kandev/internal/task/service"
)
// turnServiceAdapter adapts the task service to the orchestrator.TurnService interface.
type turnServiceAdapter struct {
svc *taskservice.Service
}
func (a *turnServiceAdapter) StartTurn(ctx context.Context, sessionID string) (*models.Turn, error) {
return a.svc.StartTurn(ctx, sessionID)
}
func (a *turnServiceAdapter) CompleteTurn(ctx context.Context, turnID string) error {
return a.svc.CompleteTurn(ctx, turnID)
}
func (a *turnServiceAdapter) GetActiveTurn(ctx context.Context, sessionID string) (*models.Turn, error) {
return a.svc.GetActiveTurn(ctx, sessionID)
}
func (a *turnServiceAdapter) AbandonOpenTurns(ctx context.Context, sessionID string) error {
return a.svc.AbandonOpenTurns(ctx, sessionID)
}
func newTurnServiceAdapter(svc *taskservice.Service) *turnServiceAdapter {
return &turnServiceAdapter{svc: svc}
}
// taskSessionCheckerAdapter adapts the task repository for github.TaskSessionChecker.
type taskSessionCheckerAdapter struct {
repo interface {
ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error)
}
}
func (a *taskSessionCheckerAdapter) HasTaskSessions(ctx context.Context, taskID string) (bool, error) {
sessions, err := a.repo.ListTaskSessions(ctx, taskID)
if err != nil {
return false, err
}
return len(sessions) > 0, nil
}
+14
View File
@@ -10,6 +10,9 @@ import (
"github.com/kandev/kandev/internal/linear"
notificationservice "github.com/kandev/kandev/internal/notifications/service"
notificationstore "github.com/kandev/kandev/internal/notifications/store"
office "github.com/kandev/kandev/internal/office"
officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite"
officeservice "github.com/kandev/kandev/internal/office/service"
promptservice "github.com/kandev/kandev/internal/prompts/service"
promptstore "github.com/kandev/kandev/internal/prompts/store"
"github.com/kandev/kandev/internal/secrets"
@@ -22,6 +25,7 @@ import (
utilitystore "github.com/kandev/kandev/internal/utility/store"
workflowrepository "github.com/kandev/kandev/internal/workflow/repository"
workflowservice "github.com/kandev/kandev/internal/workflow/service"
"github.com/kandev/kandev/internal/worktree"
)
type Repositories struct {
@@ -35,6 +39,7 @@ type Repositories struct {
Utility utilitystore.Repository
Workflow *workflowrepository.Repository
Secrets secrets.SecretStore
Office *officesqlite.Repository
}
type Services struct {
@@ -49,4 +54,13 @@ type Services struct {
Jira *jira.Service
Linear *linear.Service
Slack *slack.Service
Office *officeservice.Service
OfficeSvcs *office.Services
// OrchScheduler is the office SchedulerIntegration constructed by
// startOfficeSchedulersAndGC. Exposed here so registerRoutes can
// wire SetTaskContextProvider after the HandoffService is built.
OrchScheduler *officeservice.SchedulerIntegration
// WorktreeMgr is the worktree manager. Exposed so the office GC can
// consult it as the authoritative inventory of live worktrees.
WorktreeMgr *worktree.Manager
}
+83
View File
@@ -0,0 +1,83 @@
package main
import (
"context"
"os"
"path/filepath"
agentregistry "github.com/kandev/kandev/internal/agent/registry"
settingsstore "github.com/kandev/kandev/internal/agent/settings/store"
agentusage "github.com/kandev/kandev/internal/agent/usage"
)
// usageProviderAdapter implements officeagents.UsageProvider by:
// 1. Looking up the agent profile by ID from the settings store.
// 2. Looking up the agent type from the registry to get its billing type.
// 3. Delegating to the UsageService with the appropriate client registered.
type usageProviderAdapter struct {
svc *agentusage.UsageService
settingsStore settingsstore.Repository
agentRegistry *agentregistry.Registry
}
// GetUsage implements officeagents.UsageProvider.
func (a *usageProviderAdapter) GetUsage(ctx context.Context, profileID string) (*agentusage.ProviderUsage, error) {
profile, err := a.settingsStore.GetAgentProfile(ctx, profileID)
if err != nil {
return nil, nil //nolint:nilerr // profile missing or settings unavailable — fail-open
}
ag, ok := a.agentRegistry.Get(profile.AgentID)
if !ok {
return nil, nil
}
if ag.BillingType() != agentusage.BillingTypeSubscription {
return nil, nil
}
// Ensure client is registered for this profile.
a.ensureRegistered(profileID, profile.AgentID)
return a.svc.GetUsage(ctx, profileID)
}
// ensureRegistered creates and registers a usage client for the profile if not already registered.
func (a *usageProviderAdapter) ensureRegistered(profileID, agentName string) {
// We rely on the fact that GetUsage returns nil,nil for unregistered profiles,
// so we can call Register without causing duplicate cache entries — the cache key
// is credential-path-based, not profileID-based, so two profiles with the same
// credentials share one cache entry.
//
// `home` is required for both branches — bail rather than registering a
// client pointed at a relative ".claude/.credentials.json" / ".codex/auth.json"
// that silently misses the real file (common in containers or when HOME is
// unset). Without this guard the consumer sees BillingTypeAPIKey forever.
home, err := os.UserHomeDir()
if err != nil {
return
}
switch agentName {
case "claude-acp":
credPath := filepath.Join(home, ".claude", ".credentials.json")
client := agentusage.NewClaudeUsageClientWithPath(credPath)
key := agentusage.CacheKey("anthropic", credPath)
a.svc.Register(profileID, client, key)
case "codex-acp":
// Path must match codex_acp.go's SourceFiles / Runtime mounts —
// the real Codex CLI persists OAuth tokens at ~/.codex/auth.json,
// not the earlier XDG-style ~/.config/codex/ guess.
authPath := filepath.Join(home, ".codex", "auth.json")
client := agentusage.NewCodexUsageClientWithPath(authPath)
key := agentusage.CacheKey("openai", authPath)
a.svc.Register(profileID, client, key)
}
}
// newUsageProviderAdapter creates an adapter and returns it.
func newUsageProviderAdapter(
settingsStore settingsstore.Repository,
agentRegistry *agentregistry.Registry,
) *usageProviderAdapter {
return &usageProviderAdapter{
svc: agentusage.NewUsageService(),
settingsStore: settingsStore,
agentRegistry: agentRegistry,
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"strings"
"time"
"github.com/kandev/kandev/internal/agent/lifecycle"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/constants"
"github.com/kandev/kandev/internal/common/logger"
@@ -0,0 +1,126 @@
package workflows
import (
"testing"
"github.com/kandev/kandev/internal/workflow/engine"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
)
// TestOfficeDefault_TriggersCompileThroughEngine verifies that every step in
// the embedded office-default template can be compiled into engine.StepSpec
// without panic and that the new event-driven triggers surface as compiled
// engine.Action lists. This pins the template's JSON shape against the
// engine's compile path so future schema drift is caught at build time.
func TestOfficeDefault_TriggersCompileThroughEngine(t *testing.T) {
tmpl := loadTemplateForTest(t, "office-default")
for _, def := range tmpl.Steps {
step := stepFromDefinition(def)
spec := engine.CompileStep(step)
// Every compiled spec must carry the seven new trigger keys, even
// if the slice is empty — Engine.HandleTrigger reads them by key.
for _, trig := range []engine.Trigger{
engine.TriggerOnComment,
engine.TriggerOnBlockerResolved,
engine.TriggerOnChildrenCompleted,
engine.TriggerOnApprovalResolved,
engine.TriggerOnHeartbeat,
engine.TriggerOnBudgetAlert,
engine.TriggerOnAgentError,
} {
if _, ok := spec.Events[trig]; !ok {
t.Errorf("step %q: compiled spec missing trigger %q", def.Name, trig)
}
}
}
work := findStep(tmpl.Steps, "Work")
if work == nil {
t.Fatal("Work step not present in template")
}
workSpec := engine.CompileStep(stepFromDefinition(*work))
commentActions := workSpec.Events[engine.TriggerOnComment]
if len(commentActions) != 1 {
t.Fatalf("Work.on_comment compiled to %d actions, want 1", len(commentActions))
}
if commentActions[0].Kind != engine.ActionQueueRun {
t.Errorf("Work.on_comment[0].Kind = %q, want queue_run", commentActions[0].Kind)
}
if commentActions[0].QueueRun == nil {
t.Fatal("Work.on_comment[0].QueueRun is nil")
}
if commentActions[0].QueueRun.Target != engine.TargetPrimary {
t.Errorf("Work.on_comment[0] target = %q, want primary", commentActions[0].QueueRun.Target)
}
review := findStep(tmpl.Steps, "Review")
if review == nil {
t.Fatal("Review step not present in template")
}
reviewSpec := engine.CompileStep(stepFromDefinition(*review))
enterActions := reviewSpec.Events[engine.TriggerOnEnter]
if len(enterActions) != 2 {
t.Fatalf("Review.on_enter compiled to %d actions, want 2", len(enterActions))
}
if enterActions[0].Kind != engine.ActionClearDecisions {
t.Errorf("Review.on_enter[0].Kind = %q, want clear_decisions", enterActions[0].Kind)
}
if enterActions[1].Kind != engine.ActionQueueRunForEachParticipant {
t.Errorf("Review.on_enter[1].Kind = %q, want queue_run_for_each_participant", enterActions[1].Kind)
}
if enterActions[1].QueueRunForEachParticipant == nil {
t.Fatal("Review.on_enter[1].QueueRunForEachParticipant is nil")
}
if enterActions[1].QueueRunForEachParticipant.Role != "reviewer" {
t.Errorf("Review.on_enter[1] role = %q, want reviewer", enterActions[1].QueueRunForEachParticipant.Role)
}
completeActions := reviewSpec.Events[engine.TriggerOnTurnComplete]
if len(completeActions) != 2 {
t.Fatalf("Review.on_turn_complete compiled to %d actions, want 2", len(completeActions))
}
for i, action := range completeActions {
if action.Guard == nil || action.Guard.WaitForQuorum == nil {
t.Errorf("Review.on_turn_complete[%d] missing wait_for_quorum guard", i)
}
}
}
func loadTemplateForTest(t *testing.T, id string) *wfmodels.WorkflowTemplate {
t.Helper()
templates, err := LoadTemplates()
if err != nil {
t.Fatalf("LoadTemplates: %v", err)
}
for _, tmpl := range templates {
if tmpl.ID == id {
return tmpl
}
}
t.Fatalf("template %q not found", id)
return nil
}
func findStep(steps []wfmodels.StepDefinition, name string) *wfmodels.StepDefinition {
for i := range steps {
if steps[i].Name == name {
return &steps[i]
}
}
return nil
}
// stepFromDefinition copies the StepDefinition fields used by engine.CompileStep
// onto a WorkflowStep. The compiler only reads Name, Position, Prompt, Events.
func stepFromDefinition(def wfmodels.StepDefinition) *wfmodels.WorkflowStep {
return &wfmodels.WorkflowStep{
ID: def.ID,
Name: def.Name,
Position: def.Position,
Prompt: def.Prompt,
Events: def.Events,
StageType: def.StageType,
}
}
+79 -3
View File
@@ -28,6 +28,7 @@ type stepDefYAML struct {
Position int `yaml:"position"`
Color string `yaml:"color"`
Prompt string `yaml:"prompt,omitempty"`
StageType string `yaml:"stage_type,omitempty"`
IsStartStep bool `yaml:"is_start_step,omitempty"`
ShowInCommandPanel bool `yaml:"show_in_command_panel,omitempty"`
AllowManualMove bool `yaml:"allow_manual_move,omitempty"`
@@ -41,6 +42,17 @@ type stepEventsYAML struct {
OnTurnStart []actionYAML `yaml:"on_turn_start,omitempty"`
OnTurnComplete []actionYAML `yaml:"on_turn_complete,omitempty"`
OnExit []actionYAML `yaml:"on_exit,omitempty"`
// Phase 2 (ADR-0004) event-driven triggers. Actions on these triggers
// are GenericActions (queue_run, clear_decisions,
// queue_run_for_each_participant) — the YAML keys are the same.
OnComment []actionYAML `yaml:"on_comment,omitempty"`
OnBlockerResolved []actionYAML `yaml:"on_blocker_resolved,omitempty"`
OnChildrenCompleted []actionYAML `yaml:"on_children_completed,omitempty"`
OnApprovalResolved []actionYAML `yaml:"on_approval_resolved,omitempty"`
OnHeartbeat []actionYAML `yaml:"on_heartbeat,omitempty"`
OnBudgetAlert []actionYAML `yaml:"on_budget_alert,omitempty"`
OnAgentError []actionYAML `yaml:"on_agent_error,omitempty"`
}
// actionYAML is the YAML-friendly representation of a step action.
@@ -145,6 +157,10 @@ func convertStep(s stepDefYAML) (models.StepDefinition, error) {
if err != nil {
return models.StepDefinition{}, fmt.Errorf("step %q: %w", s.ID, err)
}
stage, err := convertStageType(s.StageType)
if err != nil {
return models.StepDefinition{}, fmt.Errorf("step %q: %w", s.ID, err)
}
return models.StepDefinition{
ID: s.ID,
Name: s.Name,
@@ -156,15 +172,42 @@ func convertStep(s stepDefYAML) (models.StepDefinition, error) {
IsStartStep: s.IsStartStep,
ShowInCommandPanel: s.ShowInCommandPanel,
AutoArchiveAfterHours: s.AutoArchiveAfterHours,
StageType: stage,
}, nil
}
// convertStageType validates a YAML stage_type field. Empty strings become
// the schema default ("custom").
func convertStageType(stage string) (models.StageType, error) {
if stage == "" {
return models.StageTypeCustom, nil
}
switch models.StageType(stage) {
case models.StageTypeWork, models.StageTypeReview, models.StageTypeApproval, models.StageTypeCustom:
return models.StageType(stage), nil
}
return "", fmt.Errorf("invalid stage_type %q", stage)
}
// validGenericAction is the YAML allow-list for actions appearing under any
// of the Phase 2 (ADR-0004) event-driven triggers. Restricting the set keeps
// templates honest while letting the engine reject malformed actions at
// compile time.
var validGenericAction = map[string]bool{
string(models.GenericActionQueueRun): true,
string(models.GenericActionClearDecisions): true,
string(models.GenericActionQueueRunForEachParticipant): true,
}
// Valid action types for each event trigger.
var (
validOnEnter = map[string]bool{
string(models.OnEnterEnablePlanMode): true,
string(models.OnEnterAutoStartAgent): true,
string(models.OnEnterResetAgentContext): true,
string(models.OnEnterEnablePlanMode): true,
string(models.OnEnterAutoStartAgent): true,
string(models.OnEnterResetAgentContext): true,
string(models.OnEnterClearDecisions): true,
string(models.OnEnterQueueRunForEachParticipant): true,
string(models.OnEnterQueueRun): true,
}
validOnTurnStart = map[string]bool{
string(models.OnTurnStartMoveToNext): true,
@@ -220,5 +263,38 @@ func convertEvents(e stepEventsYAML) (models.StepEvents, error) {
Config: a.Config,
})
}
if err := convertGenericTriggers(e, &events); err != nil {
return events, err
}
return events, nil
}
// convertGenericTriggers translates the YAML actions under the seven
// Phase 2 (ADR-0004) event-driven triggers into models.GenericAction lists.
func convertGenericTriggers(e stepEventsYAML, events *models.StepEvents) error {
mappings := []struct {
name string
in []actionYAML
out *[]models.GenericAction
}{
{"on_comment", e.OnComment, &events.OnComment},
{"on_blocker_resolved", e.OnBlockerResolved, &events.OnBlockerResolved},
{"on_children_completed", e.OnChildrenCompleted, &events.OnChildrenCompleted},
{"on_approval_resolved", e.OnApprovalResolved, &events.OnApprovalResolved},
{"on_heartbeat", e.OnHeartbeat, &events.OnHeartbeat},
{"on_budget_alert", e.OnBudgetAlert, &events.OnBudgetAlert},
{"on_agent_error", e.OnAgentError, &events.OnAgentError},
}
for _, m := range mappings {
for _, a := range m.in {
if !validGenericAction[a.Type] {
return fmt.Errorf("invalid %s action type %q", m.name, a.Type)
}
*m.out = append(*m.out, models.GenericAction{
Type: models.GenericActionType(a.Type),
Config: a.Config,
})
}
}
return nil
}
@@ -120,6 +120,8 @@ func TestLoadTemplates_ExpectedTemplateIDs(t *testing.T) {
"pr-review": false,
"feature-dev": false,
"improve-kandev": false,
"office-default": false,
"routine": false,
}
for _, tmpl := range templates {
@@ -0,0 +1,119 @@
id: office-default
name: Office Default
description: >-
Five-step office workflow with a primary agent doing the work, multi-agent
reviewers, and multi-agent approvers. Tasks flow Backlog -> Work -> Review
-> Approval -> Done with quorum-gated transitions.
steps:
- id: backlog
name: Backlog
position: 0
color: bg-neutral-400
stage_type: custom
allow_manual_move: true
show_in_command_panel: true
- id: work
name: Work
position: 1
color: bg-blue-500
stage_type: work
is_start_step: true
show_in_command_panel: true
allow_manual_move: true
events:
on_enter:
- type: auto_start_agent
on_comment:
- type: queue_run
config:
target: primary
task_id: this
reason: task_comment
on_blocker_resolved:
- type: queue_run
config:
target: primary
task_id: this
reason: blockers_resolved
on_children_completed:
- type: queue_run
config:
target: primary
task_id: this
reason: children_completed
on_agent_error:
- type: queue_run
config:
target: workspace.ceo_agent
task_id: this
reason: agent_error
on_turn_complete:
- type: move_to_step
config:
step_id: review
- id: review
name: Review
position: 2
color: bg-yellow-500
stage_type: review
show_in_command_panel: true
allow_manual_move: true
events:
on_enter:
- type: clear_decisions
- type: queue_run_for_each_participant
config:
role: reviewer
reason: review_started
on_turn_complete:
- type: move_to_step
config:
step_id: approval
wait_for_quorum:
role: reviewer
threshold: all_approve
- type: move_to_step
config:
step_id: work
wait_for_quorum:
role: reviewer
threshold: any_reject
- id: approval
name: Approval
position: 3
color: bg-violet-500
stage_type: approval
show_in_command_panel: true
allow_manual_move: true
events:
on_enter:
- type: clear_decisions
- type: queue_run_for_each_participant
config:
role: approver
reason: approval_started
on_turn_complete:
- type: move_to_step
config:
step_id: done
wait_for_quorum:
role: approver
threshold: all_approve
- type: move_to_step
config:
step_id: work
wait_for_quorum:
role: approver
threshold: any_reject
- id: done
name: Done
position: 4
color: bg-green-500
stage_type: custom
allow_manual_move: true
show_in_command_panel: true
+25
View File
@@ -0,0 +1,25 @@
id: routine
name: Routine
description: System workflow for routine-fired tasks. Single auto-completing step.
steps:
- id: in_progress
name: In Progress
position: 0
color: bg-blue-500
is_start_step: true
show_in_command_panel: false
allow_manual_move: false
events:
on_enter:
- type: auto_start_agent
on_turn_complete:
- type: move_to_step
config:
step_id: done
- id: done
name: Done
position: 1
color: bg-green-500
show_in_command_panel: false
allow_manual_move: false
+1 -1
View File
@@ -7,6 +7,7 @@ require (
github.com/coder/acp-go-sdk v0.6.3
github.com/creack/pty v1.1.21
github.com/docker/docker v28.5.2+incompatible
github.com/fsnotify/fsnotify v1.6.0
github.com/gin-gonic/gin v1.9.1
github.com/github/copilot-sdk/go v0.1.18
github.com/google/uuid v1.6.0
@@ -47,7 +48,6 @@ require (
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
+52 -14
View File
@@ -9,6 +9,8 @@ import (
"errors"
"time"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/internal/agentruntime"
"github.com/kandev/kandev/pkg/agent"
)
@@ -44,6 +46,11 @@ type Agent interface {
// --- Runtime ---
Runtime() *RuntimeConfig
// --- Billing ---
// BillingType returns how this agent is billed: api_key or subscription.
// Computed at call time from credential files, not stored in the DB.
BillingType() usage.BillingType
// --- Remote Auth ---
// RemoteAuth returns the auth methods this agent supports in remote environments.
// Returns nil if the agent has no remote auth configuration.
@@ -134,6 +141,12 @@ type CommandOptions struct {
// AgentProfile.CLIFlags (only Enabled entries, shell-tokenised). Appended
// verbatim to the built command by every agent's BuildCommand.
CLIFlagTokens []string
// Runtime is the execution backend hosting the agent subprocess.
// Agents whose binary lives in a different place inside a container
// than on the host (currently only MockAgent) consult
// Runtime.IsContainerized() to pick a host absolute path vs. a bare
// name resolved via the container's PATH.
Runtime agentruntime.Runtime
}
// PassthroughOptions are passed to BuildPassthroughCommand.
@@ -152,20 +165,22 @@ type PassthroughOptions struct {
// RuntimeConfig holds Docker / standalone runtime settings.
type RuntimeConfig struct {
Image string
Tag string
Cmd Command
Entrypoint Command
WorkingDir string
Env map[string]string
RequiredEnv []string
Mounts []MountTemplate
ResourceLimits ResourceLimits
SessionConfig SessionConfig
Protocol agent.Protocol
ModelFlag Param // e.g. NewParam("--model", "{model}")
WorkspaceFlag string // e.g. "--workspace-root"
AssumeMcpSse bool // Override: assume agent supports SSE MCP servers even if not advertised
Image string
Tag string
Cmd Command
Entrypoint Command
WorkingDir string
Env map[string]string
RequiredEnv []string
Mounts []MountTemplate
ResourceLimits ResourceLimits
SessionConfig SessionConfig
Protocol agent.Protocol
ModelFlag Param // e.g. NewParam("--model", "{model}")
WorkspaceFlag string // e.g. "--workspace-root"
AssumeMcpSse bool // Override: assume agent supports SSE MCP servers even if not advertised
ProjectSkillDir string // CWD-relative path for project-level skills (e.g. ".claude/skills")
UserSkillDir string // home-relative path for user-level skills (e.g. ".claude/skills")
}
// MountTemplate defines a mount with template variables.
@@ -247,6 +262,29 @@ var DefaultResourceLimits = ResourceLimits{
MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour,
}
// DefaultProjectSkillDir is the fallback CWD-relative skill directory used when
// an agent type does not declare a ProjectSkillDir in its RuntimeConfig.
const DefaultProjectSkillDir = ".agents/skills"
// ProjectSkillDirFromRuntime returns the CWD-relative skill directory for the
// agent. Falls back to DefaultProjectSkillDir if the agent's RuntimeConfig does
// not set one.
func ProjectSkillDirFromRuntime(a Agent) string {
if rt := a.Runtime(); rt != nil && rt.ProjectSkillDir != "" {
return rt.ProjectSkillDir
}
return DefaultProjectSkillDir
}
// UserSkillDirFromRuntime returns the home-relative user skill directory for the
// agent. An empty string means the agent does not expose a user-home skill dir.
func UserSkillDirFromRuntime(a Agent) string {
if rt := a.Runtime(); rt != nil {
return rt.UserSkillDir
}
return ""
}
// InferenceConfig describes how an agent executes one-shot prompts via ACP.
type InferenceConfig struct {
// Supported indicates the agent can do one-shot inference.
@@ -5,6 +5,7 @@ import (
_ "embed"
"time"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
@@ -83,11 +84,12 @@ func (a *AmpACP) BuildCommand(opts CommandOptions) Command {
func (a *AmpACP) Runtime() *RuntimeConfig {
canRecover := true
return &RuntimeConfig{
Cmd: Cmd("npx", "-y", ampACPPkg).Build(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
Cmd: Cmd("npx", "-y", ampACPPkg).Build(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
ProjectSkillDir: ".agents/skills",
SessionConfig: SessionConfig{
NativeSessionResume: true,
CanRecover: &canRecover,
@@ -102,6 +104,8 @@ func (a *AmpACP) InstallScript() string {
return "npm install -g " + ampACPPkg
}
func (a *AmpACP) BillingType() usage.BillingType { return defaultBillingType() }
func (a *AmpACP) PermissionSettings() map[string]PermissionSetting {
return emptyPermSettings
}
+8 -4
View File
@@ -5,6 +5,7 @@ import (
_ "embed"
"time"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
@@ -96,10 +97,11 @@ func (a *Auggie) Runtime() *RuntimeConfig {
Mounts: []MountTemplate{
{Source: "{workspace}", Target: "/workspace"},
},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
WorkspaceFlag: "--workspace-root",
AssumeMcpSse: true,
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
WorkspaceFlag: "--workspace-root",
AssumeMcpSse: true,
ProjectSkillDir: ".agents/skills",
SessionConfig: SessionConfig{
NativeSessionResume: true,
CanRecover: &canRecover,
@@ -139,6 +141,8 @@ func (a *Auggie) InstallScript() string {
return "npm install -g " + auggiePkg
}
func (a *Auggie) BillingType() usage.BillingType { return defaultBillingType() }
func (a *Auggie) PermissionSettings() map[string]PermissionSetting {
return auggiePermSettings
}
@@ -0,0 +1,59 @@
package agents
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"github.com/kandev/kandev/internal/agent/usage"
)
// defaultBillingType returns BillingTypeAPIKey. Used by agents that do not
// override BillingType().
func defaultBillingType() usage.BillingType {
return usage.BillingTypeAPIKey
}
// claudeBillingType detects whether the Claude agent is using OAuth
// subscription credentials. It reads ~/.claude/.credentials.json once and
// caches the result for the process lifetime.
var claudeBillingType = sync.OnceValue(func() usage.BillingType {
home, err := os.UserHomeDir()
if err != nil {
return usage.BillingTypeAPIKey
}
path := filepath.Join(home, ".claude", ".credentials.json")
data, err := os.ReadFile(path)
if err != nil {
return usage.BillingTypeAPIKey
}
var creds struct {
ClaudeAiOauth *struct{} `json:"claudeAiOauth"`
}
if err := json.Unmarshal(data, &creds); err != nil {
return usage.BillingTypeAPIKey
}
if creds.ClaudeAiOauth != nil {
return usage.BillingTypeSubscription
}
return usage.BillingTypeAPIKey
})
// codexBillingType detects whether the Codex agent is using subscription
// credentials. It checks for ~/.codex/auth.json once per process — that
// path matches the SourceFiles / Runtime mounts in codex_acp.go, where
// the real Codex CLI persists OAuth tokens. The earlier ~/.config/codex
// path was an XDG-style guess and never matched a real install, so
// subscription billing was undetectable.
var codexBillingType = sync.OnceValue(func() usage.BillingType {
home, err := os.UserHomeDir()
if err != nil {
return usage.BillingTypeAPIKey
}
path := filepath.Join(home, ".codex", "auth.json")
if _, err := os.Stat(path); err == nil {
return usage.BillingTypeSubscription
}
return usage.BillingTypeAPIKey
})
@@ -5,6 +5,7 @@ import (
_ "embed"
"time"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
@@ -96,8 +97,10 @@ func (a *ClaudeACP) Runtime() *RuntimeConfig {
Mounts: []MountTemplate{
{Source: "{workspace}", Target: "/workspace"},
},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
ProjectSkillDir: ".claude/skills",
UserSkillDir: ".claude/skills",
SessionConfig: SessionConfig{
NativeSessionResume: true,
CanRecover: &canRecover,
@@ -143,6 +146,8 @@ func (a *ClaudeACP) InstallScript() string {
return "npm install -g @anthropic-ai/claude-code " + claudeACPPkg
}
func (a *ClaudeACP) BillingType() usage.BillingType { return claudeBillingType() }
func (a *ClaudeACP) PermissionSettings() map[string]PermissionSetting {
return emptyPermSettings
}
@@ -5,6 +5,7 @@ import (
_ "embed"
"time"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
@@ -89,8 +90,10 @@ func (a *CodexACP) Runtime() *RuntimeConfig {
Mounts: []MountTemplate{
{Source: "{workspace}", Target: "/workspace"},
},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
ProjectSkillDir: ".agents/skills",
UserSkillDir: ".codex/skills",
SessionConfig: SessionConfig{
NativeSessionResume: true,
CanRecover: &canRecover,
@@ -143,6 +146,8 @@ func (a *CodexACP) InstallScript() string {
return "npm install -g @openai/codex " + codexACPPkg
}
func (a *CodexACP) BillingType() usage.BillingType { return codexBillingType() }
func (a *CodexACP) PermissionSettings() map[string]PermissionSetting {
return emptyPermSettings
}
@@ -5,6 +5,7 @@ import (
_ "embed"
"time"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
@@ -85,11 +86,13 @@ func (a *CopilotACP) BuildCommand(opts CommandOptions) Command {
func (a *CopilotACP) Runtime() *RuntimeConfig {
canRecover := true
return &RuntimeConfig{
Cmd: Cmd("npx", "-y", copilotACPPkg, "--acp").Build(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
Cmd: Cmd("npx", "-y", copilotACPPkg, "--acp").Build(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
ProjectSkillDir: ".agents/skills",
UserSkillDir: ".copilot/skills",
SessionConfig: SessionConfig{
NativeSessionResume: true,
CanRecover: &canRecover,
@@ -113,6 +116,8 @@ func (a *CopilotACP) InstallScript() string {
return "npm install -g " + copilotACPPkg
}
func (a *CopilotACP) BillingType() usage.BillingType { return defaultBillingType() }
func (a *CopilotACP) PermissionSettings() map[string]PermissionSetting {
return copilotPermSettings
}
@@ -6,6 +6,7 @@ import (
_ "embed"
"time"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
@@ -87,6 +88,7 @@ func (a *CursorACP) Runtime() *RuntimeConfig {
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
UserSkillDir: ".cursor/skills",
SessionConfig: SessionConfig{
NativeSessionResume: true,
CanRecover: &canRecover,
@@ -111,3 +113,5 @@ func (a *CursorACP) InferenceConfig() *InferenceConfig {
Command: NewCommand(cursorACPBin, "acp"),
}
}
func (a *CursorACP) BillingType() usage.BillingType { return defaultBillingType() }
@@ -5,6 +5,7 @@ import (
_ "embed"
"time"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
@@ -112,3 +113,5 @@ func (a *DroidACP) InferenceConfig() *InferenceConfig {
Command: NewCommand("npx", "-y", droidACPPkg, "exec", "--output-format", "acp"),
}
}
func (a *DroidACP) BillingType() usage.BillingType { return defaultBillingType() }
+9 -5
View File
@@ -5,6 +5,7 @@ import (
_ "embed"
"time"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
@@ -82,11 +83,12 @@ func (a *Gemini) BuildCommand(opts CommandOptions) Command {
func (a *Gemini) Runtime() *RuntimeConfig {
canRecover := false
return &RuntimeConfig{
Cmd: Cmd("npx", "-y", geminiPkg, "--acp").Build(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
Cmd: Cmd("npx", "-y", geminiPkg, "--acp").Build(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
ProjectSkillDir: ".agents/skills",
SessionConfig: SessionConfig{
CanRecover: &canRecover,
SessionDirTemplate: "{home}/.gemini",
@@ -128,6 +130,8 @@ func (a *Gemini) InstallScript() string {
return "npm install -g " + geminiPkg
}
func (a *Gemini) BillingType() usage.BillingType { return defaultBillingType() }
func (a *Gemini) PermissionSettings() map[string]PermissionSetting {
return emptyPermSettings
}
@@ -5,6 +5,7 @@ import (
_ "embed"
"time"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
@@ -106,3 +107,5 @@ func (a *IFlowACP) InferenceConfig() *InferenceConfig {
Command: NewCommand("npx", "-y", iflowACPPkg, "--experimental-acp"),
}
}
func (a *IFlowACP) BillingType() usage.BillingType { return defaultBillingType() }
@@ -5,6 +5,7 @@ import (
_ "embed"
"time"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
@@ -108,3 +109,5 @@ func (a *KilocodeACP) InferenceConfig() *InferenceConfig {
Command: NewCommand("npx", "-y", kilocodeACPPkg, "acp"),
}
}
func (a *KilocodeACP) BillingType() usage.BillingType { return defaultBillingType() }
@@ -6,6 +6,7 @@ import (
_ "embed"
"time"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
@@ -110,3 +111,5 @@ func (a *KimiACP) InferenceConfig() *InferenceConfig {
Command: NewCommand(kimiACPBin, "acp"),
}
}
func (a *KimiACP) BillingType() usage.BillingType { return defaultBillingType() }
@@ -6,6 +6,7 @@ import (
_ "embed"
"time"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
@@ -86,6 +87,7 @@ func (a *KiroACP) Runtime() *RuntimeConfig {
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
UserSkillDir: ".kiro/skills",
SessionConfig: SessionConfig{
// TODO: set SessionDirTemplate once the Kiro session dir is
// confirmed (likely "{home}/.kiro"). Without it, the Docker
@@ -115,3 +117,5 @@ func (a *KiroACP) InferenceConfig() *InferenceConfig {
Command: NewCommand(kiroACPBin, "acp"),
}
}
func (a *KiroACP) BillingType() usage.BillingType { return defaultBillingType() }
+80 -14
View File
@@ -5,6 +5,7 @@ import (
_ "embed"
"time"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
@@ -20,8 +21,24 @@ var (
_ InferenceAgent = (*MockAgent)(nil)
)
const (
mockTUIReadyPromptPattern = "\x1b\\[1;32m❯\x1b\\[0m $"
// mockAgentDefaultID is the canonical ID for the default MockAgent
// instance. When NewMockAgentWithID() supplies a non-empty override
// the override wins; otherwise this is returned by ID() and used as
// the default binary basename across BuildCommand/Runtime/Inference.
mockAgentDefaultID = "mock-agent"
)
type MockAgent struct {
StandardPassthrough
// id/name/displayName are per-instance overrides so the same mock
// binary can be registered under multiple canonical routing provider
// IDs (e.g. "claude-acp", "codex-acp") in E2E mode. When empty the
// defaults ("mock-agent", "Mock Agent", "Mock") are returned.
id string
name string
displayName string
enabled bool
binaryPath string
supportsMCP bool
@@ -34,11 +51,12 @@ func NewMockAgent() *MockAgent {
Supported: true,
Label: "TUI Passthrough",
Description: "Terminal UI mode for testing",
PassthroughCmd: NewCommand("mock-agent", "--tui"),
PassthroughCmd: NewCommand(mockAgentDefaultID, "--tui"),
ModelFlag: NewParam("--model", "{model}"),
PromptFlag: NewParam("--prompt", "{prompt}"),
SessionResumeFlag: NewParam("--resume"),
ResumeFlag: NewParam("-c"),
PromptPattern: mockTUIReadyPromptPattern,
IdleTimeout: 2 * time.Second,
BufferMaxBytes: DefaultBufferMaxBytes,
},
@@ -47,6 +65,20 @@ func NewMockAgent() *MockAgent {
}
}
// NewMockAgentWithID returns a MockAgent identical to NewMockAgent but
// with its ID, Name and DisplayName pre-set to the supplied values.
// Used in E2E mode (KANDEV_MOCK_PROVIDERS) to register the same mock
// binary under multiple canonical routing provider IDs so routing
// validation accepts real-looking provider_order lists while every
// launch still routes through the mock binary.
func NewMockAgentWithID(id, name, displayName string) *MockAgent {
a := NewMockAgent()
a.id = id
a.name = name
a.displayName = displayName
return a
}
// SetEnabled enables or disables the mock agent at runtime.
func (a *MockAgent) SetEnabled(enabled bool) { a.enabled = enabled }
@@ -64,9 +96,24 @@ func (a *MockAgent) SetSupportsMCP(v bool) { a.supportsMCP = v }
// SupportsMCPEnabled reports the current MCP support setting.
func (a *MockAgent) SupportsMCPEnabled() bool { return a.supportsMCP }
func (a *MockAgent) ID() string { return "mock-agent" }
func (a *MockAgent) Name() string { return "Mock Agent" }
func (a *MockAgent) DisplayName() string { return "Mock" }
func (a *MockAgent) ID() string {
if a.id != "" {
return a.id
}
return mockAgentDefaultID
}
func (a *MockAgent) Name() string {
if a.name != "" {
return a.name
}
return "Mock Agent"
}
func (a *MockAgent) DisplayName() string {
if a.displayName != "" {
return a.displayName
}
return "Mock"
}
func (a *MockAgent) Description() string {
return "Mock agent for testing. Generates simulated responses with all message types."
}
@@ -86,20 +133,37 @@ func (a *MockAgent) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
}
func (a *MockAgent) BuildCommand(opts CommandOptions) Command {
// Always resolve via PATH: works on the host (E2E PATH includes the
// kandev bin dir) and inside Docker containers (mock-agent is
// bind-mounted at /usr/local/bin/mock-agent for E2E).
return Cmd("mock-agent").Build()
// For container-bound runtimes (Docker, Sprites, remote Docker) the
// agent process exec's inside a Linux container, where the host path
// computed by configureMockAgent doesn't exist. The container's image
// has /usr/local/bin on PATH and container.go bind-mounts the
// linux/amd64 mock-agent there, so the bare name resolves correctly.
//
// For host runtimes (standalone) we prefer the absolute host path
// that configureMockAgent computed via os.Executable() — that's the
// only way the host path works in dev mode (where the shell's $PATH
// doesn't include apps/backend/bin). Fall back to the bare name when
// no path is set: e2e fixtures prepend the kandev bin dir to PATH.
if opts.Runtime.IsContainerized() {
return Cmd(mockAgentDefaultID).Build()
}
binary := mockAgentDefaultID
if a.binaryPath != "" {
binary = a.binaryPath
}
return Cmd(binary).Build()
}
func (a *MockAgent) Runtime() *RuntimeConfig {
canRecover := true
return &RuntimeConfig{
Cmd: Cmd("mock-agent").Build(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 512, CPUCores: 0.5, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
Cmd: Cmd(mockAgentDefaultID).Build(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 512, CPUCores: 0.5, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
ProjectSkillDir: DefaultProjectSkillDir,
UserSkillDir: ".mock-agent/skills",
SessionConfig: SessionConfig{
CanRecover: &canRecover,
},
@@ -127,6 +191,8 @@ func (a *MockAgent) LoginCommand() *LoginCommand {
}
}
func (a *MockAgent) BillingType() usage.BillingType { return defaultBillingType() }
func (a *MockAgent) PermissionSettings() map[string]PermissionSetting {
return emptyPermSettings
}
@@ -136,7 +202,7 @@ func (a *MockAgent) PermissionSettings() map[string]PermissionSetting {
// utility capability probe populates them into the cache without any static
// model list here.
func (a *MockAgent) InferenceConfig() *InferenceConfig {
binary := "mock-agent"
binary := mockAgentDefaultID
if a.binaryPath != "" {
binary = a.binaryPath
}
@@ -1,8 +1,10 @@
package agents
import (
"regexp"
"testing"
"github.com/kandev/kandev/internal/agentruntime"
"github.com/kandev/kandev/pkg/agent"
)
@@ -32,6 +34,63 @@ func TestMockAgent_BuildCommand_NoResumeFlag(t *testing.T) {
}
}
// TestMockAgent_BuildCommand_ContainerizedUsesBareName pins the docker
// branch of BuildCommand: when opts.Runtime is containerized (docker,
// remote_docker, sprites), MockAgent must emit the bare binary name so
// PATH lookup inside the container resolves to the bind-mounted
// linux/amd64 mock-agent at /usr/local/bin. Regressing this would
// cause docker e2e to fail with `exec: "<host-abs-path>": not found`,
// which is exactly the bug commit `8518f65` fixed.
func TestMockAgent_BuildCommand_ContainerizedUsesBareName(t *testing.T) {
a := NewMockAgent()
a.SetBinaryPath("/Users/dev/kandev/apps/backend/bin/mock-agent")
for _, rt := range []agentruntime.Runtime{
agentruntime.RuntimeDocker,
agentruntime.RuntimeRemoteDocker,
agentruntime.RuntimeSprites,
} {
t.Run(string(rt), func(t *testing.T) {
cmd := a.BuildCommand(CommandOptions{Runtime: rt})
args := cmd.Args()
if len(args) == 0 || args[0] != mockAgentDefaultID {
t.Errorf("containerized runtime %q: BuildCommand args = %v, want first arg %q (bare name for PATH lookup in container)",
rt, args, mockAgentDefaultID)
}
})
}
}
// TestMockAgent_BuildCommand_HostUsesBinaryPath pins the host branch:
// when opts.Runtime is host-side (standalone) and binaryPath is set
// (which configureMockAgent does via os.Executable()), MockAgent must
// emit the absolute host path so dev mode works without
// apps/backend/bin being on the shell's $PATH.
func TestMockAgent_BuildCommand_HostUsesBinaryPath(t *testing.T) {
a := NewMockAgent()
const absPath = "/Users/dev/kandev/apps/backend/bin/mock-agent"
a.SetBinaryPath(absPath)
cmd := a.BuildCommand(CommandOptions{Runtime: agentruntime.RuntimeStandalone})
args := cmd.Args()
if len(args) == 0 || args[0] != absPath {
t.Errorf("host runtime: BuildCommand args = %v, want first arg %q (absolute host path)",
args, absPath)
}
}
// TestMockAgent_BuildCommand_HostNoBinaryPathFallsBack covers the e2e
// case where configureMockAgent hasn't run yet (or has been bypassed)
// but the test fixture has prepended apps/backend/bin to PATH. Host
// runtime + empty binaryPath → bare name, resolved via PATH.
func TestMockAgent_BuildCommand_HostNoBinaryPathFallsBack(t *testing.T) {
a := NewMockAgent()
cmd := a.BuildCommand(CommandOptions{Runtime: agentruntime.RuntimeStandalone})
args := cmd.Args()
if len(args) == 0 || args[0] != mockAgentDefaultID {
t.Errorf("host runtime without binaryPath: BuildCommand args = %v, want first arg %q (bare name fallback)",
args, mockAgentDefaultID)
}
}
func TestMockAgent_Runtime_CanRecover(t *testing.T) {
a := NewMockAgent()
rt := a.Runtime()
@@ -82,3 +141,52 @@ func TestMockAgent_PassthroughConfig_ResumeFlags(t *testing.T) {
t.Errorf("expected SessionResumeFlag = --resume, got %v", sessionArgs)
}
}
func TestNewMockAgentWithID_AppliesIdentityOverrides(t *testing.T) {
a := NewMockAgentWithID("claude-acp", "Mock Claude", "Claude (Mock)")
if a.ID() != "claude-acp" {
t.Errorf("ID() = %q, want claude-acp", a.ID())
}
if a.Name() != "Mock Claude" {
t.Errorf("Name() = %q, want Mock Claude", a.Name())
}
if a.DisplayName() != "Claude (Mock)" {
t.Errorf("DisplayName() = %q, want Claude (Mock)", a.DisplayName())
}
}
func TestNewMockAgent_DefaultIdentity(t *testing.T) {
a := NewMockAgent()
if a.ID() != "mock-agent" {
t.Errorf("ID() = %q, want mock-agent", a.ID())
}
if a.Name() != "Mock Agent" {
t.Errorf("Name() = %q, want Mock Agent", a.Name())
}
if a.DisplayName() != "Mock" {
t.Errorf("DisplayName() = %q, want Mock", a.DisplayName())
}
}
func TestMockAgent_PassthroughConfig_PromptPattern(t *testing.T) {
a := NewMockAgent()
pattern := a.PassthroughConfig().PromptPattern
if pattern == "" {
t.Fatal("expected PromptPattern to be set for mock TUI passthrough")
}
re, err := regexp.Compile(pattern)
if err != nil {
t.Fatalf("expected PromptPattern to compile: %v", err)
}
readyPrompt := "\x1b[1;32m❯\x1b[0m "
if !re.MatchString(readyPrompt) {
t.Fatalf("expected PromptPattern to match ready prompt %q", readyPrompt)
}
initialPromptLine := "\x1b[1;32m❯\x1b[0m hello from stdin\r\n"
if re.MatchString(initialPromptLine) {
t.Fatalf("expected PromptPattern not to match prompt echo %q", initialPromptLine)
}
}
@@ -5,6 +5,7 @@ import (
_ "embed"
"time"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
@@ -84,11 +85,13 @@ func (a *OpenCodeACP) BuildCommand(opts CommandOptions) Command {
func (a *OpenCodeACP) Runtime() *RuntimeConfig {
canRecover := true
return &RuntimeConfig{
Cmd: Cmd("opencode", "acp").Build(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
Cmd: Cmd("opencode", "acp").Build(),
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
ProjectSkillDir: ".agents/skills",
UserSkillDir: ".config/opencode/skills",
SessionConfig: SessionConfig{
NativeSessionResume: true,
CanRecover: &canRecover,
@@ -103,6 +106,8 @@ func (a *OpenCodeACP) InstallScript() string {
return "npm install -g " + opencodeACPPkg
}
func (a *OpenCodeACP) BillingType() usage.BillingType { return defaultBillingType() }
func (a *OpenCodeACP) PermissionSettings() map[string]PermissionSetting {
return emptyPermSettings
}
@@ -5,6 +5,7 @@ import (
_ "embed"
"time"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
@@ -90,6 +91,7 @@ func (a *PiACP) Runtime() *RuntimeConfig {
Env: map[string]string{},
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
Protocol: agent.ProtocolACP,
UserSkillDir: ".pi/agent/skills",
SessionConfig: SessionConfig{
// TODO: set SessionDirTemplate once the Pi session dir is
// confirmed. Without it, the Docker runtime skips mounting the
@@ -117,3 +119,5 @@ func (a *PiACP) InferenceConfig() *InferenceConfig {
Command: NewCommand("npx", "-y", piACPPkg),
}
}
func (a *PiACP) BillingType() usage.BillingType { return defaultBillingType() }
@@ -0,0 +1,134 @@
package agents
import (
"context"
"testing"
"github.com/kandev/kandev/internal/agent/usage"
)
// TestProjectSkillDir_AllAgentTypes verifies that every registered agent type
// sets a non-empty ProjectSkillDir in its RuntimeConfig, and that the fallback
// function ProjectSkillDirFromRuntime returns a sensible default when the field
// is absent.
func TestProjectSkillDir_AllAgentTypes(t *testing.T) {
allAgents := []Agent{
NewClaudeACP(),
NewCodexACP(),
NewOpenCodeACP(),
NewGemini(),
NewCopilotACP(),
NewAuggie(),
NewAmpACP(),
NewMockAgent(),
}
for _, a := range allAgents {
t.Run(a.ID(), func(t *testing.T) {
rt := a.Runtime()
if rt == nil {
t.Fatal("Runtime() returned nil")
}
dir := ProjectSkillDirFromRuntime(a)
if dir == "" {
t.Errorf("ProjectSkillDirFromRuntime(%q) returned empty string", a.ID())
}
})
}
}
// TestProjectSkillDir_ClaudeUsesClaudeDir verifies that Claude uses .claude/skills
// (not .agents/skills) as its project skill directory.
func TestProjectSkillDir_ClaudeUsesClaudeDir(t *testing.T) {
a := NewClaudeACP()
dir := ProjectSkillDirFromRuntime(a)
if dir != ".claude/skills" {
t.Errorf("claude-acp ProjectSkillDir = %q, want %q", dir, ".claude/skills")
}
}
// TestProjectSkillDir_OthersUseAgentsDir verifies that non-Claude agents use
// .agents/skills as their project skill directory.
func TestProjectSkillDir_OthersUseAgentsDir(t *testing.T) {
others := []Agent{
NewCodexACP(),
NewOpenCodeACP(),
NewGemini(),
NewCopilotACP(),
NewAuggie(),
NewAmpACP(),
}
for _, a := range others {
t.Run(a.ID(), func(t *testing.T) {
dir := ProjectSkillDirFromRuntime(a)
if dir != DefaultProjectSkillDir {
t.Errorf("%s ProjectSkillDir = %q, want %q", a.ID(), dir, DefaultProjectSkillDir)
}
})
}
}
// TestProjectSkillDirFromRuntime_Fallback verifies that an agent with empty
// ProjectSkillDir in RuntimeConfig falls back to DefaultProjectSkillDir.
func TestProjectSkillDirFromRuntime_Fallback(t *testing.T) {
// Create an agent that returns a RuntimeConfig with no ProjectSkillDir set.
a := &emptySkillDirAgent{}
dir := ProjectSkillDirFromRuntime(a)
if dir != DefaultProjectSkillDir {
t.Errorf("fallback ProjectSkillDir = %q, want %q", dir, DefaultProjectSkillDir)
}
}
func TestUserSkillDir_KnownProviders(t *testing.T) {
tests := []struct {
name string
a Agent
want string
}{
{"claude", NewClaudeACP(), ".claude/skills"},
{"codex", NewCodexACP(), ".codex/skills"},
{"opencode", NewOpenCodeACP(), ".config/opencode/skills"},
{"copilot", NewCopilotACP(), ".copilot/skills"},
{"mock-agent", NewMockAgent(), ".mock-agent/skills"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := UserSkillDirFromRuntime(tt.a)
if got != tt.want {
t.Errorf("UserSkillDirFromRuntime(%q) = %q, want %q", tt.a.ID(), got, tt.want)
}
})
}
}
func TestUserSkillDirFromRuntime_EmptyWhenUnset(t *testing.T) {
a := &emptySkillDirAgent{}
if got := UserSkillDirFromRuntime(a); got != "" {
t.Errorf("fallback UserSkillDir = %q, want empty", got)
}
}
// emptySkillDirAgent is a minimal Agent implementation used only in tests to
// verify the fallback behaviour of ProjectSkillDirFromRuntime.
type emptySkillDirAgent struct{}
func (e *emptySkillDirAgent) ID() string { return "test-empty" }
func (e *emptySkillDirAgent) Name() string { return "Test Empty" }
func (e *emptySkillDirAgent) DisplayName() string { return "Test" }
func (e *emptySkillDirAgent) Description() string { return "" }
func (e *emptySkillDirAgent) Enabled() bool { return true }
func (e *emptySkillDirAgent) DisplayOrder() int { return 0 }
func (e *emptySkillDirAgent) Logo(_ LogoVariant) []byte { return nil }
func (e *emptySkillDirAgent) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
return &DiscoveryResult{}, nil
}
func (e *emptySkillDirAgent) BuildCommand(_ CommandOptions) Command {
return Command{}
}
func (e *emptySkillDirAgent) PermissionSettings() map[string]PermissionSetting { return nil }
func (e *emptySkillDirAgent) Runtime() *RuntimeConfig {
return &RuntimeConfig{} // ProjectSkillDir intentionally left empty
}
func (e *emptySkillDirAgent) BillingType() usage.BillingType { return usage.BillingTypeAPIKey }
func (e *emptySkillDirAgent) RemoteAuth() *RemoteAuth { return nil }
func (e *emptySkillDirAgent) InstallScript() string { return "" }
@@ -6,6 +6,7 @@ import (
_ "embed"
"time"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
@@ -112,3 +113,5 @@ func (a *QoderACP) InferenceConfig() *InferenceConfig {
Command: NewCommand(qoderACPBin, "--acp"),
}
}
func (a *QoderACP) BillingType() usage.BillingType { return defaultBillingType() }
@@ -5,6 +5,7 @@ import (
_ "embed"
"time"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
@@ -108,3 +109,5 @@ func (a *QwenACP) InferenceConfig() *InferenceConfig {
Command: NewCommand("npx", "-y", qwenACPPkg, "--acp"),
}
}
func (a *QwenACP) BillingType() usage.BillingType { return defaultBillingType() }
@@ -5,6 +5,7 @@ import (
_ "embed"
"time"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
@@ -111,3 +112,5 @@ func (a *TraeACP) InferenceConfig() *InferenceConfig {
Command: NewCommand(traeACPBin, "acp", "serve"),
}
}
func (a *TraeACP) BillingType() usage.BillingType { return defaultBillingType() }
@@ -4,6 +4,7 @@ import (
"context"
"time"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
@@ -112,6 +113,7 @@ func (a *TUIAgent) BuildCommand(_ CommandOptions) Command {
func (a *TUIAgent) InstallScript() string { return "" }
func (a *TUIAgent) RemoteAuth() *RemoteAuth { return nil }
func (a *TUIAgent) BillingType() usage.BillingType { return defaultBillingType() }
func (a *TUIAgent) PermissionSettings() map[string]PermissionSetting { return nil }
func (a *TUIAgent) Runtime() *RuntimeConfig {
@@ -5,8 +5,8 @@ import (
"context"
"github.com/kandev/kandev/internal/agent/dto"
"github.com/kandev/kandev/internal/agent/lifecycle"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
)
// Controller coordinates agent business logic
@@ -7,6 +7,7 @@ import (
"time"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/internal/common/logger"
)
@@ -28,6 +29,7 @@ func (a *discoveryTestAgent) PermissionSettings() map[string]agents.PermissionSe
return nil
}
func (a *discoveryTestAgent) Runtime() *agents.RuntimeConfig { return nil }
func (a *discoveryTestAgent) BillingType() usage.BillingType { return usage.BillingTypeAPIKey }
func (a *discoveryTestAgent) RemoteAuth() *agents.RemoteAuth { return nil }
func (a *discoveryTestAgent) InstallScript() string { return "" }
func (a *discoveryTestAgent) BuildCommand(opts agents.CommandOptions) agents.Command {
@@ -2,19 +2,22 @@
package executor
import (
"github.com/kandev/kandev/internal/agentruntime"
"github.com/kandev/kandev/internal/task/models"
)
// Name identifies the execution backend.
type Name string
// Name identifies the execution backend. It aliases agentruntime.Runtime
// so the executor and runtime layers share a single typed vocabulary
// without forcing every existing consumer to switch import paths.
type Name = agentruntime.Runtime
const (
NameUnknown Name = ""
NameDocker Name = "docker"
NameStandalone Name = "standalone"
NameDocker = agentruntime.RuntimeDocker
NameStandalone = agentruntime.RuntimeStandalone
NameLocal Name = "local"
NameRemoteDocker Name = "remote_docker"
NameSprites Name = "sprites"
NameRemoteDocker = agentruntime.RuntimeRemoteDocker
NameSprites = agentruntime.RuntimeSprites
)
// ExecutorTypeToBackend maps an ExecutorType to its corresponding executor Name.
@@ -8,8 +8,8 @@ import (
"strings"
"time"
"github.com/kandev/kandev/internal/agent/lifecycle"
"github.com/kandev/kandev/internal/agentctl/client"
"github.com/kandev/kandev/internal/agent/runtime/agentctl"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/common/logger"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
@@ -9,8 +9,8 @@ import (
"testing"
"time"
"github.com/kandev/kandev/internal/agent/lifecycle"
"github.com/kandev/kandev/internal/agentctl/client"
"github.com/kandev/kandev/internal/agent/runtime/agentctl"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
ws "github.com/kandev/kandev/pkg/websocket"
)
@@ -5,7 +5,7 @@ import (
"context"
"fmt"
"github.com/kandev/kandev/internal/agent/lifecycle"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/common/logger"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
@@ -4,7 +4,7 @@ import (
"context"
"fmt"
"github.com/kandev/kandev/internal/agent/lifecycle"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/common/logger"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
@@ -10,7 +10,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/kandev/kandev/internal/agent/lifecycle"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/common/scripts"
v1 "github.com/kandev/kandev/pkg/api/v1"
@@ -8,8 +8,8 @@ import (
"testing"
"time"
"github.com/kandev/kandev/internal/agent/lifecycle"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events/bus"
v1 "github.com/kandev/kandev/pkg/api/v1"
@@ -5,7 +5,7 @@ import (
"fmt"
"net/url"
"github.com/kandev/kandev/internal/agent/lifecycle"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/common/logger"
ws "github.com/kandev/kandev/pkg/websocket"
@@ -5,8 +5,8 @@ import (
"fmt"
"strings"
"github.com/kandev/kandev/internal/agent/lifecycle"
agentctl "github.com/kandev/kandev/internal/agentctl/client"
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/common/logger"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
@@ -17,7 +17,7 @@ import (
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/registry"
agentctlclient "github.com/kandev/kandev/internal/agentctl/client"
agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl"
agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/pkg/agent"
@@ -0,0 +1,34 @@
package registry
// RoutableProviderIDs is the canonical, single-source-of-truth list of
// provider IDs eligible for office provider routing in v1. It lives in
// the registry package so both:
//
// 1. internal/office/routing's catalogue (KnownProviders), and
// 2. the E2E multi-mock registration path (KANDEV_MOCK_PROVIDERS),
//
// can reference the same list without an import cycle. Adding a new
// real CLI provider to routing v2+ means appending its agent-registry
// ID here.
//
// These IDs MUST match the corresponding real agents' ID() values
// (claude_acp.go, codex_acp.go, opencode_acp.go, copilot_acp.go,
// amp_acp.go) so registry lookups against an enabled real agent
// succeed.
var RoutableProviderIDs = []string{
"claude-acp",
"codex-acp",
"opencode-acp",
"copilot-acp",
"amp-acp",
}
// IsRoutableProviderID reports whether id is in RoutableProviderIDs.
func IsRoutableProviderID(id string) bool {
for _, p := range RoutableProviderIDs {
if p == id {
return true
}
}
return false
}
@@ -1,10 +1,14 @@
package registry
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/runtime/routingerr"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
@@ -15,29 +19,191 @@ import (
// - "only" → E2E mode: only register mock agent, skip all others
// - "true" → Dev mode: load all agents AND enable mock agent
// - unset → Production: load all agents, mock agent disabled
//
// KANDEV_MOCK_PROVIDERS is a comma-separated list of canonical routing
// provider IDs registered as additional MockAgent instances. This
// unblocks coverage of the office provider-routing feature: routing
// config validation requires real-looking provider IDs to be present
// in the registry, while every launch still routes through the mock
// binary. Entries not in RoutableProviderIDs are skipped with a
// warning. It is honoured only when explicitly set, in both dev and
// E2E modes, and is never honoured in production.
//
// For the routing UI without real CLIs installed, set
// `KANDEV_MOCK_PROVIDERS=claude-acp,codex-acp,opencode-acp` when
// launching `make dev`.
func Provide(log *logger.Logger) (*Registry, func() error, error) {
reg := NewRegistry(log)
mockMode := os.Getenv("KANDEV_MOCK_AGENT")
mockProviders := os.Getenv("KANDEV_MOCK_PROVIDERS")
if mockMode == "only" {
// E2E mode: only register mock agent — skip agent discovery for all others
_ = reg.Register(agents.NewMockAgent())
configureMockAgent(reg, log)
configureMockAgent(reg, "mock-agent", log)
registerExtraMockProviders(reg, log, mockProviders)
validateMockProviders(reg, mockProviders, log)
} else {
reg.LoadDefaults()
if mockMode == "true" {
// Dev mode: enable mock agent alongside all other agents
configureMockAgent(reg, log)
// Dev mode: enable the base mock agent alongside the real
// agents. KANDEV_MOCK_PROVIDERS is opt-in — when set, the
// listed canonicals are replaced with MockAgent aliases so
// the routing UI is exercisable without the real CLIs.
configureMockAgent(reg, "mock-agent", log)
registerExtraMockProviders(reg, log, mockProviders)
validateMockProviders(reg, mockProviders, log)
}
registerRealProvidersProber(reg, log)
}
return reg, func() error { return nil }, nil
}
// validateMockProviders logs an error if any provider listed in the raw
// comma-separated string is missing from the registry after
// registerExtraMockProviders. A missing provider means the routing
// catalogue will reject every non-empty provider_order, causing 400s
// in the office-routing-* E2E specs. Failing loudly here surfaces the
// root cause instead of leaving it to cascade into opaque HTTP errors.
func validateMockProviders(reg *Registry, raw string, log *logger.Logger) {
if raw == "" {
return
}
for _, entry := range strings.Split(raw, ",") {
id := strings.TrimSpace(entry)
if id == "" || !IsRoutableProviderID(id) {
continue
}
if !reg.Exists(id) {
log.Error("KANDEV_MOCK_PROVIDERS: provider missing from registry after registration — routing catalogue will be empty",
zap.String("id", id))
}
}
}
// registerRealProvidersProber wires the shared ACP probe to each
// routable real provider. The probe spawns the agent's CLI via
// BuildCommand, performs a JSON-RPC initialize, and tears down — no
// session ever starts. Called once at boot in non-mock mode so the
// HTTP /routing/retry endpoint can flip a degraded provider back to
// healthy without waiting for the next real launch.
func registerRealProvidersProber(reg *Registry, log *logger.Logger) {
resolver := func(providerID string) ([]string, map[string]string, bool) {
ag, ok := reg.Get(providerID)
if !ok || !ag.Enabled() {
return nil, nil, false
}
cmd := ag.BuildCommand(agents.CommandOptions{})
if cmd.IsEmpty() {
return nil, nil, false
}
var env map[string]string
if rt := ag.Runtime(); rt != nil && len(rt.Env) > 0 {
env = make(map[string]string, len(rt.Env))
for k, v := range rt.Env {
env[k] = v
}
}
return cmd.Args(), env, true
}
probe := routingerr.NewACPProbe(resolver, log)
for _, id := range RoutableProviderIDs {
// Skip ids that are not actually loaded (e.g. an agent type
// that was removed from LoadDefaults but still appears in the
// routable list). The probe is harmless to register against a
// missing agent — but registering only loaded ones keeps the
// boot log noise minimal.
if !reg.Exists(id) {
continue
}
routingerr.RegisterProber(id, probe)
}
}
// registerExtraMockProviders parses the supplied comma-separated list
// of provider IDs and registers each accepted entry as a MockAgent
// alias under that canonical provider ID.
//
// In KANDEV_MOCK_AGENT=only (E2E) mode the canonicals are not loaded by
// LoadDefaults so each entry is a fresh registration. In
// KANDEV_MOCK_AGENT=true (dev) mode the real agents were just loaded —
// we Replace the entry with a MockAgent so the routing UI exercises
// the mock binary instead of requiring the real CLI to be installed
// on the developer's machine.
func registerExtraMockProviders(reg *Registry, log *logger.Logger, raw string) {
if raw == "" {
return
}
for _, entry := range strings.Split(raw, ",") {
id := strings.TrimSpace(entry)
if id == "" {
continue
}
if !IsRoutableProviderID(id) {
log.Warn("KANDEV_MOCK_PROVIDERS: skipping unknown provider id",
zap.String("id", id),
zap.Strings("allowed", RoutableProviderIDs))
continue
}
displayName := mockDisplayNameFor(id)
ag := agents.NewMockAgentWithID(id, displayName, displayName)
if err := reg.Replace(ag); err != nil {
log.Warn("KANDEV_MOCK_PROVIDERS: register failed",
zap.String("id", id), zap.Error(err))
continue
}
configureMockAgent(reg, id, log)
// Register an E2E-only prober so /routing/retry can mark the
// provider healthy without depending on a follow-up launch.
// The prober consults the live injection map: if KANDEV_PROVIDER_
// FAILURES still names this provider, the probe fails with that
// code; otherwise it succeeds (provider flips healthy).
routingerr.RegisterProber(id, &mockProvProber{providerID: id})
}
}
// mockProvProber is the E2E-only ProviderProber wired in
// registerExtraMockProviders. It mirrors how a real prober for
// claude-acp/codex-acp would behave: a cheap availability check that
// returns the injected error when the test has armed one, and nil
// when the injection has been cleared.
type mockProvProber struct{ providerID string }
func (p *mockProvProber) Probe(_ context.Context, _ routingerr.ProbeInput) *routingerr.Error {
if _, ok := routingerr.InjectedCode(p.providerID); ok {
return routingerr.Classify(routingerr.Input{
Phase: routingerr.PhaseProcessStart,
ProviderID: p.providerID,
})
}
return nil
}
// mockDisplayNameFor returns a friendly display name for a routable
// provider id (e.g. "Mock Claude" for "claude-acp"). Falls back to the
// id itself when the prefix is unknown.
func mockDisplayNameFor(id string) string {
switch id {
case "claude-acp":
return "Mock Claude"
case "codex-acp":
return "Mock Codex"
case "opencode-acp":
return "Mock OpenCode"
case "copilot-acp":
return "Mock Copilot"
case "amp-acp":
return "Mock Amp"
default:
return "Mock " + id
}
}
// configureMockAgent enables and configures the mock agent binary path and capabilities.
// KANDEV_MOCK_AGENT_MCP=false disables MCP support (defaults to enabled).
func configureMockAgent(reg *Registry, log *logger.Logger) {
ag, ok := reg.Get("mock-agent")
func configureMockAgent(reg *Registry, id string, log *logger.Logger) {
ag, ok := reg.Get(id)
if !ok {
return
}
@@ -49,14 +215,22 @@ func configureMockAgent(reg *Registry, log *logger.Logger) {
if os.Getenv("KANDEV_MOCK_AGENT_MCP") == "false" {
mock.SetSupportsMCP(false)
}
// Resolve binary path: same directory as the running executable
// Resolve binary path: same directory as the running executable.
// On Windows the binary is mock-agent.exe — exec.Command on Windows
// only auto-appends .exe for PATH lookups, not absolute paths, so
// the basename must include the extension here.
exePath, err := os.Executable()
if err != nil {
return
}
binaryPath := filepath.Join(filepath.Dir(exePath), "mock-agent")
binaryName := "mock-agent"
if runtime.GOOS == "windows" {
binaryName += ".exe"
}
binaryPath := filepath.Join(filepath.Dir(exePath), binaryName)
mock.SetBinaryPath(binaryPath)
log.Info("mock agent enabled",
zap.String("id", id),
zap.String("cmd", binaryPath),
zap.Bool("supports_mcp", mock.SupportsMCPEnabled()))
}
@@ -1,7 +1,10 @@
package registry
import (
"os"
"testing"
"github.com/kandev/kandev/internal/agent/agents"
)
func TestProvide_MockAgentModes(t *testing.T) {
@@ -80,3 +83,168 @@ func TestProvide_MockAgentModes(t *testing.T) {
})
}
}
// TestProvide_MockProviders_RegistersExtraAliases ensures that when
// KANDEV_MOCK_AGENT=only and KANDEV_MOCK_PROVIDERS lists canonical
// routing provider IDs, each accepted ID is registered as an extra
// MockAgent instance so routing config validation accepts them.
func TestProvide_MockProviders_RegistersExtraAliases(t *testing.T) {
t.Setenv("KANDEV_MOCK_AGENT", "only")
t.Setenv("KANDEV_MOCK_PROVIDERS", "claude-acp,codex-acp")
log := newTestLogger()
reg, cleanup, err := Provide(log)
if err != nil {
t.Fatalf("Provide() error: %v", err)
}
defer cleanup() //nolint:errcheck
for _, id := range []string{"mock-agent", "claude-acp", "codex-acp"} {
ag, ok := reg.Get(id)
if !ok {
t.Fatalf("expected agent %q to be registered", id)
}
if _, isMock := ag.(*agents.MockAgent); !isMock {
t.Errorf("agent %q should be *agents.MockAgent, got %T", id, ag)
}
if !ag.Enabled() {
t.Errorf("agent %q should be enabled", id)
}
}
// opencode-acp not in the env var, must not be registered.
if reg.Exists("opencode-acp") {
t.Error("opencode-acp should NOT be registered when not in KANDEV_MOCK_PROVIDERS")
}
}
// TestProvide_MockProviders_SkipsUnknownIDs verifies that entries in
// KANDEV_MOCK_PROVIDERS not present in RoutableProviderIDs are skipped
// with a warning (not registered).
func TestProvide_MockProviders_SkipsUnknownIDs(t *testing.T) {
t.Setenv("KANDEV_MOCK_AGENT", "only")
t.Setenv("KANDEV_MOCK_PROVIDERS", "claude-acp,bogus-provider, ,not-real")
log := newTestLogger()
reg, cleanup, err := Provide(log)
if err != nil {
t.Fatalf("Provide() error: %v", err)
}
defer cleanup() //nolint:errcheck
if !reg.Exists("claude-acp") {
t.Error("claude-acp should be registered")
}
if reg.Exists("bogus-provider") {
t.Error("bogus-provider should NOT be registered")
}
if reg.Exists("not-real") {
t.Error("not-real should NOT be registered")
}
}
// TestProvide_MockProviders_DevModeExplicitOverridesReal pins the
// updated dev-mode behavior: when KANDEV_MOCK_AGENT=true and the user
// explicitly sets KANDEV_MOCK_PROVIDERS, the named real-provider agents
// are replaced with MockAgent aliases so the routing UI runs against
// the mock binary instead of requiring the real CLI to be installed.
func TestProvide_MockProviders_DevModeExplicitOverridesReal(t *testing.T) {
t.Setenv("KANDEV_MOCK_AGENT", "true")
t.Setenv("KANDEV_MOCK_PROVIDERS", "claude-acp,codex-acp")
log := newTestLogger()
reg, cleanup, err := Provide(log)
if err != nil {
t.Fatalf("Provide() error: %v", err)
}
defer cleanup() //nolint:errcheck
for _, id := range []string{"claude-acp", "codex-acp"} {
ag, ok := reg.Get(id)
if !ok {
t.Fatalf("expected agent %q to be registered in dev mode", id)
}
if _, isMock := ag.(*agents.MockAgent); !isMock {
t.Errorf("agent %q should be the MockAgent alias in dev mode, got %T", id, ag)
}
}
// opencode-acp was not in the explicit env var; the real agent
// loaded by LoadDefaults must still be present.
if oc, ok := reg.Get("opencode-acp"); ok {
if _, isMock := oc.(*agents.MockAgent); isMock {
t.Error("opencode-acp should remain the real agent when not listed in KANDEV_MOCK_PROVIDERS")
}
}
}
// TestProvide_MockProviders_VisibleThroughRoutingCatalogue confirms that
// providers registered via KANDEV_MOCK_PROVIDERS are visible through
// reg.Exists() — the path used by routing.KnownProviders to build the
// routing catalogue. When any requested provider is missing from
// reg.Exists, KnownProviders returns an empty slice and every
// non-empty provider_order is rejected with "max 0 allowed".
func TestProvide_MockProviders_VisibleThroughRoutingCatalogue(t *testing.T) {
t.Setenv("KANDEV_MOCK_AGENT", "only")
t.Setenv("KANDEV_MOCK_PROVIDERS", "claude-acp,codex-acp,opencode-acp")
log := newTestLogger()
reg, cleanup, err := Provide(log)
if err != nil {
t.Fatalf("Provide() error: %v", err)
}
defer cleanup() //nolint:errcheck
// Simulate what routing.KnownProviders does: iterate RoutableProviderIDs
// and check reg.Exists(id). All three requested providers must be visible.
for _, id := range []string{"claude-acp", "codex-acp", "opencode-acp"} {
if !reg.Exists(id) {
t.Errorf("provider %q is registered via KANDEV_MOCK_PROVIDERS but reg.Exists returns false; "+
"routing.KnownProviders would exclude it and reject any provider_order containing it", id)
}
}
// Providers NOT in KANDEV_MOCK_PROVIDERS must not be in the registry
// (no silently-materialised extras that could pollute agent counts).
for _, id := range []string{"copilot-acp", "amp-acp"} {
if reg.Exists(id) {
t.Errorf("provider %q was NOT requested in KANDEV_MOCK_PROVIDERS but is in the registry", id)
}
}
}
// TestProvide_DevModeDoesNotMockCanonicalsByDefault pins the dev-mode
// default: when KANDEV_MOCK_AGENT=true and KANDEV_MOCK_PROVIDERS is
// unset, the real canonical agents remain — devs see Claude/Codex/
// OpenCode, not Mock aliases. Routing-UI testing is opt-in via an
// explicit KANDEV_MOCK_PROVIDERS value.
func TestProvide_DevModeDoesNotMockCanonicalsByDefault(t *testing.T) {
t.Setenv("KANDEV_MOCK_AGENT", "true")
// Intentionally clear KANDEV_MOCK_PROVIDERS — neither unset nor set
// should trigger the mock aliasing.
if _, ok := os.LookupEnv("KANDEV_MOCK_PROVIDERS"); ok {
if err := os.Unsetenv("KANDEV_MOCK_PROVIDERS"); err != nil {
t.Fatalf("unset env: %v", err)
}
}
log := newTestLogger()
reg, cleanup, err := Provide(log)
if err != nil {
t.Fatalf("Provide() error: %v", err)
}
defer cleanup() //nolint:errcheck
if mock, ok := reg.Get("mock-agent"); !ok || !mock.Enabled() {
t.Fatal("mock-agent must remain enabled in dev mode")
}
for _, id := range []string{"claude-acp", "codex-acp", "opencode-acp"} {
ag, ok := reg.Get(id)
if !ok {
t.Fatalf("expected canonical %q registered (real agent) in dev mode", id)
}
if _, isMock := ag.(*agents.MockAgent); isMock {
t.Errorf("canonical %q should remain the real agent when KANDEV_MOCK_PROVIDERS is unset, got MockAgent", id)
}
}
}

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