feat(effort-worktrees): key worktrees on an issue or an effort slug #409

Merged
mph merged 12 commits from effort-worktrees into main 2026-07-27 08:55:09 +00:00
Owner

Summary

iw start could only create a worktree for a tracker issue. The interactive slice loop's
sl-define --local (iterative-works plugin v1.48.0) needs the opposite: a worktree keyed on an
effort — a human-named branch with no tracker issue, hosting N slices that ship as one PR.

Before this, that flow meant git worktree add by hand, tmux by hand, no dashboard card, and — the
real cost — iw rm could not tear it down, so the build-tool cleanup hooks never ran.

This adds iw start --effort <slug> and carries the new key through the CLI and the dashboard.

Design

Worktree identity and tracker issue were the same concept. They are now split:

enum WorktreeKey:
  case Issue(id: IssueId)
  case Effort(slug: EffortSlug)

EffortSlug rejects anything IssueId.fromBranch accepts, so iw-399-followup cannot silently
resolve to issue IW-399. The guard is single-sourced against the real parser rather than a second
regex that could drift from it.

Contract with the slice loop (plugin v1.48.0, already shipped)

  1. branch name == effort slug == project-management/efforts/<slug>/ directory name
  2. project-management/.active-slice — untracked, holds the repo-relative path of the active slice directory
  3. a local slice's review-state lives at <slice-dir>/review-state.json with issue_id = the effort slug
  4. layout: efforts/<slug>/slices/<NN>-<slice-slug>/{card.md,tracker.md,review-state.json}

Issue-keyed worktrees and issue-keyed slices are untouched — this is additive.

What changed

Core — new EffortSlug and WorktreeKey; WorktreePath keyed on either. --effort on start,
grounded on the effort directory being committed on HEAD (a worktree is cut from HEAD, so an
uncommitted effort dir would produce a worktree where sl-define --local immediately fails).
open / rm / status accept a slug. review-state resolves its default path through
.active-slice and refuses to guess when the pointer is absent, instead of writing under
project-management/issues/. worktrees and register derive the parent project key-aware.

Dashboard — registration carries the worktree kind; a state file without the field reads as an
issue worktree, so existing state.json keeps working. Effort worktrees are never polled for a
tracker issue and never probed for phase task files. Their review-state resolves via .active-slice
and is cleared when sl-close --local removes the pointer. Both kinds render through shared card
chrome.

Verification

core.compile / dashboard.compile (forced fresh) · core.test 2051 assertions ·
dashboard.test 745 · dashboard.itest.testForked · full 42-file BATS suite, 169 ok / 0 not ok ·
scala-cli compile --scalac-option -Werror core/.

Also hand-run in a throwaway repo with HOME, IW_SERVER_DISABLED, IW_PLUGINS_DISABLED and
IW_TMUX_SOCKET redirected: worktree + branch + tmux session created; uncommitted effort rejected
with no branch left behind; iw worktrees lists the effort from inside its own worktree;
review-state with no --output lands in the .active-slice directory carrying
"issue_id": "<slug>"; and nothing is ever written under project-management/issues/.

Two commits here are unrelated to effort worktrees

Kept on this branch deliberately rather than split, but flagged so review can treat them separately:

  • 23a6a3fiw-run scanned installed plugins on every invocation, so a plugin on the
    developer's machine leaked into the E2E suite and start-prompt.bats could never observe the
    warning it asserts. Adds IW_PLUGINS_DISABLED=1 to the BATS setups. The suite cannot pass
    hermetically without it.
  • 93b3ad1buildFetchFunction / buildSearchFunction had no Forgejo branch, so
    /api/issues/recent and /api/issues/search answered 500 with a MatchError on a
    Forgejo-configured project — i.e. on this repo's own dashboard. Forgejo now joins GitLab as a
    tracker without recent/search support.

Follow-ups (not in this PR)

  • iw start exits 1 on success in a non-TTY because it ends in tmux attach. Pre-existing and
    identical for iw start <issue>, but an automated caller cannot distinguish it from a real
    failure by exit code.
  • The published iw-cli-ops skill needs regenerating for the new command surface
    (build-iw-cli-skills).
  • build.mill sets no scalacOptions, so the Mill compile tasks do not enforce -Werror — only
    the pre-commit hook does, and only for core/. The dashboard has no -Werror path at all.
## Summary `iw start` could only create a worktree for a tracker issue. The interactive slice loop's `sl-define --local` (iterative-works plugin v1.48.0) needs the opposite: a worktree keyed on an **effort** — a human-named branch with no tracker issue, hosting N slices that ship as one PR. Before this, that flow meant `git worktree add` by hand, tmux by hand, no dashboard card, and — the real cost — `iw rm` could not tear it down, so the build-tool cleanup hooks never ran. This adds `iw start --effort <slug>` and carries the new key through the CLI and the dashboard. ## Design Worktree identity and tracker issue were the same concept. They are now split: ```scala enum WorktreeKey: case Issue(id: IssueId) case Effort(slug: EffortSlug) ``` `EffortSlug` rejects anything `IssueId.fromBranch` accepts, so `iw-399-followup` cannot silently resolve to issue `IW-399`. The guard is single-sourced against the real parser rather than a second regex that could drift from it. ### Contract with the slice loop (plugin v1.48.0, already shipped) 1. branch name == effort slug == `project-management/efforts/<slug>/` directory name 2. `project-management/.active-slice` — untracked, holds the **repo-relative path** of the active slice directory 3. a local slice's review-state lives at `<slice-dir>/review-state.json` with `issue_id` = the effort slug 4. layout: `efforts/<slug>/slices/<NN>-<slice-slug>/{card.md,tracker.md,review-state.json}` Issue-keyed worktrees and issue-keyed slices are untouched — this is additive. ## What changed **Core** — new `EffortSlug` and `WorktreeKey`; `WorktreePath` keyed on either. `--effort` on `start`, grounded on the effort directory being **committed on HEAD** (a worktree is cut from HEAD, so an uncommitted effort dir would produce a worktree where `sl-define --local` immediately fails). `open` / `rm` / `status` accept a slug. `review-state` resolves its default path through `.active-slice` and refuses to guess when the pointer is absent, instead of writing under `project-management/issues/`. `worktrees` and `register` derive the parent project key-aware. **Dashboard** — registration carries the worktree kind; a state file without the field reads as an issue worktree, so existing `state.json` keeps working. Effort worktrees are never polled for a tracker issue and never probed for phase task files. Their review-state resolves via `.active-slice` and is cleared when `sl-close --local` removes the pointer. Both kinds render through shared card chrome. ## Verification `core.compile` / `dashboard.compile` (forced fresh) · `core.test` 2051 assertions · `dashboard.test` 745 · `dashboard.itest.testForked` · full 42-file BATS suite, 169 ok / 0 not ok · `scala-cli compile --scalac-option -Werror core/`. Also hand-run in a throwaway repo with `HOME`, `IW_SERVER_DISABLED`, `IW_PLUGINS_DISABLED` and `IW_TMUX_SOCKET` redirected: worktree + branch + tmux session created; uncommitted effort rejected with no branch left behind; `iw worktrees` lists the effort from inside its own worktree; `review-state` with no `--output` lands in the `.active-slice` directory carrying `"issue_id": "<slug>"`; and nothing is ever written under `project-management/issues/`. ## Two commits here are unrelated to effort worktrees Kept on this branch deliberately rather than split, but flagged so review can treat them separately: - **`23a6a3f`** — `iw-run` scanned installed plugins on every invocation, so a plugin on the developer's machine leaked into the E2E suite and `start-prompt.bats` could never observe the warning it asserts. Adds `IW_PLUGINS_DISABLED=1` to the BATS setups. The suite cannot pass hermetically without it. - **`93b3ad1`** — `buildFetchFunction` / `buildSearchFunction` had no `Forgejo` branch, so `/api/issues/recent` and `/api/issues/search` answered 500 with a `MatchError` on a Forgejo-configured project — i.e. on this repo's own dashboard. Forgejo now joins GitLab as a tracker without recent/search support. ## Follow-ups (not in this PR) - `iw start` exits 1 on success in a non-TTY because it ends in `tmux attach`. Pre-existing and identical for `iw start <issue>`, but an automated caller cannot distinguish it from a real failure by exit code. - The published `iw-cli-ops` skill needs regenerating for the new command surface (`build-iw-cli-skills`). - `build.mill` sets no `scalacOptions`, so the Mill compile tasks do not enforce `-Werror` — only the pre-commit hook does, and only for `core/`. The dashboard has no `-Werror` path at all.
mph added 12 commits 2026-07-27 06:47:09 +00:00
Introduces the model layer for worktrees that are keyed on a human-named
effort instead of a tracker issue:

- EffortSlug: validated slug ([a-z0-9-], letter-initial, no '--', no edge
  dashes) that is rejected outright when IssueId.fromBranch would resolve it,
  so 'iw-399' can never masquerade as issue IW-399 once it becomes a branch.
- WorktreeKind: issue/effort with a total wire codec for registration.
- WorktreeKey: the identity a worktree is created, registered and addressed
  under. parseArg resolves a CLI positional (issue first, effort fallback);
  resolveFromBranch resolves the checked-out branch and grounds the effort
  fallback in an injected filesystem predicate, so branches like 'main' keep
  failing exactly as they do today.
- WorktreePath now takes a WorktreeKey; issue directory and session names are
  unchanged, efforts get <project>-<slug>.
- WorkflowStatePaths owns the project-management layout literals: effortDir,
  activeSlicePointer, and key-based stateDir/partition overloads. The active
  slice pointer counts as state-owned for both kinds.

Issue-keyed behavior is untouched; call sites are wrapped in
WorktreeKey.Issue only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`iw start --effort <slug>` creates a worktree, branch and tmux session named
by the slug, gated on `project-management/efforts/<slug>/` already existing in
the working directory — the effort is framed by /sl-frame before the worktree
is cut. Issue and effort share one flow; only the key differs.

open, rm, status and register now accept a slug positionally and resolve the
checked-out branch through WorktreeKey. The branch fallback is grounded in the
filesystem, so ordinary branches like `main` keep failing exactly as before
instead of resolving as an effort. review-state's branch fallback yields the
slug, which is the state key for a local slice.

Registration carries the worktree kind so the dashboard can tell an effort from
an issue. An effort has no tracker team of its own, so it registers under the
project's own team identifier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Carry the worktree kind through registration, the wire format and the
persisted state file. Reading is tolerant: a state file without a `kind`
key, or with an unrecognized value, reads back as an issue worktree
rather than failing the whole file.

Effort worktrees have no tracker issue and no phase task files, so the
tracker poll and the workflow-progress probe are skipped for them. Their
review state is resolved through `project-management/.active-slice`,
whose content names the slice directory being worked; a missing, blank or
dangling pointer degrades to "no state" instead of an error card.

Cards and the detail page get an effort variant: the active slice's
display text as the headline, the slug as identity, git status, PR and
review artifacts — no invented issue metadata. Every card display context
routes through one renderer so an effort cannot fall back to a skeleton
in one of them.

`ProjectPath.deriveMainProjectPath` gains an overload that strips the
known worktree key, since an effort slug does not match the issue ID
pattern and the worktree would otherwise be silently dropped from project
grouping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add the E2E wiring smoke test for `iw start --effort` and bring the
command usage headers, README and CLAUDE.md in line with a worktree
that can be keyed on an effort slug instead of a tracker issue.

docs/effort-worktrees.md records the slug rules, the on-disk effort
layout, the branch-resolution grounding rule and what the dashboard
shows for an effort worktree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
iw-run scanned $XDG_DATA_HOME/iw/plugins and IW_PLUGIN_DIRS on every
invocation, so a plugin installed on the developer's machine leaked into
the E2E suite: a plugin hook-start file took over the tmux session and
test/start-prompt.bats could never observe the "no session action hook"
warning it asserts.

discover_plugins now returns nothing when IW_PLUGINS_DISABLED=1, and every
BATS file exports that flag in setup() alongside IW_SERVER_DISABLED. The
plugin suites keep discovery on and continue to point XDG_DATA_HOME and
IW_PLUGIN_DIRS at their own fixtures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
buildFetchRecentFunction and buildSearchFunction matched on the tracker
type without a Forgejo branch, so /api/issues/recent and
/api/issues/search answered 500 with a MatchError for a Forgejo-configured
project, and both matches emitted exhaustivity warnings.

Forgejo joins GitLab as a tracker without recent/search support, so both
now return the same "not yet implemented" error, which the endpoints
already render as empty results.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three places still read an effort worktree as if it were issue-keyed:

- `iw review-state` built both default paths from
  `project-management/issues/<key>/`, so an effort wrote a file the
  dashboard never reads into a directory that must not exist for it. The
  default now follows the key's kind — an effort resolves through
  `project-management/.active-slice` and fails with a clear error when
  there is no active slice, rather than falling back to `issues/`.
- `iw worktrees` derived the cwd's main project with the key-blind
  overload, which returns None for an effort suffix. The filter then
  matched nothing, hiding every row including the effort's own. Both
  sides now strip the key named by the covering registration.
- `iw register` discarded the resolved key before deriving the parent
  project, skipping parent registration and warning about a case it can
  resolve exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The grounding check asked the working tree whether
`project-management/efforts/<slug>/` exists, but a worktree is cut from
HEAD. With the framing commit absent from HEAD — directory still
uncommitted, or committed on another branch — `iw start --effort`
created a worktree that does not contain the effort at all, and
`/sl-define --local` failed there on its first gate: exactly what the
check exists to prevent.

`GitOps.existsInHead` asks git instead (`git cat-file -e HEAD:<path>`).
The working-tree check stays where it is right — resolving a key from
the branch inside an existing worktree — and now also distinguishes the
two ways to miss the gate: framed but not committed asks for a commit,
absent asks for /sl-frame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The card render reads review state from the filesystem every time, so the
filesystem is authoritative: no state on disk means no state. Falling back
to the cache kept a closed slice headlining its effort card after
sl-close --local removed project-management/.active-slice, and did the same
for an issue worktree whose review-state.json was deleted. A missing, blank
or dangling pointer degrades to "no active slice", never to an error card.

CaskServer now writes the render's answer straight through, so a state that
no longer exists is dropped from the cache instead of only being hidden.

Also pins `kind` on PUT /api/v1/worktrees/:issueId, the live wire contract,
which had no server-side test: effort registration, an absent field
registering an issue worktree, and an unknown kind rejected with 400.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
renderEffortCard copied its container, repo link, git status, PR link, Zed
button and footer from renderCard, and WorktreeDetailView.renderEffort did
the same against renderFull. Both kinds now go through one path: cardShell
wires the HTMX container and the last-activity footer around the sections a
kind supplies, and each repeated section is a named helper. The skeleton
card joins them. gitStatusSection, zedLink and lastActivity are shared with
the detail view the way renderReviewArtifacts already was.

Rendered output is unchanged and pinned byte for byte for the issue card,
the effort card, the skeleton card, and both detail-page kinds.

Closes the test gaps around effort rendering: WorktreeDetailView's effort
path was untested, and renderForWorktree's claim that every card context
routes an effort away from the skeleton was only exercised in one of its
three contexts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DashboardService.fetchIssueForWorktree is private and has no callers, so
the effort guard added to it never ran. The only place a worktree's tracker
is actually polled is WorktreeCardService.renderCard, which the card and
detail-content endpoints both go through; the guard lives there and
"renderCard never polls the tracker for an effort worktree" proves it. The
dead method goes back to what it was.

WorkflowStatePaths no longer claims to be the single owner of the
project-management layout literals — the phase commands still build their
own paths under it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
refactor(dashboard): drop unreachable per-worktree fetch helpers
All checks were successful
CI / format (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 7s
CI / contract (pull_request) Has been skipped
CI / compile (pull_request) Successful in 1m18s
CI / dashboard-build (pull_request) Successful in 57s
CI / test (pull_request) Successful in 8m19s
63af50647c
DashboardService.fetchIssueForWorktree and fetchPRForWorktree had no
callers: the live tracker poll runs through WorktreeCardService.renderCard
and the cached-only variants serve the card and detail routes.

Removing them orphans buildFetchFunction, buildUrlBuilder and
extractGitHubIssueNumber, which only the issue fetch called — IssueCacheService
and IssueSearchService each hold their own copy — so those go too, along with
the ten imports that served them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mph merged commit 64f8c821ad into main 2026-07-27 08:55:09 +00:00
mph deleted branch effort-worktrees 2026-07-27 08:55:09 +00:00
Sign in to join this conversation.
No description provided.