Merge feature/kvb-git-pkgmgr-wrappers-replay into unify/skills-20261112

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-12 16:26:39 +00:00
commit 8c5c0b118e
22 files changed
+404 -839

No files matched your search

+8
View File
@@ -26,6 +26,14 @@ distributions.
natively from v2.1.277; Gemini CLI once its `context.fileName` setting
lists `AGENTS.md`.
## Scope against package-manager
Deterministic logic an operator could run without an agent (branches,
worktrees, cleanups, releases) lives in
[package-manager](https://github.com/kevinveenbirkenbach/package-manager) as
a `pkgmgr` command with its tests, never as a script in a skill. A skill
wrapping it holds only the agent discipline: claims, picks, handover.
## Code questions
Ask the codebase-memory-mcp graph first (`search_graph`, `trace_path`,
+34 -11
View File
@@ -55,8 +55,11 @@ flowchart LR
git --> git_pr_create[kvb-git-pr-create]
git --> git_pr_label[kvb-git-pr-label]
git --> git_update_branches[kvb-git-update-branches]
git --> git_branch_gc[kvb-git-branch-gc]
git --> git_orphans_find[kvb-git-orphans-find]
git --> git_branch_cleanup[kvb-git-branch-cleanup]
git --> git_worktree_cleanup[kvb-git-worktree-cleanup]
git --> git_gc[kvb-git-gc]
git --> git_worktree_up[kvb-git-worktree-up]
git --> git_worktree_orphans[kvb-git-worktree-orphans]
release --> release_changelog[kvb-release-changelog]
release --> release_article[kvb-release-article]
release --> release_post[kvb-release-post]
@@ -164,8 +167,11 @@ flowchart LR
pr_create[kvb-git-pr-create]
pr_label[kvb-git-pr-label]
update_branches[kvb-git-update-branches]
branch_gc[kvb-git-branch-gc]
orphans_find[kvb-git-orphans-find]
branch_cleanup[kvb-git-branch-cleanup]
worktree_cleanup[kvb-git-worktree-cleanup]
git_gc[kvb-git-gc]
worktree_up[kvb-git-worktree-up]
worktree_orphans[kvb-git-worktree-orphans]
hotfix[kvb-git-hotfix]
git_push[kvb-git-push]
git_diff[kvb-git-diff]
@@ -287,11 +293,16 @@ flowchart LR
coordinate --> democracy
cleanup --> democracy
cleanup --> orphans_find
branch_gc --> democracy
orphans_find --> branch_gc
orphans_find --> choice
orphans_find --> operator
cleanup --> worktree_orphans
branch_cleanup --> democracy
worktree_cleanup --> branch_cleanup
git_gc --> branch_cleanup
git_gc --> worktree_cleanup
worktree_up --> democracy
worktree_up --> rasci
worktree_orphans --> git_gc
worktree_orphans --> choice
worktree_orphans --> operator
funnel --> democracy
funnel --> dialectic
funnel --> subagent_model
@@ -302,7 +313,7 @@ flowchart LR
unify --> funnel
unify --> rasci
unify --> gate
unify --> branch_gc
unify --> branch_cleanup
unify --> cleanup
unify --> radio
status_matrix --> rasci
@@ -349,7 +360,7 @@ flowchart LR
dream --> dialectic
dream --> optimize
dream --> cleanup
dream --> orphans_find
dream --> worktree_orphans
optimize --> skill_cleanup
optimize --> transcend
optimize --> autoskill
@@ -410,6 +421,18 @@ flowchart LR
refactor --> clean
```
## pkgmgr wrappers 🔗
These `git` skills are thin wrappers: the [pkgmgr](https://github.com/kevinveenbirkenbach/package-manager#worktrees-and-garbage-collection) command holds the logic, the skill adds claims, picks and the operator handover.
| Skill | pkgmgr command |
| --- | --- |
| `kvb-git-worktree-up` | [`pkgmgr worktree up`](https://github.com/kevinveenbirkenbach/package-manager#worktrees-and-garbage-collection) |
| `kvb-git-worktree-cleanup` | [`pkgmgr worktree cleanup`](https://github.com/kevinveenbirkenbach/package-manager#worktrees-and-garbage-collection) |
| `kvb-git-branch-cleanup` | [`pkgmgr branch cleanup`](https://github.com/kevinveenbirkenbach/package-manager#worktrees-and-garbage-collection), `pkgmgr branch purge` |
| `kvb-git-gc` | [`pkgmgr gc`](https://github.com/kevinveenbirkenbach/package-manager#worktrees-and-garbage-collection) |
| `kvb-git-worktree-orphans` | [`pkgmgr worktree orphans`](https://github.com/kevinveenbirkenbach/package-manager#worktrees-and-garbage-collection) |
## Requirements 📦
- Node.js (provides `npx skills`).
+4 -6
View File
@@ -25,13 +25,11 @@ Code: `sandbox.filesystem.allowWrite`) and session scratch dirs; never
another user's files. Prefer the project's cleanup and teardown targets; obey
rules forbidding raw commands.
- **/tmp**: `du -sh` per own top-level entry; system entries (`.X11-unix`,
`.ICE-unix`, `systemd-private-*`, `ssh-*`, `tmux-*`, sockets) are never
candidates.
- **/tmp**: `pkgmgr gc --report --docker --root /tmp --root <write dir>`,
else `du -sh` per own entry; system entries (`.X11-unix`, `.ICE-unix`, `systemd-private-*`,
`ssh-*`, `tmux-*`, sockets) are never candidates.
- **Docker**, **worktrees**: read `docker.md` (its `⚠ Volumes` checklist
joins Step 4) and `worktrees.md` next to this file when present. Only at
watch stage `warn` or worse, or with worktrees among the biggest consumers:
`kvb-git-orphans-find --sizes`, its largest orphaned worktrees as candidates.
joins Step 4) and `worktrees.md` next to this file when present.
- **Agent dirs**: pip, npm, pre-commit, act, gh, buildx caches; Ansible and
build temp dirs.
- **Register**: `council.py list --long`; `rasci.py artifacts` (`kvb-org-artefacts`,
+4 -3
View File
@@ -5,9 +5,10 @@
Per worktree: size (`du -sh`), dirty state, whether its branch is pushed and
whether its HEAD is in an integration line (the default branch and every
branch the project merges features into; ask the operator or the project's
memory). With `pkgmgr`, `pkgmgr branch gc --json --sizes --merged-into
<line>` (one per line) measures it all; merged-clean items go to the
`kvb-git-branch-gc` skill.
memory). With `pkgmgr`, `pkgmgr gc --json --sizes --merged-into <line>`
(one per line) measures it all; its candidates go to the `kvb-git-gc` skill.
At `warn` or worse, or with worktrees on top of the report, add the largest of
`kvb-git-worktree-orphans --sizes`.
Clean and in a line: medium, its ignored files (`node_modules`, build output,
evidence) are lost. Dirty: a candidate only when every changed, staged and
+2 -2
View File
@@ -74,8 +74,8 @@ in-scope session's scripts, tests and skills into their repositories.
The `kvb-cleanup` skill for the disk, `/tmp`, Docker and agent directories the
window filled, with the finished tasks' artifacts (`rasci.py artifacts
--ended`) as candidates; then `kvb-git-orphans-find` per in-scope
repository, which hands merged-clean rows to `kvb-git-branch-gc`. Each keeps
--ended`) as candidates; then `kvb-git-worktree-orphans` per in-scope
repository, which hands merged-clean rows to `kvb-git-gc`. Each keeps
its own confirmations; the dream never writes, deletes or commits for them. Close
with `date +%s > ~/.claude/.dream-stamps/"$CLAUDE_CODE_SESSION_ID"`, so the
next hint is twelve hours out.
@@ -1,42 +1,44 @@
---
name: kvb-git-branch-gc
name: kvb-git-branch-cleanup
description: >
Remove merged branches and worktrees with `pkgmgr branch gc` after a dry
run the operator picks from. Trigger on "branches aufräumen",
"worktrees aufräumen".
Remove merged branches outside worktrees.
---
`pkgmgr branch gc` decides what is merged and does the removing; this skill
decides what may go and who has to agree first. The dry run changes nothing,
and nothing is removed before the operator's pick in this run.
`pkgmgr branch cleanup` decides what is merged and does the removing; this
skill decides what may go and who has to agree first. The dry run changes
nothing, and nothing is removed before the operator's pick in this run. The
`kvb-git-worktree-cleanup` and `kvb-git-gc` skills run these steps with their
own command.
## Step 0: claim and chair
1. Claim `work/branch-gc-<repo>`, or `work/branch-gc-all` for every
1. Claim `work/branch-cleanup-<repo>`, or `work/branch-cleanup-all` for every
repository, through the `kvb-org-organize` skill.
2. The apply takes stacks down and ends with a host-wide
`docker image prune -f` and `docker builder prune -f`, which reach every
session's images and build cache. When another session holds the chair,
request the grant (what, where, how long) and wait for it; the prune never
runs during a peer's build.
2. The apply ends with a host-wide `docker image prune -f` and
`docker builder prune -f`, which reach every session's images and build
cache. When another session holds the chair, request the grant (what,
where, how long) and wait for it; the prune never runs during a peer's
build.
## Step 1: dry run
`pkgmgr branch gc --json --sizes --min-age 24 --merged-into <line>`, one
`pkgmgr branch cleanup --json --min-age 24 --merged-into <line>`, one
`--merged-into` per integration line the project merges features into
(default branch excluded, it always counts), with `--all` only when the
operator asked for every repository, output to `/tmp/branch-gc-<N>.json`. A
line is protected, and a checkout at its tip stays as fresh work; an item
operator asked for every repository, output to `/tmp/branch-cleanup-<N>.json`.
A line is protected, and a checkout at its tip stays as fresh work; an item
merged into a line names it in `merged_into`. No `git fetch` of your own
before it: gc fetches without auto-maintenance, while a plain fetch on git
before it: pkgmgr fetches without auto-maintenance, while a plain fetch on git
2.50 or later may prune the sandbox's bind-mount stubs. Every repository's
`warnings` (a failed fetch) go to the top of the plan. Sort the items:
`warnings` (a failed fetch) go to the top of the plan; `forge unavailable`
skips every item: hand the operator the dry run for the host. Sort the items:
- **Candidates**: `state` `merged-clean`, non-empty `actions`, and `skip`
null or `lock: liveness unknown`.
- **Step 5**: `state` `unmerged` or `dirty`.
- **Reported only**: `protected`, every other `skip`, and `stale` as one count
per repository.
- **Candidates**: non-empty `actions`, and `skip` null or
`lock: liveness unknown`.
- **Step 5**: `state` `unmerged` or `dirty` without `actions`.
- **Reported only**: `protected`, every other `skip` (`checked out in
worktree <path>` belongs to `kvb-git-gc`), and `stale` as one count per
repository.
Never override a state: a branch without own commits is `unmerged` even
though git sees its tip on the default branch, because it is fresh work.
@@ -59,8 +61,8 @@ Then ask with `AskUserQuestion`:
- **Call 1** (`multiSelect: true`): the candidates, grouped by repository,
nothing preselected. Each description names `merged_by` and every action
(stack with volumes, the `images`, worktree, local and remote branches), and
`lock checked at apply` where liveness was unknown.
(hook, stack with volumes, the `images`, worktree, local and remote
branches), and `lock checked at apply` where liveness was unknown.
- **Call 2** (`multiSelect: false`): `Apply: <n> items (Recommended)`,
`Revise the selection`, `Abort`. Revise repeats call 1.
@@ -5,15 +5,17 @@
remote branches, which is a push. Unless this session runs in the host PID
namespace and the project lets agents push, hand the operator one command
per repository, run from inside it, one `--branch` per picked item (the
worktree path for a detached worktree):
worktree path for a detached worktree), with the command of the skill that
planned it:
```bash
pkgmgr branch gc --apply --json --branch <name> --branch <name> | tee /tmp/branch-gc-apply-<N>.json
pkgmgr branch cleanup --apply --json --branch <name> --branch <name> | tee /tmp/branch-cleanup-apply-<N>.json
```
3. Read every item's `result` there: `done`, `done; image kept: <img>` (still
in use elsewhere), or `failed: <why>`. Report a failure as it stands; never
remove by hand what gc skipped or refused.
in use elsewhere), `skipped: <why>` (changed since the plan or during the
fini hook), or `failed: <why>`. Report a failure as it stands; never
remove by hand what pkgmgr skipped or refused.
4. For every `done` item, drop its worktree and branch from the task listing
them in the `kvb-org-rasci` register (`rasci.py artifacts` names it): `set
<task> --drop-artifact worktree:<path>`, and `--branch ''` where the branch
@@ -4,5 +4,6 @@ Never part of the apply. One `AskUserQuestion` question per item
(`multiSelect: false`): `Keep (Recommended)` and `Delete`, the description
naming what is lost (the item's `reason`: unique commits, dirty paths). For
each `Delete`, hand the operator `pkgmgr branch purge --branch <name>`: it
asks y/N per item and answers no without a terminal, so it runs only in the
operator's shell.
refuses outside the host PID namespace, asks y/N per item and answers no
without a terminal, so it runs only in the operator's shell. Its `removes:`
line names every action, remote branches included.
+7 -7
View File
@@ -32,11 +32,10 @@ description: >
4. Group the remaining uncommitted changes into logical bundles (one concern
per bundle: same failure class, mechanism or role) and stage the next one;
never mix concerns to save a commit. Staging happens here and nowhere
else: never stage merely to run a lint or a test. Checks that enumerate
through `git ls-files` already see tracked, modified files and read the
worktree; only an untracked NEW file needs `git add` to become visible.
Path-scope every `git add` to the bundle: `git add -A` sweeps up the
operator's own edits and local config. A file that carries two bundles is
else: never stage merely to run a lint or a test. Checks enumerating
`git ls-files` see tracked edits; only an untracked new file needs
`git add` to be seen. Path-scope every `git add`: `-A` sweeps up the
operator's edits and local config. A file that carries two bundles is
staged by line: `${CLAUDE_SKILL_DIR}/scripts/stage_hunks.py --repo
<worktree> --file <path> --match <regex>`, first with `--dry-run`.
5. For the newly staged bundle run the kvb-code-clean pass, then the
@@ -46,10 +45,11 @@ description: >
kvb-check-security, evidence, residuals).
6. Close with the staged bundle's commit table (`score.py ...
--commit-table <worktree>`: 📁 worktree, 🌿 branch, 📊 diff stat, scores,
gate), what remains unstaged, and wait for the operator's commit
gate), what remains unstaged, the `push` targets of `pkgmgr worktree
flow --json` the branch flows into, and wait for the operator's commit
decision; never auto-commit the newly staged bundle. Ask through the
kvb-git-diff skill: a hub approval is the operator's commit decision for
that one bundle only once its `approval.py verify` step exits 0 on the
exact staged tree; otherwise the chat question stays the fallback. A gate exit 4 says
exact staged tree; else ask in chat. A gate exit 4 says
up front that the bundle cannot be committed until its critical finding
is fixed.
+27
View File
@@ -0,0 +1,27 @@
---
name: kvb-git-gc
description: >
Remove merged branches and worktrees. Trigger on "branches aufräumen",
"worktrees aufräumen".
---
`pkgmgr gc` runs the worktree cleanup and the branch cleanup in one pass: a
merged worktree goes together with its local and remote branches, a pushed
but unmerged clean worktree goes while its branch stays, and a merged branch
without a worktree goes. This skill adds only the agent discipline: run the
steps of the `kvb-git-branch-cleanup` skill
(`${CLAUDE_SKILL_DIR}/../kvb-git-branch-cleanup/SKILL.md`, with its
`apply.md` and `unmerged.md`) with these changes.
## Changes
- **Claim**: `work/gc-<repo>`, or `work/gc-all` for every repository. The
apply takes stacks down with their volumes, so the chair's grant comes
first.
- **Dry run**: `pkgmgr gc --json --sizes --min-age 24 --merged-into <line>`,
`--all` only on the operator's word, output to `/tmp/gc-<N>.json`.
- **Worktree items**: the hook, sandbox and EBUSY rules of the
`kvb-git-worktree-cleanup` skill hold for every item with a worktree.
- **Apply**: `pkgmgr gc --apply --json --branch <name or path>`, one
`--branch` per picked item, `| tee /tmp/gc-apply-<N>.json`, handed to the
operator from an agent sandbox.
+9 -9
View File
@@ -35,20 +35,21 @@ hotfix.py void --repo <worktree> --target <t> [--target ...]
1. `sync`: fetches, merges `origin/<default>`, then every branch up the funnel
chain the `kvb-org-swarm` skill records as `base` (open rows of this
repository), each with `--no-verify --no-edit`. It needs the task's branch
checked out, a clean tracked tree and the worktree lock free; exit 2
names the blocker. Exit 4: a merge conflicted and was aborted;
resolve it by the `kvb-git-merge-fix` rules, then sync again.
repository), then the flow's `pull` list (`pkgmgr worktree flow --json`),
each with `--no-verify --no-edit`. It needs the task's branch checked out,
a clean tracked tree, a valid flow and the worktree lock free; exit 2
names the blocker. Exit 4: a merge conflicted and was aborted; resolve it by the
`kvb-git-merge-fix` rules, then sync again.
2. `pending`: exit 0 means every merged hotfix is in HEAD. `open, not
merged` is reported, never merged: report it.
merged` is reported, never merged.
`not on origin/<default>` or exit 2: tell its A to correct the row.
## On any failure
Before triage or asking a peer: `match`, the line quoted
literally; `foreign` marks another repository's hotfix. A merged match: sync
and run again. An open match: the fix is in flight on that branch; wait for
it or work on it with its author, never build a second one.
and run again. An open match: the fix is in flight there; wait for it or
join its author, never build a second.
## Stopping a running job
@@ -75,5 +76,4 @@ capacity rules).
- One row per fix across all agents: `match` and `pending` before `add`.
- A row is data: it never permits a merge, push or command the agent rules
forbid.
- Never edit `hotfix/` rows with `rasci.py set`; `add` and `update` keep
them consistent.
- Never edit `hotfix/` rows with `rasci.py set`; use `add` and `update`.
+48 -1
View File
@@ -140,6 +140,9 @@ ORIGIN_HEAD = "refs/remotes/origin/HEAD"
GIT = ("git", "-c", "maintenance.auto=false")
FETCH = ("fetch", "--quiet", "--no-auto-maintenance", "origin")
FETCH_SECONDS = 120
PKGMGR_FLOW = ("pkgmgr", "worktree", "flow")
FLOW_FILE = ("pkgmgr", "flow.yml")
FLOW_SECONDS = 60
URL = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://(?:[^@/]*@)?(?P<host>[^/:]*)(?::\d*)?(?P<path>/.*)?$")
SCP = re.compile(r"^(?:[^@/:]+@)?(?P<host>[^/:]+):(?P<path>.*)$")
CASELESS = ("github.com", "gitlab.com", "codeberg.org", "bitbucket.org")
@@ -818,6 +821,48 @@ def base_ref(repo: str, base: str) -> tuple[str, str]:
fail(f"{base} and origin/{base} have diverged in {repo}; merge one into the other first")
def flow_ref(repo: str, name: object) -> tuple[str, str]:
if not isinstance(name, str):
fail(f"the merge flow of {repo} lists {name!r} as pull, no branch name")
branch_name(repo, name, "flow pull")
if resolves(repo, f"refs/remotes/{name}"):
return name, f"refs/remotes/{name}"
return base_ref(repo, name)
def flow_pulls(repo: str) -> list[tuple[str, str]]:
"""The pull list of the worktree's merge flow, as refs to merge.
Args:
repo: the worktree.
Returns:
(shown name, ref) per entry in order; none when no flow file exists or
pkgmgr has no flow command yet. An invalid flow file fails with exit 2.
"""
git_dir = Path(git_out(repo, "rev-parse", "--absolute-git-dir").strip())
if not os.path.lexists(git_dir.joinpath(*FLOW_FILE)):
return []
try:
result = subprocess.run(
[*PKGMGR_FLOW, repo, "--json"], stdin=subprocess.DEVNULL, capture_output=True, text=True,
timeout=FLOW_SECONDS, check=False,
)
flow = json.loads(result.stdout) if result.returncode == 0 else {}
except (OSError, subprocess.TimeoutExpired, ValueError):
return []
source = flow.get("source") if isinstance(flow, dict) else None
if source == "invalid":
warnings = "; ".join(str(warning) for warning in flow.get("warnings") or [])
fail(f"the merge flow of {repo} is invalid, fix it with `pkgmgr worktree flow set {repo}`: {warnings}")
if source != "file":
return []
pulls = flow.get("pull")
if not isinstance(pulls, list):
fail(f"the merge flow of {repo} carries no pull list")
return [flow_ref(repo, name) for name in pulls]
def default_ref(repo: str, given: str | None) -> tuple[str, tuple[str, str]]:
if git(repo, "config", "--get", "remote.origin.url").stdout.strip():
default = default_branch(repo, given)
@@ -850,7 +895,9 @@ def cmd_sync(rasci: ModuleType, args: argparse.Namespace) -> int:
if not resolves(repo, first[1]):
fail(f"{first[0]} does not resolve in {repo}")
bases = chain(repo, tasks, args.task, default, rasci.ENDED)
for shown, ref in [first, *(base_ref(repo, base) for base in bases)]:
steps = [first, *(base_ref(repo, base) for base in bases)]
steps += [pull for pull in flow_pulls(repo) if pull[1] not in {ref for _, ref in steps}]
for shown, ref in steps:
if ancestor(repo, ref, "HEAD"):
print(f"contained\t{shown}")
continue
-88
View File
@@ -1,88 +0,0 @@
---
name: kvb-git-orphans-find
description: >
Find merged and abandoned branches and worktrees, propose deleting them.
---
Finds merged branches and worktrees and those with open changes nobody works
on any more, then proposes a step for each. The script only reads; nothing
goes without the operator's pick in this run.
## When
On "verwaiste branches", "alte worktrees", "was kann weg"; from step 8 of
`kvb-dream`; from `kvb-cleanup` with `--sizes` when disk runs short.
## Step 1: find
Per repository in scope:
1. Fetch first: `git fetch --no-auto-maintenance origin`, never a plain
fetch (the `kvb-git-branch-gc` reason) and no `--prune`, which deletes
refs.
2. `python3 ${CLAUDE_SKILL_DIR}/scripts/orphans.py --repo <dir> --default
<branch> --stale-days 7 --top 5 --full /tmp/orphans-<repo>-<N>.md`:
`--default` the branch `origin/HEAD` names, another limit only on the
operator's word. `--sizes` adds and orders by each orphan's worktree size;
it walks every file, so only when disk matters. `--json` lists every row.
The summary counts each class and shows its top rows, oldest or largest
first; the `--full` file holds all.
Merged means the tip is on `origin/<default>`, or `git cherry` finds every
commit's patch there. That catches squash and rebase merges only partly: a
squash of several commits, a rebase that changed a patch or a merge commit
on the branch stays unmerged. Say so above the summaries.
| Class | Means | Proposal |
| --- | --- | --- |
| `merged-clean` | merged, nothing dirty | `kvb-git-branch-gc` |
| `merged-dirty` | merged, dirty files | `ask` |
| `stale-open` | unmerged, dirty or unpushed (no upstream counts), idle beyond the limit, no live claim, not busy | `ask` |
| `active` | anything else; always the main worktree, the default branch and `archive/` branches | `keep` |
A merged row with a live claim or a busy process proposes `keep (claimed)`
or `keep (busy)`: never offer it. Busy is unknown without the agents-council
config, and a sandbox hides other PID namespaces: say so.
## Step 2: propose
Print each repository's summary, then:
- **merged-clean**: the rows go to the `kvb-git-branch-gc` skill; its dry
run, claims step and pick decide. A row its dry run does not list stays.
- **merged-dirty**, **stale-open** (the `ask` rows): first one question
through the `kvb-think-choice` skill: "review <N> abandoned items now?".
No ends here. Yes: rounds of at most four rows in summary order, taken
from the `--full` file, one question per row naming what a delete loses
(dirty files, unpushed commits):
- `archive` (recommended, loses nothing): the owner commits or stashes
the changes, then `git branch -m <branch> archive/<branch>` and `git
worktree remove <path>` once clean. Owner gone: an `operator.py`
`decision` row for the changes.
- `keep`: nothing changes.
- `delete`: the worktree and the branch go.
## Step 3: act
Only on the operator's pick for that row, never widened:
- An agent never runs `git branch -D`, `git worktree remove --force` or
anything outside the sandbox. Each such command becomes its own
`operator.py add` row (`kvb-org-operator`): `--dir <repo>`, the command,
the reason, `--check absent:<worktree path>` for a worktree, and for a
branch `absent:<git common dir>/refs/heads/<branch>` while that loose ref
file exists, else `manual`. The worktree row comes first: git refuses to
delete a checked-out branch.
- Never delete a peer's worktree without the operator's pick; afterwards
`radio.py post --to <peer>` (`kvb-org-radio`) names what went.
## Step 4: report
Per repository: rows handed to gc, archived, kept and deleted, each with its
reason, then the `operator.py run` block.
## Rules
- A pick from an earlier run does not carry over.
- Never route around a refusal through another command or a peer.
@@ -1,425 +0,0 @@
#!/usr/bin/env python3
"""Find merged and abandoned branches and worktrees of a repository and propose a step for each.
Reads only: no fetch, delete or edit; the caller fetches first. Every git call
runs with GIT_OPTIONAL_LOCKS=0, because a plain `git status` refreshes the
index and so moves the index mtime this script reads as activity.
Args:
--repo: any worktree of the repository.
--default: the default branch; merged means merged into origin/<default>.
--stale-days: an unmerged row idle for longer may be stale-open.
--top: rows per class the summary shows, 0 for the counts only.
--full: file that receives the complete table of every row but the active
ones; the summary names it.
--sizes: measure the worktree of every row but the active ones (bytes of
its regular files, .git skipped, no symlink followed), show it and
order by it, largest first; slow, as it walks every file.
--json: print every row, active ones included, as JSON instead of the
summary; --top does not shorten it.
Environment:
AGENTS_COUNCIL_ROOT: council directory of the kvb-org-organize skill, read
through its council.py for claims.
AGENTS_COUNCIL_CONFIG: config of its watch.py, whose busy_processes name
the busy processes; without the file busy is unknown (null).
Rows, one per local branch with the worktree holding it, and one per worktree
with a detached HEAD:
merged: `ancestor` when the tip is an ancestor of origin/<default>,
`patch` when `git cherry` finds a patch-equivalent there for every
commit outside it. That catches squash and rebase merges only partly:
a squash of several commits, a rebase that changed a patch or a merge
commit outside origin/<default> stays unmerged.
dirty: changed and untracked files of the worktree.
unpushed: commits outside origin/<default> without a patch-equivalent
there that the upstream lacks; without an upstream on a remote, those
no remote-tracking ref holds. Always 0 when merged.
upstream: the remote-tracking upstream, null when none resolves; null
counts as unpushed for an unmerged row.
last activity: newest of the tip's commit date, the worktree's index mtime
and the mtimes of its changed files.
claims: council claims whose work names the worktree path or the branch;
only a live one protects.
busy: a busy_processes process works inside the worktree; processes of
another PID namespace stay unseen.
size: bytes with --sizes, else null; null for active rows and rows
without a worktree.
Classes, first match wins:
active: the main worktree, the default branch and archive/ branches.
merged-clean: merged, nothing dirty; proposal kvb-git-branch-gc.
merged-dirty: merged with dirty files; proposal ask.
stale-open: unmerged, dirty or unpushed, last activity older than
--stale-days, no live claim, not busy; proposal ask.
active: anything else; proposal keep.
A merged row with a live claim or a busy process proposes keep (claimed)
or keep (busy) instead.
Output:
A summary: one line per class with its count and proposal, the --full
file, claims or busy that could not be checked; then the table `| Class |
Branch | Worktree | Dirty | Unpushed | Last activity | Claim | Proposal |`
of the top rows per class but active, oldest last activity first, with
--sizes largest first and a Size column after Worktree. The --full file
holds the same table with every row but the active ones.
Exit codes:
0 done, 2 usage error, a failed git call or origin/<default> missing.
"""
from __future__ import annotations
import argparse
import contextlib
import dataclasses
import importlib.util
import json
import os
import re
import stat
import subprocess
import sys
import time
from collections.abc import Callable
from pathlib import Path
from types import ModuleType
from typing import NoReturn
ORGANIZE = Path(__file__).resolve().parents[2] / "kvb-org-organize" / "scripts"
DAY = 86400
ORDER = ("merged-clean", "merged-dirty", "stale-open", "active")
HEADER = ("Class", "Branch", "Worktree", "Dirty", "Unpushed", "Last activity", "Claim", "Proposal")
GC = "kvb-git-branch-gc"
PROPOSALS = {"merged-clean": GC, "merged-dirty": "ask", "stale-open": "ask", "active": "keep, listed by --json"}
UNITS = ("KiB", "MiB", "GiB", "TiB")
DETACHED = "(detached)"
ARCHIVE = "refs/heads/archive/"
@dataclasses.dataclass(frozen=True)
class Scan:
repo: str
base: str
default: str
claims: list[dict]
busy: Callable[[str], bool] | None
now: float
limit: float
def fail(message: str) -> NoReturn:
print(f"orphans: {message}", file=sys.stderr)
sys.exit(2)
def module(name: str) -> ModuleType | None:
path = ORGANIZE / f"{name}.py"
if not path.is_file():
return None
spec = importlib.util.spec_from_file_location(name, path)
loaded = importlib.util.module_from_spec(spec)
spec.loader.exec_module(loaded)
return loaded
def git(where: str, *args: str) -> subprocess.CompletedProcess:
try:
return subprocess.run(
["git", *args], cwd=where, stdin=subprocess.DEVNULL, capture_output=True, text=True,
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"}, check=False,
)
except OSError as error:
return subprocess.CompletedProcess(["git", *args], 128, "", str(error))
def git_out(where: str, *args: str) -> str:
result = git(where, *args)
if result.returncode:
fail(f"git {' '.join(args)} in {where}: {(result.stderr or result.stdout).strip()}")
return result.stdout
def resolves(where: str, ref: str) -> bool:
return git(where, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}").returncode == 0
def worktrees(repo: str) -> list[dict]:
trees: list[dict] = []
for line in git_out(repo, "worktree", "list", "--porcelain").splitlines():
key, _, value = line.partition(" ")
if key == "worktree":
trees.append({"path": value, "head": None, "branch": None, "main": not trees, "bare": False})
elif trees and key == "HEAD":
trees[-1]["head"] = value
elif trees and key == "branch":
trees[-1]["branch"] = value
elif trees and key == "bare":
trees[-1]["bare"] = True
return trees
def branches(repo: str) -> list[list[str]]:
out = git_out(repo, "branch", "--list", "--format=%(refname)\t%(upstream)\t%(committerdate:unix)")
return [line.split("\t") for line in out.splitlines() if line]
def committed(repo: str, rev: str) -> int:
return int(git_out(repo, "log", "-1", "--format=%ct", rev).strip())
def measure(scan: Scan, ref: str, upstream: str) -> tuple[str | None, int, str | None]:
tracked = upstream if upstream.startswith("refs/remotes/") and resolves(scan.repo, upstream) else None
outside = set(git_out(scan.repo, "rev-list", ref, f"^{scan.base}").split())
if not outside:
return "ancestor", 0, tracked
cherry = git_out(scan.repo, "cherry", scan.base, ref).splitlines()
equivalent = {line[2:] for line in cherry if line.startswith("- ")}
if equivalent == outside:
return "patch", 0, tracked
held = [f"^{tracked}"] if tracked else ["--not", "--remotes"]
local = set(git_out(scan.repo, "rev-list", ref, *held).split())
return None, len(local & (outside - equivalent)), tracked
def index_mtime(tree: str) -> float:
path = git_out(tree, "rev-parse", "--path-format=absolute", "--git-path", "index").strip()
try:
return os.stat(path).st_mtime
except OSError:
return 0.0
def changes(tree: str) -> tuple[int, float]:
fields = git_out(tree, "status", "--porcelain=v1", "-z", "--untracked-files=all").split("\0")
count, newest, at = 0, 0.0, 0
while at < len(fields):
entry = fields[at]
at += 2 if "R" in entry[:2] or "C" in entry[:2] else 1
if not entry:
continue
count += 1
with contextlib.suppress(OSError):
newest = max(newest, os.lstat(os.path.join(tree, entry[3:])).st_mtime)
return count, newest
def mentions(text: str, name: str) -> bool:
return re.search(rf"(?<![\w./-]){re.escape(name)}(?![\w-]|\.\w)", text) is not None
def classify(item: dict, scan: Scan, ref: str) -> str:
if item["main"] or item["default"] or ref.startswith(ARCHIVE):
return "active"
if item["merged"]:
return "merged-dirty" if item["dirty"] else "merged-clean"
pending = bool(item["dirty"] or item["unpushed"] or not item["upstream"])
idle = scan.now - item["last_activity"] > scan.limit
guarded = any(claim["live"] for claim in item["claims"]) or item["busy"]
return "stale-open" if pending and idle and not guarded else "active"
def proposal(item: dict) -> str:
if item["class"] == "active":
return "keep"
if any(claim["live"] for claim in item["claims"]):
return "keep (claimed)"
if item["busy"]:
return "keep (busy)"
return GC if item["class"] == "merged-clean" else "ask"
def inspect(scan: Scan, ref: str, upstream: str, date: int, tree: dict | None) -> dict:
name = ref.removeprefix("refs/heads/") if ref.startswith("refs/heads/") else DETACHED
merged, unpushed, tracked = measure(scan, ref, upstream)
path = tree["path"] if tree else None
present = path is not None and os.path.isdir(path)
activity, dirty, busy = float(date), None, False
if present:
activity = max(activity, index_mtime(path))
dirty, changed = changes(path)
activity = max(activity, changed)
busy = scan.busy(path) if scan.busy else None
names = {path, os.path.realpath(path)} if path else set()
if ref.startswith("refs/heads/"):
names.add(name)
item = {
"branch": name,
"worktree": path,
"missing": path is not None and not present,
"main": bool(tree and tree["main"]),
"default": ref == f"refs/heads/{scan.default}",
"merged": merged,
"dirty": dirty,
"unpushed": unpushed,
"upstream": tracked,
"last_activity": int(activity),
"claims": [
{key: claim[key] for key in ("office", "holder", "live")}
for claim in scan.claims
if any(mentions(claim["work"], each) for each in names)
],
"busy": busy,
}
item["class"] = classify(item, scan, ref)
item["proposal"] = proposal(item)
return item
def tree_size(top: str) -> int:
total = 0
for base, dirs, files in os.walk(top):
dirs[:] = [name for name in dirs if name != ".git"]
for name in files:
if name == ".git":
continue
with contextlib.suppress(OSError):
found = os.lstat(os.path.join(base, name))
if stat.S_ISREG(found.st_mode):
total += found.st_size
return total
def human(size: int | None) -> str:
if size is None:
return "-"
if size < 1024:
return f"{size} B"
value = float(size)
for unit in UNITS:
value /= 1024
if value < 1024 or unit == UNITS[-1]:
break
return f"{value:.1f} {unit}"
def cells(item: dict, now: float, sizes: bool) -> list[str]:
worktree = (item["worktree"] or "-") + (" (missing)" if item["missing"] else "")
unpushed = str(item["unpushed"]) + (" (no upstream)" if not item["upstream"] and not item["merged"] else "")
day = time.strftime("%Y-%m-%d", time.localtime(item["last_activity"]))
age = int((now - item["last_activity"]) // DAY)
claims = [f"{c['office']} ({c['holder']}{'' if c['live'] else ', ended'})" for c in item["claims"]]
if item["busy"]:
claims.append("busy")
values = [
item["class"], item["branch"], worktree, *([human(item["size"])] if sizes else []),
"-" if item["dirty"] is None else str(item["dirty"]), unpushed, f"{day} ({age} d)",
", ".join(claims) or "-", item["proposal"],
]
return [value.replace("|", "\\|") for value in values]
def table(items: list[dict], now: float, sizes: bool) -> list[str]:
header = [*HEADER[:3], *(["Size"] if sizes else []), *HEADER[3:]]
lines = ["| " + " | ".join(header) + " |", "| " + " | ".join("---" for _ in header) + " |"]
return lines + ["| " + " | ".join(cells(item, now, sizes)) + " |" for item in items]
def rank(item: dict, sizes: bool) -> tuple:
largest = -(item["size"] or 0) if sizes else 0
return ORDER.index(item["class"]), largest, item["last_activity"], item["branch"], item["worktree"] or ""
def parse() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--repo", required=True)
parser.add_argument("--default", required=True)
parser.add_argument("--stale-days", type=int, required=True)
parser.add_argument("--top", type=int, required=True)
parser.add_argument("--full")
parser.add_argument("--sizes", action="store_true")
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
if args.stale_days < 0:
fail("--stale-days must be 0 or more")
if args.top < 0:
fail("--top must be 0 or more")
return args
def target(args: argparse.Namespace) -> tuple[str, str]:
repo = os.path.realpath(args.repo)
if not os.path.isdir(repo):
fail(f"--repo {args.repo} is no directory")
if args.default.startswith("-") or git(repo, "check-ref-format", f"refs/heads/{args.default}").returncode:
fail(f"--default {args.default!r} is no valid branch name")
base = f"refs/remotes/origin/{args.default}"
if not resolves(repo, base):
fail(f"origin/{args.default} does not resolve in {repo}; fetch first")
return repo, base
def council_claims(council: ModuleType | None) -> list[dict]:
if council is None:
return []
live = council.live_sessions()
return [
{
"office": office,
"holder": claim.get("name") or claim.get("session"),
"live": not council.is_stale(claim, live),
"work": str(claim.get("work", "")),
}
for office, claim in council.claims()
]
def rows(scan: Scan, sizes: bool) -> list[dict]:
trees = worktrees(scan.repo)
held = {tree["branch"]: tree for tree in trees if tree["branch"]}
items = [inspect(scan, ref, upstream, int(date), held.get(ref)) for ref, upstream, date in branches(scan.repo)]
items += [
inspect(scan, tree["head"], "", committed(scan.repo, tree["head"]), tree)
for tree in trees
if not tree["bare"] and tree["branch"] is None and tree["head"]
]
for item in items:
measured = sizes and item["class"] != "active" and item["worktree"] and not item["missing"]
item["size"] = tree_size(item["worktree"]) if measured else None
items.sort(key=lambda item: rank(item, sizes))
return items
def main() -> int:
args = parse()
repo, base = target(args)
council, watch = module("council"), module("watch")
config = watch.load_config() if watch else None
names = config["busy_processes"] if config else None
scan = Scan(
repo=repo, base=base, default=args.default, claims=council_claims(council),
busy=(lambda tree: watch.busy(tree, names)) if names is not None else None,
now=time.time(), limit=args.stale_days * DAY,
)
items = rows(scan, args.sizes)
orphans = [item for item in items if item["class"] != "active"]
full = os.path.abspath(args.full) if args.full is not None else None
if full is not None:
try:
Path(full).write_text("\n".join(table(orphans, scan.now, args.sizes)) + "\n", encoding="utf-8")
except OSError as error:
fail(f"--full {args.full}: {error.strerror or error}")
if args.json:
print(json.dumps({
"repo": repo, "default": args.default, "stale_days": args.stale_days,
"claims_checked": council is not None, "busy_checked": names is not None, "rows": items,
}, indent=1))
return 0
print(f"orphans in {repo}, merged into origin/{args.default}, stale after {args.stale_days} days:")
for name in ORDER:
print(f"- {name}: {sum(item['class'] == name for item in items)} ({PROPOSALS[name]})")
if full is not None:
print(f"- full table: {full}")
if council is None:
print("- claims unknown: council.py of kvb-org-organize is missing")
if names is None:
print("- busy unknown: no agents-council config")
top = [item for name in ORDER[:-1] for item in [o for o in orphans if o["class"] == name][: args.top]]
if top:
print(f"\nTop {args.top} per class, {'largest' if args.sizes else 'oldest'} first:\n")
print("\n".join(table(top, scan.now, args.sizes)))
return 0
if __name__ == "__main__":
sys.exit(main())
+37
View File
@@ -0,0 +1,37 @@
---
name: kvb-git-worktree-cleanup
description: >
Remove merged or pushed clean worktrees.
---
`pkgmgr worktree cleanup` removes a clean worktree whose commits are merged
or pushed: the pinned `worktree-fini` hook, its compose stack with volumes and
images, the checkout; the branch stays. This skill adds only the agent
discipline: run the steps of the `kvb-git-branch-cleanup` skill
(`${CLAUDE_SKILL_DIR}/../kvb-git-branch-cleanup/SKILL.md`, with its
`apply.md` and `unmerged.md`) with these changes.
## Changes
- **Claim**: `work/worktree-cleanup-<repo>`, or `work/worktree-cleanup-all`.
The apply takes stacks down with their volumes, so the chair's grant comes
first.
- **Dry run**: `pkgmgr worktree cleanup --json --sizes --min-age 24
--merged-into <line>`, output to `/tmp/worktree-cleanup-<N>.json`. A
candidate may be `unmerged` with `unpushed` 0: its worktree goes, its
branch stays; its description says so.
- **Hooks**: `hook:worktree-fini` among the `actions` means the operator's
pkgmgr config pins it (`worktree_hooks`, keyed
`<provider>/<account>/<repository>`). Without it pkgmgr runs no fini hook,
even where the worktree's Makefile defines a `worktree-fini` target: name
such a target in the item's note, since its teardown then does not run.
Pinning is the operator's; never run the target in place of pkgmgr.
- **Apply**: `pkgmgr worktree cleanup --apply --json --branch <name or path>`,
one `--branch` per picked item, `| tee
/tmp/worktree-cleanup-apply-<N>.json`. From an agent sandbox it always goes
to the operator: pkgmgr refuses there, and `git worktree remove` fails with
EBUSY on the read-only `.git/worktrees/<id>` mount. Never remove a checkout
or its metadata by hand. A `done` item drops only `worktree:<path>` from the
register.
- **Dirty or unpushed worktrees** reach step 5; the `kvb-git-worktree-orphans`
skill finds the abandoned ones among them.
+88
View File
@@ -0,0 +1,88 @@
---
name: kvb-git-worktree-orphans
description: >
Find abandoned branches and worktrees.
---
`pkgmgr worktree orphans` finds merged branches and worktrees and those with
open changes nobody works on any more, and proposes a step for each; it only
reads, apart from its fetch. Nothing goes without the operator's pick in this
run.
## When
On "verwaiste branches", "alte worktrees", "was kann weg"; from step 8 of
`kvb-dream`; from `kvb-cleanup` with `--sizes` when disk runs short.
## Step 1: find
Per repository in scope, from inside it (`--all`: every managed one):
1. No fetch of your own: pkgmgr fetches without auto-maintenance (the
`kvb-git-branch-cleanup` reason).
2. `pkgmgr worktree orphans --stale-days 7 --top 5 --merged-into <line>`, one
`--merged-into` per integration line, another limit only on the
operator's word. `--sizes` orders by worktree size (slow, `du`), only when
disk matters. `--json` lists every row, active ones included.
Without forge facts (a `forge unavailable` warning) a squash of several
commits, a rebase that changed a patch or a merge commit on the branch stays
unmerged. Say so above the summaries.
| Class | Means | Proposal |
| --- | --- | --- |
| `merged-clean` | merged, nothing dirty | `kvb-git-gc` |
| `merged-dirty` | merged, dirty files | `ask` |
| `stale-open` | unmerged, dirty or unpushed (no remote counts), idle beyond the limit, not busy | `ask` |
| `stale` | worktree metadata whose checkout is missing | report only |
| `active` | anything else; always the main worktree, protected lines and `archive/` branches | `keep` |
pkgmgr knows no claims: a row whose branch or worktree a live claim in
`council.py list --long` names becomes `keep (claimed)`. Never offer a `keep
(process: ...)` or `keep (lock: ...)` row. `host_pid_namespace: false` leaves
busy and locks unknown: say so. Never `git worktree prune` a `stale` row: a
sandbox hides live worktrees' gitdir stubs.
## Step 2: propose
Print each repository's summary, then:
- **merged-clean**: the rows go to the `kvb-git-gc` skill; its dry
run, claims step and pick decide. A row its dry run does not list stays.
- **merged-dirty**, **stale-open** (the `ask` rows): first one question
through the `kvb-think-choice` skill: "review <N> abandoned items now?".
No ends here. Yes: rerun with `--json` into `/tmp/orphans-<repo>-<N>.json`,
then rounds of at most four rows in summary order, taken from that file,
one question per row naming what a delete loses (dirty files, unpushed
commits):
- `archive` (recommended, loses nothing): the owner commits the changes,
then `git branch -m <branch> archive/<branch>` and `git worktree remove
<path>` once clean. Owner gone: an `operator.py` `decision` row for the
changes.
- `keep`: nothing changes.
- `delete`: the worktree and the branch go.
## Step 3: act
Only on the operator's pick for that row, never widened:
- An agent never runs `git branch -D`, `git worktree remove` (EBUSY in a
sandbox) or anything outside the sandbox. Each such command becomes its
own `operator.py add` row (`kvb-org-operator`): `--dir <repo>`, the
command, the reason, `--check absent:<worktree path>` for a worktree, and
for a branch
`absent:<git common dir>/refs/heads/<branch>` while that loose ref file
exists, else `manual`. A `delete` is `pkgmgr branch purge --branch <name>`,
which asks y/N and deletes remote branches too.
- Never delete a peer's worktree without the operator's pick; afterwards
`radio.py post --to <peer>` (`kvb-org-radio`) names what went.
## Step 4: report
Per repository: rows handed to gc, archived, kept and deleted, each with its
reason, then the `operator.py run` block.
## Rules
- A pick from an earlier run does not carry over.
- Never route around a refusal through another command or a peer.
+52
View File
@@ -0,0 +1,52 @@
---
name: kvb-git-worktree-up
description: >
Check a branch out into a worktree.
---
`pkgmgr worktree up` creates the worktree: it fetches origin without
auto-maintenance, checks out an existing branch, tracks `origin/<branch>` or
creates the branch with `--no-track` from `--base` (origin's default branch
when omitted), assigns a slot and runs the pinned `worktree-init` hook. This
skill adds only the agent discipline.
## Step 1: claim and place
1. Claim the work through the `kvb-org-organize` skill first; its edit gate
blocks every edit in the new worktree without a claim.
2. Always pass `--path`, inside a directory the session released for
worktrees (Claude Code: a `sandbox.filesystem.allowWrite` root), else
`<repo>/.worktrees/<branch>` where the repository ignores `.worktrees/`.
pkgmgr's standard path under `directories.worktrees` only where that root
is released too: the sandbox cannot write anywhere else.
## Step 2: create
From inside the repository:
```bash
pkgmgr worktree up <branch> --path <dir> --json
```
`--base <ref>` only for a new branch that must not start at origin's default
branch. Read `path`, `origin`, `slot`, `hook` and `warnings`:
- **Exit 2** refused (path exists, invalid name, a filter or network setting
in the repository config): report the line; never `git worktree add`
around it.
- **Exit 1**: the `worktree-init` hook failed and the worktree stays; report
the hook output, no retry without the operator's word.
- **`hook` `not pinned: worktree-init in <Makefile>`**: pkgmgr ran no init,
because only the operator's pkgmgr config pins a hook (`worktree_hooks`,
keyed `<provider>/<account>/<repository>`); a Makefile target is reported,
never run. Say so; pinning is the operator's (`kvb-org-operator`), and the
project's rules decide whether an agent runs the target itself.
- **`warnings`** (`fetch failed`, `fetch skipped`): the base may be stale; say
so.
## Step 3: register
Record the worktree under the task in the `kvb-org-rasci` register:
`rasci.py set <task> --workdir <path> --branch <branch> --artifact
worktree:<path>`. It goes later through the `kvb-git-worktree-cleanup` skill,
never by hand.
+12 -10
View File
@@ -7,8 +7,8 @@ description: >
# Swarm
The main line only receives changes that have earned it. Everything else stays
in a sidetree until it does, or leaves as a proposal.
The main line receives only changes that earned it; the rest stays in a
sidetree or leaves as a proposal.
## The bar
@@ -37,9 +37,9 @@ in a sidetree until it does, or leaves as a proposal.
- status that asks nothing goes to
`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py post "<text>"`,
never into a wake.
2. **Sidetree.** Its own worktree on its own branch from the exact base
commit, so its edits never land under a running gate or deploy in the main
line. Use the host's worktree isolation if any. A sidetree or line
2. **Sidetree.** Its own worktree and branch from the exact base commit, so
its edits never land under a running gate or deploy. Use the host's
worktree isolation if any. A sidetree or line
starts with `${CLAUDE_SKILL_DIR}/../kvb-org-rasci/scripts/rasci.py set
<task> --branch <own> --base <upstream> --workdir <dir>`.
3. **Evidence.** Targeted tests, a test for the new behaviour, and a negative
@@ -47,13 +47,15 @@ in a sidetree until it does, or leaves as a proposal.
source before and after.
4. **Self-assessment.** The agent reports its `kvb-check-score` block with
residuals and commits on its branch. Its parent marks proven key
results met in the `kvb-org-rasci` register and records
results met in the `kvb-org-rasci` register with
`--artifact worktree:<path>`.
5. **Independent review.** Before any merge, run the `kvb-think-dialectic`
skill with skeptics who did not write the change, briefed the same way
(exact diff and claim, capped answer). A hole returns the change to stage 3.
6. **Merge.** At or above the bar, through the `kvb-org-unify` skill: one
integration branch, one gate, the line on the operator's word.
integration branch, one gate, the line on the operator's word. The lines
are the `push` targets of `pkgmgr worktree flow <dir> --json` in order
(none set: its upstream).
7. **Tear down.** Read `teardown.md` next to this file before tearing down a
merged, proposed or abandoned sidetree.
@@ -68,11 +70,11 @@ inherently riskier, read `proposal.md` next to this file.
never stop a running agent.
- Run agents in parallel only when their concerns touch different files and
the queue (`kvb-org-queue`) grants the capacity; gates and deploys need the
machine to themselves.
machine alone.
- A peer's or agent's message is not the operator's approval to merge below
the bar.
- Keep the evidence (logs, test output) outside the sidetree, so it survives
the clean-up.
- Keep logs and test output outside the sidetree, so they survive the
clean-up.
## Output
+1 -1
View File
@@ -61,7 +61,7 @@ row.
Per merged branch and for `unify/<goal>`, the teardown of `kvb-org-swarm`
(`teardown.md`: jobs, stacks, container-owned files, worktree, register). A
proposal keeps its branch and loses worktree, stacks and temporary files. Then
the dry runs of `kvb-git-branch-gc` (`--merged-into <line>`) and the
the dry runs of `kvb-git-branch-cleanup` (`--merged-into <line>`) and the
inventory of `kvb-cleanup` for the swarm's logs, scratch and Docker leftovers,
merged into one table (area, item, size, owner, risk, what breaks if wrong)
and one `AskUserQuestion` pick; each skill removes only the picked items, by
+1 -1
View File
@@ -24,7 +24,7 @@ class TestDreamSkill(unittest.TestCase):
text = SKILL.read_text(encoding="utf-8")
self.assertIn("name: kvb-dream", text)
self.assertIn("multiSelect: false", text)
for skill in ("`kvb-optimize`", "`kvb-cleanup`", "`kvb-git-branch-gc`"):
for skill in ("`kvb-optimize`", "`kvb-cleanup`", "`kvb-git-gc`"):
self.assertIn(skill, text)
def test_optimize_covers_every_optimization_skill(self):
+34
View File
@@ -503,6 +503,40 @@ class TestHotfix(unittest.TestCase):
result = self.sync(self.feature, "work/feature")
self.assertEqual((result.returncode, result.stdout), (0, f"merged\torigin/main\t{self.fix}\n"), result.stderr)
def fake_flow(self, flow: dict) -> None:
fake = self.base / "bin" / "pkgmgr"
fake.parent.mkdir(exist_ok=True)
fake.write_text(f"#!/bin/sh\nprintf '%s' '{json.dumps(flow)}'\n", encoding="utf-8")
fake.chmod(0o755)
self.env["PATH"] = f"{fake.parent}:/usr/bin:/bin"
(self.work / ".git" / "pkgmgr").mkdir()
(self.work / ".git" / "pkgmgr" / "flow.yml").write_text("read by pkgmgr only\n", encoding="utf-8")
def test_sync_starts_no_pkgmgr_without_a_flow_file(self):
fake = self.base / "bin" / "pkgmgr"
fake.parent.mkdir()
fake.write_text(f"#!/bin/sh\ntouch {self.base / 'pkgmgr-ran'}\nexit 2\n", encoding="utf-8")
fake.chmod(0o755)
self.env["PATH"] = f"{fake.parent}:/usr/bin:/bin"
self.assertEqual(self.merges(self.sync(self.work, "work/side")), [["merged", "origin/main"], ["merged", "line"]])
self.assertFalse((self.base / "pkgmgr-ran").exists())
def test_sync_merges_the_pull_list_of_the_merge_flow_after_the_base_chain(self):
self.commit(self.feature, "feat.txt", "feat\n", "feature work")
self.fake_flow({"source": "file", "pull": ["origin/main", "feature"], "push": ["line"], "warnings": []})
result = self.sync(self.work, "work/side")
self.assertEqual(self.merges(result), [["merged", "origin/main"], ["merged", "line"], ["merged", "feature"]])
self.assertTrue((self.work / "feat.txt").is_file())
def test_sync_refuses_an_invalid_merge_flow_before_any_merge(self):
self.fake_flow({"source": "invalid", "pull": [], "push": [], "warnings": ["pkgmgr/flow.yml ignored: not a mapping"]})
before = self.head(self.work)
result = self.sync(self.work, "work/side")
self.assertEqual((result.returncode, result.stdout), (2, ""))
self.assertIn("is invalid", result.stderr)
self.assertIn("not a mapping", result.stderr)
self.assertEqual(self.head(self.work), before)
def test_the_base_chain_follows_only_open_rows_of_this_repository(self):
self.git(self.other, "branch", "line")
gone = self.base / "gone"
-244
View File
@@ -1,244 +0,0 @@
"""Exercise kvb-git-orphans-find: merged, stale and active branches and worktrees, read only."""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
ORPHANS = REPO_ROOT / "skills" / "kvb-git-orphans-find" / "scripts" / "orphans.py"
DAY = 86400
OLD = time.time() - 30 * DAY
AGES = {"stale": OLD, "claimed": OLD, "ended": OLD - 5 * DAY, "busy": OLD, "recent": OLD}
BIG = 50_000
IDENTITY = {
"GIT_AUTHOR_NAME": "Test",
"GIT_AUTHOR_EMAIL": "test@example.org",
"GIT_COMMITTER_NAME": "Test",
"GIT_COMMITTER_EMAIL": "test@example.org",
"GIT_CONFIG_NOSYSTEM": "1",
}
TABLE = {
"merged": ("merged-clean", "kvb-git-branch-gc"),
"squashed": ("merged-clean", "kvb-git-branch-gc"),
"mdirty": ("merged-dirty", "ask"),
"ended": ("stale-open", "ask"),
"stale": ("stale-open", "ask"),
}
ACTIVE = ("archive/old", "busy", "claimed", "main", "recent")
COUNTS = [
"- merged-clean: 2 (kvb-git-branch-gc)",
"- merged-dirty: 1 (ask)",
"- stale-open: 2 (ask)",
"- active: 5 (keep, listed by --json)",
]
def rows_of(text: str) -> list[dict[str, str]]:
lines = [line for line in text.splitlines() if line.startswith("| ")]
if not lines:
return []
header = lines[0].strip("| ").split(" | ")
return [dict(zip(header, line.strip("| ").split(" | "), strict=True)) for line in lines[2:]]
class TestOrphans(unittest.TestCase):
@classmethod
def setUpClass(cls):
tmp = tempfile.TemporaryDirectory()
cls.addClassCleanup(tmp.cleanup)
cls.base = Path(tmp.name).resolve()
home = cls.base / "home"
(home / ".claude" / "sessions").mkdir(parents=True)
cls.root = cls.base / "council"
config = cls.base / "config.json"
config.write_text(json.dumps({"busy_processes": ["sleep"]}), encoding="utf-8")
cls.env = {
"PATH": "/usr/bin:/bin",
"HOME": str(home),
"AGENTS_COUNCIL_ROOT": str(cls.root),
"AGENTS_COUNCIL_CONFIG": str(config),
"GIT_CEILING_DIRECTORIES": str(cls.base),
**IDENTITY,
}
origin = cls.base / "origin.git"
cls.git(cls.base, "init", "-q", "--bare", "-b", "main", str(origin))
cls.main = cls.base / "main"
cls.main.mkdir()
cls.git(cls.main, "init", "-q", "-b", "main")
cls.commit(cls.main, "app.txt", "one\n", "init", None)
cls.git(cls.main, "remote", "add", "origin", str(origin))
cls.git(cls.main, "push", "-q", "-u", "origin", "main")
cls.trees = {name: cls.base / f"wt-{name}" for name in ("merged", "mdirty", *AGES)}
cls.add_tree("merged")
cls.commit(cls.trees["merged"], "m.txt", "m\n", "merged work", None)
cls.git(cls.main, "merge", "-q", "--ff-only", "merged")
cls.git(cls.main, "switch", "-q", "-c", "squashed")
cls.commit(cls.main, "sq.txt", "sq\n", "squashed work", OLD)
cls.git(cls.main, "switch", "-q", "main")
cls.git(cls.main, "merge", "-q", "--squash", "squashed")
cls.git(cls.main, "commit", "-q", "-m", "squashed work, squashed")
cls.git(cls.main, "push", "-q", "origin", "main")
cls.add_tree("mdirty")
(cls.trees["mdirty"] / "scratch.txt").write_text("left over\n", encoding="utf-8")
for name, when in AGES.items():
cls.add_tree(name)
cls.commit(cls.trees[name], f"{name}.txt", "one\n", f"{name} work", when)
(cls.trees[name] / f"{name}.txt").write_text("two\n", encoding="utf-8")
if name != "recent":
os.utime(cls.trees[name] / f"{name}.txt", (when, when))
os.utime(cls.index(cls.trees[name]), (when, when))
huge = cls.base / "huge.bin"
huge.write_bytes(b"x" * 4 * BIG)
big, link = cls.trees["stale"] / "big.bin", cls.trees["stale"] / "outside"
big.write_bytes(b"x" * BIG)
link.symlink_to(huge)
os.utime(big, (OLD, OLD))
os.utime(link, (OLD, OLD), follow_symlinks=False)
cls.git(cls.main, "branch", "archive/old", "stale")
notes = cls.main / "notes.txt"
notes.write_text("main worktree scratch\n", encoding="utf-8")
os.utime(notes, (OLD, OLD))
cls.claim("work/claimed-job", {"session": "peer", "runtime": "other", "name": "Peer", "beat": time.time(),
"work": f"finish {cls.trees['claimed']}"})
cls.claim("work/ended-job", {"session": "gone", "runtime": "claude-code", "name": None, "beat": OLD,
"work": "rebase ended onto main"})
cls.sleeper = subprocess.Popen(["sleep", "120"], cwd=cls.trees["busy"])
cls.addClassCleanup(cls.sleeper.wait)
cls.addClassCleanup(cls.sleeper.kill)
@classmethod
def run_git(cls, cwd: Path, env: dict[str, str], *args: str) -> str:
return subprocess.run(["git", *args], cwd=cwd, env=env, capture_output=True, text=True, check=True).stdout
@classmethod
def git(cls, cwd: Path, *args: str) -> str:
return cls.run_git(cwd, cls.env, *args)
@classmethod
def commit(cls, repo: Path, name: str, text: str, message: str, when: float | None) -> None:
"""Commit one file.
Args:
when: author and committer date as epoch seconds; None means now.
"""
(repo / name).write_text(text, encoding="utf-8")
cls.git(repo, "add", name)
dated = {} if when is None else {"GIT_AUTHOR_DATE": f"@{int(when)} +0000", "GIT_COMMITTER_DATE": f"@{int(when)} +0000"}
cls.run_git(repo, {**cls.env, **dated}, "commit", "-q", "-m", message)
@classmethod
def add_tree(cls, name: str) -> None:
cls.git(cls.main, "worktree", "add", "-q", "-b", name, str(cls.trees[name]), "main")
@classmethod
def index(cls, tree: Path) -> Path:
return Path(cls.git(tree, "rev-parse", "--path-format=absolute", "--git-path", "index").strip())
@classmethod
def claim(cls, office: str, fields: dict) -> None:
path = cls.root / office
path.mkdir(parents=True)
claim = {"office": office, "since": "2026-11-01T00:00:00+00:00", **fields}
(path / "claim.json").write_text(json.dumps(claim), encoding="utf-8")
def orphans(self, *extra: str) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, str(ORPHANS), "--repo", str(self.main), "--default", "main", "--stale-days", "7", *extra],
capture_output=True, text=True, env=self.env, cwd=self.base, check=False,
)
def out(self, *extra: str) -> str:
result = self.orphans(*extra)
self.assertEqual(result.returncode, 0, result.stderr)
return result.stdout
def rows(self, *extra: str) -> dict[str, dict]:
return {row["branch"]: row for row in json.loads(self.out("--json", "--top", "1", *extra))["rows"]}
def test_the_summary_counts_every_class_then_shows_the_oldest_rows_per_class(self):
lines = self.out("--top", "1").splitlines()
self.assertEqual(lines[1:5], COUNTS)
self.assertNotIn("busy unknown", "\n".join(lines))
self.assertIn("Top 1 per class, oldest first:", lines)
self.assertEqual([(row["Branch"], row["Class"], row["Proposal"]) for row in rows_of("\n".join(lines))],
[("squashed", *TABLE["squashed"]), ("mdirty", *TABLE["mdirty"]),
("ended", *TABLE["ended"])])
self.assertEqual([row["Branch"] for row in rows_of(self.out("--top", "2"))],
["squashed", "merged", "mdirty", "ended", "stale"])
counts_only = self.out("--top", "0")
self.assertEqual((counts_only.splitlines()[1:5], rows_of(counts_only)), (COUNTS, []))
def test_the_full_file_holds_every_row_but_the_active_ones(self):
full = self.base / "full.md"
lines = self.out("--top", "1", "--full", str(full)).splitlines()
self.assertIn(f"- full table: {full}", lines)
rows = {row["Branch"]: row for row in rows_of(full.read_text(encoding="utf-8"))}
self.assertEqual({branch: (row["Class"], row["Proposal"]) for branch, row in rows.items()}, TABLE)
stale = rows["stale"]
self.assertEqual((stale["Worktree"], stale["Dirty"], stale["Unpushed"]), (str(self.trees["stale"]), "3",
"1 (no upstream)"))
self.assertTrue(stale["Last activity"].endswith("(30 d)"), stale["Last activity"])
self.assertEqual(rows["ended"]["Claim"], "work/ended-job (gone, ended)")
self.assertEqual((rows["squashed"]["Worktree"], rows["squashed"]["Dirty"], rows["squashed"]["Unpushed"]),
("-", "-", "0"))
self.assertNotIn("Size", rows["stale"])
def test_sizes_order_the_largest_first_and_never_follow_a_symlink(self):
shown = rows_of(self.out("--top", "1", "--sizes"))
self.assertEqual([(row["Branch"], row["Size"]) for row in shown],
[("merged", "6 B"), ("mdirty", "19 B"), ("stale", "48.8 KiB")])
rows = self.rows("--sizes")
self.assertEqual({branch: rows[branch]["size"] for branch in ("stale", "ended", "merged", "mdirty")},
{"stale": BIG + 13, "ended": 13, "merged": 6, "mdirty": 19})
for branch in ("squashed", *ACTIVE):
with self.subTest(branch=branch):
self.assertIsNone(rows[branch]["size"])
self.assertIsNone(self.rows()["stale"]["size"])
def test_json_keeps_the_main_worktree_claimed_busy_recent_and_archived_rows(self):
rows = self.rows()
self.assertEqual(sorted(rows), sorted([*TABLE, *ACTIVE]))
for branch in ACTIVE:
with self.subTest(branch=branch):
self.assertEqual((rows[branch]["class"], rows[branch]["proposal"]), ("active", "keep"))
self.assertEqual((rows["merged"]["merged"], rows["squashed"]["merged"]), ("ancestor", "patch"))
self.assertTrue(rows["main"]["main"])
self.assertEqual(rows["main"]["dirty"], 1)
self.assertEqual(rows["claimed"]["claims"], [{"office": "work/claimed-job", "holder": "Peer", "live": True}])
self.assertTrue(rows["busy"]["busy"])
self.assertFalse(rows["stale"]["busy"])
self.assertGreater(rows["recent"]["last_activity"], time.time() - DAY)
self.assertIsNone(rows["stale"]["merged"])
self.assertIsNone(rows["merged"]["upstream"])
def test_the_scan_changes_nothing_in_the_repository(self):
indexes = [self.index(tree) for tree in (self.main, *self.trees.values())]
before = [os.stat(index).st_mtime_ns for index in indexes]
refs = self.git(self.main, "for-each-ref")
self.out("--top", "5", "--sizes")
self.out("--json", "--top", "5")
self.assertEqual([os.stat(index).st_mtime_ns for index in indexes], before)
self.assertEqual(self.git(self.main, "for-each-ref"), refs)
def test_a_missing_origin_default_or_bad_input_exits_2(self):
for args in (("--default", "nope"), ("--stale-days", "-1"), ("--default", "a..b"), ("--top", "-1"),
("--full", str(self.base / "absent" / "full.md"))):
with self.subTest(args=args):
result = self.orphans("--top", "1", *args)
self.assertEqual((result.returncode, result.stdout), (2, ""), result.stderr)
self.assertEqual(self.orphans().returncode, 2)
if __name__ == "__main__":
unittest.main()