Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
e680ae5
bobby: sync tickets and sessions
ccevans Aug 15, 2026
a56f82e
PRO-027: refuse a studio worktree_root that resolves outside the studio
ccevans Aug 15, 2026
f1355a4
TKT-021: vet chat — conversational planning with executor --resume
ccevans Aug 15, 2026
1b806ce
bobby: auto-sync
ccevans Aug 15, 2026
1f32172
bobby: auto-sync
ccevans Aug 15, 2026
dbf4eea
TKT-022: studio mode — switch projects from inside the app
ccevans Aug 15, 2026
3eab305
bobby: auto-sync
ccevans Aug 15, 2026
bb38bf8
TKT-021: review + test evidence, final stage → shipping
ccevans Aug 15, 2026
a4b26c9
bobby: auto-sync
ccevans Aug 15, 2026
0987d4d
TKT-022: pin a run to the board it started on (AC3 blocker)
ccevans Aug 15, 2026
42b9532
TKT-022: building → reviewing after the AC3 fix
ccevans Aug 15, 2026
d92d739
bobby: auto-sync
ccevans Aug 16, 2026
c6f196d
TKT-022: review fixes
ccevans Aug 16, 2026
4f9e691
TKT-022: wire ProjectContext into the command that actually ships
ccevans Aug 16, 2026
559e7fa
TKT-022 → reviewing; TKT-070 → done (orphan deleted in b823e54)
ccevans Aug 16, 2026
4ca08b8
bobby: auto-sync
ccevans Aug 16, 2026
0df860c
TKT-022: review fixes
ccevans Aug 16, 2026
a95b543
TKT-022: stop ProjectContext re-deriving what config already resolved
ccevans Aug 16, 2026
dfe3365
TKT-022: building → reviewing after the B1/B2 fix
ccevans Aug 16, 2026
0ea60ca
bobby: auto-sync
ccevans Aug 16, 2026
f4863b2
TKT-022: review fixes
ccevans Aug 16, 2026
dfc791c
TKT-022: reviewing → building (rejected on B3)
ccevans Aug 16, 2026
c1c718c
TKT-022: the config is per-project too — pin it to the run
ccevans Aug 16, 2026
fde102a
TKT-022: building → reviewing after the B3/C1/C2 fix
ccevans Aug 16, 2026
68f4cd2
TKT-022: correct two terms lost to shell evaluation in the last comment
ccevans Aug 16, 2026
e2e6536
bobby: auto-sync
ccevans Aug 16, 2026
af90926
TKT-022: review fixes
ccevans Aug 16, 2026
7112dc1
TKT-022: pin the exit path's last two per-project reads (D1, D2)
ccevans Aug 16, 2026
29dea74
TKT-022 → reviewing (D1/D2 fixed); file TKT-071 for the plugin config…
ccevans Aug 16, 2026
0c9a26e
TKT-023: live-app test failed — relay config-load race (fix is in Pro…
ccevans Aug 16, 2026
ea689cb
TKT-022: review fixes
ccevans Aug 16, 2026
6d87b2a
TKT-022: reviewing → shipping (approved with notes)
ccevans Aug 16, 2026
7d0f1b5
TKT-022: close review notes N1/N2 (approved, in shipping)
ccevans Aug 16, 2026
24e5539
chore(tickets): PR #12 bookkeeping — TKT-021/022 shipped, TKT-072 filed
ccevans Aug 17, 2026
d88d667
test: close undici's keep-alive socket before server.close (Node 18)
ccevans Aug 17, 2026
da07cc1
TKT-072: make the scaffold-commit failure name its own cause
ccevans Aug 18, 2026
3661a8d
TKT-072: carry git's stderr into commitError
ccevans Aug 18, 2026
436c357
TKT-072: give the CI runner a git identity
ccevans Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions .bobby/decisions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
ticket: TKT-051
why: "Tickets are shared state; worktrees isolate CODE only. That is not a new choice — resolveTicketsDir has always redirected to the main checkout, so `bobby ticket move` run inside a worktree writes there and an agent has no way to write its own worktree's copy. The orchestrator contradicted it in two places and both were bugs. Reading the worktree when BUILDING the prompt meant any ticket not merged to main threw `Ticket X not found` — i.e. every ticket created on a feature branch, the normal way anyone works. Reading it when DETECTING advancement was worse and silent: the copy is a checkout frozen at fork time, so stageAdvanced was always false for a real run, awaiting_approval was unreachable, and the approve → next-agent chain had never once fired outside tests whose stub wrote into the worktree file. The trade-off accepted: two workspaces on one ticket now see each other's stage moves, and a run's ticket state is not rolled back by discarding its worktree — the price of one board per repository, which is the same trade-off tickets-resolve-to-main-worktree already made. Evidence: Orchestrator.runAgent/_onExit/featureProgress/_requireTicket; test/lib/dashboard/orchestrator-fsm.test.js, whose fake agent now moves tickets only via moveTicket on the shared board."
supersedes: stage-advance-is-the-success-signal
invalidated: null
invalidated: "2026-08-16"

- id: prompts-name-the-tickets-dir-absolutely
fact: "Every generated agent prompt names the tickets directory as the RESOLVED, main-worktree-rooted ABSOLUTE path — `ticketsPath` in buildPromptFor's ctx, supplied as `this.ticketsDir` by the orchestrator and `resolveTicketsDir(root, config)` by the CLI. Never `config.tickets_dir`. Prompts are therefore machine-specific, which is safe: they are built per run, handed to a local subprocess, and never stored or shared."
Expand Down Expand Up @@ -237,7 +237,7 @@
ticket: TKT-015
why: "Every running agent is a CLI subprocess spending real tokens on the user's own subscription, and a mis-click on a large epic could start ten. A queue is worse than a refusal here: it starts work minutes later, unattended, after the user has forgotten they asked. Refusal keeps the human in the loop, so the message names the holders of the slots and the config key to raise. Known gap: nothing coordinates across processes, so two servers on the same repo each get their own budget. Evidence: Orchestrator._assertConcurrencyHeadroom; surfaced as a 400 through POST /api/workspaces/:id/run."
supersedes: null
invalidated: null
invalidated: "2026-08-16"

- id: main-checkout-guarded-by-a-lock-file
fact: "Anything that touches the main checkout's working tree takes an exclusive lock at `.bobby/main-checkout.lock` — repo runs (kind 'repo', no worktree) and merges (mergeToMain stashes and swaps branches there). Ordinary worktree runs never take it. The lock is reclaimed when EITHER the holder's pid is dead on this host OR the record is older than 6 hours; failure to acquire is a refusal naming the holder, never a queue."
Expand Down Expand Up @@ -286,3 +286,31 @@
why: "This ticket (TKT-069) exists to ship in the RIGHT repo. A two-repo ticket has no single right repo; taking the first and proceeding would ship only repo A's work while the ticket claims both — the exact silent-wrong-repo failure this ticket kills (it is what made TKT-023 manufacture a dead branch in the launch repo). Refusing early with an actionable message is the established pattern here (_assertConcurrencyHeadroom, the main-checkout lock's heldMessage, _assertRepoRunnable): refuse before you spend, name what is wrong, name the way out. Take-first was rejected because a workspace that runs anyway is the not-quite-silent half-ship the no-op guard (TKT-062) was added to stop. Two-worktrees-one-workspace is deferred out of v1. Evidence: Orchestrator._resolveTargetRepo/createWorkspace; newWorkspace repoRoot/lockFile in lib/dashboard/state.js; test/lib/dashboard/orchestrator-repo-target.test.js."
supersedes: null
invalidated: null
- id: a-run-is-pinned-to-the-board-it-started-on
fact: "A workspace records the board it was created on (`ticketsDir`/`sessionsDir` on the record) and every per-workspace read goes through `_ticketsDirFor(ws)`/`_sessionsDirFor(ws)` — prompt building, the existence check, feature children, the stage re-read on exit, the session log, the mergedAt stamp. The orchestrator's own `this.ticketsDir` getter stays LIVE and is only for the UI's board and for picking a NEW ticket off it. Records with no pin (single-project dashboards, records written before the field) fall back to the live getter, which is the only board they have."
decided: "2026-08-15"
ticket: TKT-022
why: "In a studio the live getter moves when the user selects another project, and a run takes minutes — so switching projects mid-run, the exact thing switching is for, moved the board out from under the running agent's bookkeeping. On exit the orchestrator asked the NEWLY selected project's board how the run went: absent id -> newStage null -> stageAdvanced false, and a successful run that had moved its ticket was recorded as a no-op the user could not approve. Where both boards held the same id (two projects, one prefix) it was worse and silent — an unrelated ticket's stage was compared to this workspace's, which can reach ready_to_merge or auto-approve the next agent against the wrong project's board. Session logs split too: header in project A, tail in project B, both files incomplete. This does NOT weaken orchestrator-reads-tickets-from-the-shared-board — the pinned dir IS a shared main-rooted board, and it names WHICH shared board rather than trusting the moment. Evidence: Orchestrator._ticketsDirFor/_sessionsDirFor, createWorkspace's pin, scopeToProject in server.js; test/lib/dashboard/orchestrator-project-pin.test.js, whose alpha run exits while the UI sits on beta."
supersedes: null
invalidated: "2026-08-16"
- id: orchestrator-reads-tickets-from-the-workspaces-own-board
fact: "A worktree's own .bobby/tickets is NEVER consulted — tickets are shared state, worktrees isolate code only. WHICH shared board depends on the reader. Per-workspace reads (prompt building, the existence check, feature children, the stage re-read on exit, session init and logging, mergedAt) go through `_ticketsDirFor(ws)`/`_sessionsDirFor(ws)`: the board pinned on the workspace record at creation, so a studio project switch cannot move it mid-run. Reads that are about the CURRENT VIEW — the API's board, picking a new ticket off it in createWorkspace — go through the live `this.ticketsDir`/`this.sessionsDir` getters, which follow the selected project. Unpinned records fall back to the live getters. A run is successful only when it exits 0 AND the ticket's stage ON ITS OWN BOARD differs from the workspace's recorded stage."
decided: "2026-08-16"
ticket: TKT-022
why: "Supersedes orchestrator-reads-tickets-from-the-shared-board (TKT-051), whose fact literally named `this.ticketsDir` for four reads that are no longer live — the log contradicted the code, and a reviewer enforcing it verbatim would have flagged the fix that made it wrong. TKT-051's substance is unchanged and restated here: reading the worktree copy broke both ends of a run (prompt unbuildable for any ticket not on main, stageAdvanced always false so awaiting_approval was unreachable). TKT-022 added the studio, where the live getters move when the user selects another project — and a run takes minutes, so switching mid-run pointed the exit bookkeeping at the wrong project's board: a successful run read as a no-op, or, on a shared prefix, an unrelated same-id ticket deciding nextStatus and auto-approving the next agent against the wrong board. The distinction to preserve is not 'shared vs worktree' but 'the workspace's board vs the moment's board' — a new per-workspace read that reaches for `this.ticketsDir` is the bug this decision exists to stop. Absorbs a-run-is-pinned-to-the-board-it-started-on, now invalidated, so there is one active decision on which board is read. Evidence: Orchestrator._ticketsDirFor/_sessionsDirFor and createWorkspace/createRepoRun's pin; scopeToProject and sessionsBoardDir in server.js; test/lib/dashboard/orchestrator-project-pin.test.js and orchestrator-fsm.test.js."
supersedes: orchestrator-reads-tickets-from-the-shared-board
invalidated: null
- id: concurrency-cap-refuses-per-orchestrator
fact: "dashboard.max_concurrent (default 4) caps agents in flight PER ORCHESTRATOR — one per `bobby app` process — and an orchestrator now spans every project in a studio, so the budget is shared across projects, not per project. Exceeding it REFUSES the run with an error naming what is already running; it never queues. The value itself is read live off the ACTIVE project's config, so switching projects can change the cap while runs from another project are still counted against it."
decided: "2026-08-16"
ticket: TKT-022
why: "Re-recorded, not changed: the cap's behaviour is exactly as TKT-015 set it, but the sentence 'so per project per server process' became false when TKT-022 let one server switch projects. One Orchestrator, one process map, one budget — start three agents on alpha, switch to beta, and only one slot remains, which is correct (they are all subprocesses on the same machine spending the same subscription) but is NOT what the old wording promised. Reading the cap off the active project's config is the deliberate half: a studio where one project sets max_concurrent: 8 should honour that while you are working in it. The known cross-process gap is unchanged — two servers on the same repo still get two budgets. Evidence: Orchestrator._maxConcurrent and _assertConcurrencyHeadroom, now reading the live `config` getter; surfaced as a 400 through POST /api/workspaces/:id/run."
supersedes: concurrency-cap-refuses-per-server-process
invalidated: null
- id: run-scoped-reads-pin-to-the-workspaces-project
fact: "Everything a run does AFTER launch resolves against the project pinned on its workspace record, not the live UI project: the board via _ticketsDirFor(ws)/_sessionsDirFor(ws), and the CONFIG via _configFor(ws) — which feeds permission posture, executor, model, the whole prompt context, auto_approve_stages in _onExit, and _pipelineFor. this.config / this.ticketsDir (the live getters) are only for what the user is looking at: picking a new ticket off the board and creation-time resolution in createWorkspace. A field DERIVED from the boot config in the constructor (this.pipeline = resolveWorkflow(bootConfig)) is not exempt — it must be re-resolved per run through _configFor(ws), never read raw."
decided: "2026-08-16"
ticket: TKT-022
why: "A studio orchestrator spans every project and the UI can switch mid-run. Any run-scoped read left on the live getter — or on a constructor-captured derivative of it — takes a decision from the project the user happened to switch TO: an agent auto-launched that the run's own project forbids, or a workflow stage (e.g. security) silently skipped. This class recurred across six review rounds (B3/C1/C2/D1/D2); the invariant makes 'is this read run-scoped?' the review question."
supersedes: null
invalidated: null
2 changes: 1 addition & 1 deletion .bobby/tickets/.counter
Original file line number Diff line number Diff line change
@@ -1 +1 @@
70
72
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Feature Plan — TKT-020: Phase 3 — the app beyond a single project

## Architecture Decisions

- **Workspace as the universal run unit.** Chats (TKT-021), project switches
(TKT-022), and onboarding (TKT-024) all use the existing workspace/orchestrator
pattern rather than parallel state machines. Chat is a workspace in `plan` mode;
project switch re-scopes the orchestrator's context; onboarding calls
`createProject` then opens the new project in the app.

- **`--resume` is the conversation primitive.** Claude CLI's `--resume` handles
context restoration, compaction, and token management. We pass it through the
executor rather than reimplementing conversation state.

- **Studio-level pairing (TKT-067) replaces per-project pairing.** One pairing
code reaches every project the studio serves. The tunnel gains a `project`
field in request frames; the server routes by current project context.

- **`/classic/` lifecycle.** The classic dashboard at `/classic/` and hq/web in
bobbycode-pro are retired together (TKT-026) once the App is proven default.
No new features go to classic.

- **`createProject` is the shared entry point.** TKT-025 extracted it to
`lib/project.js`. Both the CLI (`bobby new`) and the app onboarding (TKT-024)
call it. The function takes `cwd` as an argument and returns structured results
(no stdout, no process.exit).

## Shared Utilities & Components

| Utility/Component | Created By | Used By | Location |
|---|---|---|---|
| `createProject()` | TKT-025 | TKT-024 (onboarding) | `lib/project.js` |
| `PROJECT_STACKS` | TKT-025 | TKT-024 (stack cards) | `lib/project.js` |
| `--resume` executor support | TKT-021 | Future interactive modes | `lib/dashboard/executor.js` |
| `ChatManager` | TKT-021 | TKT-021 API routes | `lib/dashboard/chat.js` |
| `resolveRepoPath` | TKT-069 | TKT-067 (multi-project relay) | `lib/config.js` |
| `listProjects()` | existing | TKT-022 (project picker) | `lib/studio.js` |
| `ProjectContext` | TKT-022 | TKT-022, TKT-067 (project-scoped tunnel) | `lib/dashboard/project-context.js` |
| `setActiveProject` / `getActiveProject` | existing | TKT-022 (persist selection) | `lib/studio.js` |

## Naming Conventions

- API routes: `/api/<resource>` (REST-style, existing pattern)
- Chat routes: `/api/chats`, `/api/chats/:id/message`, `/api/chats/:id/commit`
- Project routes: `/api/projects`, `/api/projects/select`
- Onboarding routes: `/api/onboard`, `/api/onboard/create`
- State files: `.bobby/<name>.json` (parallel to `workspaces.json`)

## Ticket Dependencies

| Ticket | Depends On | Provides |
|---|---|---|
| TKT-068 | — | Converged trunk (app + studio on one branch) |
| TKT-069 | TKT-068 | `ws.repoRoot` per-workspace repo targeting |
| TKT-025 | — | `createProject()` in `lib/project.js` |
| TKT-023 | TKT-068 | RelayTransport (app frontend over relay) |
| TKT-021 | TKT-068 | `--resume`, ChatManager, `plan` permission mode |
| TKT-022 | TKT-069 | Mutable project context, `/api/projects/select` |
| TKT-024 | TKT-025 | Browser-based project creation, stack cards |
| TKT-067 | TKT-023, TKT-022 | Studio-level pairing, project-scoped tunnel frames |
| TKT-026 | TKT-023 (proven) | Classic + hq/web removal |

## Build Order Rationale

The execution order respects hard dependencies while minimizing integration risk:

1. **TKT-023** (testing) — Already built, just needs to complete testing stage.
2. **TKT-021** (vet chat) — Independent: new module + executor extension. No
dependency on TKT-022.
3. **TKT-022** (studio mode) — ProjectContext is a dependency for TKT-067.
4. **TKT-024** (onboarding) — Soft dependency on TKT-022 (studio integration
feature-flagged by `isStudio`). Can build without it.
5. **TKT-067** (pair-once) — Hard dependency on TKT-022 (ProjectContext).
6. **TKT-026** (delete /classic) — Cleanup; no code depends on it. Last.

## Cross-Cutting Concerns

- **server.js is touched by 4 tickets** (TKT-021 chat routes, TKT-022 project
routes, TKT-024 onboard routes, TKT-026 classic removal). Merge conflicts
are likely. Each ticket adds routes in the route registration block — append,
don't interleave.
- **orchestrator.js is touched by 2 tickets** (TKT-021 runChat, TKT-022
ProjectContext). Both are additive — new methods, not modified existing ones.
- **`commands/remote.js` is touched by TKT-067.** The ProjectContext wiring
is the only change.

## Out of Scope

- Multi-user / team mode (one dev machine, one phone per pairing)
- Service worker + push notifications for the phone app (PRO-005)
- Full hq/web feature parity audit (the App is the replacement, proven by TKT-023)
- Interactive refine (TKT-021 covers plan chat only; extend to other agents later)
- hq/web deletion (lives in bobbycode-pro repo; documented in TKT-026)
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
id: TKT-020
title: 'Phase 3: the app beyond a single project'
stage: backlog
stage: building
type: epic
priority: medium
area: null
Expand All @@ -14,7 +14,7 @@ blocked_reason: null
previous_stage: null
parent: null
created: '2026-08-07'
updated: '2026-08-07'
updated: '2026-08-12'
---

## Description
Expand Down
Loading
Loading