🐛 fix(kanban): keep an edit-in-place repo-root dir card in place, not a worktree - #113
Conversation
… a worktree `_resolve_dir_workspace` redirected any `dir` workspace whose path is a git repo ROOT to a per-task `<repo>/.worktrees/<id>` worktree on a `wt/*` branch. That is correct for a shared deploy clone (isolates the worker from a clone pinned to its deploy branch), but wrong for an EDIT-IN-PLACE checkout that is itself the deploy target: `~/.hermes` (the working tree of the live config repo) is edited in place with an hourly backup cron owning commit+push, and has no PR flow. Redirecting it made the worker commit to a branch and open an unwanted PR, leaving a stray worktree + branch in the live install. Add a declared single source of truth, `hermes_cli/edit_in_place_repos.py` (`edit_in_place_roots()` / `is_edit_in_place_root()`), resolving the Hermes home checkout to real paths at call time — a declaration, not a heuristic, and no new env var. `_resolve_dir_workspace` returns an edit-in-place root unchanged (no worktree, no branch, guard skipped); every other repo root keeps the redirect-and-guard behavior. `_resolve_worktree_workspace` refuses a worktree card aimed at an edit-in-place root as a caller error rather than silently coercing it. RED-first tests cover both directions: an edit-in-place dir card resolves in place through the full dispatch path with no branch persisted, and a non-edit-in-place repo root still redirects and still guards (the regression fence for the deploy-clone bug).
cwest
left a comment
There was a problem hiding this comment.
No changes needed.
The carve-out lands where it should: after the repo-root check and before the fast-forward guard, so an edit-in-place root returns unchanged with no worktree, no branch, and no guard run, while every other repo root falls through to the existing redirect-and-guard path untouched.
I checked the part that matters most here — that the carve-out isn't too wide. is_edit_in_place_root resolves to ~/.hermes alone (the shared root, correctly derived even under a profile home like ~/.hermes/profiles/lamport). ~/src/office and ~/src/hermes-agent both classify False, so they keep redirecting and guarding exactly as before; a subdirectory such as ~/.hermes/skills also classifies False, since only the root itself is the deploy checkout. Exact-root equality is the right test for this predicate, distinct from _card_requires_pr's subtree exclusion — the docstring calls that out.
The tests genuinely catch the defect rather than restating it. Neutering the predicate to always-False drops the five carve-out tests to red while the four regression-fence tests stay green, which is the shape you want: the fence doesn't depend on the carve-out being active. The worktree-kind refusal is a clear error naming the fix rather than a silent coercion.
Ran the targeted file plus both fence files: 25 passed, 0 failed. All CI checks are green (mergeStateStatus CLEAN); the skipped Docker/JS/Docs contexts are the expected skips for a Python-only change. Commit is signed and correctly authored.
One wording nit for later, not blocking: the PR body describes the root set as identical to _card_requires_pr's exclusion. It isn't literally — one is exact-root, the other subtree — though both are correct for their purpose.
Why
_resolve_dir_workspace(hermes_cli/kanban_db.py) redirects anydirworkspace whose
workspace_pathis a git repo ROOT to a per-task linked worktreeat
<repo>/.worktrees/<task-id>, on awt/<task-id>branch. That redirect iscorrect for a shared deploy clone (
~/src/office, pinned tomainso apost-merge
git pull --ff-onlycan fast-forward): handing the shared clone to aworker let the worker
git checkout <topic>in place and break the fast-forward.The same redirect is wrong for an EDIT-IN-PLACE repo.
~/.hermesis theworking tree of
cwest/hermes-config: the checkout IS the live running install,changes land directly in it, and an hourly
backup_commitcron owns commit+pushto
main. There is no PR flow for this repo. Because~/.hermesis a repo root,a card correctly filed
dir @ ~/.hermesis silently converted into awt/*worktree; the worker then commits to that branch and opens an unwanted PR, and a
stray worktree + branch is left in the live install. The card was filed right —
the resolver produced the wrong vehicle. Observed three times (most recently
2026-08-04: two cards materialized at
~/.hermes/.worktrees/<id>and produced anunwanted, closed PR against
cwest/hermes-config).What
Teach
_resolve_dir_workspacethat some repo roots are EDIT-IN-PLACE and must bereturned as-is: no worktree redirect, no branch, and no default-branch/upstream
guard (that guard is meaningless for a checkout that is itself the deploy target).
Every other
dircard keeps today's redirect-and-guard behavior exactly.hermes_cli/edit_in_place_repos.py— a single sourceof truth:
edit_in_place_roots()resolves the explicit set of edit-in-placeroots to real absolute paths, and
is_edit_in_place_root(path)is the predicateboth the resolver and the homestead filing side assert against.
_resolve_dir_workspaceconsultsis_edit_in_place_root(repo_root)beforethe redirect/guard and returns the repo root unchanged when it matches.
_resolve_worktree_workspacerefuses aworktree-kind card explicitly aimedat an edit-in-place root (see decision below).
Design decision #1 — where the declaration lives, and how it avoids a heuristic
The list lives in core (
hermes_cli/edit_in_place_repos.py), because theredirect logic being fixed is in core (
kanban_db.py) and the homestead filingside runs on the same machine/pythonpath and can simply
importit. This mirrorsthe
merge_authority.pyprecedent (a fact scripts assert against, not proserestated across skills) — the difference is that merge authority keys on repo
slugs, while edit-in-place keys on local paths, resolved at dispatch time.
The declared fact is "the Hermes home checkout is edit-in-place." It resolves to
real paths via
get_default_hermes_root()(honoursHERMES_HOMEfor Docker /custom / profiled installs) and
~/.hermesunder$HOME— the exact two roots_card_requires_pralready uses for its edit-in-place exclusion, so the two agreeby construction. This is not one of the banned heuristics ("path is under
$HOME/.hermes", "repo has no remote", "a cron touches it"): it is a declared,enumerated set resolved to concrete paths at call time. Resolving at call time
(not import time) is what makes it profile-correct without a new
HERMES_*env var(repo policy:
.envis secrets only; behavioral settings are declared code).The uncommitted homestead sketch
~/.hermes/scripts/repo_vehicle.pyis supersededby this module and can be removed.
Note (out of scope here, worth a follow-up):
_card_requires_prstill computes thesame two roots inline. It could consume
edit_in_place_repos.edit_in_place_roots()to collapse the duplication, but the card scoped
_card_requires_prasalready-correct and out of bounds, so this PR leaves it untouched and only ensures
the new module's root set is identical to it.
Design decision #2 —
worktree-kind aimed at an edit-in-place root: REFUSEA
worktreecard explicitly aimed at an edit-in-place root is refused atresolution time with a clear
ValueError, not silently coerced to the in-placepath. Rationale: asking for an isolated worktree/branch on a checkout that IS the
deploy target is contradictory — the card should be
workspace_kind='dir'.Refusing surfaces the filing mistake loudly; coercing would mask it and leave the
caller believing they got an isolated worktree. The error message names the fix
("File this card with
workspace_kind='dir'instead").Done when — evidence
RED-first tests (
tests/hermes_cli/test_kanban_edit_in_place_workspace.py)Both directions, since the risk is a carve-out that is too wide:
diron an edit-in-place root resolves to that exact path; asserts no.worktrees/<id>created and no branch set. (Failed before the fix.)dispatch_once→ claim → resolve → spawn): adircardon an edit-in-place root reaches the worker with the in-place workspace and no
branch_namepersisted; declared anchor unchanged on the row.worktree-kind on an edit-in-place root is refused.still redirected to
<repo>/.worktrees/<id>and the default-branch/upstreamguard still raises. The pre-existing office-clone fences in
test_kanban_dir_workspace_worktree.pyandtest_kanban_dir_workspace_worker_run.pyare unchanged and green.Execution proof against the real defect
Isolated probe (temp kanban DB, scrubbed env — the live board and install were
never mutated):
~/.hermesis its own git toplevel andis_edit_in_place_rootis True, so theredirect the mirror demonstrates is the identical redirect that fired against
~/.hermespre-fix — now suppressed.Full suite
scripts/run_tests.shrun on this branch and on the merge base (cwest/integrationhead). The failing set is identical on both — all pre-existing,
host/environment-specific (approval clusters, computer-use, ACP MCP e2e collection
errors, gateway platform callbacks, live-system guard, anthropic adapter, qwen-oauth
resolution, WSL/service-manager). Zero new failures introduced by this change;
none of the failing files reference the changed code.
Scope
Primary file:
hermes_cli/kanban_db.py(_resolve_dir_workspace,_resolve_worktree_workspace). New:hermes_cli/edit_in_place_repos.py,tests/hermes_cli/test_kanban_edit_in_place_workspace.py. No change to the office/ dev-checkout behavior,
_card_requires_pr, the kanban CLI, or the backup cron.No new env var.