Compare commits

...

11 Commits

Author SHA1 Message Date
nntrivi2001 5a793e70cc fix(test): stop lane-runtime leaking a live server on every run
The two `upLane qc option` tests booted a real stack and then only released
the slot. `downLane` locates a service's pid file through the lane's slot
directory, so releasing the slot first orphaned the child with nothing left
able to reach it — one `python3 -m http.server` survived every run, holding
a port from a pool that is only ten wide. Thirteen had accumulated; the
eleventh run onwards fails with EPORTBUSY in whichever test boots next,
which reads as an unrelated flake.

Both tests now stop the stack before releasing the slot, and assert the port
went quiet — so a teardown that breaks again fails here rather than leaking
into the next run. A suite-level `after` covers the case a test throws
before its own teardown; it runs before SUITE_ROOT is removed, since the pid
files it needs live inside it.
2026-08-07 10:01:01 +07:00
nntrivi2001 201eae68bb fix(client): repair the TypeScript build
`npm run build` runs `tsc -b` first and it has been failing: `api.ts` used
`NamedLock` without importing it, and four lane test fixtures predate
`Lane.active_feature_id` / the widened `LaneRuntime`, so spreading a
`Partial<Lane>` over them no longer satisfied the required fields.

Nothing shipped could be rebuilt while this was red, which is how a client
change reaches a dashboard running in production mode. The fixture fixes are
casts with a note, not type relaxations — the base literals still list every
required field, so the assertion states what they already prove.
2026-08-07 09:52:13 +07:00
nntrivi2001 8fcef5a10b feat(lanes): let Add Lane choose the pipeline template
Creation is the only point the UI could ever set a lane's template, and it
never offered the choice — so every lane added from "+ Add lane" was born
on `default` and rendered an 8-node map for a 16-node workflow, with no
screen able to change it afterwards. That is the defect that made the
ship-feature template unreachable from the browser.

The modal now shows a *Pipeline template* select fed by
`GET /api/lanes/pipelines`, labelled with each template's node count so the
consequence of the choice is visible. A failed fetch degrades to a `default`
option rather than blocking lane creation.

`pipeline` was already accepted by `POST /api/lanes` but silently dropped by
`/ensure` and `/worktree`, which build their own createLane payloads; both
now pass it through, and both map `EBADPIPELINE` to 400 like `EBADCWD`.
2026-08-07 09:44:48 +07:00
nntrivi2001 67edda77eb feat(lanes): make the pipeline map track a skill's real progress
A lane's pipeline map only ever moved when a skill remembered to call
`ccam stage`, and the ship-feature template shipped with no detection rules
at all — so a lane driven by Superpowers skills sat at whatever stage it
last declared, and the `gates` node was never declared by anything.

Detection (`detect` rules on each node) now covers the Superpowers skill
invocations and the `ccam`/`gh` commands the ship-feature-lane skill
actually runs. It stays a safety net, not the mechanism: forward-only,
never `done`, never overriding a declaration. Two rules were deliberately
left out — `git diff` on `review` (this repo's own tests record it pinning
a lane at `review` on a real session) and anything on `merged`/`done`.

Stage vocabulary grows to 50 names over the same 16 nodes, following
Shipyard's PHASES shape: sub-states like `migration-collision`,
`e2e-scoped` and `gate-blocked` say WHY a lane sits on a node without the
map growing a node per reason. Every alias has a source — the skill
declares it, `default.json` uses it, or Shipyard's PHASES lists it.

Two silent failures fixed along the way:

- `lane.stages` is keyed by the raw declared string, so a stage declared
  under an alias lost its `--evidence` and rendered amber instead of
  green. `stageRecords` resolves each key onto its node.
- `ccam stage <typo>` stored fine and then rendered nowhere. It now warns
  on stderr while still exiting 0.

`ccam lanes pipeline` closes the gap that made all of this invisible: a
lane could only be assigned a template at creation, and no screen in the
web UI offers the choice, so every lane added from "+ Add lane" was stuck
on `default`'s 8 nodes. An unknown template id is now refused rather than
silently falling back to `default` on read.

Also merges the repo's own `ship-feature` skill into the Superpowers
workflow: it delegates planning/TDD/review/verification instead of
restating them, and declares a stage at each phase.
2026-08-07 09:34:20 +07:00
nntrivi2001 87b5e1c3db fix(lanes): make Add Lane mode segments fill their row and read as selected
The two-way segment used a translucent accent wash for the active state,
which at this size read as a hover tint rather than a selection. Solid
accent plus a shadow makes the choice unambiguous, and `flex-1` stops the
two segments from sizing to their label text.
2026-08-07 09:29:59 +07:00
nntrivi2001 61443f4814 chore(deps): resync package-lock license and funding with package.json
The lockfile still carried `MIT` and a `funding` URL pointing at an
unrelated sponsors page, both left over from the template this project was
scaffolded from. package.json declares `UNLICENSED` and no funding.
2026-08-07 09:28:28 +07:00
nntrivi2001 bd829ba1c3 fix(lanes): capitalize Vietnamese action-button labels, gitignore .ccam/
action.start/stop/clear/forget/purge/remove/reset in lanes.json (vi)
were lowercase (bắt đầu, dừng...) while every other button label in the
app capitalizes its first letter. Also gitignore /.ccam/ - the local
lane profile that appears in a repo's own working tree only when that
repo is adopted as its own lane (machine-specific runtime config, not
source).
2026-08-06 16:14:55 +07:00
nntrivi2001 78f6e1be8e feat(lanes): Add Lane repo/worktree mode toggle, manual branch, folder browse
- Repo mode adopts a directory as-is via /lanes/ensure (no worktree, no
  branch fields) - the right choice for a main repo you want stage
  detection on. Worktree mode (default) keeps the existing provisioning
  flow but now requires a manually-typed branch name instead of deriving
  one from the title.
- POST /lanes/worktree accepts an optional `branch`, validated via
  `git check-ref-format --branch`; omitting it preserves the CLI's
  existing auto-derived-branch behavior.
- New GET /lanes/browse lists a directory's immediate subdirectories,
  backing a small folder-browse modal on both path fields - browsers
  cannot expose an absolute path from a native picker, so this is
  server-backed instead, consistent with the tool's local-first model.
2026-08-06 16:03:49 +07:00
nntrivi2001 9f13769fb4 feat(lanes): surface child worktrees on a lane card + detect Superpowers skills
Adopting the main repo as its own lane now gets stage detection
(cwd matches, same as any other lane), and its card lists every
managed-worktree lane provisioned from it with a jump-to link.
Also add the two missing Skill-tool detect rules (implement, ship)
so detection covers all four Superpowers workflow phases, not just
plan/review.
2026-08-06 15:33:07 +07:00
nntrivi2001 54299f119e fix(lanes): wait for worktree provisioning before auto-setup
POST /worktree answers with 202 before the background git worktree
add finishes, so profileInit/agentsInstall/mcpSync were racing the
lane's own directory into existence and mostly failing. Poll
GET /api/lanes/:id until provisioning leaves "provisioning" first.
2026-08-06 14:28:21 +07:00
nntrivi2001 e31d261fd7 fix(lanes): don't auto-close Add Lane modal after setup summary
The 3s auto-close timer closed before a user reasonably had time to
look at the setup results, making the feature appear to do nothing.
Require an explicit dismiss (Cancel/X) instead.
2026-08-06 13:55:43 +07:00
31 changed files with 1816 additions and 174 deletions
+9 -8
View File
@@ -83,17 +83,18 @@ Do NOT jump to code. Understand the requirement first.
- `ccam stage implementing` - `ccam stage implementing`
### 2 — Pre-push CI gates + dev preflight (on the feature branch) ### 2 — Pre-push CI gates + dev preflight (on the feature branch)
- `ccam stage gates --status running` — declare it FIRST. This is also the fix-loop's re-entry point, and the declaration is what moves the lane BACK down the pipeline: detection alone can't (`recordDetection` is forward-only and never overrides a higher declared stage), so a re-entry that skips this line leaves the dashboard showing the stage you already left.
- `ccam lanes hook ci-gate` — runs the profile's CI gate (lint / test / contract checks) against an isolated per-lane test DB. On failure: read the output, fix on the feature branch, commit, re-run. Loop until green. - `ccam lanes hook ci-gate` — runs the profile's CI gate (lint / test / contract checks) against an isolated per-lane test DB. On failure: read the output, fix on the feature branch, commit, re-run. Loop until green.
- `ccam lanes sync-base --check feat/<slug>` — the dev preflight: fetches and checks the branch against the CURRENT `origin/development` without merging anything. Exit 5 on a migration-number collision: rename the printed file to the suggested number on the feature branch (`git mv`, update any in-file references), then re-run Stage 2. Exit 0 with `DEV_DELTA:`/`DEV_OVERLAP:` output otherwise — informational, you do NOT sync the branch for it (GitHub merges non-conflicting histories fine); a large overlapping delta is a heads-up that post-merge behavior may differ from what you test locally. - `ccam lanes sync-base --check feat/<slug>` — the dev preflight: fetches and checks the branch against the CURRENT `origin/development` without merging anything. Exit 5 on a migration-number collision: declare `ccam stage migration-collision --status running` (an alias of `gates` — the map stays put, the lane's stage names WHY it is sitting there), rename the printed file to the suggested number on the feature branch (`git mv`, update any in-file references), then re-run Stage 2. Exit 0 with `DEV_DELTA:`/`DEV_OVERLAP:` output otherwise — informational, you do NOT sync the branch for it (GitHub merges non-conflicting histories fine); a large overlapping delta is a heads-up that post-merge behavior may differ from what you test locally.
### 3 — E2E on the feature branch ### 3 — E2E on the feature branch
The e2e hook doesn't run migrations itself — it tests the already-running stack. To exercise the feature's code and any new schema, boot the lane stack with the feature branch first: The e2e hook doesn't run migrations itself — it tests the already-running stack. To exercise the feature's code and any new schema, boot the lane stack with the feature branch first:
- `ccam stage e2e-feature --status running` - `ccam stage booting --status running` — an alias of `e2e-feature`; the boot below can take minutes and this says which minutes they are.
- `ccam lanes up --qc` — boots with the profile's QC env (mock/stub flags so QC is deterministic, from `QC_BOOT_ENV`), applies the feature branch's own migrations, and reboots the stack. Idempotent; safe to re-run. (The branch was cut from `origin/development`, so this stack IS development + your feature as of the branch point.) - `ccam lanes up --qc` — boots with the profile's QC env (mock/stub flags so QC is deterministic, from `QC_BOOT_ENV`), applies the feature branch's own migrations, and reboots the stack. Idempotent; safe to re-run. (The branch was cut from `origin/development`, so this stack IS development + your feature as of the branch point.)
- `ccam lanes hook e2e` — Playwright e2e under the e2e lock against the now-booted stack. **This is the only e2e gate in the flow** — there is no dev-merged suite behind it. - `ccam stage e2e-feature --status running`, then `ccam lanes hook e2e` — Playwright e2e under the e2e lock against the now-booted stack. **This is the only e2e gate in the flow** — there is no dev-merged suite behind it.
- On failure: fix on the feature branch, commit, re-run from Stage 2. - On failure: fix on the feature branch, commit, re-run from Stage 2.
- **Iterating on a failing spec:** use scoped runs through the hook — `ccam lanes hook e2e -- <spec file/filter>` — never a bare test-runner invocation in the lane (bare runs skip the cross-lane lock, the hard timeout, and the heartbeat, so the dashboard false-flags STALLED). A scoped green is never the gate; finish with the full suite (unless the localized fast-path applies — see Hard rules). - **Iterating on a failing spec:** declare `ccam stage e2e-scoped` (alias of `e2e-feature`) so the dashboard shows this is a narrowed run, not the gate, and use scoped runs through the hook — `ccam lanes hook e2e -- <spec file/filter>` — never a bare test-runner invocation in the lane (bare runs skip the cross-lane lock, the hard timeout, and the heartbeat, so the dashboard false-flags STALLED). A scoped green is never the gate; finish with the full suite (unless the localized fast-path applies — see Hard rules).
- On success: `ccam stage e2e-feature-passed --status running` - On success: `ccam stage e2e-feature-passed --status running`
### 4 — Code review *(no open PR yet — use local diff)* ### 4 — Code review *(no open PR yet — use local diff)*
@@ -119,12 +120,12 @@ The e2e hook doesn't run migrations itself — it tests the already-running stac
- Launch the **senior-gate-reviewer** agent (Agent tool, `subagent_type: senior-gate-reviewer` — installed the same way as `qc-local`, see Stage 6). Give it: the lane's working directory, the requirement + Stage-0 answers (the lane spec file), the feature branch, the Stage-4 code-review findings + resolutions, the Stage-6 `qc-local` report (the user-flow review), and confirmation that gates/e2e/review/QC passed. The agent inspects the local diff with `git diff origin/development...feat/<slug>` — no open PR is required (and none exists yet). - Launch the **senior-gate-reviewer** agent (Agent tool, `subagent_type: senior-gate-reviewer` — installed the same way as `qc-local`, see Stage 6). Give it: the lane's working directory, the requirement + Stage-0 answers (the lane spec file), the feature branch, the Stage-4 code-review findings + resolutions, the Stage-6 `qc-local` report (the user-flow review), and confirmation that gates/e2e/review/QC passed. The agent inspects the local diff with `git diff origin/development...feat/<slug>` — no open PR is required (and none exists yet).
- Parse its final line: - Parse its final line:
- `VERDICT: GO` → proceed to Stage 8. - `VERDICT: GO` → proceed to Stage 8.
- `VERDICT: NO-GO — <fixes>` → fix on the feature branch, re-run **from Stage 2**. No attempt cap — the loop re-enters; the dashboard's time-on-stage surfaces a lane stuck cycling so the human can step in. Set `--status blocked` only for a genuine blocker you can't resolve. - `VERDICT: NO-GO — <fixes>` `ccam stage gate-blocked --evidence "NO-GO — <reason>"` (an alias of `gate`, so the map holds while the lane's stage name says the gate refused), then fix on the feature branch and re-run **from Stage 2**. No attempt cap — the loop re-enters; the dashboard's time-on-stage surfaces a lane stuck cycling so the human can step in. Set `--status blocked` only for a genuine blocker you can't resolve.
- `ccam stage gate --evidence "GO"` (or `NO-GO — <reason>`) - `ccam stage gate --evidence "GO"` (or `NO-GO — <reason>`)
### 8 — Publish: push branch + open/update PR *(GATED — only on GO)* ### 8 — Publish: push branch + open/update PR *(GATED — only on GO)*
- `ccam stage publishing --status running` - `ccam stage publishing --status running`
- Re-run the preflight: `ccam lanes sync-base --check feat/<slug>` — development may have moved while you were in QC. A migration collision here (exit 5) sends you back to Stage 2 with the rename; a clean result (exit 0) proceeds to the push. - `ccam stage push-revalidate` (alias of `pr-open`) — then re-run the preflight: `ccam lanes sync-base --check feat/<slug>` — development may have moved while you were in QC. A migration collision here (exit 5) sends you back to Stage 2 with the rename; a clean result (exit 0) proceeds to the push.
- `git push -u origin feat/<slug>` — first push of the feature branch to remote. All gates have passed before this point; the PR is finalized before reviewers see it. - `git push -u origin feat/<slug>` — first push of the feature branch to remote. All gates have passed before this point; the PR is finalized before reviewers see it.
- Open or update the PR **based on and targeting `development`**: `gh pr create --base development --fill` (or `gh pr edit` / the push itself if a prior run already created it). Capture the URL. - Open or update the PR **based on and targeting `development`**: `gh pr create --base development --fill` (or `gh pr edit` / the push itself if a prior run already created it). Capture the URL.
- `ccam stage pr-open --evidence "<pr-url>"` — the dashboard shows the PR link from here (via `--evidence` in `ccam feature show`/`ccam lanes`). - `ccam stage pr-open --evidence "<pr-url>"` — the dashboard shows the PR link from here (via `--evidence` in `ccam feature show`/`ccam lanes`).
@@ -151,12 +152,12 @@ The e2e hook doesn't run migrations itself — it tests the already-running stac
- Check PR state: `gh pr view <pr_url> --json state,mergeable -q '.state + " " + (.mergeable|tostring)'`, and bump the heartbeat (`ccam stage watching-pr`). - Check PR state: `gh pr view <pr_url> --json state,mergeable -q '.state + " " + (.mergeable|tostring)'`, and bump the heartbeat (`ccam stage watching-pr`).
- `MERGED` → a human merged it: go to **Stage 13** (post-merge verification). - `MERGED` → a human merged it: go to **Stage 13** (post-merge verification).
- `CLOSED` (unmerged) → the human rejected/abandoned it: `ccam stage done --status passed --note "PR closed unmerged by human"` → STOP. - `CLOSED` (unmerged) → the human rejected/abandoned it: `ccam stage done --status passed --note "PR closed unmerged by human"` → STOP.
- `CONFLICTING` → the feature branch conflicts with `development`. **Base guard first:** check the PR's actual base — `gh pr view <pr_url> --json baseRefName -q .baseRefName`. If it is NOT `development` (the base drifted — a human retargeted the PR, or it predates this pipeline), do NOT auto-merge anything: `--status blocked --note "PR base is not development — human decision"` and STOP. If the base IS `development`, resolve it as real work: - `CONFLICTING` → the feature branch conflicts with `development`. **Base guard first:** check the PR's actual base — `gh pr view <pr_url> --json baseRefName -q .baseRefName`. If it is NOT `development` (the base drifted — a human retargeted the PR, or it predates this pipeline), do NOT auto-merge anything: `--status blocked --note "PR base is not development — human decision"` and STOP. If the base IS `development`, declare `ccam stage sync-conflict --status running` (an alias of `gates`, which is also where the re-entry below lands — one declaration, honest about both) and resolve it as real work:
- `ccam lanes sync-base feat/<slug>` (merges the latest `origin/development` INTO the feature branch — the only sanctioned merge). A migration-number collision (exit 5) means nothing was merged — rename the printed file on the feature branch, re-run Stage 2, then retry this step. - `ccam lanes sync-base feat/<slug>` (merges the latest `origin/development` INTO the feature branch — the only sanctioned merge). A migration-number collision (exit 5) means nothing was merged — rename the printed file on the feature branch, re-run Stage 2, then retry this step.
- **Exit 4 — merge conflict, left in place on purpose.** Resolve every conflict thoughtfully on the feature branch — keep `development`'s behavior for code unrelated to this feature, preserve the feature's intent where they overlap; when genuinely ambiguous, STOP and escalate (`--status blocked`, note the files) rather than guess. Never hand-merge a generated contract/client file listed in the profile's `GENERATED_MERGE_PATHS` — the keep-ours driver + regen own them. `git add` ONLY the conflicted files, `git commit --no-edit`, then `ccam lanes sync-base --continue feat/<slug>` (folds any regenerated artifacts into a follow-up commit). - **Exit 4 — merge conflict, left in place on purpose.** Resolve every conflict thoughtfully on the feature branch — keep `development`'s behavior for code unrelated to this feature, preserve the feature's intent where they overlap; when genuinely ambiguous, STOP and escalate (`--status blocked`, note the files) rather than guess. Never hand-merge a generated contract/client file listed in the profile's `GENERATED_MERGE_PATHS` — the keep-ours driver + regen own them. `git add` ONLY the conflicted files, `git commit --no-edit`, then `ccam lanes sync-base --continue feat/<slug>` (folds any regenerated artifacts into a follow-up commit).
- Re-enter the pipeline **from Stage 2 through Stage 8** (the push updates the PR), then return here and keep watching. - Re-enter the pipeline **from Stage 2 through Stage 8** (the push updates the PR), then return here and keep watching.
- For each new PR comment (list with `gh pr view <pr_url> --json comments`, tracking which you've already handled by comment id in your own notes), triage AND **always reply on its thread** (every comment gets a response — no silent handling, so reviewers see it was considered): - For each new PR comment (list with `gh pr view <pr_url> --json comments`, tracking which you've already handled by comment id in your own notes), triage AND **always reply on its thread** (every comment gets a response — no silent handling, so reviewers see it was considered):
- **Worth fixing** (reviewer-requested change, real bug, test/doc gap): this is a NEW CHANGE — apply it on the feature branch and re-enter the pipeline **from Stage 2 through Stage 8** (+ Stage 10 CI watch). The full process applies; no shortcuts because "it's just review feedback". **After the fix is pushed, reply to the comment** confirming resolution — what changed + the commit/PR ref — by writing the reply to a file and posting `gh pr comment <pr_url> --body-file <path>` referencing the comment. **Never inline `--body "..."`** — bodies carry backticks/`file:line`/`$(...)` that bash reads as command substitution inside double quotes, which corrupts the comment and trips an approval prompt. Then come back here and keep watching. - **Worth fixing** (reviewer-requested change, real bug, test/doc gap): this is a NEW CHANGE — `ccam stage pr-comment-fix --status running` (alias of `watching-pr`, so the lane reads as *acting on a comment* rather than idly polling), then apply it on the feature branch and re-enter the pipeline **from Stage 2 through Stage 8** (+ Stage 10 CI watch). The full process applies; no shortcuts because "it's just review feedback". **After the fix is pushed, reply to the comment** confirming resolution — what changed + the commit/PR ref — by writing the reply to a file and posting `gh pr comment <pr_url> --body-file <path>` referencing the comment. **Never inline `--body "..."`** — bodies carry backticks/`file:line`/`$(...)` that bash reads as command substitution inside double quotes, which corrupts the comment and trips an approval prompt. Then come back here and keep watching.
- **Question / discussion**: answer it via `gh pr comment <pr_url> --body-file <path>` (same file-not-inline rule) — no code change. - **Question / discussion**: answer it via `gh pr comment <pr_url> --body-file <path>` (same file-not-inline rule) — no code change.
- **Not worth fixing** (out of scope, working as intended, deferred): **reply with the reasoning** so the reviewer knows why it wasn't actioned (don't just skip it). - **Not worth fixing** (out of scope, working as intended, deferred): **reply with the reasoning** so the reviewer knows why it wasn't actioned (don't just skip it).
- **Sign every reply** with a distinct attribution — end each posted body, on its own line, with: `— 🤖 ship-feature-lane pipeline`. - **Sign every reply** with a distinct attribution — end each posted body, on its own line, with: `— 🤖 ship-feature-lane pipeline`.
+87 -16
View File
@@ -1,28 +1,99 @@
--- ---
name: ship-feature name: ship-feature
description: Implement a feature safely end-to-end in this repository. Use when adding or changing functionality across backend, frontend, or MCP with required verification and documentation updates. description: Implement a feature safely end-to-end in this repository. Use when adding or changing functionality across backend, frontend, or MCP with required verification and documentation updates. Drives the Superpowers workflow skills and declares each phase with `ccam stage` so the dashboard's pipeline map follows along.
--- ---
# Ship Feature # Ship Feature
Use this workflow for medium or large implementation tasks. Use this workflow for medium or large implementation tasks. It does not restate
how to plan, test, or review — the Superpowers skills own that. What lives here
is the phase order, this repository's own rules, and the stage declaration at
each boundary.
## Steps For a feature inside a CCAM **lane**, use `ship-feature-lane` instead: it adds
- Explore impacted modules first. the branch/e2e/QC/senior-gate/PR half this skill deliberately leaves out.
- Write a short implementation plan before editing.
- Implement smallest coherent diff that satisfies requirements.
- Run relevant verification commands.
- Update docs when commands, paths, architecture, or behavior changed.
## Required quality checks ## Declaring the stage
- Keep API and websocket contracts stable unless intentionally changed.
- Keep destructive operations behind explicit guardrails.
- Avoid broad refactors in feature tickets unless requested.
## Finish checklist Each phase below starts with `ccam stage <node>`, which is what puts the phase
- Tests/build/typecheck completed or explicitly reported as not run. on the lane's pipeline map. The nodes are the `default` template's:
- Changed file set is scoped and intentional. `intake → plan → implement → tests → review → gate → ship → done`.
- User-facing docs updated if behavior changed.
`ccam stage` needs a lane owning the current directory. If it reports no lane,
this repo was never adopted (`ccam lanes add --cwd $(pwd)` fixes it) — carry on
with the workflow and skip the stage calls; they are reporting, not control
flow. Never skip a phase because its stage call failed.
Declaring beats detection. The dashboard also *infers* these stages from the
Superpowers skill invocations below, but an inference never renders `done` and
never overrides a declaration — and detection cannot move a lane BACKWARD past
a declared stage, so a rework loop is invisible unless you re-declare the phase
you dropped back to.
## Phases
### intake — understand before touching anything
- `ccam stage intake --status running`
- Restate the requirement and its success criteria.
- Explore the impacted modules: `Explore` agent for breadth, then read the key
files yourself. Identify which layers are hit (server / client / mcp / docs /
scripts) — `repo-onboarding` if the area is unfamiliar.
- Requirement fuzzy or open to more than one reading? **superpowers:brainstorming**.
### plan — a written plan, challenged before it is code
- `ccam stage plan`
- **superpowers:writing-plans**: approach, files to change, the test strategy
(which behavior each test pins), and how each success criterion is met.
- If the task is a bug rather than a feature: **superpowers:systematic-debugging**
first. Root cause, not symptom — and grep every caller of the function you
are about to change, not just the path the report names.
### implement — smallest coherent diff
- `ccam stage implementing`
- **superpowers:test-driven-development**: failing test → minimal code → green.
- Smallest diff that satisfies the requirement. Every changed line traces to it.
- **file-headers** applies to every source file you create or edit.
### tests — this repo's verification, not a claim
- `ccam stage tests --status running`
- Backend changed → `npm run test:server`. Frontend → `npm run test:client`.
MCP → `npm run mcp:typecheck` + `npm run mcp:build`.
- A UI snapshot diff is reviewed, never blindly regenerated
(`cd client && npx vitest run -u` only after you have read the diff).
- Record the outcome: `ccam stage tests --evidence "<what passed>"`, or
`--result fail` with what failed. A step you could not run is reported as not
run, never as passed.
### review — a real review pass, not a re-read
- `ccam stage review`
- Run the **code-review** skill on the working diff, or
**superpowers:requesting-code-review** when handing it to an agent.
- Apply what is worth applying with **superpowers:receiving-code-review**
judgment: verify each point, neither blind agreement nor blind rejection.
- Changed code in response? Re-run the `tests` phase.
### gate — evidence before the completion claim
- **superpowers:verification-before-completion**. Commands actually run, output
actually read. This is the phase that stops "should work" from shipping.
- `ccam stage gate --evidence "<what was verified>"`
### ship — docs, then the user's call
- `ccam stage ship`
- **update-project-docs** — mandatory for any change to behavior, config,
interfaces, events, schema, CLI commands, or features. Not optional, not
deferred, not "if asked".
- Commit / push / PR **only when the user asks in this turn**. Finishing an
implementation is not authorization to commit.
- `ccam stage done --status passed --evidence "<what shipped>"`
## This repository's own rules
Not restated here. `CLAUDE.md` is loaded in every session and already binds
them — backward-compatible API/WebSocket contracts, fail-safe hooks,
migration-safe schema changes, the destructive-lane and git-argv guards, and
the lane boundaries (the console never writes a stage; the runtime never writes
`stage`/`status`/`notes`). A second copy here would only drift out of sync with
the first. Read `CLAUDE.md` and `.claude/rules/` for the area you are touching.
## References ## References
- Checklist template: `references/feature-checklist.md` - Checklist template: `references/feature-checklist.md`
+4
View File
@@ -18,6 +18,10 @@ desktop/assets/icon.iconset/
*.db-wal *.db-wal
*.db-shm *.db-shm
# Local lane profile - only present when this repo itself is adopted as a
# lane (`ccam lanes add --cwd`); machine-specific runtime config, not source.
/.ccam/
# Environment variables # Environment variables
.env .env
.env.local .env.local
+62
View File
@@ -192,6 +192,7 @@ async function api(method, pathname, body, options = {}) {
} }
const get = (p, b, options) => api("GET", p, undefined, options); const get = (p, b, options) => api("GET", p, undefined, options);
const post = (p, b, options) => api("POST", p, b, options); const post = (p, b, options) => api("POST", p, b, options);
const patch = (p, b, options) => api("PATCH", p, b, options);
/** /**
* Print the standard "server is not running" indicator and exit 1. Every * Print the standard "server is not running" indicator and exit 1. Every
@@ -2262,6 +2263,43 @@ async function cmdLanesIntegration(args) {
process.exitCode = enabled ? 0 : 1; process.exitCode = enabled ? 0 : 1;
} }
/**
* `ccam lanes pipeline [<template-id>] [<id>]` — show or switch which pipeline
* template a lane renders against. `ccam lanes add --pipeline` could only set
* this at creation time, so every lane added from the dashboard's "+ Add lane"
* was stuck on `default` with no way back to a 16-node template.
*/
async function cmdLanesPipeline(args) {
const target = args.find((arg) => !arg.startsWith("--") && !/^\d+$/.test(arg));
const resolved = await resolveLaneArg(args.filter((a) => a !== target));
if (!resolved) return;
if (!target) {
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
const { pipelines } = await get("/api/lanes/pipelines");
console.log(`lane #${lane.id}${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
return;
}
const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target });
console.log(
`${c.green("✔")} lane #${lane.id} → pipeline ${lane.pipeline} ` +
`(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
);
// Switching templates re-resolves the SAME declared stage string against a
// different node list, so a stage that meant something in the old pipeline
// can land nowhere in the new one. Same warning as `ccam stage`, same reason.
if (!lane.pipeline_nodes.some((n) => n.state === "current")) {
console.error(
c.yellow(
`! the lane's current stage "${lane.stage}" matches no node in "${lane.pipeline}" — ` +
`declare one of: ${lane.pipeline_nodes.map((n) => n.id).join(", ")}`
)
);
}
}
async function cmdFeatureShow(args) { async function cmdFeatureShow(args) {
const slug = args.find((arg) => !arg.startsWith("--")); const slug = args.find((arg) => !arg.startsWith("--"));
if (!slug) { if (!slug) {
@@ -2315,6 +2353,22 @@ async function cmdStage(args) {
result: flag("result"), result: flag("result"),
}); });
console.log(`lane #${lane.id}${lane.stage} (${lane.progress}%)`); console.log(`lane #${lane.id}${lane.stage} (${lane.progress}%)`);
// A stage name matching no node (nor alias) still stores — setStage takes the
// string verbatim — but phaseIdx() then returns -1, so nothing renders as
// `current` and progress reads 0. Warn, never fail: a typo must not break a
// declaration the pipeline can still record, but it must not pass silently.
// Skipped for `--result fail`, which paints the node `failed` rather than
// `current` and would otherwise look identical to an unknown stage.
const nodes = lane.pipeline_nodes || [];
if (flag("result") !== "fail" && nodes.length && !nodes.some((n) => n.state === "current")) {
console.error(
c.yellow(
`! "${stage}" matches no node in pipeline "${lane.pipeline}" — recorded, but the ` +
`pipeline map won't show it. Nodes: ${nodes.map((n) => n.id).join(", ")}`
)
);
}
} }
// ── Command catalog ───────────────────────────────────────────────────────── // ── Command catalog ─────────────────────────────────────────────────────────
@@ -2425,6 +2479,11 @@ const COMMAND_GROUPS = [
"[<path>]", "[<path>]",
"Validate a profile (path defaults to cwd, not a lane id)", "Validate a profile (path defaults to cwd, not a lane id)",
], ],
[
"lanes pipeline",
"[<template-id>] [<id>]",
"Show, or switch, which pipeline template a lane renders against",
],
[ [
"lanes reset|remove|purge", "lanes reset|remove|purge",
"<id> [--force] [--keep-db] --yes", "<id> [--force] [--keep-db] --yes",
@@ -3300,6 +3359,9 @@ async function runCommand(argv) {
if (rest[0] === "integration") { if (rest[0] === "integration") {
return cmdLanesIntegration(rest.slice(1)); return cmdLanesIntegration(rest.slice(1));
} }
if (rest[0] === "pipeline") {
return cmdLanesPipeline(rest.slice(1));
}
if (rest[0] === "gc") { if (rest[0] === "gc") {
return cmdLanesGc(rest.slice(1)); return cmdLanesGc(rest.slice(1));
} }
+226 -39
View File
@@ -1,24 +1,76 @@
/** /**
* @file AddLaneModal.tsx * @file AddLaneModal.tsx
* @description The "+ Add lane" flow: pick a SOURCE repo (not a folder to * @description The "+ Add lane" flow, in one of two modes chosen with a
* adopt), pick which of its branches to fork from, name the feature, and the * segmented toggle: "Repo" adopts an existing directory as-is via
* dashboard provisions a managed git worktree via `POST /api/lanes/worktree` * `POST /api/lanes/ensure` (no worktree, no branch the right mode for a
* the dashboard invents the lane's own directory and branch name, the same * main repo you want stage detection on); "Worktree" provisions a
* way Shipyard's "+ Add lane" never asks a human to name a folder. The lane * dashboard-managed git worktree via `POST /api/lanes/worktree` with a
* returned is `status: "provisioning"`; the existing `lane_update` WebSocket * manually-typed branch name. Either mode's path field can be filled by
* subscription in the Workspace page flips it to idle when the worktree is * typing, by the CwdAutocomplete suggestions, or by browsing
* actually ready, so this component does not poll. * (`FolderBrowseModal`) a native folder picker cannot hand a web page an
* absolute filesystem path, so browsing is server-backed instead. The
* worktree lane returned is `status: "provisioning"`: the route answers
* before the actual `git worktree add` runs, so this component polls
* `GET /api/lanes/:id` until that finishes before running the auto-setup
* calls against a cwd that must actually exist on disk first.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FolderOpen } from "lucide-react";
import { ConfirmModal } from "../ConfirmModal"; import { ConfirmModal } from "../ConfirmModal";
import { CwdAutocomplete } from "../run/RunSetup"; import { CwdAutocomplete } from "../run/RunSetup";
import { FolderBrowseModal } from "./FolderBrowseModal";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import type { CwdSuggestion } from "../../lib/api"; import type { CwdSuggestion } from "../../lib/api";
import type { Lane } from "../../lib/types"; import type { Lane } from "../../lib/types";
/** Polls the lane until the background `git worktree add` finishes (status
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
* lane record, or `null` on timeout. */
async function waitForProvisioned(
laneId: number,
{ intervalMs = 500, timeoutMs = 30000 } = {}
): Promise<Lane | null> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const { lane } = await api.lanes.get(laneId);
if (lane.status !== "provisioning") return lane;
if (Date.now() >= deadline) return null;
await new Promise((r) => window.setTimeout(r, intervalMs));
}
}
/** One segment of a two-way inline choice, styled to match RunSetup's `Seg`. */
function Seg({
active,
label,
title,
onClick,
}: {
active: boolean;
label: string;
title?: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
title={title}
aria-pressed={active}
className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors ${
active
? "bg-accent text-white shadow-sm"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{label}
</button>
);
}
export function AddLaneModal({ export function AddLaneModal({
open, open,
onClose, onClose,
@@ -27,20 +79,25 @@ export function AddLaneModal({
}: { }: {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
/** Called with the newly provisioned (still-provisioning) lane. */ /** Called with the newly created (possibly still-provisioning) lane. */
onAdded: (lane: Lane) => void; onAdded: (lane: Lane) => void;
/** The same suggestion list the Run form already fetched (dashboard cwd, /** The same suggestion list the Run form already fetched (dashboard cwd,
* home, recently-used paths) reused rather than fetched a second time. */ * home, recently-used paths) reused rather than fetched a second time. */
cwdSuggestions: CwdSuggestion[]; cwdSuggestions: CwdSuggestion[];
}) { }) {
const { t } = useTranslation(["lanes"]); const { t } = useTranslation(["lanes"]);
const [mode, setMode] = useState<"repo" | "worktree">("worktree");
const [sourceRepo, setSourceRepo] = useState(""); const [sourceRepo, setSourceRepo] = useState("");
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [branch, setBranch] = useState("");
const [branches, setBranches] = useState<string[] | null>(null); const [branches, setBranches] = useState<string[] | null>(null);
const [base, setBase] = useState(""); const [base, setBase] = useState("");
const [pipeline, setPipeline] = useState("default");
const [pipelines, setPipelines] = useState<{ id: string; name: string; nodes: unknown[] }[]>([]);
const [branchesError, setBranchesError] = useState<string | null>(null); const [branchesError, setBranchesError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [browseOpen, setBrowseOpen] = useState(false);
const [setupResult, setSetupResult] = useState<{ const [setupResult, setSetupResult] = useState<{
profile: "scaffolded" | "skipped" | "failed"; profile: "scaffolded" | "skipped" | "failed";
agents: "ok" | "failed"; agents: "ok" | "failed";
@@ -48,40 +105,48 @@ export function AddLaneModal({
} | null>(null); } | null>(null);
const reset = () => { const reset = () => {
setMode("worktree");
setSourceRepo(""); setSourceRepo("");
setTitle(""); setTitle("");
setBranch("");
setBranches(null); setBranches(null);
setBase(""); setBase("");
setPipeline("default");
setBranchesError(null); setBranchesError(null);
setError(null); setError(null);
setBusy(false); setBusy(false);
setSetupResult(null); setSetupResult(null);
}; };
// The setup summary (below) is shown for a few seconds before the modal // The template a lane is created with is the ONLY chance to get it right
// auto-closes, so the user actually sees whether profile/agents/mcp // from here: nothing else in the UI can change it afterwards, so a lane
// succeeded instead of the modal vanishing the instant the lane exists. // silently born on `default` renders an 8-node map for a 16-node workflow.
const closeTimerRef = useRef<number | null>(null); // Fetched on open (templates are file-backed and can change between opens).
const finishAndClose = useCallback(() => { useEffect(() => {
if (closeTimerRef.current !== null) { if (!open) return;
window.clearTimeout(closeTimerRef.current); let cancelled = false;
closeTimerRef.current = null; api.lanes
} .pipelines()
reset(); .then((r) => {
onClose(); if (!cancelled) setPipelines(r.pipelines);
}, [onClose]); })
useEffect( .catch(() => {
() => () => { // Quiet: the select just falls back to the single `default` option
if (closeTimerRef.current !== null) window.clearTimeout(closeTimerRef.current); // below, and the lane still gets created.
}, if (!cancelled) setPipelines([]);
[] });
); return () => {
cancelled = true;
};
}, [open]);
// Look up the repo's branches once the path settles - debounced so every // Look up the repo's branches once the path settles - debounced so every
// keystroke while typing a path doesn't fire a request against a path that // keystroke while typing a path doesn't fire a request against a path that
// isn't finished yet. // isn't finished yet. Worktree mode only: "Repo" mode adopts as-is and
// never forks a branch.
const lookedUpFor = useRef<string>(""); const lookedUpFor = useRef<string>("");
useEffect(() => { useEffect(() => {
if (mode !== "worktree") return;
const path = sourceRepo.trim(); const path = sourceRepo.trim();
if (!path) { if (!path) {
setBranches(null); setBranches(null);
@@ -107,20 +172,54 @@ export function AddLaneModal({
} }
}, 300); }, 300);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [sourceRepo, t]); }, [mode, sourceRepo, t]);
const submit = async () => { const submit = async () => {
const repo = sourceRepo.trim(); const repo = sourceRepo.trim();
const name = title.trim(); const name = title.trim();
if (!repo || !branches || !name) return; if (!repo) return;
setBusy(true); setBusy(true);
setError(null); setError(null);
if (mode === "repo") {
try {
const result = await api.lanes.ensure({
cwd: repo,
title: name || undefined,
pipeline,
});
onAdded(result.lane);
reset();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setBusy(false);
}
return;
}
if (!branches || !name || !branch.trim()) return;
try { try {
const result = await api.lanes.worktree({ const result = await api.lanes.worktree({
sourceRepo: repo, sourceRepo: repo,
title: name, title: name,
base: base || undefined, base: base || undefined,
branch: branch.trim(),
pipeline,
}); });
onAdded(result.lane);
// POST /worktree returns as soon as the DB row exists (202) — the
// actual `git worktree add` runs afterward, in the background, on the
// server. Firing setup against the lane's cwd before that finishes
// means agents/mcp write into a directory that doesn't exist yet, so
// wait for provisioning to leave the "provisioning" status first.
const provisionedLane = await waitForProvisioned(result.lane.id);
if (!provisionedLane || provisionedLane.status === "failed") {
setBusy(false);
setError(t("addLaneProvisionFailed"));
return;
}
const [profileOutcome, agentsOutcome, mcpOutcome] = await Promise.allSettled([ const [profileOutcome, agentsOutcome, mcpOutcome] = await Promise.allSettled([
api.lanes.profileInit(result.lane.id), api.lanes.profileInit(result.lane.id),
@@ -147,9 +246,10 @@ export function AddLaneModal({
}); });
} }
// Leave the modal open so the setup summary below stays on screen; the
// user dismisses it themselves (Cancel/X) once they've seen it, rather
// than racing a timer that can close before they've looked at it.
setBusy(false); setBusy(false);
onAdded(result.lane);
closeTimerRef.current = window.setTimeout(finishAndClose, 3000);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
setBusy(false); setBusy(false);
@@ -163,8 +263,21 @@ export function AddLaneModal({
// into after the very first character. useCallback keeps the identity stable // into after the very first character. useCallback keeps the identity stable
// across renders so only mount/unmount (and a real onClose change) refocuses. // across renders so only mount/unmount (and a real onClose change) refocuses.
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
finishAndClose(); // ConfirmModal's own Escape/backdrop/X handling calls this directly.
}, [finishAndClose]); // While the folder browser is open on top of it, that dismissal should
// close only the browser, not both modals at once.
if (browseOpen) {
setBrowseOpen(false);
return;
}
reset();
onClose();
}, [onClose, browseOpen]);
const disabled =
!!setupResult ||
!sourceRepo.trim() ||
(mode === "worktree" && (!title.trim() || !branches || !branch.trim()));
return ( return (
<ConfirmModal <ConfirmModal
@@ -174,22 +287,52 @@ export function AddLaneModal({
cancelLabel={t("destructive.cancel")} cancelLabel={t("destructive.cancel")}
destructive={false} destructive={false}
busy={busy} busy={busy}
disabled={!!setupResult || !sourceRepo.trim() || !title.trim() || !branches} disabled={disabled}
onConfirm={submit} onConfirm={submit}
onCancel={handleCancel} onCancel={handleCancel}
> >
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={mode === "repo"}
label={t("mode.repo")}
title={t("mode.repoHint")}
onClick={() => setMode("repo")}
/>
<Seg
active={mode === "worktree"}
label={t("mode.worktree")}
title={t("mode.worktreeHint")}
onClick={() => setMode("worktree")}
/>
</div>
<div> <div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo"> <label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
{t("addLaneRepoLabel")} {mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
</label> </label>
<div className="flex gap-1.5">
<div className="min-w-0 flex-1">
<CwdAutocomplete <CwdAutocomplete
inputId="add-lane-repo" inputId="add-lane-repo"
value={sourceRepo} value={sourceRepo}
onChange={setSourceRepo} onChange={setSourceRepo}
suggestions={cwdSuggestions} suggestions={cwdSuggestions}
/> />
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneRepoHint")}</p> </div>
<button
type="button"
onClick={() => setBrowseOpen(true)}
title={t("browse.title")}
className="flex items-center gap-1 rounded-md border border-border-light px-2 text-xs text-fg-secondary hover:bg-surface-2"
>
<FolderOpen className="h-3.5 w-3.5" />
{t("browse.button")}
</button>
</div>
<p className="mt-1 text-[10px] text-fg-muted">
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
</p>
</div> </div>
<div> <div>
@@ -205,7 +348,28 @@ export function AddLaneModal({
/> />
</div> </div>
{branches && ( <div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-pipeline">
{t("addLanePipelineLabel")}
</label>
<select
id="add-lane-pipeline"
value={pipeline}
onChange={(e) => setPipeline(e.target.value)}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
>
{(pipelines.length ? pipelines : [{ id: "default", name: "default", nodes: [] }]).map(
(p) => (
<option key={p.id} value={p.id}>
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
</option>
)
)}
</select>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLanePipelineHint")}</p>
</div>
{mode === "worktree" && branches && (
<div> <div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base"> <label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
{t("addLaneBaseLabel")} {t("addLaneBaseLabel")}
@@ -228,10 +392,26 @@ export function AddLaneModal({
)} )}
</div> </div>
)} )}
{branchesError && !branches && ( {mode === "worktree" && branchesError && !branches && (
<p className="text-[10px] text-status-warning">{branchesError}</p> <p className="text-[10px] text-status-warning">{branchesError}</p>
)} )}
{mode === "worktree" && branches && (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch">
{t("addLaneBranchLabel")}
</label>
<input
id="add-lane-branch"
value={branch}
onChange={(e) => setBranch(e.target.value)}
placeholder={t("addLaneBranchPlaceholder")}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none"
/>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHint")}</p>
</div>
)}
{setupResult && ( {setupResult && (
<div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1"> <div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1">
<p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p> <p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p>
@@ -270,6 +450,13 @@ export function AddLaneModal({
</p> </p>
)} )}
</div> </div>
<FolderBrowseModal
open={browseOpen}
initialPath={sourceRepo.trim() || undefined}
onSelect={setSourceRepo}
onClose={() => setBrowseOpen(false)}
/>
</ConfirmModal> </ConfirmModal>
); );
} }
@@ -0,0 +1,137 @@
/**
* @file A server-backed folder browser for the Add Lane modal's path inputs.
* Browsers refuse to expose an absolute filesystem path from a native folder
* picker, so path selection here is done by browsing `GET /api/lanes/browse`
* (immediate subdirectories of a path) instead breadcrumb-free, just an
* up-one-level button and a click-to-descend list, since this tool is
* local-first and the server already trusts arbitrary local paths.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen, FolderGit2, ArrowUp } from "lucide-react";
import { api } from "../../lib/api";
export function FolderBrowseModal({
open,
initialPath,
onSelect,
onClose,
}: {
open: boolean;
/** Path to start browsing from; omitted defaults server-side to the home dir. */
initialPath?: string;
onSelect: (path: string) => void;
onClose: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const [listing, setListing] = useState<Awaited<ReturnType<typeof api.lanes.browse>> | null>(null);
const [error, setError] = useState<string | null>(null);
const load = async (path?: string) => {
setError(null);
try {
setListing(await api.lanes.browse(path));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
useEffect(() => {
if (open) void load(initialPath);
// Only re-run when the modal actually opens - not on every initialPath
// keystroke in the field behind it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={onClose}
role="presentation"
>
<div
className="relative flex max-h-[70vh] w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label={t("browse.title")}
>
<div className="border-b border-border p-3">
<div className="truncate font-mono text-xs text-fg-secondary" title={listing?.path}>
{listing?.path || "…"}
</div>
</div>
<div className="flex-1 overflow-y-auto p-1">
{error && (
<p role="alert" className="p-2 text-xs text-status-danger">
{error}
</p>
)}
{listing?.parent && (
<button
type="button"
onClick={() => load(listing.parent!)}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-secondary hover:bg-surface-2"
>
<ArrowUp className="h-3.5 w-3.5" />
..
</button>
)}
{listing?.entries.map((entry) => (
<button
key={entry.path}
type="button"
onClick={() => load(entry.path)}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
>
{entry.isGitRepo ? (
<FolderGit2 className="h-3.5 w-3.5 text-blue-400" />
) : (
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
)}
<span className="truncate">{entry.name}</span>
</button>
))}
{listing && listing.entries.length === 0 && !listing.parent && (
<p className="p-2 text-xs text-fg-muted">{t("browse.empty")}</p>
)}
</div>
<div className="flex items-center justify-end gap-2 border-t border-border p-3">
<button
type="button"
onClick={onClose}
className="btn-ghost border border-border text-xs"
>
{t("destructive.cancel")}
</button>
<button
type="button"
disabled={!listing}
onClick={() => {
if (listing) onSelect(listing.path);
onClose();
}}
className="btn-primary text-xs disabled:opacity-50"
>
{t("browse.select")}
</button>
</div>
</div>
</div>
);
}
+33
View File
@@ -180,9 +180,17 @@ function since(sec: number | null): string {
export default function LaneCard({ export default function LaneCard({
lane, lane,
onAction, onAction,
childWorktrees,
onSelectLane,
}: { }: {
lane: Lane; lane: Lane;
onAction: (action: string, body?: Record<string, unknown>) => void; onAction: (action: string, body?: Record<string, unknown>) => void;
/** Other lanes whose `source_repo` is this lane's `cwd` populated only
* when this lane is itself a source repo (typically an adopted one) that
* other lanes were provisioned as worktrees from. */
childWorktrees?: Lane[];
/** Jumps the Workspace page's selection to another lane's card. */
onSelectLane?: (id: number) => void;
}) { }) {
const { t } = useTranslation(["lanes"]); const { t } = useTranslation(["lanes"]);
const [destructiveAction, setDestructiveAction] = useState<"reset" | "remove" | "purge" | null>( const [destructiveAction, setDestructiveAction] = useState<"reset" | "remove" | "purge" | null>(
@@ -452,6 +460,31 @@ export default function LaneCard({
</div> </div>
</dl> </dl>
{childWorktrees && childWorktrees.length > 0 && (
<div
data-testid="lane-child-worktrees"
className="mb-3 space-y-1 text-[11px] text-fg-secondary"
>
<div className="text-fg-muted">
{t("worktrees.heading", { count: childWorktrees.length })}
</div>
<ul className="space-y-0.5">
{childWorktrees.map((w) => (
<li key={w.id}>
<button
type="button"
onClick={() => onSelectLane?.(w.id)}
className="truncate text-left text-blue-400 hover:underline"
title={w.cwd}
>
#{w.id} {w.title || w.cwd} · {w.status}
</button>
</li>
))}
</ul>
</div>
)}
{integrations && ( {integrations && (
<div className="mb-2 flex items-center gap-1.5 text-[10px]"> <div className="mb-2 flex items-center gap-1.5 text-[10px]">
{(["tracker", "dev_qc", "ci_wait"] as const).map((name) => ( {(["tracker", "dev_qc", "ci_wait"] as const).map((name) => (
@@ -1,11 +1,11 @@
/** /**
* @file AddLaneModal.test.tsx * @file AddLaneModal.test.tsx
* @description Pins the "+ Add lane" flow after it was rebuilt around a source * @description Pins the "+ Add lane" flow's two modes: "Worktree" (default -
* repo instead of an existing folder: picking or typing a repo path triggers a * pick a source repo, fork a branch, type a new branch name, submit through
* branch lookup, the base-branch picker only appears once that lookup resolves, * the provisioning endpoint) and "Repo" (adopt a directory as-is through
* confirm submits through the provisioning endpoint (not the adopt/ensure one), * `ensure`, no branch fields). Also covers the branch-lookup debounce, the
* an unresolvable path degrades to a quiet hint instead of blocking the form, * unresolvable-path degrade, server-error handling, the auto-setup summary,
* and a server error surfaces instead of closing the modal. * the provisioning-wait race, and the folder-browse modal.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/ */
@@ -21,7 +21,11 @@ vi.mock("../../../lib/api", () => ({
api: { api: {
lanes: { lanes: {
branches: vi.fn(), branches: vi.fn(),
pipelines: vi.fn(),
worktree: vi.fn(), worktree: vi.fn(),
ensure: vi.fn(),
browse: vi.fn(),
get: vi.fn(),
profileInit: vi.fn(), profileInit: vi.fn(),
agentsInstall: vi.fn(), agentsInstall: vi.fn(),
mcpSync: vi.fn(), mcpSync: vi.fn(),
@@ -30,12 +34,17 @@ vi.mock("../../../lib/api", () => ({
})); }));
function laneFixture(over: Partial<Lane> = {}): Lane { function laneFixture(over: Partial<Lane> = {}): Lane {
// `as Lane`: spreading a Partial<Lane> widens every field it may carry to
// `T | undefined`, which no longer satisfies Lane's required fields. The
// base object below still lists all of them, so the cast asserts what the
// literal already proves.
return { return {
id: 9, id: 9,
title: "", title: "",
cwd: "/lanes/repo__feature", cwd: "/lanes/repo__feature",
branch: "feat/feature", branch: "feat/feature",
kind: "managed", kind: "managed",
source_repo: null,
pipeline: "default", pipeline: "default",
session_id: null, session_id: null,
run_id: null, run_id: null,
@@ -59,7 +68,7 @@ function laneFixture(over: Partial<Lane> = {}): Lane {
slot: null, slot: null,
ports: {}, ports: {},
...over, ...over,
}; } as Lane;
} }
const SUGGESTIONS: CwdSuggestion[] = [ const SUGGESTIONS: CwdSuggestion[] = [
@@ -81,19 +90,51 @@ async function focusField(user: ReturnType<typeof userEvent.setup>, el: HTMLElem
await user.click(el); await user.click(el);
} }
/** Fills the default "Worktree" mode's form up through a resolved branch
* list, title, and new-branch name - everything Add lane needs to enable. */
async function fillWorktreeForm(
user: ReturnType<typeof userEvent.setup>,
{ repo = "/Users/tester/projects/repo", title = "demo", branch = "feat/demo" } = {}
) {
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, repo);
await screen.findByLabelText("Branch to fork from");
await user.type(screen.getByLabelText("Title"), title);
await user.type(screen.getByLabelText("New branch name"), branch);
}
beforeEach(() => { beforeEach(() => {
vi.mocked(api.lanes.branches).mockReset(); vi.mocked(api.lanes.branches).mockReset();
vi.mocked(api.lanes.worktree).mockReset(); vi.mocked(api.lanes.pipelines)
vi.mocked(api.lanes.profileInit).mockResolvedValue({ .mockReset()
scaffolded: false, .mockResolvedValue({
reason: "no detectable Node.js project", pipelines: [
{ id: "default", name: "Default feature pipeline", nodes: new Array(8).fill({ id: "n" }) },
{
id: "ship-feature",
name: "Ship feature (lane pipeline)",
nodes: new Array(16).fill({ id: "n" }),
},
],
}); });
vi.mocked(api.lanes.agentsInstall).mockResolvedValue({ installed: [] }); vi.mocked(api.lanes.worktree).mockReset();
vi.mocked(api.lanes.mcpSync).mockResolvedValue({ servers: [], profilesSeeded: [] }); vi.mocked(api.lanes.ensure).mockReset();
vi.mocked(api.lanes.browse).mockReset();
// Provisioning finishes instantly by default - tests that care about the
// provisioning-in-progress race override this per-test.
vi.mocked(api.lanes.get)
.mockReset()
.mockResolvedValue({ lane: laneFixture({ status: "idle" }) });
vi.mocked(api.lanes.profileInit)
.mockReset()
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
vi.mocked(api.lanes.agentsInstall).mockReset().mockResolvedValue({ installed: [] });
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
}); });
describe("AddLaneModal", () => { describe("AddLaneModal — worktree mode (default)", () => {
it("disables confirm until a repo, a title, and a resolved branch list are all present", () => { it("disables confirm until a repo, a title, a resolved branch list, and a new branch name are all present", () => {
renderModal(); renderModal();
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
}); });
@@ -133,7 +174,7 @@ describe("AddLaneModal", () => {
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
}); });
it("submits through the worktree provisioning endpoint, not ensure", async () => { it("submits through the worktree provisioning endpoint, with the typed branch name", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" }); vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) }); vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
const onAdded = vi.fn(); const onAdded = vi.fn();
@@ -141,11 +182,7 @@ describe("AddLaneModal", () => {
renderModal({ onClose, onAdded }); renderModal({ onClose, onAdded });
const user = userEvent.setup(); const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository"); await fillWorktreeForm(user, { title: "New feature", branch: "feat/new-feature" });
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByLabelText("Branch to fork from");
await user.type(screen.getByLabelText("Title"), "New feature");
await user.click(screen.getByRole("button", { name: "Add lane" })); await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() => { await waitFor(() => {
@@ -153,13 +190,16 @@ describe("AddLaneModal", () => {
sourceRepo: "/Users/tester/projects/repo", sourceRepo: "/Users/tester/projects/repo",
title: "New feature", title: "New feature",
base: "main", base: "main",
branch: "feat/new-feature",
pipeline: "default",
}); });
}); });
expect(onAdded).toHaveBeenCalledWith( expect(onAdded).toHaveBeenCalledWith(
expect.objectContaining({ id: 9, status: "provisioning" }) expect.objectContaining({ id: 9, status: "provisioning" })
); );
// The setup summary stays on screen for a few seconds before auto-closing. // The modal stays open showing the setup summary until dismissed - it
await waitFor(() => expect(onClose).toHaveBeenCalled(), { timeout: 4000 }); // does not close itself just because the lane was added.
expect(onClose).not.toHaveBeenCalled();
}); });
it("shows a server error and leaves the modal open instead of closing silently", async () => { it("shows a server error and leaves the modal open instead of closing silently", async () => {
@@ -169,11 +209,7 @@ describe("AddLaneModal", () => {
renderModal({ onClose }); renderModal({ onClose });
const user = userEvent.setup(); const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository"); await fillWorktreeForm(user, { title: "New feature" });
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByLabelText("Branch to fork from");
await user.type(screen.getByLabelText("Title"), "New feature");
await user.click(screen.getByRole("button", { name: "Add lane" })); await user.click(screen.getByRole("button", { name: "Add lane" }));
expect(await screen.findByText("EWORKTREEDIRCOLLISION")).toBeInTheDocument(); expect(await screen.findByText("EWORKTREEDIRCOLLISION")).toBeInTheDocument();
@@ -194,11 +230,7 @@ describe("AddLaneModal", () => {
renderModal({ onAdded }); renderModal({ onAdded });
const user = userEvent.setup(); const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository"); await fillWorktreeForm(user);
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByLabelText("Branch to fork from");
await user.type(screen.getByLabelText("Title"), "demo");
await user.click(screen.getByRole("button", { name: "Add lane" })); await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() => expect(api.lanes.worktree).toHaveBeenCalled()); await waitFor(() => expect(api.lanes.worktree).toHaveBeenCalled());
@@ -208,7 +240,7 @@ describe("AddLaneModal", () => {
await waitFor(() => expect(onAdded).toHaveBeenCalled()); await waitFor(() => expect(onAdded).toHaveBeenCalled());
}); });
it("still calls onAdded and closes even when every setup call fails", async () => { it("still calls onAdded even when every setup call fails", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" }); vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.worktree).mockResolvedValue({ vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 43, title: "demo2", cwd: "/lanes/demo2", status: "provisioning" } as Lane, lane: { id: 43, title: "demo2", cwd: "/lanes/demo2", status: "provisioning" } as Lane,
@@ -221,18 +253,14 @@ describe("AddLaneModal", () => {
renderModal({ onAdded, onClose }); renderModal({ onAdded, onClose });
const user = userEvent.setup(); const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository"); await fillWorktreeForm(user, { title: "demo2" });
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByLabelText("Branch to fork from");
await user.type(screen.getByLabelText("Title"), "demo2");
await user.click(screen.getByRole("button", { name: "Add lane" })); await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() => expect(onAdded).toHaveBeenCalled()); await waitFor(() => expect(onAdded).toHaveBeenCalled());
await waitFor(() => expect(onClose).toHaveBeenCalled(), { timeout: 4000 }); expect(onClose).not.toHaveBeenCalled();
}); });
it("shows the setup summary and lets the user dismiss it early instead of waiting out the auto-close timer", async () => { it("shows the setup summary and lets the user dismiss it manually", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" }); vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.worktree).mockResolvedValue({ vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane, lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane,
@@ -241,11 +269,7 @@ describe("AddLaneModal", () => {
renderModal({ onClose }); renderModal({ onClose });
const user = userEvent.setup(); const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository"); await fillWorktreeForm(user, { title: "demo3" });
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByLabelText("Branch to fork from");
await user.type(screen.getByLabelText("Title"), "demo3");
await user.click(screen.getByRole("button", { name: "Add lane" })); await user.click(screen.getByRole("button", { name: "Add lane" }));
expect(await screen.findByText("Setup")).toBeInTheDocument(); expect(await screen.findByText("Setup")).toBeInTheDocument();
@@ -256,4 +280,162 @@ describe("AddLaneModal", () => {
await user.click(dismissButton); await user.click(dismissButton);
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
}); });
it("waits for the background worktree provisioning to finish before running setup, and skips setup if it fails", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 45, title: "demo4", cwd: "/lanes/demo4", status: "provisioning" } as Lane,
});
// First poll still provisioning, second poll reports the background
// `git worktree add` failed.
vi.mocked(api.lanes.get)
.mockReset()
.mockResolvedValueOnce({ lane: laneFixture({ id: 45, status: "provisioning" }) })
.mockResolvedValueOnce({ lane: laneFixture({ id: 45, status: "failed" }) });
const onAdded = vi.fn();
renderModal({ onAdded });
const user = userEvent.setup();
await fillWorktreeForm(user, { title: "demo4" });
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() => expect(onAdded).toHaveBeenCalled());
expect(await screen.findByText(/auto-setup was skipped/)).toBeInTheDocument();
expect(api.lanes.profileInit).not.toHaveBeenCalled();
expect(api.lanes.agentsInstall).not.toHaveBeenCalled();
expect(api.lanes.mcpSync).not.toHaveBeenCalled();
});
});
describe("AddLaneModal — repo mode (adopt)", () => {
it("hides the branch fields and enables confirm on a path alone", async () => {
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/Users/tester/projects/repo");
expect(screen.getByRole("button", { name: "Add lane" })).toBeEnabled();
expect(screen.queryByLabelText("Branch to fork from")).toBeNull();
expect(screen.queryByLabelText("New branch name")).toBeNull();
expect(api.lanes.branches).not.toHaveBeenCalled();
});
it("submits through ensure, not worktree, and closes immediately", async () => {
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ id: 23, kind: "adopted", status: "idle" }),
created: true,
});
const onAdded = vi.fn();
const onClose = vi.fn();
renderModal({ onAdded, onClose });
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/Users/tester/projects/repo");
await user.type(screen.getByLabelText("Title"), "main repo");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() =>
expect(api.lanes.ensure).toHaveBeenCalledWith({
cwd: "/Users/tester/projects/repo",
title: "main repo",
pipeline: "default",
})
);
expect(api.lanes.worktree).not.toHaveBeenCalled();
await waitFor(() => expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 23 })));
// Repo mode never runs the profile/agents/mcp setup summary - it should
// close right away like the old adopt flow did.
expect(onClose).toHaveBeenCalled();
});
});
describe("AddLaneModal — folder browse", () => {
it("opens the browser, lists subdirectories, and selecting one fills the path field", async () => {
vi.mocked(api.lanes.browse).mockResolvedValue({
path: "/Users/tester",
parent: "/Users",
entries: [{ name: "projects", path: "/Users/tester/projects", isGitRepo: false }],
});
renderModal();
const user = userEvent.setup();
await user.click(screen.getByTitle("Browse for a folder"));
expect(await screen.findByText("projects")).toBeInTheDocument();
await user.click(screen.getByText("projects"));
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects");
});
it("Escape closes only the folder browser, not the whole modal", async () => {
vi.mocked(api.lanes.browse).mockResolvedValue({
path: "/Users/tester",
parent: null,
entries: [],
});
const onClose = vi.fn();
renderModal({ onClose });
const user = userEvent.setup();
await user.click(screen.getByTitle("Browse for a folder"));
await screen.findByRole("dialog", { name: "Browse for a folder" });
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog", { name: "Browse for a folder" })).toBeNull();
expect(
screen.getByRole("dialog", { name: "Create a lane from a working directory" })
).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
});
describe("AddLaneModal — pipeline template", () => {
it("offers every template the server reports and sends the chosen one when adopting a repo", async () => {
// Creation is the ONLY point the UI can set a template, so a lane born on
// `default` renders 8 nodes for a 16-node workflow with no way back from
// any screen.
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle" }),
created: true,
});
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const select = await screen.findByLabelText("Pipeline template");
await waitFor(() =>
expect(screen.getByRole("option", { name: /Ship feature/ })).toBeInTheDocument()
);
expect(
screen.getByRole("option", { name: /Default feature pipeline \(8\)/ })
).toBeInTheDocument();
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.selectOptions(select, "ship-feature");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() =>
expect(api.lanes.ensure).toHaveBeenCalledWith(
expect.objectContaining({ pipeline: "ship-feature" })
)
);
});
it("still renders a usable select, and still creates the lane, when the template list cannot be fetched", async () => {
vi.mocked(api.lanes.pipelines).mockRejectedValue(new Error("offline"));
renderModal();
const select = await screen.findByLabelText("Pipeline template");
await waitFor(() => expect(select).toHaveValue("default"));
expect(screen.getByRole("option", { name: "default" })).toBeInTheDocument();
});
}); });
@@ -21,6 +21,10 @@ import { DestructiveLaneModal } from "../DestructiveLaneModal";
import type { Lane, LanePurgePreflight, LaneWorktreePreflight } from "../../../lib/types"; import type { Lane, LanePurgePreflight, LaneWorktreePreflight } from "../../../lib/types";
function makeLane(overrides: Partial<Lane> = {}): Lane { function makeLane(overrides: Partial<Lane> = {}): Lane {
// `as Lane`: spreading a Partial<Lane> widens every field it may carry to
// `T | undefined`, which no longer satisfies Lane's required fields. The
// base object below still lists all of them, so the cast asserts what the
// literal already proves.
return { return {
id: 1, id: 1,
title: "demo", title: "demo",
@@ -50,7 +54,7 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
slot: null, slot: null,
ports: {}, ports: {},
...overrides, ...overrides,
}; } as Lane;
} }
function worktreePreflight(overrides: Partial<LaneWorktreePreflight> = {}): LaneWorktreePreflight { function worktreePreflight(overrides: Partial<LaneWorktreePreflight> = {}): LaneWorktreePreflight {
@@ -12,7 +12,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import LaneCard from "../LaneCard"; import LaneCard from "../LaneCard";
import type { Lane } from "../../../lib/types"; import type { Lane, LaneRuntime } from "../../../lib/types";
import { api } from "../../../lib/api"; import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({ vi.mock("../../../lib/api", () => ({
@@ -48,12 +48,17 @@ vi.mocked(api.locks.list).mockReset();
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] }); vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
function makeLane(overrides: Partial<Lane> = {}): Lane { function makeLane(overrides: Partial<Lane> = {}): Lane {
// `as Lane`: spreading a Partial<Lane> widens every field it may carry to
// `T | undefined`, which no longer satisfies Lane's required fields. The
// base object below still lists all of them, so the cast asserts what the
// literal already proves.
return { return {
id: 1, id: 1,
title: "demo", title: "demo",
cwd: "/work/demo", cwd: "/work/demo",
branch: "lane/demo", branch: "lane/demo",
kind: "adopted", kind: "adopted",
source_repo: null,
pipeline: "default", pipeline: "default",
session_id: null, session_id: null,
run_id: null, run_id: null,
@@ -77,7 +82,7 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
slot: null, slot: null,
ports: {}, ports: {},
...overrides, ...overrides,
}; } as Lane;
} }
describe("LaneCard status badge", () => { describe("LaneCard status badge", () => {
@@ -90,6 +95,31 @@ describe("LaneCard status badge", () => {
} }
}); });
describe("LaneCard child worktrees", () => {
it("lists worktrees provisioned from this lane and jumps to one on click", async () => {
const onSelectLane = vi.fn();
const worktree = makeLane({ id: 8, title: "Worktree A", status: "running" });
render(
<LaneCard
lane={makeLane({ id: 1 })}
onAction={vi.fn()}
childWorktrees={[worktree]}
onSelectLane={onSelectLane}
/>
);
expect(screen.getByTestId("lane-child-worktrees")).toBeInTheDocument();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: /Worktree A/ }));
expect(onSelectLane).toHaveBeenCalledWith(8);
});
it("renders nothing when there are no child worktrees", () => {
render(<LaneCard lane={makeLane({ id: 1 })} onAction={vi.fn()} />);
expect(screen.queryByTestId("lane-child-worktrees")).not.toBeInTheDocument();
});
});
const pipelineNodes: Lane["pipeline_nodes"] = [ const pipelineNodes: Lane["pipeline_nodes"] = [
{ id: "intake", label: "intake", icon: "📥", gate: false, state: "done" }, { id: "intake", label: "intake", icon: "📥", gate: false, state: "done" },
{ id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" }, { id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" },
@@ -370,6 +400,9 @@ describe("LaneCard — named locks", () => {
describe("agents install / mcp sync / integration badges / sync check", () => { describe("agents install / mcp sync / integration badges / sync check", () => {
beforeEach(() => { beforeEach(() => {
// Only the fields this describe block's assertions read; `as LaneRuntime`
// keeps the double from having to restate a shape the component never
// touches here.
vi.mocked(api.lanes.runtime).mockResolvedValue({ vi.mocked(api.lanes.runtime).mockResolvedValue({
available: true as const, available: true as const,
provisioned: true as const, provisioned: true as const,
@@ -377,7 +410,7 @@ describe("agents install / mcp sync / integration badges / sync check", () => {
slot: 1, slot: 1,
profileDir: "/work/demo/.ccam/profile", profileDir: "/work/demo/.ccam/profile",
ports: {}, ports: {},
}); } as unknown as LaneRuntime);
vi.mocked(api.lanes.integration).mockImplementation((_id, name) => vi.mocked(api.lanes.integration).mockImplementation((_id, name) =>
Promise.resolve({ enabled: name === "tracker" }) Promise.resolve({ enabled: name === "tracker" })
); );
+18
View File
@@ -19,15 +19,33 @@
"add": "Add lane", "add": "Add lane",
"addLane": "Create a lane from a working directory", "addLane": "Create a lane from a working directory",
"addLaneBaseLabel": "Branch to fork from", "addLaneBaseLabel": "Branch to fork from",
"addLaneBranchHint": "Must be a valid, not-yet-existing git branch name.",
"addLaneBranchLabel": "New branch name",
"addLaneBranchPlaceholder": "feat/my-feature",
"addLaneNoBranches": "This repo has no commits yet — the worktree will start empty.", "addLaneNoBranches": "This repo has no commits yet — the worktree will start empty.",
"addLaneNotARepo": "Not a git repository (or no read access) yet.", "addLaneNotARepo": "Not a git repository (or no read access) yet.",
"addLanePipelineHint": "Which stages this lane's map shows. Pick the one the skill driving it declares against — nothing else in this UI changes it later.",
"addLanePipelineLabel": "Pipeline template",
"addLaneProvisionFailed": "Worktree provisioning failed or timed out — the lane exists but auto-setup was skipped. Check the lane's git facts, then run agents/mcp setup manually.",
"addLaneRepoHint": "An existing git repo. The dashboard creates a new worktree for the lane, not a folder you pick.", "addLaneRepoHint": "An existing git repo. The dashboard creates a new worktree for the lane, not a folder you pick.",
"addLaneRepoHintAdopt": "An existing directory. The dashboard tracks it as-is — no worktree, no new branch.",
"addLaneRepoLabel": "Source repository", "addLaneRepoLabel": "Source repository",
"addLaneRepoLabelAdopt": "Directory",
"addLaneSetupAgents": "Agents", "addLaneSetupAgents": "Agents",
"addLaneSetupMcp": "MCP servers", "addLaneSetupMcp": "MCP servers",
"addLaneSetupProfile": "Profile", "addLaneSetupProfile": "Profile",
"addLaneSetupTitle": "Setup", "addLaneSetupTitle": "Setup",
"addLaneTitleLabel": "Title", "addLaneTitleLabel": "Title",
"mode.repo": "Repo",
"mode.repoHint": "Adopt this directory as a lane, as-is — no worktree, no new branch.",
"mode.worktree": "Worktree",
"mode.worktreeHint": "Provision a new git worktree + branch from a source repo.",
"browse.button": "Browse",
"browse.empty": "This folder is empty.",
"browse.select": "Select this folder",
"browse.title": "Browse for a folder",
"worktrees.heading_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktrees",
"addLaneTitlePlaceholder": "Optional", "addLaneTitlePlaceholder": "Optional",
"autoStage": "auto: {{stage}}", "autoStage": "auto: {{stage}}",
"cardId": "Lane {{id}}", "cardId": "Lane {{id}}",
+25 -7
View File
@@ -1,11 +1,11 @@
{ {
"action.clear": "dọn trạng thái", "action.clear": "Dọn trạng thái",
"action.forget": "xóa làn", "action.forget": "Xóa làn",
"action.purge": "xóa lịch sử", "action.purge": "Xóa lịch sử",
"action.remove": "xóa làn + worktree", "action.remove": "Xóa làn + worktree",
"action.reset": "đặt lại worktree", "action.reset": "Đặt lại worktree",
"action.start": "bắt đầu", "action.start": "Bắt đầu",
"action.stop": "dừng", "action.stop": "Dừng",
"actionError": "Thao tác làn đường thất bại: {{message}}", "actionError": "Thao tác làn đường thất bại: {{message}}",
"actionErrorUnknown": "Lỗi không xác định", "actionErrorUnknown": "Lỗi không xác định",
"actions.agentsInstall": "Cài agent", "actions.agentsInstall": "Cài agent",
@@ -19,15 +19,33 @@
"add": "Thêm lane", "add": "Thêm lane",
"addLane": "Tạo lane từ một thư mục làm việc", "addLane": "Tạo lane từ một thư mục làm việc",
"addLaneBaseLabel": "Nhánh để tạo nhánh mới", "addLaneBaseLabel": "Nhánh để tạo nhánh mới",
"addLaneBranchHint": "Phải là tên nhánh git hợp lệ và chưa tồn tại.",
"addLaneBranchLabel": "Tên nhánh mới",
"addLaneBranchPlaceholder": "feat/tinh-nang-cua-toi",
"addLaneNoBranches": "Repo này chưa có commit nào — worktree sẽ bắt đầu trống.", "addLaneNoBranches": "Repo này chưa có commit nào — worktree sẽ bắt đầu trống.",
"addLaneNotARepo": "Chưa phải repo git (hoặc không có quyền đọc).", "addLaneNotARepo": "Chưa phải repo git (hoặc không có quyền đọc).",
"addLanePipelineHint": "Bản đồ lane này sẽ hiện những stage nào. Chọn đúng cái mà skill điều khiển nó khai báo — sau này không màn hình nào đổi được.",
"addLanePipelineLabel": "Mẫu pipeline",
"addLaneProvisionFailed": "Tạo worktree thất bại hoặc quá thời gian chờ — lane vẫn tồn tại nhưng tự động thiết lập đã bị bỏ qua. Kiểm tra git facts của lane, rồi chạy thiết lập agents/mcp thủ công.",
"addLaneRepoHint": "Một repo git có sẵn. Dashboard tự tạo worktree mới cho lane, không phải thư mục bạn chọn.", "addLaneRepoHint": "Một repo git có sẵn. Dashboard tự tạo worktree mới cho lane, không phải thư mục bạn chọn.",
"addLaneRepoHintAdopt": "Một thư mục có sẵn. Dashboard theo dõi nguyên trạng — không tạo worktree, không tạo nhánh mới.",
"addLaneRepoLabel": "Repo nguồn", "addLaneRepoLabel": "Repo nguồn",
"addLaneRepoLabelAdopt": "Thư mục",
"addLaneSetupAgents": "Agent", "addLaneSetupAgents": "Agent",
"addLaneSetupMcp": "MCP server", "addLaneSetupMcp": "MCP server",
"addLaneSetupProfile": "Profile", "addLaneSetupProfile": "Profile",
"addLaneSetupTitle": "Thiết lập", "addLaneSetupTitle": "Thiết lập",
"addLaneTitleLabel": "Tiêu đề", "addLaneTitleLabel": "Tiêu đề",
"mode.repo": "Repo",
"mode.repoHint": "Gắn thẳng thư mục này làm lane, nguyên trạng — không worktree, không nhánh mới.",
"mode.worktree": "Worktree",
"mode.worktreeHint": "Tạo worktree git mới + nhánh mới từ repo nguồn.",
"browse.button": "Duyệt",
"browse.empty": "Thư mục này trống.",
"browse.select": "Chọn thư mục này",
"browse.title": "Duyệt chọn thư mục",
"worktrees.heading_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktree",
"addLaneTitlePlaceholder": "Không bắt buộc", "addLaneTitlePlaceholder": "Không bắt buộc",
"autoStage": "tự động: {{stage}}", "autoStage": "tự động: {{stage}}",
"cardId": "Làn đường {{id}}", "cardId": "Làn đường {{id}}",
+32 -2
View File
@@ -394,6 +394,7 @@ import type {
LaneGitFacts, LaneGitFacts,
LaneRuntime, LaneRuntime,
ModelPricing, ModelPricing,
NamedLock,
Session, Session,
SessionDrillIn, SessionDrillIn,
SessionStats, SessionStats,
@@ -1900,11 +1901,20 @@ export const api = {
* @param body The cwd and optional title. * @param body The cwd and optional title.
* @returns `{ lane, created }` the lane (newly created or existing) and whether it was created. * @returns `{ lane, created }` the lane (newly created or existing) and whether it was created.
*/ */
ensure: (body: { cwd: string; title?: string }) => ensure: (body: { cwd: string; title?: string; pipeline?: string }) =>
request<{ lane: Lane; created: boolean }>("/lanes/ensure", { request<{ lane: Lane; created: boolean }>("/lanes/ensure", {
method: "POST", method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
/**
* GET /api/lanes/pipelines every pipeline template the server can render
* a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override.
* @returns `{ pipelines }` each with its `id`, display `name` and `nodes`.
*/
pipelines: () =>
request<{ pipelines: { id: string; name: string; nodes: { id: string }[] }[] }>(
"/lanes/pipelines"
),
/** /**
* GET /api/lanes/branches a candidate source repo's local branches. * GET /api/lanes/branches a candidate source repo's local branches.
* @param repo Absolute path to an existing git repository. * @param repo Absolute path to an existing git repository.
@@ -1922,11 +1932,31 @@ export const api = {
* @param body The source repo, a slug/title, and the branch to fork from. * @param body The source repo, a slug/title, and the branch to fork from.
* @returns `{ lane }` the lane row created immediately, before provisioning finishes. * @returns `{ lane }` the lane row created immediately, before provisioning finishes.
*/ */
worktree: (body: { sourceRepo: string; slug?: string; title?: string; base?: string }) => worktree: (body: {
sourceRepo: string;
slug?: string;
title?: string;
base?: string;
branch?: string;
pipeline?: string;
}) =>
request<{ lane: Lane }>("/lanes/worktree", { request<{ lane: Lane }>("/lanes/worktree", {
method: "POST", method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
/**
* GET /api/lanes/browse list a directory's immediate subdirectories, for
* the Add Lane modal's folder browser. Browsers cannot expose absolute
* filesystem paths from a native picker, so this drives a server-backed one.
* @param path Absolute path to list; defaults server-side to the home dir.
* @returns `{ path, parent, entries }` `parent` is null at the root.
*/
browse: (path?: string) =>
request<{
path: string;
parent: string | null;
entries: { name: string; path: string; isGitRepo: boolean }[];
}>(`/lanes/browse${path ? `?path=${encodeURIComponent(path)}` : ""}`),
/** /**
* GET /api/lanes/:id fetch one lane. * GET /api/lanes/:id fetch one lane.
* @param id The lane id. * @param id The lane id.
+3
View File
@@ -2319,6 +2319,9 @@ export interface Lane {
cwd: string; cwd: string;
branch: string | null; branch: string | null;
kind: "managed" | "adopted"; kind: "managed" | "adopted";
/** For a managed worktree lane, the repo it was provisioned from. Null for
* an adopted lane (it IS a source repo, not a worktree of one). */
source_repo: string | null;
pipeline: string; pipeline: string;
session_id: string | null; session_id: string | null;
run_id: string | null; run_id: string | null;
+4
View File
@@ -1079,6 +1079,10 @@ export function Workspace() {
<LaneCard <LaneCard
lane={currentLane} lane={currentLane}
onAction={(a, b) => handleLaneAction(currentLane.id, a, b)} onAction={(a, b) => handleLaneAction(currentLane.id, a, b)}
childWorktrees={lanes.filter(
(l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id
)}
onSelectLane={setSelectedLaneId}
/> />
</div> </div>
<div className="mb-3"> <div className="mb-3">
@@ -24,6 +24,7 @@ type LaneFixture = {
pipeline_nodes: never[]; pipeline_nodes: never[];
detected_signal: string | null; detected_signal: string | null;
run_id: string | null; run_id: string | null;
active_feature_id?: number | null;
}; };
let lanesToReturn: LaneFixture[] = [ let lanesToReturn: LaneFixture[] = [
@@ -583,7 +584,8 @@ describe("Workspace — proof gallery", () => {
], ],
}); });
// Set active_feature_id and ensure features list is populated // Set active_feature_id and ensure features list is populated
lanesToReturn[0].active_feature_id = 1; // (indexed access is `| undefined` under noUncheckedIndexedAccess)
lanesToReturn[0]!.active_feature_id = 1;
vi.mocked(api.lanes.features.list).mockResolvedValue({ vi.mocked(api.lanes.features.list).mockResolvedValue({
features: [ features: [
{ {
+16 -1
View File
@@ -135,9 +135,14 @@ POST /api/lanes/ensure
``` ```
```json ```json
{ "cwd": "/absolute/path/to/work", "title": "App package" } { "cwd": "/absolute/path/to/work", "title": "App package", "pipeline": "ship-feature" }
``` ```
`pipeline` is optional and, like `title`, applies **only when a lane is
actually created** — an existing lane is returned untouched. Omitted, the lane
gets `default`. `POST /api/lanes/worktree` and `POST /api/lanes` take the same
optional field. An id no template defines returns `400 EBADPIPELINE`.
Idempotent lookup by working directory — the Workspace page opens on a `cwd`, Idempotent lookup by working directory — the Workspace page opens on a `cwd`,
not on a lane id. Returns the lane whose own `cwd` is that path, or the longest not on a lane id. Returns the lane whose own `cwd` is that path, or the longest
path-boundary parent of it, as `{ "lane": {...}, "created": false }` with `200`. path-boundary parent of it, as `{ "lane": {...}, "created": false }` with `200`.
@@ -159,6 +164,16 @@ PATCH /api/lanes/:id
Partially updates a lane. This route is same-origin guarded because its patch Partially updates a lane. This route is same-origin guarded because its patch
can set `run_id`. can set `run_id`.
`pipeline` is patchable, which is how a lane moves between templates after
creation (`ccam lanes pipeline <template-id>`). An id no template defines is
rejected with `400 EBADPIPELINE` rather than stored: `getPipeline` falls back
to the default template when READING, so an unvalidated write would be
accepted and then silently render the wrong map forever. Same 400 treatment as
`EBADKIND`. Switching a template re-resolves the lane's existing declared
`stage` string against the new node list — a stage the old template knew may
resolve to nothing in the new one, leaving `progress: 0` and no `current` node
until the next `ccam stage`.
#### Provision a managed worktree #### Provision a managed worktree
```http ```http
+2 -1
View File
@@ -227,9 +227,10 @@ A lane is a durable unit of parallel agent work — one working directory, many
| `ccam lanes add --cwd <path> --title <text>` | Adopt an existing directory as a lane | | `ccam lanes add --cwd <path> --title <text>` | Adopt an existing directory as a lane |
| `ccam lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>]` | Provision a dashboard-managed git worktree as a new lane | | `ccam lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>]` | Provision a dashboard-managed git worktree as a new lane |
| `ccam lanes profile init <repo> [--force]` | Detect a Node.js project (single-service or backend+frontend monorepo) and scaffold `.ccam/profile/`. Refuses to overwrite an existing one without `--force` | | `ccam lanes profile init <repo> [--force]` | Detect a Node.js project (single-service or backend+frontend monorepo) and scaffold `.ccam/profile/`. Refuses to overwrite an existing one without `--force` |
| `ccam lanes pipeline [<template-id>] [<id>]` | Show which pipeline template a lane renders against, plus the available templates and their node counts; pass a template id to switch. An unknown id is refused (`400 EBADPIPELINE`), and a switch that leaves the lane's current stage unresolvable warns |
| `ccam lanes profile check [<path>]` | Validate a profile — parses, every referenced hook exists and is executable, no leftover `TODO:`, declared ports free. `<path>` defaults to the current directory (not a lane id) | | `ccam lanes profile check [<path>]` | Validate a profile — parses, every referenced hook exists and is executable, no leftover `TODO:`, declared ports free. `<path>` defaults to the current directory (not a lane id) |
| `ccam lanes reset\|remove\|purge <id> [--force] [--keep-db] --yes` | Show preflight facts, then perform a destructive action. Refuses without `--yes`; `--force` is required when commits are unpushed; `--keep-db` (`reset` only) skips dropping/recreating a data-isolated lane's database | | `ccam lanes reset\|remove\|purge <id> [--force] [--keep-db] --yes` | Show preflight facts, then perform a destructive action. Refuses without `--yes`; `--force` is required when commits are unpushed; `--keep-db` (`reset` only) skips dropping/recreating a data-isolated lane's database |
| `ccam stage <stage> [--evidence <text>] [--note <text>] [--result pass\|fail]` | Declare the lane's current pipeline stage. Called by a skill at each phase boundary | | `ccam stage <stage> [--evidence <text>] [--note <text>] [--result pass\|fail]` | Declare the lane's current pipeline stage. Called by a skill at each phase boundary. `<stage>` may be a node id or one of its template's aliases; a name matching neither is still recorded but warns on stderr (exit stays `0`) that the pipeline map won't show it |
| `ccam feature list [<id>]` | List every feature this lane has activated, archived or live | | `ccam feature list [<id>]` | List every feature this lane has activated, archived or live |
| `ccam feature activate <slug> [--title text] [<id>]` | Switch to a feature by slug (echoes the canonicalized slug), archiving the current one first | | `ccam feature activate <slug> [--title text] [<id>]` | Switch to a feature by slug (echoes the canonicalized slug), archiving the current one first |
| `ccam feature show <slug> [<id>]` | Show one feature's saved pipeline — works on an archived one too | | `ccam feature show <slug> [<id>]` | Show one feature's saved pipeline — works on an archived one too |
+154 -4
View File
@@ -32,7 +32,18 @@ ccam lanes add --repo /path/to/repo --title "My Feature" --base main --slug my-f
`--title`, `--base`, and `--slug` are optional. The CLI waits for background provisioning to finish and reports either the ready lane or its failure notes. `--title`, `--base`, and `--slug` are optional. The CLI waits for background provisioning to finish and reports either the ready lane or its failure notes.
Adding a lane through the dashboard's "+ Add lane" flow also auto-runs, best-effort, in parallel: `ccam lanes profile init` (only if a Node.js project is detected — most repos won't be, and that's a normal outcome, not a failure), `ccam lanes agents install`, and `ccam lanes mcp sync`. None of the three blocks the lane from being created or from each other — a lane whose repo has no MCP servers configured, for instance, still gets created and is still usable, just without a synced `.mcp.json`. The modal shows a ✓/✗ summary of the three results for a few seconds (or until dismissed) before closing. Run any of the three manually later (from the lane's own card, or the CLI) if the automatic attempt didn't apply. Adopting your main repo (`ccam lanes add --cwd $(pwd)`) is also how you get stage detection working for sessions that work directly in it rather than in a worktree — see "Superpowers skill invocations" below. Once adopted, that lane's own card lists every managed-worktree lane whose `source_repo` matches its `cwd`, each a clickable link to jump to that lane.
Adding a lane through the dashboard's "+ Add lane" flow also auto-runs, best-effort, in parallel: `ccam lanes profile init` (only if a Node.js project is detected — most repos won't be, and that's a normal outcome, not a failure), `ccam lanes agents install`, and `ccam lanes mcp sync`. None of the three blocks the lane from being created or from each other — a lane whose repo has no MCP servers configured, for instance, still gets created and is still usable, just without a synced `.mcp.json`. The modal shows a ✓/✗ summary of the three results and stays open until dismissed (Cancel/X) — it does not auto-close. Run any of the three manually later (from the lane's own card, or the CLI) if the automatic attempt didn't apply.
### The "+ Add lane" modal
The modal has a segmented toggle mirroring the CLI's two modes:
- **Repo** — adopts the given directory as-is (maps to `ccam lanes add --cwd`). No branch fields; the auto-setup summary above does not run (adopting is instant, nothing to wait on).
- **Worktree** (default) — provisions a managed worktree (maps to `ccam lanes add --repo`). Unlike the CLI, the modal requires you to type the new branch's name yourself rather than deriving one from the title — the underlying route still derives one when `branch` is omitted, so the CLI's behavior is unchanged.
Both modes' path field has a **Browse** button next to it, opening a small folder browser (`GET /api/lanes/browse?path=<abs>`) instead of a native OS picker — a browser cannot hand a web page an absolute filesystem path from a native dialog, so this dashboard (local-first, server and browser on the same machine) lists directories server-side instead: click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field. Git repos are marked in the listing.
## Destructive lane actions ## Destructive lane actions
@@ -409,6 +420,17 @@ The dashboard web UI shows each lane as a card in a grid, with the selected lane
A lane moves through stages defined in a **pipeline template** (see "Custom pipeline templates" below). The default pipeline has eight stages: `intake`, `plan`, `implement`, `tests`, `review`, `gate`, `ship`, and `done`. A lane moves through stages defined in a **pipeline template** (see "Custom pipeline templates" below). The default pipeline has eight stages: `intake`, `plan`, `implement`, `tests`, `review`, `gate`, `ship`, and `done`.
**A lane is created on `default` unless told otherwise.** The "+ Add lane" modal has a *Pipeline template* select listing every template the server reports, so this is chosen at creation; `ccam lanes add --pipeline <id>` is the same choice from the terminal. Creation is the point that matters — a lane born on `default` renders 8 nodes for a 16-node workflow.
For a lane that already exists (including every lane created before the picker shipped):
```bash
ccam lanes pipeline # which template this lane uses, and what else exists
ccam lanes pipeline ship-feature # switch it
```
Switching re-resolves the lane's existing declared `stage` against the new node list. A stage the old template knew may resolve to nothing in the new one; the command warns when that happens, and the next `ccam stage` fixes it.
The dashboard renders every node in the pipeline in one of five **states**: The dashboard renders every node in the pipeline in one of five **states**:
| State | Color | Meaning | | State | Color | Meaning |
@@ -460,6 +482,20 @@ ccam stage <stage> [--lane <id>] [--cwd <path>] [--status <s>] [--evidence <text
- `ship` / `pr` / `pr-open` / `publishing` / `commit` / `push` - `ship` / `pr` / `pr-open` / `publishing` / `commit` / `push`
- `done` / `complete` / `completed` / `merged` - `done` / `complete` / `completed` / `merged`
Aliases are **per template** — the `ship-feature` pipeline has its own set
(see "Pipeline template: ship-feature" below). A name matching no node and no
alias is still recorded verbatim, but `phaseIdx` then resolves it to nothing:
no node renders `current` and progress reads `0`. The CLI prints a warning to
stderr and still exits `0` in that case — a typo must not break a
declaration the lane can record, but it must not pass silently either:
```
! "revieww" matches no node in pipeline "default" — recorded, but the pipeline map won't show it. Nodes: intake, plan, implement, …
```
The check is skipped for `--result fail`, which paints the node `failed`
rather than `current`.
- `--lane <id>` (optional): the numeric lane ID. If omitted, the command resolves the lane by `cwd`. - `--lane <id>` (optional): the numeric lane ID. If omitted, the command resolves the lane by `cwd`.
- `--cwd <path>` (optional): working directory to match against a lane's cwd. If omitted, uses the current working directory. Useful when calling from outside the repo. - `--cwd <path>` (optional): working directory to match against a lane's cwd. If omitted, uses the current working directory. Useful when calling from outside the repo.
@@ -615,7 +651,11 @@ proof would let a lane's pipeline map lie about what actually happened.
The node the agent *declared* itself on is never flagged as detected, so it The node the agent *declared* itself on is never flagged as detected, so it
keeps its blue `current` ring — including when the declaration came in through keeps its blue `current` ring — including when the declaration came in through
an alias (`ccam stage coding` resolves to the `implement` node, and `stages` is an alias (`ccam stage coding` resolves to the `implement` node, and `stages` is
keyed by the raw declared word, not the node id). keyed by the raw declared word, not the node id). Past nodes get the same
protection: `stageRecords` (`server/lib/pipelines.js`) resolves every recorded
key back onto its node, so a stage declared by alias keeps its record — and its
`--evidence` — instead of reading as an inference or losing its `done`. Where a
node has both a canonical record and an alias record, the canonical one wins.
The same rule governs `ccam lanes`: the inferred stage is printed only when it The same rule governs `ccam lanes`: the inferred stage is printed only when it
leads the declared one (see "Viewing lanes" below), and `LaneCard.tsx`'s leads the declared one (see "Viewing lanes" below), and `LaneCard.tsx`'s
@@ -672,6 +712,23 @@ to the flattened input. Either way the result is capped at 120 characters
lane's tooltip sometimes shows a file path or a skill name rather than a shell lane's tooltip sometimes shows a file path or a skill name rather than a shell
command: it's whichever of the fields above the matching rule's `tool` carried. command: it's whichever of the fields above the matching rule's `tool` carried.
### Superpowers skill invocations
The built-in `default` pipeline's `plan`, `implement`, `review`, and `ship`
nodes each carry a `{"tool": "Skill", "match": "..."}` rule matching the
Superpowers workflow skill names (`brainstorming`/`writing-plans`,
`executing-plans`/`subagent-driven-development`, `code-review`/
`requesting-code-review`, `finishing-a-development-branch`). Invoking one of
these skills is a much stronger signal than a matched Bash command, but it is
still detection, not declaration — it renders dashed amber and never `done`,
same as every other detected stage.
Detection only ever attributes to a lane whose `cwd` matches the hook's
session `cwd` (see "Which lane a signal is credited to" above). A session
working directly in a source repo that was never itself adopted as a lane —
run `ccam lanes add --cwd $(pwd)` from that repo to fix that — gets no
detection at all, because no lane owns that `cwd`.
### Detection expires ### Detection expires
Forward-only would otherwise park a lane at the highest stage it ever touched: Forward-only would otherwise park a lane at the highest stage it ever touched:
@@ -744,8 +801,43 @@ accepts `git push`, `git -c core.hooksPath=/dev/null push` and the credential
-helper form, while rejecting `git log … "push"`, `git commit -m "don't push"`, -helper form, while rejecting `git log … "push"`, `git commit -m "don't push"`,
`docker push`, and `npm run push-docs`. `docker push`, and `npm run push-docs`.
`intake`, `gate`, and `done` deliberately have no rules. `intake` is where a The `ship-feature` template (`server/data/pipelines/ship-feature.json`) carries
lane starts — there is no tool event that means "just claimed," so there is its own rules, aimed at the commands and Superpowers skills its driving skill
actually runs:
| Node | Detect rules |
|---|---|
| `intake` | `Skill` matching `brainstorming`; `Bash` matching `ccam … feature activate` |
| `plan` | `Skill` matching `writing-plans`; `Write` matching `docs/superpowers/specs/lane-.*\.md` |
| `implementing` | `Skill` matching `test-driven-development\|executing-plans\|subagent-driven-development\|systematic-debugging`; `Edit`/`Write` to any path NOT under `docs/superpowers/specs/` (resp. not under `docs/`) |
| `gates` | `Bash` matching `ccam … hook ci-gate` or `ccam … sync-base --check` |
| `e2e-feature` | `Bash` matching `ccam … up --qc` or `ccam … hook e2e` |
| `review` | `Skill` matching `code-review\|requesting-code-review\|receiving-code-review` |
| `qc` | `Agent` matching `qc-local`; `Bash` matching `ccam … lanes proof-link` |
| `gate` | `Agent` matching `senior-gate-reviewer`; `Skill` matching `verification-before-completion` |
| `publishing` | `Skill` matching `finishing-a-development-branch`; `Bash` matching a `git push` **invocation** (the same pattern `default.json`'s `ship` uses) |
| `pr-open` | `Bash` matching `gh … pr create` |
| `watching-pr` | `Bash` matching `gh … pr view` |
| `e2e-feature-passed`, `qc-plan`, `reported`, `merged`, `done` | none — declaration-only |
Two rules are deliberately absent from this template. There is **no `git diff`
rule on `review`**, for the reason the `default` template learned the hard way
below; the `code-review` skill invocation is the honest signal. And `merged` /
`done` carry no rule at all, because inference must never reach a terminal
state — a test pins that.
Detection here is a **safety net, not the mechanism**: the `ship-feature-lane`
skill declares every one of these stages with `ccam stage` itself. What
detection adds is the stage an agent forgot after a context compaction, the
heartbeat that keeps a working lane from reading STALLED, and coverage for a
lane running Superpowers skills without the driving skill at all. It cannot
substitute for the skill's own declarations: because `recordDetection` is
forward-only AND never overrides a higher declared stage, a fix-loop re-entry
that drops back to `gates` is invisible to detection — only the skill's
`ccam stage gates` moves the lane back down.
`intake`, `gate`, and `done` in `default.json` deliberately have no rules.
`intake` is where a lane starts — there is no tool event that means "just claimed," so there is
nothing to detect. `gate` and `done` are explicitly out of scope for nothing to detect. `gate` and `done` are explicitly out of scope for
inference (see the task's "Out of scope" list): a gate's pass/fail is a human inference (see the task's "Out of scope" list): a gate's pass/fail is a human
or skill decision, and `done` is the one state detection must never reach on or skill decision, and `done` is the one state detection must never reach on
@@ -1123,6 +1215,64 @@ The skill uses the `ship-feature` pipeline template, which defines the following
The Workspace page (`/run`) displays this template with nodes rendered in five states: `failed` (rejected), `current` (now), `done` (with evidence), `passed-no-evidence` (claimed or skipped), and `pending` (not reached). The Workspace page (`/run`) displays this template with nodes rendered in five states: `failed` (rejected), `current` (now), `done` (with evidence), `passed-no-evidence` (claimed or skipped), and `pending` (not reached).
### More stage names than nodes
Each node carries aliases, and they do two different jobs. Some absorb a
near-miss (`implement` for `implementing`). Others are **sub-states**: a name
the skill declares to say *why* the lane is sitting on a node, without adding a
node to the map.
This is the shape Shipyard converged on — its dashboard renders 13 nodes while
its `PHASES` table folds roughly 35 stage names onto them. A pipeline map is
read at a glance across many lanes at once, so it stays coarse; the stage name
is read one lane at a time, so it can be specific.
| Node | Near-miss aliases | Sub-states the skill declares |
|---|---|---|
| `intake` | `assigned`, `claimed`, `start` | `bootstrapping` |
| `plan` | `planning`, `brainstorm`, `design` | — |
| `implementing` | `implement`, `coding`, `build` | — |
| `gates` | `pre-push-gate`, `tests` | `migration-collision`, `sync-conflict` |
| `e2e-feature` | `e2e`, `live` | `booting`, `e2e-scoped` |
| `e2e-feature-passed` | `e2e-passed` | — |
| `review` | `reviewing`, `code-review`, `self-review` | — |
| `qc-plan` | — | — |
| `qc` | — | — |
| `gate` | `sr-gate`, `verify`, `verification` | `gate-blocked` |
| `publishing` | `push` | — |
| `pr-open` | `ship`, `pr`, `push-conflict` | `push-revalidate` |
| `reported` | — | — |
| `watching-pr` | — | `pr-comment-fix` |
| `merged` | — | — |
| `done` | `complete`, `completed` | — |
50 names over 16 nodes. Every one of them has a **source**: the skill declares
it, or `default.json` uses it (an agent moving between pipelines will type
what the other one taught it), or Shipyard's `PHASES` lists it. An alias with
no source is not "flexibility" — it is a synonym someone imagined, and the
`ccam stage` warning already catches a name that resolves to nothing, which is
better feedback than silently absorbing every plausible spelling. Twelve
sourceless aliases were written and then cut for exactly this reason.
Two constraints on this:
- **No alias may collide** with another node's id or alias. `phaseIdx` takes the
FIRST match, so a duplicate would silently resolve a declaration onto the
wrong node. A test asserts the whole template is collision-free.
- **Aliases of one node share one record slot.** `lane.stages` is keyed by the
declared string, and `stageRecords` resolves each key onto its node — so
declaring `migration-collision` and then `gates` leaves ONE record for that
node (the canonical `gates` one wins). A sub-state that needs to keep its own
`--evidence` separately has to be a real node, not an alias.
`integrate`, `dev-gates`, `e2e-on-dev` and `push-dev` are deliberately **not**
nodes here. Shipyard had them and retired them on 2026-07-16 (see the comment
above `PHASES` in its `dashboard/src/lib/constants.js`); it now folds those
names into the surviving phases so old feature cards still render. CCAM never
shipped them, so there is nothing to fold.
These nodes also carry `detect` rules; see "Stage detection → Where the rules live".
## Orchestration: what CCAM does NOT do ## Orchestration: what CCAM does NOT do
**CCAM does not chain, queue, retry, or evaluate gates.** **CCAM does not chain, queue, retry, or evaluate gates.**
+1 -4
View File
@@ -8,7 +8,7 @@
"name": "agent-dashboard", "name": "agent-dashboard",
"version": "1.4.6", "version": "1.4.6",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"cors": "^2.8.5", "cors": "^2.8.5",
@@ -33,9 +33,6 @@
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
}, },
"funding": {
"url": "https://github.com/sponsors/hoangsonww"
},
"optionalDependencies": { "optionalDependencies": {
"better-sqlite3": "^12.0.0" "better-sqlite3": "^12.0.0"
} }
+62
View File
@@ -488,6 +488,68 @@ describe("managed worktree provisioning", () => {
const missing = await request("GET", `/api/lanes/${lane.id}`); const missing = await request("GET", `/api/lanes/${lane.id}`);
assert.equal(missing.status, 404); assert.equal(missing.status, 404);
}); });
it("uses a caller-supplied branch name instead of deriving one from the slug", async () => {
const created = await request("POST", "/api/lanes/worktree", {
sourceRepo: SRC,
title: "Custom Branch",
base: "main",
branch: "custom/my-branch",
});
assert.equal(created.status, 202);
assert.equal(created.body.lane.branch, "custom/my-branch");
const lane = await waitForProvisioning(created.body.lane.id);
assert.equal(lane.status, "idle");
assert.equal(g(lane.cwd, "branch", "--show-current").trim(), "custom/my-branch");
});
it("rejects an invalid caller-supplied branch name before creating anything", async () => {
const response = await request("POST", "/api/lanes/worktree", {
sourceRepo: SRC,
title: "Bad Branch",
base: "main",
branch: "not a valid branch..name",
});
assert.equal(response.status, 400);
assert.equal(response.body.error.code, "EBADBRANCH");
});
});
describe("GET /api/lanes/browse", () => {
it("lists a directory's immediate subdirectories, marking git repos", async () => {
const response = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(ROOT)}`);
assert.equal(response.status, 200);
assert.equal(response.body.path, ROOT);
const names = response.body.entries.map((e) => e.name);
assert.ok(names.includes("src-repo"));
const srcRepoEntry = response.body.entries.find((e) => e.name === "src-repo");
assert.equal(srcRepoEntry.isGitRepo, true);
});
it("reports the parent directory, or null at the filesystem root", async () => {
const response = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(ROOT)}`);
assert.equal(response.body.parent, path.dirname(ROOT));
const rootResponse = await request("GET", "/api/lanes/browse?path=/");
assert.equal(rootResponse.body.parent, null);
});
it("rejects a path that does not exist or is not a directory", async () => {
const missing = await request(
"GET",
`/api/lanes/browse?path=${encodeURIComponent(path.join(ROOT, "does-not-exist"))}`
);
assert.equal(missing.status, 400);
assert.equal(missing.body.error.code, "ENOTFOUND");
const filePath = path.join(ROOT, "a-file.txt");
fs.writeFileSync(filePath, "hi\n");
const notADir = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(filePath)}`);
assert.equal(notADir.status, 400);
assert.equal(notADir.body.error.code, "ENOTADIR");
});
}); });
describe("destructive lane lifecycle actions", () => { describe("destructive lane lifecycle actions", () => {
+41 -1
View File
@@ -29,7 +29,30 @@ const profileLib = require("../lib/lane-profile");
const runtime = require("../lib/lane-runtime"); const runtime = require("../lib/lane-runtime");
const { isListening } = require("../lib/ports"); const { isListening } = require("../lib/ports");
after(() => fsMod.rmSync(SUITE_ROOT, { recursive: true, force: true })); /**
* Lanes whose boot hook actually spawned a detached process. `harness_spawn`
* children outlive the hook by design, so a test that throws before its own
* `downLane` or never calls one leaves a real server holding a real port
* AFTER the suite exits. The port pool is only ten wide per name, so ten such
* runs exhaust it and every later boot fails with EPORTBUSY: a leak in these
* tests reads as a failure in whichever test runs next.
*/
const bootedLanes = new Set();
// One teardown, in this order on purpose: downLane reads the pid files under
// LANES_ROOT, which lives inside SUITE_ROOT — removing the tree first would
// leave nothing to kill the children with.
after(async () => {
for (const id of bootedLanes) {
try {
const lane = lanesLib.getLane(id);
if (lane) await runtime.downLane(lane);
} catch {
/* already stopped, or torn down by the test itself */
}
}
fsMod.rmSync(SUITE_ROOT, { recursive: true, force: true });
});
let laneSeq = 0; let laneSeq = 0;
/** A lane row backed by a real directory, so profile lookup and mkdir work. */ /** A lane row backed by a real directory, so profile lookup and mkdir work. */
@@ -274,6 +297,7 @@ describe("lifecycle", () => {
const lane = makeLane(); const lane = makeLane();
serverProfile(lane, 19100); serverProfile(lane, 19100);
bootedLanes.add(lane.id);
const facts = await runtime.upLane(lanesLib.getLane(lane.id)); const facts = await runtime.upLane(lanesLib.getLane(lane.id));
assert.equal(facts.available, true); assert.equal(facts.available, true);
assert.equal(facts.up, true); assert.equal(facts.up, true);
@@ -301,6 +325,7 @@ describe("lifecycle", () => {
assert.equal(afterDown.up, false); assert.equal(afterDown.up, false);
assert.deepEqual(afterDown.services, []); assert.deepEqual(afterDown.services, []);
bootedLanes.delete(lane.id);
slots.releaseSlot(lane.id); slots.releaseSlot(lane.id);
}); });
@@ -377,12 +402,22 @@ describe("upLane qc option", () => {
].join("\n"), ].join("\n"),
}); });
bootedLanes.add(lane.id);
await runtime.upLane(lanesLib.getLane(lane.id), { qc: true }); await runtime.upLane(lanesLib.getLane(lane.id), { qc: true });
const booted = lanesLib.getLane(lane.id); const booted = lanesLib.getLane(lane.id);
const markerPath = pathMod.join(booted.cwd, "qc-marker.txt"); const markerPath = pathMod.join(booted.cwd, "qc-marker.txt");
const marker = fsMod.readFileSync(markerPath, "utf8"); const marker = fsMod.readFileSync(markerPath, "utf8");
assert.match(marker, /QC=from-qc/); assert.match(marker, /QC=from-qc/);
// Stop the stack BEFORE releasing the slot: downLane locates the pid file
// through the lane's slot directory, so a released slot orphans a running
// child with no way left to reach it. Asserting the port went quiet is what
// makes a broken teardown fail HERE instead of leaking a live server into
// the next run's port pool.
const bootedPort = booted.ports.web;
await runtime.downLane(lanesLib.getLane(lane.id));
assert.equal(await isListening(bootedPort), false, "boot hook's child outlived downLane");
bootedLanes.delete(lane.id);
slots.releaseSlot(lane.id); slots.releaseSlot(lane.id);
}); });
@@ -408,12 +443,17 @@ describe("upLane qc option", () => {
].join("\n"), ].join("\n"),
}); });
bootedLanes.add(lane.id);
await runtime.upLane(lanesLib.getLane(lane.id)); await runtime.upLane(lanesLib.getLane(lane.id));
const booted = lanesLib.getLane(lane.id); const booted = lanesLib.getLane(lane.id);
const markerPath = pathMod.join(booted.cwd, "qc-marker-no-qc.txt"); const markerPath = pathMod.join(booted.cwd, "qc-marker-no-qc.txt");
const marker = fsMod.readFileSync(markerPath, "utf8"); const marker = fsMod.readFileSync(markerPath, "utf8");
assert.match(marker, /QC=not-set/); assert.match(marker, /QC=not-set/);
const bootedPort = booted.ports.web;
await runtime.downLane(lanesLib.getLane(lane.id));
assert.equal(await isListening(bootedPort), false, "boot hook's child outlived downLane");
bootedLanes.delete(lane.id);
slots.releaseSlot(lane.id); slots.releaseSlot(lane.id);
}); });
}); });
+54
View File
@@ -186,6 +186,31 @@ describe("ccam stage", () => {
assert.notEqual(r.status, 0); assert.notEqual(r.status, 0);
assert.match(`${r.stdout}${r.stderr}`, /no lane/i); assert.match(`${r.stdout}${r.stderr}`, /no lane/i);
}); });
it("warns, but still records, a stage name matching no pipeline node", async () => {
// setStage stores the string verbatim, so a typo'd stage is accepted and
// then renders nowhere (phaseIdx -1, progress 0). Silent acceptance is how
// a lane ends up looking unstarted for an entire pipeline run.
const r = await cli(["stage", "revieww"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /revieww/);
assert.match(r.stderr, /matches no node/i);
assert.match(r.stderr, /revieww/);
});
it("does not warn for a stage declared by alias", async () => {
const r = await cli(["stage", "planning"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
assert.doesNotMatch(r.stderr, /matches no node/i);
});
it("does not warn for a valid stage reported as failed", async () => {
// --result fail paints the node `failed`, not `current`; the warning must
// not read that as an unknown stage.
const r = await cli(["stage", "review", "--result", "fail"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
assert.doesNotMatch(r.stderr, /matches no node/i);
});
}); });
describe("ccam lanes add", () => { describe("ccam lanes add", () => {
@@ -248,6 +273,35 @@ describe("ccam lanes add", () => {
} }
}); });
it("switches an existing lane's pipeline, and shows it when given no target", async () => {
// `--pipeline` was creation-only, so every lane added from the dashboard
// was pinned to `default`'s 8 nodes with no way to reach a longer template.
const show = await cli(["lanes", "pipeline"], LANE_DIR);
assert.equal(show.status, 0, show.stderr);
assert.match(show.stdout, /default/);
assert.match(show.stdout, /available:.*ship-feature/);
const set = await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR);
assert.equal(set.status, 0, set.stderr);
assert.match(set.stdout, /ship-feature/);
assert.match(set.stdout, /16 nodes/);
const back = await cli(["lanes", "pipeline", "default"], LANE_DIR);
assert.equal(back.status, 0, back.stderr);
assert.match(back.stdout, /8 nodes/);
});
it("refuses an unknown pipeline id instead of silently falling back to default", async () => {
// getPipeline() returns the default template for an unknown id, so without
// a write-side check a typo would store, render `default`, and look fine.
const r = await cli(["lanes", "pipeline", "no-such-pipeline"], LANE_DIR);
assert.notEqual(r.status, 0);
assert.match(`${r.stdout}${r.stderr}`, /unknown pipeline/i);
const after = await cli(["lanes", "pipeline"], LANE_DIR);
assert.match(after.stdout, /default/);
});
it("provisions a managed worktree lane and reports it ready", async () => { it("provisions a managed worktree lane and reports it ready", async () => {
const r = await cli( const r = await cli(
["lanes", "add", "--repo", SOURCE_REPO, "--title", "CLI worktree", "--slug", "cli-worktree"], ["lanes", "add", "--repo", SOURCE_REPO, "--title", "CLI worktree", "--slug", "cli-worktree"],
+38
View File
@@ -77,6 +77,44 @@ describe("pipelines", () => {
assert.equal(byId.plan, "passed-no-evidence"); assert.equal(byId.plan, "passed-no-evidence");
}); });
it("keeps the evidence of a stage declared under an alias", () => {
// `lane.stages` is keyed by the raw declared string, so `ccam stage
// planning --evidence x` files the record under "planning" while the node
// is "plan". Looking it up by node id alone loses the evidence and paints
// a `done` node amber — silently, and only for agents that use an alias.
const p = getPipeline(DEFAULT_PIPELINE_ID);
const lane = {
stage: "review",
stages: { planning: { enteredAt: "2026-07-27T00:00:00Z", evidence: "docs/plan.md" } },
};
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
assert.equal(byId.plan, "done");
});
it("prefers a node-id record over an alias record for the same node", () => {
const p = getPipeline(DEFAULT_PIPELINE_ID);
const lane = {
stage: "review",
stages: {
planning: { enteredAt: "2026-07-27T00:00:00Z", evidence: null },
plan: { enteredAt: "2026-07-27T00:30:00Z", evidence: "docs/plan.md" },
},
};
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
assert.equal(byId.plan, "done");
});
it("ignores a recorded stage matching no node instead of shifting the others", () => {
const p = getPipeline(DEFAULT_PIPELINE_ID);
const lane = {
stage: "review",
stages: { "totally-unknown": { enteredAt: "x", evidence: "y" } },
};
const byId = Object.fromEntries(nodeStates(p, lane).map((n) => [n.id, n.state]));
assert.equal(byId.plan, "passed-no-evidence");
assert.equal(byId.review, "current");
});
it("computes progress from node position, 0 for an unknown stage", () => { it("computes progress from node position, 0 for an unknown stage", () => {
const p = getPipeline(DEFAULT_PIPELINE_ID); const p = getPipeline(DEFAULT_PIPELINE_ID);
assert.equal(progressPct(p, { stage: p.nodes[0].id, stages: {} }), 0); assert.equal(progressPct(p, { stage: p.nodes[0].id, stages: {} }), 0);
+134
View File
@@ -482,4 +482,138 @@ test.describe("ship-feature pipeline template", () => {
const ids = pipeline.nodes.map((n) => n.id); const ids = pipeline.nodes.map((n) => n.id);
assert.deepEqual(ids, [...new Set(ids)]); assert.deepEqual(ids, [...new Set(ids)]);
}); });
test.it("no alias collides with another node's id or with a second node's alias", () => {
// phaseIdx() takes the FIRST node whose id or alias matches, so a duplicate
// silently resolves a declaration onto the wrong node — the failure mode
// aliases are supposed to prevent.
const { reload } = require("../lib/pipelines");
reload();
const pipeline = getPipeline("ship-feature");
const seen = new Map();
for (const n of pipeline.nodes) {
for (const name of [n.id, ...n.aliases]) {
const key = name.toLowerCase();
assert.equal(
seen.has(key),
false,
`"${name}" claimed by both ${seen.get(key)} and ${n.id}`
);
seen.set(key, n.id);
}
}
});
// Same discipline as default.json's end-to-end rule sweep: a rule pinned only
// against a fixture can ship inert, and these fire on a live lane's hooks.
test.it("every shipped ship-feature.json rule fires end-to-end via getPipeline", () => {
const { reload } = require("../lib/pipelines");
reload();
const pipeline = getPipeline("ship-feature");
const cases = [
["intake", "Skill", { skill: "superpowers:brainstorming" }],
["intake", "Bash", { command: "ccam feature activate lane-stage-detect" }],
["plan", "Skill", { skill: "superpowers:writing-plans" }],
["plan", "Write", { file_path: "docs/superpowers/specs/lane-foo.md" }],
["implementing", "Skill", { skill: "superpowers:test-driven-development" }],
["implementing", "Skill", { skill: "superpowers:systematic-debugging" }],
["implementing", "Edit", { file_path: "server/lib/app.js" }],
["implementing", "Write", { file_path: "server/lib/app.js" }],
["gates", "Bash", { command: "ccam lanes hook ci-gate" }],
["gates", "Bash", { command: "ccam lanes sync-base --check feat/foo" }],
["e2e-feature", "Bash", { command: "ccam lanes up --qc" }],
["e2e-feature", "Bash", { command: "ccam lanes hook e2e" }],
["review", "Skill", { skill: "code-review" }],
["review", "Skill", { skill: "superpowers:requesting-code-review" }],
["qc", "Agent", { subagent_type: "qc-local" }],
["qc", "Bash", { command: "ccam lanes proof-link" }],
["gate", "Agent", { subagent_type: "senior-gate-reviewer" }],
["gate", "Skill", { skill: "superpowers:verification-before-completion" }],
["publishing", "Skill", { skill: "superpowers:finishing-a-development-branch" }],
["publishing", "Bash", { command: "git push -u origin feat/foo" }],
["pr-open", "Bash", { command: "gh pr create --base development --fill" }],
["watching-pr", "Bash", { command: "gh pr view https://x/pull/1 --json state" }],
];
for (const [nodeId, tool_name, tool_input] of cases) {
const got = detect(pipeline, { tool_name, tool_input });
assert.equal(got && got.nodeId, nodeId, `${tool_name} ${JSON.stringify(tool_input)}`);
}
});
test.it("every sub-state the skill declares resolves onto its node", () => {
// These names exist so a skill can say WHY a lane sits on a node without
// the map growing a node per reason (Shipyard's PHASES shape: ~35 stage
// names over 13 nodes). An unresolvable one is worse than no alias: it
// records, renders nowhere, and only warns.
const { reload, phaseIdx } = require("../lib/pipelines");
reload();
const pipeline = getPipeline("ship-feature");
const subStates = {
bootstrapping: "intake",
"migration-collision": "gates",
"sync-conflict": "gates",
booting: "e2e-feature",
"e2e-scoped": "e2e-feature",
live: "e2e-feature",
"gate-blocked": "gate",
"push-conflict": "pr-open",
"push-revalidate": "pr-open",
"pr-comment-fix": "watching-pr",
};
for (const [declared, nodeId] of Object.entries(subStates)) {
assert.equal(
phaseIdx(pipeline, declared),
pipeline.nodes.findIndex((n) => n.id === nodeId),
`${declared} must resolve to ${nodeId}`
);
}
});
test.it("does not stamp implementing for an edit to the lane spec", () => {
// Stage 5 appends the QC Plan to docs/superpowers/specs/lane-<slug>.md.
// Without the exclusion that edit reads as code work, and because
// `implementing` sits early in the pipeline the mis-read is only invisible
// by luck (forward-only) — on a fix-loop re-entry it would be the live stage.
const { reload } = require("../lib/pipelines");
reload();
const pipeline = getPipeline("ship-feature");
for (const tool_name of ["Edit", "Write"]) {
const r = detect(pipeline, {
tool_name,
tool_input: { file_path: "/lanes/lane1/docs/superpowers/specs/lane-foo.md" },
});
assert.notEqual(r && r.nodeId, "implementing", tool_name);
}
});
test.it("never infers a terminal stage — merged and done are declaration-only", () => {
// CLAUDE.md: an inferred node never renders `done`. Shipping a detect rule
// for these nodes would be the one way to break that from the data side.
const { reload } = require("../lib/pipelines");
reload();
const pipeline = getPipeline("ship-feature");
for (const id of ["merged", "done", "e2e-feature-passed", "reported", "qc-plan"]) {
const node = pipeline.nodes.find((n) => n.id === id);
assert.deepEqual(node.detect, [], `${id} must carry no detect rule`);
}
});
test.it("does not read the word push, or an unrelated ccam call, as a later stage", () => {
const { reload } = require("../lib/pipelines");
reload();
const pipeline = getPipeline("ship-feature");
for (const command of [
'git commit -m "do not push this yet"',
"ccam lanes logs 3 e2e",
"ccam stage gates --status running",
"gh pr diff 42",
]) {
const r = detect(pipeline, { tool_name: "Bash", tool_input: { command } });
assert.equal(
["publishing", "pr-open", "watching-pr", "e2e-feature"].includes(r && r.nodeId),
false,
`${command}${r && r.nodeId}`
);
}
});
}); });
+8
View File
@@ -45,6 +45,10 @@
"build" "build"
], ],
"detect": [ "detect": [
{
"tool": "Skill",
"match": "executing-plans|subagent-driven-development"
},
{ {
"tool": "Edit" "tool": "Edit"
}, },
@@ -118,6 +122,10 @@
"push" "push"
], ],
"detect": [ "detect": [
{
"tool": "Skill",
"match": "finishing-a-development-branch"
},
{ {
"tool": "Bash", "tool": "Bash",
"match": "\\bgit\\b(?:\\s+-c\\s+[\\w.-]+=(?:'[^']*'|\\\"[^\\\"]*\\\"|\\S+)|\\s+-{1,2}[\\w.-]+(?:=(?:'[^']*'|\\\"[^\\\"]*\\\"|\\S+))?)*\\s+push\\b|\\bgh\\b[^;&|]*\\bpr create\\b" "match": "\\bgit\\b(?:\\s+-c\\s+[\\w.-]+=(?:'[^']*'|\\\"[^\\\"]*\\\"|\\S+)|\\s+-{1,2}[\\w.-]+(?:=(?:'[^']*'|\\\"[^\\\"]*\\\"|\\S+))?)*\\s+push\\b|\\bgh\\b[^;&|]*\\bpr create\\b"
+252 -16
View File
@@ -2,21 +2,257 @@
"id": "ship-feature", "id": "ship-feature",
"name": "Ship feature (lane pipeline)", "name": "Ship feature (lane pipeline)",
"nodes": [ "nodes": [
{ "id": "intake", "label": "intake", "icon": "📝", "gate": false, "aliases": [] }, {
{ "id": "plan", "label": "plan", "icon": "🧭", "gate": false, "aliases": [] }, "id": "intake",
{ "id": "implementing", "label": "implement (TDD)", "icon": "🛠", "gate": false, "aliases": [] }, "label": "intake",
{ "id": "gates", "label": "CI gates + preflight", "icon": "🧪", "gate": true, "aliases": [] }, "icon": "📝",
{ "id": "e2e-feature", "label": "e2e on feature branch", "icon": "🧪", "gate": false, "aliases": [] }, "gate": false,
{ "id": "e2e-feature-passed", "label": "e2e passed", "icon": "🧪", "gate": true, "aliases": [] }, "aliases": [
{ "id": "review", "label": "code review", "icon": "👀", "gate": true, "aliases": [] }, "assigned",
{ "id": "qc-plan", "label": "QC plan", "icon": "📋", "gate": false, "aliases": [] }, "claimed",
{ "id": "qc", "label": "browser QC", "icon": "🔍", "gate": true, "aliases": [] }, "start",
{ "id": "gate", "label": "senior GO/NO-GO gate", "icon": "🚦", "gate": true, "aliases": [] }, "bootstrapping"
{ "id": "publishing", "label": "publish PR", "icon": "🔀", "gate": false, "aliases": [] }, ],
{ "id": "pr-open", "label": "PR open", "icon": "🔀", "gate": false, "aliases": ["ship"] }, "detect": [
{ "id": "reported", "label": "reported", "icon": "📣", "gate": false, "aliases": [] }, {
{ "id": "watching-pr", "label": "watching PR", "icon": "👁", "gate": false, "aliases": [] }, "tool": "Skill",
{ "id": "merged", "label": "merged — post-verify", "icon": "🔗", "gate": false, "aliases": [] }, "match": "brainstorming"
{ "id": "done", "label": "done", "icon": "✅", "gate": false, "aliases": ["complete", "completed"] } },
{
"tool": "Bash",
"match": "\\bccam\\b[^;&|]*\\bfeature activate\\b"
}
]
},
{
"id": "plan",
"label": "plan",
"icon": "🧭",
"gate": false,
"aliases": [
"planning",
"brainstorm",
"design"
],
"detect": [
{
"tool": "Skill",
"match": "writing-plans"
},
{
"tool": "Write",
"match": "docs/superpowers/specs/lane-.*\\.md"
}
]
},
{
"id": "implementing",
"label": "implement (TDD)",
"icon": "🛠",
"gate": false,
"aliases": [
"implement",
"coding",
"build"
],
"detect": [
{
"tool": "Skill",
"match": "test-driven-development|executing-plans|subagent-driven-development|systematic-debugging"
},
{
"tool": "Edit",
"match": "^(?!.*docs/superpowers/specs/)"
},
{
"tool": "Write",
"match": "^(?!.*(?:^|/)docs/)"
}
]
},
{
"id": "gates",
"label": "CI gates + preflight",
"icon": "🧪",
"gate": true,
"aliases": [
"pre-push-gate",
"tests",
"migration-collision",
"sync-conflict"
],
"detect": [
{
"tool": "Bash",
"match": "\\bccam\\b[^;&|]*(\\bhook ci-gate\\b|\\bsync-base --check\\b)"
}
]
},
{
"id": "e2e-feature",
"label": "e2e on feature branch",
"icon": "🧪",
"gate": false,
"aliases": [
"e2e",
"e2e-scoped",
"booting",
"live"
],
"detect": [
{
"tool": "Bash",
"match": "\\bccam\\b[^;&|]*(\\bup --qc\\b|\\bhook e2e\\b)"
}
]
},
{
"id": "e2e-feature-passed",
"label": "e2e passed",
"icon": "🧪",
"gate": true,
"aliases": [
"e2e-passed"
]
},
{
"id": "review",
"label": "code review",
"icon": "👀",
"gate": true,
"aliases": [
"reviewing",
"code-review",
"self-review"
],
"detect": [
{
"tool": "Skill",
"match": "code-review|requesting-code-review|receiving-code-review"
}
]
},
{
"id": "qc-plan",
"label": "QC plan",
"icon": "📋",
"gate": false,
"aliases": []
},
{
"id": "qc",
"label": "browser QC",
"icon": "🔍",
"gate": true,
"aliases": [],
"detect": [
{
"tool": "Agent",
"match": "qc-local"
},
{
"tool": "Bash",
"match": "\\bccam\\b[^;&|]*\\blanes proof-link\\b"
}
]
},
{
"id": "gate",
"label": "senior GO/NO-GO gate",
"icon": "🚦",
"gate": true,
"aliases": [
"sr-gate",
"verify",
"verification",
"gate-blocked"
],
"detect": [
{
"tool": "Agent",
"match": "senior-gate-reviewer"
},
{
"tool": "Skill",
"match": "verification-before-completion"
}
]
},
{
"id": "publishing",
"label": "publish PR",
"icon": "🔀",
"gate": false,
"aliases": [
"push"
],
"detect": [
{
"tool": "Skill",
"match": "finishing-a-development-branch"
},
{
"tool": "Bash",
"match": "\\bgit\\b(?:\\s+-c\\s+[\\w.-]+=(?:'[^']*'|\\\"[^\\\"]*\\\"|\\S+)|\\s+-{1,2}[\\w.-]+(?:=(?:'[^']*'|\\\"[^\\\"]*\\\"|\\S+))?)*\\s+push\\b"
}
]
},
{
"id": "pr-open",
"label": "PR open",
"icon": "🔀",
"gate": false,
"aliases": [
"ship",
"pr",
"push-conflict",
"push-revalidate"
],
"detect": [
{
"tool": "Bash",
"match": "\\bgh\\b[^;&|]*\\bpr create\\b"
}
]
},
{
"id": "reported",
"label": "reported",
"icon": "📣",
"gate": false,
"aliases": []
},
{
"id": "watching-pr",
"label": "watching PR",
"icon": "👁",
"gate": false,
"aliases": [
"pr-comment-fix"
],
"detect": [
{
"tool": "Bash",
"match": "\\bgh\\b[^;&|]*\\bpr view\\b"
}
]
},
{
"id": "merged",
"label": "merged — post-verify",
"icon": "🔗",
"gate": false,
"aliases": []
},
{
"id": "done",
"label": "done",
"icon": "✅",
"gate": false,
"aliases": [
"complete",
"completed"
]
}
] ]
} }
+28 -3
View File
@@ -10,7 +10,14 @@
*/ */
const { db } = require("../db"); const { db } = require("../db");
const { getPipeline, phaseIdx, nodeStates, progressPct } = require("./pipelines"); const {
listPipelines,
getPipeline,
phaseIdx,
stageRecords,
nodeStates,
progressPct,
} = require("./pipelines");
const DEAD_SEC = Number(process.env.LANE_DEAD_SEC || 300); const DEAD_SEC = Number(process.env.LANE_DEAD_SEC || 300);
/** /**
@@ -88,6 +95,18 @@ function validateKind(kind) {
} }
} }
/**
* `getPipeline` falls back to the default template for an unknown id correct
* when READING (a lane must always render something), wrong when WRITING: a
* typo'd id would be accepted, stored, and then silently draw the default map
* forever. Reject it at the write, where the caller can still be told.
*/
function validatePipeline(id) {
if (!listPipelines().some((p) => p.id === id)) {
throw Object.assign(new Error(`unknown pipeline: ${id}`), { code: "EBADPIPELINE" });
}
}
function hydrate(row) { function hydrate(row) {
if (!row) return null; if (!row) return null;
let stages = {}; let stages = {};
@@ -125,6 +144,7 @@ function createLane({
throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" }); throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" });
} }
validateKind(kind); validateKind(kind);
validatePipeline(pipeline);
const info = db const info = db
.prepare( .prepare(
"INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, stage_since) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" "INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, stage_since) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
@@ -156,6 +176,9 @@ function updateLane(id, patch = {}) {
if ("kind" in patch && patch.kind !== null && patch.kind !== undefined) { if ("kind" in patch && patch.kind !== null && patch.kind !== undefined) {
validateKind(patch.kind); validateKind(patch.kind);
} }
if ("pipeline" in patch && patch.pipeline !== null && patch.pipeline !== undefined) {
validatePipeline(patch.pipeline);
}
const cols = []; const cols = [];
const vals = []; const vals = [];
for (const [k, v] of Object.entries(patch)) { for (const [k, v] of Object.entries(patch)) {
@@ -441,14 +464,16 @@ function classifyLiveness({ status, stage, ageSec }, deadSec = DEAD_SEC) {
* its own id: declaring by ALIAS (`ccam stage coding` the `implement` node) * its own id: declaring by ALIAS (`ccam stage coding` the `implement` node)
* keys `stages` by the raw declared string, so the node the agent says it is on * keys `stages` by the raw declared string, so the node the agent says it is on
* would otherwise render as an inference instead of the blue `current` ring. * would otherwise render as an inference instead of the blue `current` ring.
* Past nodes go through `stageRecords`, which resolves those alias keys a
* node the agent DECLARED must never be painted as merely detected.
*/ */
function withDetected(states, pipeline, lane) { function withDetected(states, pipeline, lane) {
const detectedIdx = phaseIdx(pipeline, lane.detected_stage); const detectedIdx = phaseIdx(pipeline, lane.detected_stage);
if (detectedIdx === -1) return states.map((n) => ({ ...n, detected: false })); if (detectedIdx === -1) return states.map((n) => ({ ...n, detected: false }));
const stages = lane.stages || {}; const records = stageRecords(pipeline, lane.stages);
return states.map((n, i) => ({ return states.map((n, i) => ({
...n, ...n,
detected: i <= detectedIdx && !stages[n.id] && n.state !== "current", detected: i <= detectedIdx && !records.has(i) && n.state !== "current",
})); }));
} }
+24 -2
View File
@@ -77,6 +77,27 @@ function phaseIdx(pipeline, stage) {
); );
} }
/**
* Index every recorded stage by the node it resolves to, so a stage declared
* under an ALIAS keeps its record and therefore its evidence. Keying the
* records by node id alone (what `lane.stages` is keyed by, verbatim from the
* declaration) silently drops `--evidence` the moment an agent says `e2e`
* instead of `e2e-feature`, which is exactly what aliases exist to allow.
* A record stored under the node's own id always wins over an alias record for
* the same node; a key matching no node is skipped.
*/
function stageRecords(pipeline, stages) {
const byIdx = new Map();
for (const [key, rec] of Object.entries(stages || {})) {
const i = phaseIdx(pipeline, key);
if (i === -1) continue;
const isCanonical = key.toLowerCase() === pipeline.nodes[i].id.toLowerCase();
if (byIdx.has(i) && !isCanonical) continue;
byIdx.set(i, rec);
}
return byIdx;
}
/** /**
* Render state per node: * Render state per node:
* failed the stage recorded result "fail" * failed the stage recorded result "fail"
@@ -86,10 +107,10 @@ function phaseIdx(pipeline, stage) {
* pending not reached * pending not reached
*/ */
function nodeStates(pipeline, lane) { function nodeStates(pipeline, lane) {
const stages = lane.stages || {}; const records = stageRecords(pipeline, lane.stages);
const cur = phaseIdx(pipeline, lane.stage); const cur = phaseIdx(pipeline, lane.stage);
return pipeline.nodes.map((n, i) => { return pipeline.nodes.map((n, i) => {
const rec = stages[n.id]; const rec = records.get(i);
let state; let state;
if (rec && rec.result === "fail") state = "failed"; if (rec && rec.result === "fail") state = "failed";
else if (i === cur) state = "current"; else if (i === cur) state = "current";
@@ -111,6 +132,7 @@ module.exports = {
listPipelines, listPipelines,
getPipeline, getPipeline,
phaseIdx, phaseIdx,
stageRecords,
nodeStates, nodeStates,
progressPct, progressPct,
reload, reload,
+17 -5
View File
@@ -78,6 +78,21 @@ async function isGitRepo(dir) {
} }
} }
/**
* Whether `name` is a legal git branch name, per git's own rules rather than
* a hand-rolled regex. `cwd` need not be `sourceRepo` specifically the
* check is not repo-dependent but `git()` requires a directory to run in.
*/
async function isValidBranchName(cwd, name) {
if (typeof name !== "string" || !name) return false;
try {
await git(cwd, ["check-ref-format", "--branch", name]);
return true;
} catch {
return false;
}
}
/** /**
* List a repo's local branches plus its current HEAD branch, so a caller can * List a repo's local branches plus its current HEAD branch, so a caller can
* offer a real picker instead of asking someone to remember a branch name. * offer a real picker instead of asking someone to remember a branch name.
@@ -85,11 +100,7 @@ async function isGitRepo(dir) {
* can actually check a new worktree out onto without a fetch first. * can actually check a new worktree out onto without a fetch first.
*/ */
async function listBranches(sourceRepo) { async function listBranches(sourceRepo) {
const result = await git(sourceRepo, [ const result = await git(sourceRepo, ["for-each-ref", "--format=%(refname:short)", "refs/heads"]);
"for-each-ref",
"--format=%(refname:short)",
"refs/heads",
]);
const branches = result.stdout const branches = result.stdout
.split("\n") .split("\n")
.map((line) => line.trim()) .map((line) => line.trim())
@@ -643,6 +654,7 @@ module.exports = {
LANES_ROOT, LANES_ROOT,
git, git,
isGitRepo, isGitRepo,
isValidBranchName,
listBranches, listBranches,
resolveBase, resolveBase,
slugify, slugify,
+66 -7
View File
@@ -10,6 +10,7 @@
const { Router } = require("express"); const { Router } = require("express");
const fs = require("node:fs"); const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path"); const path = require("node:path");
const { db } = require("../db"); const { db } = require("../db");
const lanesLib = require("../lib/lanes"); const lanesLib = require("../lib/lanes");
@@ -25,6 +26,7 @@ const {
addWorktree, addWorktree,
gitFacts, gitFacts,
isGitRepo, isGitRepo,
isValidBranchName,
listBranches, listBranches,
removeWorktree, removeWorktree,
resetWorktree, resetWorktree,
@@ -159,11 +161,15 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
const owner = lanesLib.resolveLaneByCwd(body.cwd); const owner = lanesLib.resolveLaneByCwd(body.cwd);
if (owner) return res.json({ lane: payload(owner), created: false }); if (owner) return res.json({ lane: payload(owner), created: false });
try { try {
const lane = lanesLib.createLane({ cwd: body.cwd, title: body.title || "" }); const lane = lanesLib.createLane({
cwd: body.cwd,
title: body.title || "",
...(body.pipeline ? { pipeline: body.pipeline } : {}),
});
broadcastLane(lane.id); broadcastLane(lane.id);
return res.status(201).json({ lane: payload(lane), created: true }); return res.status(201).json({ lane: payload(lane), created: true });
} catch (err) { } catch (err) {
if (err.code === "EBADCWD") { if (err.code === "EBADCWD" || err.code === "EBADPIPELINE") {
return res.status(400).json({ error: { code: err.code, message: err.message } }); return res.status(400).json({ error: { code: err.code, message: err.message } });
} }
// The cwd UNIQUE constraint is the arbiter: someone else won the race, so // The cwd UNIQUE constraint is the arbiter: someone else won the race, so
@@ -193,6 +199,47 @@ router.post("/gc", sameOriginGuard, (req, res) => {
} }
}); });
/**
* Directory listing for the Add Lane modal's folder browser browsers cannot
* expose absolute filesystem paths from a native picker, so path selection is
* done by browsing server-side instead. Read-only; registered ahead of
* "/:id" so the literal "browse" segment is never captured as a lane id.
*/
router.get("/browse", (req, res) => {
const raw =
typeof req.query.path === "string" && req.query.path.trim() ? req.query.path : os.homedir();
const resolved = path.resolve(raw);
let stat;
try {
stat = fs.statSync(resolved);
} catch {
return res.status(400).json({ error: { code: "ENOTFOUND", message: "path does not exist" } });
}
if (!stat.isDirectory()) {
return res
.status(400)
.json({ error: { code: "ENOTADIR", message: "path is not a directory" } });
}
let entries = [];
try {
entries = fs
.readdirSync(resolved, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
.map((e) => {
const full = path.join(resolved, e.name);
return { name: e.name, path: full, isGitRepo: fs.existsSync(path.join(full, ".git")) };
})
.sort((a, b) => a.name.localeCompare(b.name));
} catch {
// An unreadable entry mid-listing is skipped, not a request failure.
}
const parent = path.dirname(resolved) === resolved ? null : path.dirname(resolved);
res.json({ path: resolved, parent, entries });
});
router.get("/:id", (req, res) => { router.get("/:id", (req, res) => {
const lane = lanesLib.getLane(req.params.id); const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } }); if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
@@ -205,7 +252,7 @@ router.post("/", sameOriginGuard, (req, res) => {
broadcastLane(lane.id); broadcastLane(lane.id);
res.status(201).json({ lane: payload(lane) }); res.status(201).json({ lane: payload(lane) });
} catch (err) { } catch (err) {
if (err.code === "EBADCWD") { if (err.code === "EBADCWD" || err.code === "EBADPIPELINE" || err.code === "EBADKIND") {
return res.status(400).json({ error: { code: err.code, message: err.message } }); return res.status(400).json({ error: { code: err.code, message: err.message } });
} }
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) { if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
@@ -225,9 +272,10 @@ router.patch("/:id", sameOriginGuard, (req, res) => {
try { try {
lane = lanesLib.updateLane(req.params.id, req.body || {}); lane = lanesLib.updateLane(req.params.id, req.body || {});
} catch (err) { } catch (err) {
// A bad `kind` is invalid input, not a server fault — every sibling route // A bad `kind` or `pipeline` is invalid input, not a server fault — every
// answers 400 here, so this one must too instead of throwing into Express. // sibling route answers 400 here, so this one must too instead of throwing
if (err.code === "EBADKIND") { // into Express.
if (err.code === "EBADKIND" || err.code === "EBADPIPELINE") {
return res.status(400).json({ error: { code: err.code, message: err.message } }); return res.status(400).json({ error: { code: err.code, message: err.message } });
} }
return res.status(500).json({ error: { code: err.code, message: err.message } }); return res.status(500).json({ error: { code: err.code, message: err.message } });
@@ -503,8 +551,18 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
}); });
} }
let branch;
if (body.branch !== undefined) {
if (!(await isValidBranchName(resolvedSourceRepo, body.branch))) {
return res.status(400).json({
error: { code: "EBADBRANCH", message: "branch is not a valid git branch name" },
});
}
branch = body.branch;
} else {
const branchPrefix = process.env.LANE_BRANCH_PREFIX || "feat/"; const branchPrefix = process.env.LANE_BRANCH_PREFIX || "feat/";
const branch = `${branchPrefix}${slug}`; branch = `${branchPrefix}${slug}`;
}
let lane; let lane;
try { try {
lane = lanesLib.createLane({ lane = lanesLib.createLane({
@@ -515,6 +573,7 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
source_repo: resolvedSourceRepo, source_repo: resolvedSourceRepo,
base_branch: body.base || null, base_branch: body.base || null,
slug, slug,
...(body.pipeline ? { pipeline: body.pipeline } : {}),
}); });
lane = lanesLib.updateLane(lane.id, { status: "provisioning" }); lane = lanesLib.updateLane(lane.id, { status: "provisioning" });
} catch (err) { } catch (err) {