Commits
pm-sidebar--render rebuilt the buffer with erase-buffer but never deleted
the overlays the previous render created (visibility indicators, magit
highlight/selection). erase-buffer only collapses overlays to point 1, so
they accumulated every refresh; the leaked overlays anchor markers near
point 1, turning every insert into O(stale-markers) and growing RSS without
bound until the single-threaded daemon pegged a core and wedged (observed
23GB+ RSS, unrecoverable without a daemon restart).
- render: remove-overlays before erase-buffer (stops the leak)
- handle + window-size-change: coalesce renders behind a 0.2s timer so an
SSE burst (snapshot replay, status churn, resize storm) collapses into
one render instead of one full rebuild per event
- proc-filter: walk lines with a running index + one trailing substring
(was O(n^2) substring-per-line), use string-replace, and cap the
accumulator to guard a non-terminating line
Add ERT coverage: overlay cleanup, render coalescing, quit cancels the
render timer, chunked-SSE parsing, accumulator cap.
create_or_update_current_pr treated every find_open_pr hit as a PR pm
already owns, refreshing only the managed stack block and preserving the
existing title/body. But find_open_pr matches by head ref, and stack/*
branches get recycled for new work -- a reused branch can land on a
still-open PR from an unrelated prior feature, whose title/description
belong to the wrong work. The result was a pushed PR titled and described
for the old feature (a stale "[AUTH-5322] ..." title/body surviving onto
an unrelated branch).
Snapshot whether pm already had a pr_state row for the branch before
find_open_pr runs. With a cache row, keep the preserve-edits behavior.
Without one (a fresh head-ref-search adoption), claim the PR the way a
create would: set the title to this branch's bottom-commit subject and
reset the body to its first commit, then let refresh_component_pr_bodies
splice in the managed stack block.
Adds test_push_resets_title_body_when_adopting_recycled_pr.
A local-only checkout (no remote) has nothing to pull, so it must not
fail `pm repo pull`'s exit code — and thus the systemd timer that runs
it. The no-upstream precheck now reports ok=True (message unchanged);
detached HEAD and dirty working trees stay failures.
The Tier-2 transcript classifier now treats a no-stop_reason tail as
working regardless of record type, matching tributary's jsonl_status.rs.
A cancelled/interrupted turn has no Claude hook to key off, so it stays
"working" until the hook row goes stale; documented inline against
claude-code#9516.
pm-sidebar-visit now branches on the section: a session row still jumps to
its terminal buffer; a project heading opens `pm-project-dispatch' scoped to
that project (binds default-directory to its container so the dispatch path
resolves without prompting). The "(no project)" placeholder errors. Adds
red/green tests for both project branches.
- Rename the sidebar module pm-serve.el -> pm-sidebar.el and every pm-serve*
symbol -> pm-sidebar* (pm-agent-serve-source, a daemon concept, is left as
is). pm.el autoloads `pm-sidebar'.
- `pm-sidebar' is now a toggle: open in a left side window if hidden, hide the
window if showing (the SSE connection stays live for an instant re-open).
`pm-sidebar-quit' still tears the connection down.
- Preserve point and window scroll across a refresh: the render rebuilds the
whole buffer, which used to yank the cursor to the top on every SSE update.
- Tests: rename pm-test--serve-* -> pm-test--sidebar-* and add a regression
test that point survives a re-render.
Create the pm-serve-sse network process with :noquery, so Emacs no longer
asks "Active processes exist; kill them?" about the sidebar connection on
exit -- it closes cleanly.
Cover the pure pieces of pm-serve: the SOURCE&EMACS instance-scoping filter
query, session-key derivation, the snapshot/update/remove SSE handlers
(render stubbed) against the session table, and the fit pad/ellipsize
invariant.
The daemon is shared across Emacs instances, and they all tag agents with
the same PM_META_SOURCE, so the sidebar's SOURCE-only filter showed every
instance's agents. Inject PM_META_EMACS (the Emacs pid, already the prefix
of the buffer id) on launch and filter the sidebar on SOURCE *and* EMACS,
so each Emacs sees only the sessions it launched.
pm.el declares an autoload for every sub-module command so they load on
demand (and so the use-package :commands entry resolves through pm.el);
add the missing one for pm-serve.
pm-serve.el: a magit-section sidebar that streams session state from
`pm agent serve` over SSE, grouped by project, colored by status, mapping a
row back to its launching buffer via the injected PM_META_BUF. The SSE
connection is async (`:nowait`) and auto-reconnects on drop, so a daemon
restart never wedges or strands the sidebar.
pm-agent.el: inject the PM_META_* launch seeds into *every* ghostel session
via `ghostel-pre-spawn-hook` (installed by `pm-agent-setup-ghostel`), so an
agent started by hand in any ghostel buffer is tracked too — not only
`pm-agent-launch` dispatches. The ghostel dispatcher drops its now-redundant
manual env seeding.
A headless daemon (stdlib threaded HTTP + SSE, ephemeral WAL SQLite) that
ingests best-effort hook events from Claude/Codex/Cursor and broadcasts live
session state to observers (the Emacs sidebar, curl).
- serve/{server,store,status,fallback}: HTTP+SSE server, WAL store, pure
status-derivation, and a Tier-2 transcript fallback poller. Status vocab:
working / waiting_response / waiting_permission / idle / unknown, with
openui-style activity-gating and a pid-liveness cleanup backstop.
- install-hooks: a shared stdlib reporter installed for Claude (settings.json),
Codex (a --profile pm config, since isaac regenerates ~/.codex/hooks.json),
and Cursor (~/.cursor/hooks.json, incl. shell/MCP approval). Skips the
Claude-hook double-report Cursor triggers.
- transcript: shared title library (custom-title > ai-title > synthetic >
first prompt) that also parses Codex rollouts natively; the fallback fills
source-reader titles (e.g. Cursor) for sessions it cannot parse.
- config [serve], opt-in WAL in sqlite_db, systemd unit, tests.
`pm project status` is a report, not a health gate — it should fail
only on an actual error (unknown project, IO), not because a worktree
is non-ACTIVE. The old `return 1 if any non-ACTIVE worktree` treated
intentional DETACHED worktrees (and informational DRIFT / mid-op
OPS_OWNED) as failures, which made the Emacs status/stacker buffers
refuse to render valid output. Always return 0 on success; genuine
errors still raise and exit non-zero via the central CLI handler.
`pm check` remains the linter-style gate whose exit code tracks health.
Build out interactive stacker support in the pm Emacs integration,
backed by structured CLI output.
Python:
- pm project status --json now carries the structured stacker tree per
repo (StackerRow.branches / current_branch / operation), reusing the
same branch selection the text arm renders (ls.ls_structure +
arm_branch_set). Text mode is unchanged; --json skips the text render
to stay at one prefetch per repo.
- Every `pm stacker` subcommand gains --json via a uniform envelope
(_common.emit_result): {ok, command, repo, branch, message, operation}.
message is the raw markup result; operation surfaces a paused/
conflicted op so a client can offer continue/abort. ls/config keep
their existing richer JSON.
Emacs (new pm-stacker.el + integration):
- *pm-stacker: <project>* — a foldable, multi-repo branch tree with
CLI-matching status icons, commit-group markers (count/needs-sync/
ahead), current-branch + merged styling, and a paused-op banner.
- Branch-scoped action transient (RET) covering the full verb set with
infix flags; direct keys for the common verbs. Every action runs
`pm stacker <verb> --json` and refreshes; destructive verbs confirm.
- The status buffer's Stacker section reuses the same renderer, so it's
now the same interactive tree (closes the old plain-face gap).
- Branch rows use a dedicated section class (pm-stacker-branch-section)
whose highlight covers only the heading line — so a repo heading
highlights its whole block (table-style) while a branch row (even a
parent) highlights as a single line, with folding intact.
- Faces: pm-faces-stacker-status (icon+face per status mirroring
graph.py), pm-stacker-current, pm-stacker-merged, strike markup token.
Tests: status structured-tree + --json envelope (Python); stacker tree
render, faces, RET scope, wrapper argv (Emacs). README documents the
buffer, transient, and the --json envelope.
Theme every pm magit-section buffer consistently and fix two
buffer bugs surfaced while doing it.
Theming (pm-faces.el, pm-table.el + all renderers):
- distinct face hierarchy — pm-buffer-title / pm-section-heading
with dim pm-count / pm-group-heading / pm-column-header (a quiet
ruler, previously reused the section-heading face) / rows
- themed value columns: repo dirty/ahead/behind, pool & project
status (pm-faces-status), session ids (pm-id) + dim timestamps,
agent names
- dim "(none)" for empty sections
- centralized via pm-table-banner/heading/row-faced/insert-* so
every buffer renders identically
Apply faces via font-lock-face, not face (pm-propertize-face,
mirroring magit--propertize-face): magit-section-mode keeps
font-lock on with font-lock-defaults '(nil t), and jit-lock strips
the plain face property on display — only font-lock-face survives.
This is what makes the theming actually show up.
Per-project agent-ls buffers (pm-agent.el): pm-agent-list--buffer-name
names single-project views *pm-agent-ls: <project>* so they no
longer clobber each other or the all-projects view.
Stacker rich-markup body left on plain face for now (no stacker
integration yet). Tests + README updated.
- pr/resolve: when a tracked parent's PR is MERGED, treat it as
transparent and recurse to resolve the child's PR base against the
grandparent (sync collapses a merged parent to a no-commit branch,
so the child should target whatever the parent would have). Renames
find_open_pr -> find_pr and distinguishes MERGED vs non-OPEN states.
- pool/worktree: skip orphan gitlinks during submodule init — a gitlink
in HEAD with no .gitmodules URL can never be initialized (common after
an upstream submodule removal), so continue instead of erroring.
- tests: cover the merged-parent PR-base resolution.
Follow-up to the wt add/remove CLI rename: the core ops are worktree
addition/removal (container lifecycle is a side effect), so name them
that way. project/create.py -> add.py (add, AddedWt, ADDED_COLUMNS),
project/delete.py -> remove.py (remove, plan_remove, RemovePlan),
emit_delete_plan -> emit_remove_plan. Also fixes the stale
attach|detach|delete reference in the generated project README
template. CLI command names are untouched.
Clean rename, no back-compat aliases. Updates every integration:
zsh completion (regenerated _pm; also fixes the stale --repo
substitution that had lost _pm_repos completion), the pm-workflow
Claude Code skill (plugin bumped to 0.1.1), and the Emacs package
(pm-wt-add/pm-wt-remove function chain, prompts, and transient).
`pm stacker absorb` cherry-picked the format pass onto master, so the
master-side commit has a different SHA from the stack-side one. List
both so `git blame` skips the format pass regardless of which side of
the absorb you're inspecting.
Updates the pm-stacker-workflow skill so Claude reaches for the new
flags by name and understands the new merged-PR collapse:
- Sync table loses the obsolete cherry-pick mode row, gains
--allow-drop-parent-modifications, --allow-drop-merge, --offline.
- Reparent row picks up the same gate / offline flags.
- New "Sync Gates" section explains when each gate fires, what the
--allow-drop-* flags do, and when the right answer is to recover
from reflog or `pm repo pull` instead of using the flag.
- "After a PR Is Merged" rewritten: a plain `pm stacker sync` now
auto-collapses the merged branch to a no-commit branch; removal
is a follow-up cleanup, not a prerequisite.
- "Common Mistakes" gains rows steering away from the
--allow-drop-* flags as a default response to a gate failure.
- `pm stacker ls --json` field-use cheat sheet distinguishes
`merged + commit_count > 0` (needs sync to collapse) from
`merged + commit_count == 0` (already collapsed, ready to remove).
`make lint` (and so the pre-commit hook) now fails when files would be
reformatted, pointing at `make fmt`. The format-pass commit is listed
in `.git-blame-ignore-revs` so `git blame` (with
`blame.ignoreRevsFile=.git-blame-ignore-revs`) skips it.
Mechanical run of `uv run ruff format src tests`. No behavior change —
just line wrapping and trailing-comma normalization that the project
hadn't been applying. Listed in `.git-blame-ignore-revs` (added next
commit) so `git blame` skips this revision.
Sync always cherry-picks `managed_base..HEAD` now — the patch-id dedup
path was misfiring whenever a parent was rewritten in place, replaying
shared commits the child never authored. The old `--hard` opt-in is
gone; cherry-pick uses `--empty=drop` so absorb→sync flows still work.
Two preflight gates protect the replay:
- `--allow-drop-parent-modifications` fires when the branch's recorded
`last_synced_parent_commit` is no longer in the slot HEAD's
ancestry. Catches out-of-band history surgery below the stack range
(`git rebase --onto` from outside stacker, amends touching pre-stack
commits). Without the flag, sync errors; with it, those changes are
dropped and only the branch's working commits are replayed.
- Merged-PR auto-collapse: when the cached `pr_state` says the PR is
MERGED, sync skips the cherry-pick and resets the branch to its
parent tip (no-commit branch). Squash presence is detected via
`git merge-tree --write-tree` against the parent. If the squash is
missing, `--allow-drop-merge` is required to perform the same reset.
`--offline` skips the upfront one-batched-GraphQL-call-per-repo
`pr_state` refresh and uses cached state. Gate and collapse flags
travel through the layers as a `SyncOptions` dataclass (mirrors
`PushOptions`); `OperationState` persists the two opt-out booleans so
multi-branch downstream syncs apply them consistently.
`tests/stacker/test_sync.py` swaps the six `--hard` tests for eleven
new ones covering the gate, the collapse, the offline path, and the
chained no-commit-branch case (mocked via `RecordingPRBackend`). Two
`reparent --hard` tests collapse into one that asserts the
exact-range behavior is now the default.
`make hooks-install` points `core.hooksPath` at `.githooks/` so commits
run lint locally. The hook chains to `$PM_PRE_COMMIT_CHAIN/pre-commit`
when set so a global Databricks pre-commit still gets to run its
secret-scan / commit-msg checks.
The lint target now exits with an explicit "don't use noqa / type:
ignore to silence" hint so contributors fix the underlying issue
instead of suppressing the warning.
push, sync, ls, and PR-body rendering all default to the "current"
scope, which previously walked only ancestors + target + descendants
of target. In a multi-arm stack this silently dropped sibling arms:
pushing from one arm only created/refreshed PRs on the spine, sync
left cousins unrebased, ls hid them, and PR description trees got
overwritten without the missing arm.
lineage(target) now walks up to the topmost-tracked ancestor and
toposorts its full subtree — same result for linear stacks, full
component for multi-arm. resolve_scope's --skip-ancestors /
--skip-descendants switch to structural ancestor/descendant lookups
so the slice still drops the right branches when target sits between
sibling arms in the toposort.
`("l" "sessions" pm-agent-list-current-project)` in
`pm--project-dispatch-menu' was reading `default-directory' (which
has reverted to the original buffer by the time the suffix runs)
instead of the bound project: picking project B from a buffer in
project A silently listed A's sessions.
`pm-agent-list-current-project' now consults `(transient-scope)' as
the highest-priority source on its context chain, matching the
convention every other project-dispatch suffix already follows.
M-x invocations are unchanged (transient-scope is nil outside a
transient → falls through).
The prior README leaned on implementation specifics (SQLite db
layouts, PRIMARY KEY race, ownership semantics) that nobody installing
`pm` for the first time needs. Drop those, describe each of the four
layers (pool, project, stacker, repo) in user-facing terms, and
update the command listing to match the current CLI surface (project
wt subcommands, repo subcommand, agent runners, stacker absorb / pr
refresh).
Add an Integrations section that surfaces the three things people
actually want to plug in: zsh hooks (`pm-git-guard`, `pm-cd`,
completions), the Claude Code skill at integrations/claude-code/, and
the Emacs package at integrations/emacs/. Keep Testing as the last
section before Acknowledgements.
Drop the universe `ci/gitstack` mention (PR caching has diverged
enough that the comparison no longer pays its way) and the
`pool/worktree.py` line (the wt-init port is small enough that a
single attribution covers both that and the stacker model). Keep one
focused acknowledgement of Rodrigo Gomes' standalone stacker.
`create_or_update_current_pr` was unconditionally syncing the PR title
from the bottom-commit subject on every push. That meant any manual
title rewrite — via `gh pr edit --title` or the GitHub UI — got
clobbered the next time the user ran `pm stacker push`, even when no
new commits were pushed (the post-push body refresh alone is enough
to trigger the title write).
Skip the title field on `EditPRRequest` when the live PR title no
longer matches the computed bottom-commit subject. We can't tell from
cache whether the divergence came from a UI edit or from an amended
bottom commit whose subject hasn't been propagated, so the safe
default is to never overwrite. Users who amend the bottom commit and
want the new title on GitHub can re-set it once with `gh pr edit
--title <subject>`; subsequent pushes then keep it in sync (until the
next divergence).
Two tests cover the contract: the manual-edit case (no title in
EditPRRequest), and the idempotent-match case (title still passed
through when it matches).
`pr_base_for_current_branch` previously called `remote_branch_name(parent)`,
which read the worktree-local `branch.<name>.merge` git config and so
required the parent branch to be checked out in a pm pool slot.
During `pm stacker push`, the slot is released after each branch is pushed.
When the next iteration resolves a child's PR base, the parent's slot is
already gone and the run fails late with:
pm: <repo>:<parent> is not checked out in any worktree.
Check it out in a pm pool slot first.
`find_open_pr` already returned a `gh.PullRequest` with `head_ref_name`
populated by gh, so use that directly. No worktree dependency, no extra
network call beyond the one we already make.
Add two regression tests:
- parent tracked + cached PR + parent absent from every worktree → returns
parent's `head_ref_name`.
- parent open PR with empty `head_ref_name` → clear `GitError` instead of
silently emitting `base=""` to `gh pr edit`.
`(ghostel)` with no prefix arg falls into the `get-buffer-create`
branch and reuses an existing buffer of the same name; the second
launch of the same agent/project would then paste the new argv
into a buffer with a live agent already running.
Pass a non-numeric, non-nil prefix arg so ghostel routes through
`generate-new-buffer' and appends `<2>`, `<3>`, ... when the base
name is taken.
Renamed the per-verb stderr buffer from `*pm: <verb>*` to
` *pm-err: <verb>*` so it stays out of the buffer list. Emacs's
implicit stderr-pipe sentinel writes a "Process … stderr finished"
line on every successful exit; surfacing that buffer to the user
created startup noise (e.g. `pm-refresh` on `after-init-hook`).
`pm--surface-error` still reads the same buffer for `display-warning`
text on non-zero exits — only the visibility changed.
Project-scoped buffers (pm-status, pm-agent-list when given a
project) bind default-directory to the container so transient,
project-current, and pm--container-of resolve to the right project
from inside the buffer.
Global buffers (pm-list, pm-pool, pm-repos, all-projects
pm-agent-list) bind default-directory to a new pm-manager-project
defcustom (a project name); when unset, they fall back to ~.
Adds `pm-agent-launch' for spawning a new `pm agent <name>' session,
flowing through the same `pm-agent-dispatch-function' as resumes.
Wires it into:
- `pm-agent-launch-dispatch' (autoloaded transient, picks
claude/codex/cursor)
- `pm-project-dispatch' `r' suffix (forwards parent scope)
- `pm-dispatch' `r' suffix (prompts for project when outside a tree)
- `project-switch-commands' `?a' binding
`pm project ls` walked every `.pm.db` but only emitted rows per
worktree finding, so projects with no attached worktrees produced no
rows and `sections()` dropped them entirely from JSON and text output.
The Emacs integration drives off `pm project ls --json`, so empty
projects were invisible there too — and `pm-project-create` insisted on
≥1 worktree, even though the CLI accepts zero.
- `sections()` takes an optional projects list to seed empty groups.
- `build_sections_table` emits a stub row for empty groups (without
bumping `flat_idx`, so the picker's row indexing stays aligned).
- `pm-project-create` now allows RET-empty and offers to switch into
the bare container when no worktrees were created.
magit-section's `(CHAR . CHAR)' visibility-indicator form draws into
the buffer's left margin, but `magit-section-mode' itself doesn't
allocate one — full magit handles that via `magit-set-buffer-margins'
in magit-margin.el. pm only depends on magit-section, so it mirrors
the relevant pieces here.
- pm-table.el: add `pm-section-set-window-margin' /
`pm-section-setup-margin' (mirrors `magit-set-window-margins') and
`pm-table-show-root-section' (populates visibility-indicator overlays
via `magit-section-show', mirroring `magit-refresh-buffer').
- pm-list, pm-pool, pm-repo, pm-status, pm-agent: call
`pm-section-setup-margin' from each mode body and after each
entry-point's `pop-to-buffer'; move root-section banners off
`magit-insert-heading' to plain `insert' so the root has no `content'
slot and no buffer-level indicator appears for the banner.
`pm project wt create' takes SPEC as the first positional with PROJECT
named (--project / -p); the wrapper was sending `NAME --wt SPEC' which
failed with `Unknown option: --wt' against current cyclopts CLI. Swap to
`SPEC --project NAME'.
`pm project delete' has no `--repos' flag — the wrapper declared a
`repos' parameter that mapped to it. The only call site already passed
`nil', so it never fired at runtime, but the dead surface is misleading.
Drop the parameter and the call-site `nil'.
On PR creation, load `.github/PULL_REQUEST_TEMPLATE.md` from the worktree
and inject the first commit's body into the template's first `## ` section
(mirrors universe gitstack `inject_body_into_template`). Update path stays
body-less so user edits to the template-filled body survive re-push
(universe PR #1859413 regression guard).
Reformat the managed block to ReviewStack's gitstack layout: emit
`## 🥞 Stacked PR` header + `---------` separator inside the existing
`<!-- stacker:begin/end -->` markers. The HTML markers remain our
authoritative parsing boundary; the inner header + separator are what
ReviewStack's gitstackParser keys off so PRs are recognized as stacked.
Default sync builds the cherry-pick list with `git rev-list --cherry-pick
--right-only <parent>...<child>` — the same patch-id heuristic `git
rebase` uses to drop already-applied commits. When a parent branch is
rewritten in place (e.g. a stacker branch is re-edited and its commits
get new patch-ids while keeping the same logical change), the child's
copy of the now-superseded commit no longer matches the parent by
patch-id, and sync replays the duplicate, hitting avoidable conflicts.
`--hard` is the explicit escape hatch: instead of the patch-id walk, it
takes the recorded `managed_base_commit` from the stacker DB and
cherry-picks exactly `<managed_base>..<HEAD>` — the commits the child
itself added since its last sync. The flag flows through the existing
sync entry point, persists on `OperationState` so `pm stacker continue`
can resume a paused `--hard` sync without re-passing the flag, and
applies to every branch in a multi-branch (downstream_sync) walk. The
new column is migrated lazily on existing DBs.
Two SKILL.md files behind one plugin manifest, mirroring the existing
zsh / emacs / systemd integrations under integrations/. pm-workflow
covers projects, worktree slots, pool, and agent launches; pm-stacker-
workflow covers the cherry-pick stacked-PR loop. Both skills bake
--json into the read-state critical rules so an invoking agent parses
output rather than the human-readable tree/table rendering.
`pm cd <project> [<wt>]` is now a real directory change via the new
`integrations/pm-cd.zsh` wrapper (the python program still prints the
path; the wrapper captures stdout and runs `builtin cd`). `pm cd
--print …` keeps the previous print-only behavior for scripting.
Drive-by: ruff RUF022 fixes for unsorted `__all__` in three pre-existing
files (auto-applied).
Three styles existed: dataclass (ProjectFlag/RepoFlag), bare arg
(repo: str | None = None), and a one-off Annotated alias in agent/run.
--repo had no -r short form anywhere. New cli/_params.py owns every
form (annotated aliases, dataclass wrappers, resolvers); _shared.py
re-exports the dataclasses and rewires its scope dataclasses through
the new aliases, so every stacker command picks up -r for free.
Prints the project dir (or the forward symlink to a wt) so the caller
can wrap it: `cd "$(pm cd myproj wt1)"`. Errors clearly when the wt is
detached so a shell function doesn't silently land in the wrong place.
Wires zsh completion for both positionals via `_pm_wt_for_cd`, which
reads the project from `$words[3]` and falls back to `--project`.
Default-scope push from a leaf walked back through every tracked
ancestor and force-pushed each one. Once an ancestor PR merged, its
branch stuck around in the DB until `remove`, so subsequent pushes
clobbered the merged ref on GitHub and rewrote the closed PR's body.
Filter merged entries out of the resolved scope, log each skip, and
short-circuit when the target itself is merged.
`DriveHandle` was a shadow of `AcquiredSlot` that the cherry-pick driver
constructed from the same path/ops pair callers already had. Replace
it with `AcquiredSlot` and reuse `slot.release_if_clean` so the
release predicate lives in one place.
While there, fix a `downstream_sync` slot leak: when the loop's
`finalize_local_op` returned None (transition to the next branch in
the queue), the in-flight handle was dropped without releasing,
leaving the final branch's slot stuck as `stacker|ops` after a
clean sync. The driver now wraps each branch's work in an `_owned_slot`
context manager so the release fires whether the iteration ends in a
return, a transition, an exception, or a `KeyboardInterrupt`.
`test_ls_runs_sources_concurrently` flaked under contended xdist
workers — elapsed times around 0.4s tripped the previous 2x = 0.4s
ceiling. Move to 2.5x; still well under the 3x serial fallback so a
real regression to serial would still trip the assertion.
The stack-block renderer was emitting `<sha>..<sha>` Files-changed URLs
(empty diffs) whenever a branch had local commits since init/sync, and
generating 404s for root branches off trunk because the trunk SHA isn't
in the PR's commit graph. Read the live branch ref via a new
`live_heads` map populated from the canonical repo, and emit
`<pr>/files` (no range) for trunk-rooted branches — same shape universe
gitstack's `get_diff_link` produces.
Replace the per-callsite ad-hoc release with a single
`release_if_clean` predicate (worktree has no `tracked_changes` or
`CHERRY_PICK_HEAD`) and an `acquired_for_op` context manager so
push/sync/absorb release the slot uniformly on success, exception, and
KeyboardInterrupt. `ops_slot.acquire` widens its except clause to
`BaseException` so a Ctrl+C between the pool claim and the caller's
try/finally no longer orphans the slot. The cherry-pick driver gets an
outer try/finally + a guard around `advance_downstream` so an interrupt
mid-queue still hands the in-flight slot back. `slot.resolve_slot`
falls back to cwd-reuse when the pool is exhausted, so a user sitting
in a slot of the same repo can still push without manually adding pool
capacity.
`pm check` splits stacker-owned slots into OPS_OWNED (mid-op, hold for
`pm stacker continue`) and STALE_OPS (clean worktree, leaked); `--fix`
releases the latter via the same `try_release_ops_slot` helper that
`release_if_clean` uses, so the dirty-check policy lives in one place.
The shared `git.detach_head` primitive replaces the open-coded
`git checkout --detach HEAD` in `ops_slot.release` and `ops/remove`.
`refresh_component_pr_bodies` had an override that did
`pr_map[tracked.branch] = current_pr`. The caller in `push.py` passed
`latest_pr` from the loop's last iteration, so when the resolved scope
included descendants and the focus branch wasn't last, the focus's
entry in `pr_map` got clobbered with the leaf's PR. Result: every line
referencing the focus in a stack block linked to the leaf's PR, the
focus PR's own body never got refreshed, and the "PR ready" message
pointed at the leaf.
`create_or_update_current_pr` already calls `record_pr` for every
iteration, so the cache-first lookup in `pr_map_for_component` resolves
each branch to its own PR without an override. Drop the override and
its parameter; capture the focus PR separately in `push.py` for the
final "PR ready" log.
OnUnitActiveSec drifts whenever the service runs longer than expected
or boots late, so the 15-minute cadence wandered off the quarter-hour.
OnCalendar=*:0/15 anchors runs to :00/:15/:30/:45 regardless of how the
prior run went.
Collapse the three duplicated worktree-acquisition policies (fresh `create`,
`create --replace` adoption, sync/push/etc.) into a single `stacker.slot`
module. Adds a cwd-reuse step that preserves the user's current pm slot
when it's detached, eliminating spurious ops-slot mints. `track()` now
auto-discovers an existing GitHub PR for the adopted branch as a
best-effort step.
Replace `pm stacker unlink-pr` with a `pm stacker pr` sub-app exposing
`unlink` (former behavior) and `refresh` (drop the cache row and
re-discover the PR via search), so a PR opened by another tool can be
linked without running `push`.
`create_or_update_current_pr` was passing the first-commit body to
`edit_pr` on every push of an existing PR, so any edits a user had
made to the PR description (e.g. filling in the repo's
PULL_REQUEST_TEMPLATE) got reset to the commit message before the
follow-up `refresh_component_pr_bodies` step ran.
Drop `body_file` from the existing-PR `EditPRRequest`. The refresh
step already updates only the managed `<!-- stacker:begin/end -->`
block via `pr_base_body` + `compose_body_with_block`, so user prose
around the block survives end-to-end. The body is still written on
initial PR creation.
Hit on real universe PR #1859413: editing the body to use the
template, then re-running `pm stacker push`, silently restored the
first-commit message.
Bracketed-pastes the shell-quoted argv into a `ghostel` buffer and
sends Return, alongside the existing term/vterm dispatchers.
The buffer headers said `UUID/Claim/Path' and the cell extractors
looked up `claim'/`path' — fields that no longer exist (and
possibly never did) on the JSON payload `pm pool ls --json' emits.
The CLI's per-slot shape is `(repo, uuid, branch, status)' where
`status' carries the claiming project name (or `FREE'/`OPS').
Every cell was coming back empty.
Fix: rename the columns and look up the real fields. RET on a
slot now resolves the on-disk path through `pm-pool--slot-path'
(`<pm-pool-worktrees-dir>/<repo>/<uuid>'), matching
`paths.worktree' on the Python side, and opens it in dired.
`pm-pool-worktrees-dir' is a defcustom so users with a non-default
pool layout can override.
Two regression tests:
pm-test--pool-list-renders-real-cli-fields — feeds the real CLI
shape through `pm-pool-refresh' (with `pm--pool-ls' mocked) and
asserts the buffer contains the branch and status. Red without
the fix.
pm-test--pool-slot-path-resolves-from-uuid-and-repo — pins the
path-construction convention.
Without a prefix arg, the global agent-list buffer now scopes to
every project — `pm agent ls --all --json'. The previous default
deferred to the CLI's cwd-aware behavior, which collapsed the view
to a single project whenever Emacs's `default-directory' happened
to land inside one (i.e. usually). The single-project view is
still available via `pm-agent-list-current-project' or `C-u M-x
pm-agent-list' (which prompts for the project).
Two pinning tests:
pm-test--agent-list-no-prefix-defaults-to-all — interactive
no-prefix call sets scope to `(nil . t)' and invokes
`pm--agent-ls' with `all=t'.
pm-test--agent-list-prefix-prompts-for-project — `C-u' prompts
for a project and scopes to it.
The CLI emits a sectioned payload (`JsonShape("project",
"sessions")` in `agent/cli/ls.py`): a top-level array of
`{project, sessions: [...]}` groups. The renderer was iterating
that array as if it were a flat list of sessions, so every cell
looked up `agent`/`session_id`/`title` on the *group* object —
got nil — and the buffer printed blank rows.
`pm-agent-list--flatten-groups' concatenates each group's
`sessions' list (each session already carries its `project'
field via `AgentRow.__pm_json__'); the rest of the renderer is
unchanged.
Two regression tests:
pm-test--agent-list-flattens-sectioned-json — unit test on the
helper.
pm-test--agent-list-render-shows-rows-after-refresh — feeds the
real CLI shape through `pm-agent-list-refresh' (with
`pm--agent-ls' mocked) and asserts the buffer contains the
session id, title, and agent name. Red without the fix.
Real root cause of the post-command-hook crash. When the user
activates a region that begins on a position inside the root
section's heading (e.g. point-min on our `pm: <name>' line),
magit-section's post-command path calls `(magit-region-sections)',
which in turn calls `(magit-section-at rbeg)' and feeds the result
to `magit-section-siblings'. `magit-section--set-section-properties'
explicitly skips the root, so root-heading text and any inter-child
gap have no `magit-section' property; `magit-section-at' returns nil
there; `(magit-section-siblings nil 'next)' calls `(oref nil
parent)' inside `and-let*' and signals
`(wrong-type-argument (or eieio-object cl-structure-object oclosure)
nil)'.
Fix: `pm-table-cover-root-section' walks the root's [start..end] at
the end of every render and stamps the property anywhere it's
missing — same logic `magit-section--set-section-properties' uses
for non-root sections. Called from every render in pm-status,
pm-list, pm-pool, pm-repo, pm-agent.
Adds two genuinely red regression tests:
pm-test--status-render-covers-root-with-section-property — every
char in the buffer must carry `magit-section' (the structural
invariant).
pm-test--status-region-from-root-heading-no-error — direct
`magit-region-sections' call with point-min in the region —
repros the live error path without needing a frame.
Both fail without the fix; both pass with it.
pm-sidebar--render rebuilt the buffer with erase-buffer but never deleted
the overlays the previous render created (visibility indicators, magit
highlight/selection). erase-buffer only collapses overlays to point 1, so
they accumulated every refresh; the leaked overlays anchor markers near
point 1, turning every insert into O(stale-markers) and growing RSS without
bound until the single-threaded daemon pegged a core and wedged (observed
23GB+ RSS, unrecoverable without a daemon restart).
- render: remove-overlays before erase-buffer (stops the leak)
- handle + window-size-change: coalesce renders behind a 0.2s timer so an
SSE burst (snapshot replay, status churn, resize storm) collapses into
one render instead of one full rebuild per event
- proc-filter: walk lines with a running index + one trailing substring
(was O(n^2) substring-per-line), use string-replace, and cap the
accumulator to guard a non-terminating line
Add ERT coverage: overlay cleanup, render coalescing, quit cancels the
render timer, chunked-SSE parsing, accumulator cap.
create_or_update_current_pr treated every find_open_pr hit as a PR pm
already owns, refreshing only the managed stack block and preserving the
existing title/body. But find_open_pr matches by head ref, and stack/*
branches get recycled for new work -- a reused branch can land on a
still-open PR from an unrelated prior feature, whose title/description
belong to the wrong work. The result was a pushed PR titled and described
for the old feature (a stale "[AUTH-5322] ..." title/body surviving onto
an unrelated branch).
Snapshot whether pm already had a pr_state row for the branch before
find_open_pr runs. With a cache row, keep the preserve-edits behavior.
Without one (a fresh head-ref-search adoption), claim the PR the way a
create would: set the title to this branch's bottom-commit subject and
reset the body to its first commit, then let refresh_component_pr_bodies
splice in the managed stack block.
Adds test_push_resets_title_body_when_adopting_recycled_pr.
pm-sidebar-visit now branches on the section: a session row still jumps to
its terminal buffer; a project heading opens `pm-project-dispatch' scoped to
that project (binds default-directory to its container so the dispatch path
resolves without prompting). The "(no project)" placeholder errors. Adds
red/green tests for both project branches.
- Rename the sidebar module pm-serve.el -> pm-sidebar.el and every pm-serve*
symbol -> pm-sidebar* (pm-agent-serve-source, a daemon concept, is left as
is). pm.el autoloads `pm-sidebar'.
- `pm-sidebar' is now a toggle: open in a left side window if hidden, hide the
window if showing (the SSE connection stays live for an instant re-open).
`pm-sidebar-quit' still tears the connection down.
- Preserve point and window scroll across a refresh: the render rebuilds the
whole buffer, which used to yank the cursor to the top on every SSE update.
- Tests: rename pm-test--serve-* -> pm-test--sidebar-* and add a regression
test that point survives a re-render.
The daemon is shared across Emacs instances, and they all tag agents with
the same PM_META_SOURCE, so the sidebar's SOURCE-only filter showed every
instance's agents. Inject PM_META_EMACS (the Emacs pid, already the prefix
of the buffer id) on launch and filter the sidebar on SOURCE *and* EMACS,
so each Emacs sees only the sessions it launched.
pm-serve.el: a magit-section sidebar that streams session state from
`pm agent serve` over SSE, grouped by project, colored by status, mapping a
row back to its launching buffer via the injected PM_META_BUF. The SSE
connection is async (`:nowait`) and auto-reconnects on drop, so a daemon
restart never wedges or strands the sidebar.
pm-agent.el: inject the PM_META_* launch seeds into *every* ghostel session
via `ghostel-pre-spawn-hook` (installed by `pm-agent-setup-ghostel`), so an
agent started by hand in any ghostel buffer is tracked too — not only
`pm-agent-launch` dispatches. The ghostel dispatcher drops its now-redundant
manual env seeding.
A headless daemon (stdlib threaded HTTP + SSE, ephemeral WAL SQLite) that
ingests best-effort hook events from Claude/Codex/Cursor and broadcasts live
session state to observers (the Emacs sidebar, curl).
- serve/{server,store,status,fallback}: HTTP+SSE server, WAL store, pure
status-derivation, and a Tier-2 transcript fallback poller. Status vocab:
working / waiting_response / waiting_permission / idle / unknown, with
openui-style activity-gating and a pid-liveness cleanup backstop.
- install-hooks: a shared stdlib reporter installed for Claude (settings.json),
Codex (a --profile pm config, since isaac regenerates ~/.codex/hooks.json),
and Cursor (~/.cursor/hooks.json, incl. shell/MCP approval). Skips the
Claude-hook double-report Cursor triggers.
- transcript: shared title library (custom-title > ai-title > synthetic >
first prompt) that also parses Codex rollouts natively; the fallback fills
source-reader titles (e.g. Cursor) for sessions it cannot parse.
- config [serve], opt-in WAL in sqlite_db, systemd unit, tests.
`pm project status` is a report, not a health gate — it should fail
only on an actual error (unknown project, IO), not because a worktree
is non-ACTIVE. The old `return 1 if any non-ACTIVE worktree` treated
intentional DETACHED worktrees (and informational DRIFT / mid-op
OPS_OWNED) as failures, which made the Emacs status/stacker buffers
refuse to render valid output. Always return 0 on success; genuine
errors still raise and exit non-zero via the central CLI handler.
`pm check` remains the linter-style gate whose exit code tracks health.
Build out interactive stacker support in the pm Emacs integration,
backed by structured CLI output.
Python:
- pm project status --json now carries the structured stacker tree per
repo (StackerRow.branches / current_branch / operation), reusing the
same branch selection the text arm renders (ls.ls_structure +
arm_branch_set). Text mode is unchanged; --json skips the text render
to stay at one prefetch per repo.
- Every `pm stacker` subcommand gains --json via a uniform envelope
(_common.emit_result): {ok, command, repo, branch, message, operation}.
message is the raw markup result; operation surfaces a paused/
conflicted op so a client can offer continue/abort. ls/config keep
their existing richer JSON.
Emacs (new pm-stacker.el + integration):
- *pm-stacker: <project>* — a foldable, multi-repo branch tree with
CLI-matching status icons, commit-group markers (count/needs-sync/
ahead), current-branch + merged styling, and a paused-op banner.
- Branch-scoped action transient (RET) covering the full verb set with
infix flags; direct keys for the common verbs. Every action runs
`pm stacker <verb> --json` and refreshes; destructive verbs confirm.
- The status buffer's Stacker section reuses the same renderer, so it's
now the same interactive tree (closes the old plain-face gap).
- Branch rows use a dedicated section class (pm-stacker-branch-section)
whose highlight covers only the heading line — so a repo heading
highlights its whole block (table-style) while a branch row (even a
parent) highlights as a single line, with folding intact.
- Faces: pm-faces-stacker-status (icon+face per status mirroring
graph.py), pm-stacker-current, pm-stacker-merged, strike markup token.
Tests: status structured-tree + --json envelope (Python); stacker tree
render, faces, RET scope, wrapper argv (Emacs). README documents the
buffer, transient, and the --json envelope.
Theme every pm magit-section buffer consistently and fix two
buffer bugs surfaced while doing it.
Theming (pm-faces.el, pm-table.el + all renderers):
- distinct face hierarchy — pm-buffer-title / pm-section-heading
with dim pm-count / pm-group-heading / pm-column-header (a quiet
ruler, previously reused the section-heading face) / rows
- themed value columns: repo dirty/ahead/behind, pool & project
status (pm-faces-status), session ids (pm-id) + dim timestamps,
agent names
- dim "(none)" for empty sections
- centralized via pm-table-banner/heading/row-faced/insert-* so
every buffer renders identically
Apply faces via font-lock-face, not face (pm-propertize-face,
mirroring magit--propertize-face): magit-section-mode keeps
font-lock on with font-lock-defaults '(nil t), and jit-lock strips
the plain face property on display — only font-lock-face survives.
This is what makes the theming actually show up.
Per-project agent-ls buffers (pm-agent.el): pm-agent-list--buffer-name
names single-project views *pm-agent-ls: <project>* so they no
longer clobber each other or the all-projects view.
Stacker rich-markup body left on plain face for now (no stacker
integration yet). Tests + README updated.
- pr/resolve: when a tracked parent's PR is MERGED, treat it as
transparent and recurse to resolve the child's PR base against the
grandparent (sync collapses a merged parent to a no-commit branch,
so the child should target whatever the parent would have). Renames
find_open_pr -> find_pr and distinguishes MERGED vs non-OPEN states.
- pool/worktree: skip orphan gitlinks during submodule init — a gitlink
in HEAD with no .gitmodules URL can never be initialized (common after
an upstream submodule removal), so continue instead of erroring.
- tests: cover the merged-parent PR-base resolution.
Follow-up to the wt add/remove CLI rename: the core ops are worktree
addition/removal (container lifecycle is a side effect), so name them
that way. project/create.py -> add.py (add, AddedWt, ADDED_COLUMNS),
project/delete.py -> remove.py (remove, plan_remove, RemovePlan),
emit_delete_plan -> emit_remove_plan. Also fixes the stale
attach|detach|delete reference in the generated project README
template. CLI command names are untouched.
Clean rename, no back-compat aliases. Updates every integration:
zsh completion (regenerated _pm; also fixes the stale --repo
substitution that had lost _pm_repos completion), the pm-workflow
Claude Code skill (plugin bumped to 0.1.1), and the Emacs package
(pm-wt-add/pm-wt-remove function chain, prompts, and transient).
Updates the pm-stacker-workflow skill so Claude reaches for the new
flags by name and understands the new merged-PR collapse:
- Sync table loses the obsolete cherry-pick mode row, gains
--allow-drop-parent-modifications, --allow-drop-merge, --offline.
- Reparent row picks up the same gate / offline flags.
- New "Sync Gates" section explains when each gate fires, what the
--allow-drop-* flags do, and when the right answer is to recover
from reflog or `pm repo pull` instead of using the flag.
- "After a PR Is Merged" rewritten: a plain `pm stacker sync` now
auto-collapses the merged branch to a no-commit branch; removal
is a follow-up cleanup, not a prerequisite.
- "Common Mistakes" gains rows steering away from the
--allow-drop-* flags as a default response to a gate failure.
- `pm stacker ls --json` field-use cheat sheet distinguishes
`merged + commit_count > 0` (needs sync to collapse) from
`merged + commit_count == 0` (already collapsed, ready to remove).
Sync always cherry-picks `managed_base..HEAD` now — the patch-id dedup
path was misfiring whenever a parent was rewritten in place, replaying
shared commits the child never authored. The old `--hard` opt-in is
gone; cherry-pick uses `--empty=drop` so absorb→sync flows still work.
Two preflight gates protect the replay:
- `--allow-drop-parent-modifications` fires when the branch's recorded
`last_synced_parent_commit` is no longer in the slot HEAD's
ancestry. Catches out-of-band history surgery below the stack range
(`git rebase --onto` from outside stacker, amends touching pre-stack
commits). Without the flag, sync errors; with it, those changes are
dropped and only the branch's working commits are replayed.
- Merged-PR auto-collapse: when the cached `pr_state` says the PR is
MERGED, sync skips the cherry-pick and resets the branch to its
parent tip (no-commit branch). Squash presence is detected via
`git merge-tree --write-tree` against the parent. If the squash is
missing, `--allow-drop-merge` is required to perform the same reset.
`--offline` skips the upfront one-batched-GraphQL-call-per-repo
`pr_state` refresh and uses cached state. Gate and collapse flags
travel through the layers as a `SyncOptions` dataclass (mirrors
`PushOptions`); `OperationState` persists the two opt-out booleans so
multi-branch downstream syncs apply them consistently.
`tests/stacker/test_sync.py` swaps the six `--hard` tests for eleven
new ones covering the gate, the collapse, the offline path, and the
chained no-commit-branch case (mocked via `RecordingPRBackend`). Two
`reparent --hard` tests collapse into one that asserts the
exact-range behavior is now the default.
`make hooks-install` points `core.hooksPath` at `.githooks/` so commits
run lint locally. The hook chains to `$PM_PRE_COMMIT_CHAIN/pre-commit`
when set so a global Databricks pre-commit still gets to run its
secret-scan / commit-msg checks.
The lint target now exits with an explicit "don't use noqa / type:
ignore to silence" hint so contributors fix the underlying issue
instead of suppressing the warning.
push, sync, ls, and PR-body rendering all default to the "current"
scope, which previously walked only ancestors + target + descendants
of target. In a multi-arm stack this silently dropped sibling arms:
pushing from one arm only created/refreshed PRs on the spine, sync
left cousins unrebased, ls hid them, and PR description trees got
overwritten without the missing arm.
lineage(target) now walks up to the topmost-tracked ancestor and
toposorts its full subtree — same result for linear stacks, full
component for multi-arm. resolve_scope's --skip-ancestors /
--skip-descendants switch to structural ancestor/descendant lookups
so the slice still drops the right branches when target sits between
sibling arms in the toposort.
`("l" "sessions" pm-agent-list-current-project)` in
`pm--project-dispatch-menu' was reading `default-directory' (which
has reverted to the original buffer by the time the suffix runs)
instead of the bound project: picking project B from a buffer in
project A silently listed A's sessions.
`pm-agent-list-current-project' now consults `(transient-scope)' as
the highest-priority source on its context chain, matching the
convention every other project-dispatch suffix already follows.
M-x invocations are unchanged (transient-scope is nil outside a
transient → falls through).
The prior README leaned on implementation specifics (SQLite db
layouts, PRIMARY KEY race, ownership semantics) that nobody installing
`pm` for the first time needs. Drop those, describe each of the four
layers (pool, project, stacker, repo) in user-facing terms, and
update the command listing to match the current CLI surface (project
wt subcommands, repo subcommand, agent runners, stacker absorb / pr
refresh).
Add an Integrations section that surfaces the three things people
actually want to plug in: zsh hooks (`pm-git-guard`, `pm-cd`,
completions), the Claude Code skill at integrations/claude-code/, and
the Emacs package at integrations/emacs/. Keep Testing as the last
section before Acknowledgements.
Drop the universe `ci/gitstack` mention (PR caching has diverged
enough that the comparison no longer pays its way) and the
`pool/worktree.py` line (the wt-init port is small enough that a
single attribution covers both that and the stacker model). Keep one
focused acknowledgement of Rodrigo Gomes' standalone stacker.
`create_or_update_current_pr` was unconditionally syncing the PR title
from the bottom-commit subject on every push. That meant any manual
title rewrite — via `gh pr edit --title` or the GitHub UI — got
clobbered the next time the user ran `pm stacker push`, even when no
new commits were pushed (the post-push body refresh alone is enough
to trigger the title write).
Skip the title field on `EditPRRequest` when the live PR title no
longer matches the computed bottom-commit subject. We can't tell from
cache whether the divergence came from a UI edit or from an amended
bottom commit whose subject hasn't been propagated, so the safe
default is to never overwrite. Users who amend the bottom commit and
want the new title on GitHub can re-set it once with `gh pr edit
--title <subject>`; subsequent pushes then keep it in sync (until the
next divergence).
Two tests cover the contract: the manual-edit case (no title in
EditPRRequest), and the idempotent-match case (title still passed
through when it matches).
`pr_base_for_current_branch` previously called `remote_branch_name(parent)`,
which read the worktree-local `branch.<name>.merge` git config and so
required the parent branch to be checked out in a pm pool slot.
During `pm stacker push`, the slot is released after each branch is pushed.
When the next iteration resolves a child's PR base, the parent's slot is
already gone and the run fails late with:
pm: <repo>:<parent> is not checked out in any worktree.
Check it out in a pm pool slot first.
`find_open_pr` already returned a `gh.PullRequest` with `head_ref_name`
populated by gh, so use that directly. No worktree dependency, no extra
network call beyond the one we already make.
Add two regression tests:
- parent tracked + cached PR + parent absent from every worktree → returns
parent's `head_ref_name`.
- parent open PR with empty `head_ref_name` → clear `GitError` instead of
silently emitting `base=""` to `gh pr edit`.
`(ghostel)` with no prefix arg falls into the `get-buffer-create`
branch and reuses an existing buffer of the same name; the second
launch of the same agent/project would then paste the new argv
into a buffer with a live agent already running.
Pass a non-numeric, non-nil prefix arg so ghostel routes through
`generate-new-buffer' and appends `<2>`, `<3>`, ... when the base
name is taken.
Renamed the per-verb stderr buffer from `*pm: <verb>*` to
` *pm-err: <verb>*` so it stays out of the buffer list. Emacs's
implicit stderr-pipe sentinel writes a "Process … stderr finished"
line on every successful exit; surfacing that buffer to the user
created startup noise (e.g. `pm-refresh` on `after-init-hook`).
`pm--surface-error` still reads the same buffer for `display-warning`
text on non-zero exits — only the visibility changed.
Project-scoped buffers (pm-status, pm-agent-list when given a
project) bind default-directory to the container so transient,
project-current, and pm--container-of resolve to the right project
from inside the buffer.
Global buffers (pm-list, pm-pool, pm-repos, all-projects
pm-agent-list) bind default-directory to a new pm-manager-project
defcustom (a project name); when unset, they fall back to ~.
Adds `pm-agent-launch' for spawning a new `pm agent <name>' session,
flowing through the same `pm-agent-dispatch-function' as resumes.
Wires it into:
- `pm-agent-launch-dispatch' (autoloaded transient, picks
claude/codex/cursor)
- `pm-project-dispatch' `r' suffix (forwards parent scope)
- `pm-dispatch' `r' suffix (prompts for project when outside a tree)
- `project-switch-commands' `?a' binding
`pm project ls` walked every `.pm.db` but only emitted rows per
worktree finding, so projects with no attached worktrees produced no
rows and `sections()` dropped them entirely from JSON and text output.
The Emacs integration drives off `pm project ls --json`, so empty
projects were invisible there too — and `pm-project-create` insisted on
≥1 worktree, even though the CLI accepts zero.
- `sections()` takes an optional projects list to seed empty groups.
- `build_sections_table` emits a stub row for empty groups (without
bumping `flat_idx`, so the picker's row indexing stays aligned).
- `pm-project-create` now allows RET-empty and offers to switch into
the bare container when no worktrees were created.
magit-section's `(CHAR . CHAR)' visibility-indicator form draws into
the buffer's left margin, but `magit-section-mode' itself doesn't
allocate one — full magit handles that via `magit-set-buffer-margins'
in magit-margin.el. pm only depends on magit-section, so it mirrors
the relevant pieces here.
- pm-table.el: add `pm-section-set-window-margin' /
`pm-section-setup-margin' (mirrors `magit-set-window-margins') and
`pm-table-show-root-section' (populates visibility-indicator overlays
via `magit-section-show', mirroring `magit-refresh-buffer').
- pm-list, pm-pool, pm-repo, pm-status, pm-agent: call
`pm-section-setup-margin' from each mode body and after each
entry-point's `pop-to-buffer'; move root-section banners off
`magit-insert-heading' to plain `insert' so the root has no `content'
slot and no buffer-level indicator appears for the banner.
`pm project wt create' takes SPEC as the first positional with PROJECT
named (--project / -p); the wrapper was sending `NAME --wt SPEC' which
failed with `Unknown option: --wt' against current cyclopts CLI. Swap to
`SPEC --project NAME'.
`pm project delete' has no `--repos' flag — the wrapper declared a
`repos' parameter that mapped to it. The only call site already passed
`nil', so it never fired at runtime, but the dead surface is misleading.
Drop the parameter and the call-site `nil'.
On PR creation, load `.github/PULL_REQUEST_TEMPLATE.md` from the worktree
and inject the first commit's body into the template's first `## ` section
(mirrors universe gitstack `inject_body_into_template`). Update path stays
body-less so user edits to the template-filled body survive re-push
(universe PR #1859413 regression guard).
Reformat the managed block to ReviewStack's gitstack layout: emit
`## 🥞 Stacked PR` header + `---------` separator inside the existing
`<!-- stacker:begin/end -->` markers. The HTML markers remain our
authoritative parsing boundary; the inner header + separator are what
ReviewStack's gitstackParser keys off so PRs are recognized as stacked.
Default sync builds the cherry-pick list with `git rev-list --cherry-pick
--right-only <parent>...<child>` — the same patch-id heuristic `git
rebase` uses to drop already-applied commits. When a parent branch is
rewritten in place (e.g. a stacker branch is re-edited and its commits
get new patch-ids while keeping the same logical change), the child's
copy of the now-superseded commit no longer matches the parent by
patch-id, and sync replays the duplicate, hitting avoidable conflicts.
`--hard` is the explicit escape hatch: instead of the patch-id walk, it
takes the recorded `managed_base_commit` from the stacker DB and
cherry-picks exactly `<managed_base>..<HEAD>` — the commits the child
itself added since its last sync. The flag flows through the existing
sync entry point, persists on `OperationState` so `pm stacker continue`
can resume a paused `--hard` sync without re-passing the flag, and
applies to every branch in a multi-branch (downstream_sync) walk. The
new column is migrated lazily on existing DBs.
Two SKILL.md files behind one plugin manifest, mirroring the existing
zsh / emacs / systemd integrations under integrations/. pm-workflow
covers projects, worktree slots, pool, and agent launches; pm-stacker-
workflow covers the cherry-pick stacked-PR loop. Both skills bake
--json into the read-state critical rules so an invoking agent parses
output rather than the human-readable tree/table rendering.
`pm cd <project> [<wt>]` is now a real directory change via the new
`integrations/pm-cd.zsh` wrapper (the python program still prints the
path; the wrapper captures stdout and runs `builtin cd`). `pm cd
--print …` keeps the previous print-only behavior for scripting.
Drive-by: ruff RUF022 fixes for unsorted `__all__` in three pre-existing
files (auto-applied).
Three styles existed: dataclass (ProjectFlag/RepoFlag), bare arg
(repo: str | None = None), and a one-off Annotated alias in agent/run.
--repo had no -r short form anywhere. New cli/_params.py owns every
form (annotated aliases, dataclass wrappers, resolvers); _shared.py
re-exports the dataclasses and rewires its scope dataclasses through
the new aliases, so every stacker command picks up -r for free.
Prints the project dir (or the forward symlink to a wt) so the caller
can wrap it: `cd "$(pm cd myproj wt1)"`. Errors clearly when the wt is
detached so a shell function doesn't silently land in the wrong place.
Wires zsh completion for both positionals via `_pm_wt_for_cd`, which
reads the project from `$words[3]` and falls back to `--project`.
Default-scope push from a leaf walked back through every tracked
ancestor and force-pushed each one. Once an ancestor PR merged, its
branch stuck around in the DB until `remove`, so subsequent pushes
clobbered the merged ref on GitHub and rewrote the closed PR's body.
Filter merged entries out of the resolved scope, log each skip, and
short-circuit when the target itself is merged.
`DriveHandle` was a shadow of `AcquiredSlot` that the cherry-pick driver
constructed from the same path/ops pair callers already had. Replace
it with `AcquiredSlot` and reuse `slot.release_if_clean` so the
release predicate lives in one place.
While there, fix a `downstream_sync` slot leak: when the loop's
`finalize_local_op` returned None (transition to the next branch in
the queue), the in-flight handle was dropped without releasing,
leaving the final branch's slot stuck as `stacker|ops` after a
clean sync. The driver now wraps each branch's work in an `_owned_slot`
context manager so the release fires whether the iteration ends in a
return, a transition, an exception, or a `KeyboardInterrupt`.
The stack-block renderer was emitting `<sha>..<sha>` Files-changed URLs
(empty diffs) whenever a branch had local commits since init/sync, and
generating 404s for root branches off trunk because the trunk SHA isn't
in the PR's commit graph. Read the live branch ref via a new
`live_heads` map populated from the canonical repo, and emit
`<pr>/files` (no range) for trunk-rooted branches — same shape universe
gitstack's `get_diff_link` produces.
Replace the per-callsite ad-hoc release with a single
`release_if_clean` predicate (worktree has no `tracked_changes` or
`CHERRY_PICK_HEAD`) and an `acquired_for_op` context manager so
push/sync/absorb release the slot uniformly on success, exception, and
KeyboardInterrupt. `ops_slot.acquire` widens its except clause to
`BaseException` so a Ctrl+C between the pool claim and the caller's
try/finally no longer orphans the slot. The cherry-pick driver gets an
outer try/finally + a guard around `advance_downstream` so an interrupt
mid-queue still hands the in-flight slot back. `slot.resolve_slot`
falls back to cwd-reuse when the pool is exhausted, so a user sitting
in a slot of the same repo can still push without manually adding pool
capacity.
`pm check` splits stacker-owned slots into OPS_OWNED (mid-op, hold for
`pm stacker continue`) and STALE_OPS (clean worktree, leaked); `--fix`
releases the latter via the same `try_release_ops_slot` helper that
`release_if_clean` uses, so the dirty-check policy lives in one place.
The shared `git.detach_head` primitive replaces the open-coded
`git checkout --detach HEAD` in `ops_slot.release` and `ops/remove`.
`refresh_component_pr_bodies` had an override that did
`pr_map[tracked.branch] = current_pr`. The caller in `push.py` passed
`latest_pr` from the loop's last iteration, so when the resolved scope
included descendants and the focus branch wasn't last, the focus's
entry in `pr_map` got clobbered with the leaf's PR. Result: every line
referencing the focus in a stack block linked to the leaf's PR, the
focus PR's own body never got refreshed, and the "PR ready" message
pointed at the leaf.
`create_or_update_current_pr` already calls `record_pr` for every
iteration, so the cache-first lookup in `pr_map_for_component` resolves
each branch to its own PR without an override. Drop the override and
its parameter; capture the focus PR separately in `push.py` for the
final "PR ready" log.
Collapse the three duplicated worktree-acquisition policies (fresh `create`,
`create --replace` adoption, sync/push/etc.) into a single `stacker.slot`
module. Adds a cwd-reuse step that preserves the user's current pm slot
when it's detached, eliminating spurious ops-slot mints. `track()` now
auto-discovers an existing GitHub PR for the adopted branch as a
best-effort step.
Replace `pm stacker unlink-pr` with a `pm stacker pr` sub-app exposing
`unlink` (former behavior) and `refresh` (drop the cache row and
re-discover the PR via search), so a PR opened by another tool can be
linked without running `push`.
`create_or_update_current_pr` was passing the first-commit body to
`edit_pr` on every push of an existing PR, so any edits a user had
made to the PR description (e.g. filling in the repo's
PULL_REQUEST_TEMPLATE) got reset to the commit message before the
follow-up `refresh_component_pr_bodies` step ran.
Drop `body_file` from the existing-PR `EditPRRequest`. The refresh
step already updates only the managed `<!-- stacker:begin/end -->`
block via `pr_base_body` + `compose_body_with_block`, so user prose
around the block survives end-to-end. The body is still written on
initial PR creation.
Hit on real universe PR #1859413: editing the body to use the
template, then re-running `pm stacker push`, silently restored the
first-commit message.
The buffer headers said `UUID/Claim/Path' and the cell extractors
looked up `claim'/`path' — fields that no longer exist (and
possibly never did) on the JSON payload `pm pool ls --json' emits.
The CLI's per-slot shape is `(repo, uuid, branch, status)' where
`status' carries the claiming project name (or `FREE'/`OPS').
Every cell was coming back empty.
Fix: rename the columns and look up the real fields. RET on a
slot now resolves the on-disk path through `pm-pool--slot-path'
(`<pm-pool-worktrees-dir>/<repo>/<uuid>'), matching
`paths.worktree' on the Python side, and opens it in dired.
`pm-pool-worktrees-dir' is a defcustom so users with a non-default
pool layout can override.
Two regression tests:
pm-test--pool-list-renders-real-cli-fields — feeds the real CLI
shape through `pm-pool-refresh' (with `pm--pool-ls' mocked) and
asserts the buffer contains the branch and status. Red without
the fix.
pm-test--pool-slot-path-resolves-from-uuid-and-repo — pins the
path-construction convention.
Without a prefix arg, the global agent-list buffer now scopes to
every project — `pm agent ls --all --json'. The previous default
deferred to the CLI's cwd-aware behavior, which collapsed the view
to a single project whenever Emacs's `default-directory' happened
to land inside one (i.e. usually). The single-project view is
still available via `pm-agent-list-current-project' or `C-u M-x
pm-agent-list' (which prompts for the project).
Two pinning tests:
pm-test--agent-list-no-prefix-defaults-to-all — interactive
no-prefix call sets scope to `(nil . t)' and invokes
`pm--agent-ls' with `all=t'.
pm-test--agent-list-prefix-prompts-for-project — `C-u' prompts
for a project and scopes to it.
The CLI emits a sectioned payload (`JsonShape("project",
"sessions")` in `agent/cli/ls.py`): a top-level array of
`{project, sessions: [...]}` groups. The renderer was iterating
that array as if it were a flat list of sessions, so every cell
looked up `agent`/`session_id`/`title` on the *group* object —
got nil — and the buffer printed blank rows.
`pm-agent-list--flatten-groups' concatenates each group's
`sessions' list (each session already carries its `project'
field via `AgentRow.__pm_json__'); the rest of the renderer is
unchanged.
Two regression tests:
pm-test--agent-list-flattens-sectioned-json — unit test on the
helper.
pm-test--agent-list-render-shows-rows-after-refresh — feeds the
real CLI shape through `pm-agent-list-refresh' (with
`pm--agent-ls' mocked) and asserts the buffer contains the
session id, title, and agent name. Red without the fix.
Real root cause of the post-command-hook crash. When the user
activates a region that begins on a position inside the root
section's heading (e.g. point-min on our `pm: <name>' line),
magit-section's post-command path calls `(magit-region-sections)',
which in turn calls `(magit-section-at rbeg)' and feeds the result
to `magit-section-siblings'. `magit-section--set-section-properties'
explicitly skips the root, so root-heading text and any inter-child
gap have no `magit-section' property; `magit-section-at' returns nil
there; `(magit-section-siblings nil 'next)' calls `(oref nil
parent)' inside `and-let*' and signals
`(wrong-type-argument (or eieio-object cl-structure-object oclosure)
nil)'.
Fix: `pm-table-cover-root-section' walks the root's [start..end] at
the end of every render and stamps the property anywhere it's
missing — same logic `magit-section--set-section-properties' uses
for non-root sections. Called from every render in pm-status,
pm-list, pm-pool, pm-repo, pm-agent.
Adds two genuinely red regression tests:
pm-test--status-render-covers-root-with-section-property — every
char in the buffer must carry `magit-section' (the structural
invariant).
pm-test--status-region-from-root-heading-no-error — direct
`magit-region-sections' call with point-min in the region —
repros the live error path without needing a frame.
Both fail without the fix; both pass with it.