diff --git a/AGENTS.md b/AGENTS.md index ce2bbc6..b094cc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`, diff --git a/README.md b/README.md index d38305e..68a2638 100644 --- a/README.md +++ b/README.md @@ -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`). diff --git a/skills/kvb-cleanup/SKILL.md b/skills/kvb-cleanup/SKILL.md index 0ed1f5c..219fda5 100644 --- a/skills/kvb-cleanup/SKILL.md +++ b/skills/kvb-cleanup/SKILL.md @@ -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 `, + 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`, diff --git a/skills/kvb-cleanup/worktrees.md b/skills/kvb-cleanup/worktrees.md index 9731e70..451b5ab 100644 --- a/skills/kvb-cleanup/worktrees.md +++ b/skills/kvb-cleanup/worktrees.md @@ -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 -` (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 ` +(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 diff --git a/skills/kvb-dream/SKILL.md b/skills/kvb-dream/SKILL.md index 76e2fdb..fde3619 100644 --- a/skills/kvb-dream/SKILL.md +++ b/skills/kvb-dream/SKILL.md @@ -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. diff --git a/skills/kvb-git-branch-gc/SKILL.md b/skills/kvb-git-branch-cleanup/SKILL.md similarity index 55% rename from skills/kvb-git-branch-gc/SKILL.md rename to skills/kvb-git-branch-cleanup/SKILL.md index 7d8d2b8..832e5db 100644 --- a/skills/kvb-git-branch-gc/SKILL.md +++ b/skills/kvb-git-branch-cleanup/SKILL.md @@ -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-`, or `work/branch-gc-all` for every +1. Claim `work/branch-cleanup-`, 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 `, one +`pkgmgr branch cleanup --json --min-age 24 --merged-into `, 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-.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-.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 ` 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: items (Recommended)`, `Revise the selection`, `Abort`. Revise repeats call 1. diff --git a/skills/kvb-git-branch-gc/apply.md b/skills/kvb-git-branch-cleanup/apply.md similarity index 64% rename from skills/kvb-git-branch-gc/apply.md rename to skills/kvb-git-branch-cleanup/apply.md index 169b410..e28d104 100644 --- a/skills/kvb-git-branch-gc/apply.md +++ b/skills/kvb-git-branch-cleanup/apply.md @@ -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 --branch | tee /tmp/branch-gc-apply-.json + pkgmgr branch cleanup --apply --json --branch --branch | tee /tmp/branch-cleanup-apply-.json ``` 3. Read every item's `result` there: `done`, `done; image kept: ` (still - in use elsewhere), or `failed: `. Report a failure as it stands; never - remove by hand what gc skipped or refused. + in use elsewhere), `skipped: ` (changed since the plan or during the + fini hook), or `failed: `. 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 --drop-artifact worktree:`, and `--branch ''` where the branch diff --git a/skills/kvb-git-branch-gc/unmerged.md b/skills/kvb-git-branch-cleanup/unmerged.md similarity index 61% rename from skills/kvb-git-branch-gc/unmerged.md rename to skills/kvb-git-branch-cleanup/unmerged.md index 9602b38..9ea46fe 100644 --- a/skills/kvb-git-branch-gc/unmerged.md +++ b/skills/kvb-git-branch-cleanup/unmerged.md @@ -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 `: 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. diff --git a/skills/kvb-git-commit-next/SKILL.md b/skills/kvb-git-commit-next/SKILL.md index 4759a18..ad23a30 100644 --- a/skills/kvb-git-commit-next/SKILL.md +++ b/skills/kvb-git-commit-next/SKILL.md @@ -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 --file --match `, 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, 🌿 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. diff --git a/skills/kvb-git-gc/SKILL.md b/skills/kvb-git-gc/SKILL.md new file mode 100644 index 0000000..c016a1c --- /dev/null +++ b/skills/kvb-git-gc/SKILL.md @@ -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-`, 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 `, + `--all` only on the operator's word, output to `/tmp/gc-.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 `, one + `--branch` per picked item, `| tee /tmp/gc-apply-.json`, handed to the + operator from an agent sandbox. diff --git a/skills/kvb-git-hotfix/SKILL.md b/skills/kvb-git-hotfix/SKILL.md index 7507d99..7f717d8 100644 --- a/skills/kvb-git-hotfix/SKILL.md +++ b/skills/kvb-git-hotfix/SKILL.md @@ -35,20 +35,21 @@ hotfix.py void --repo --target [--target ...] 1. `sync`: fetches, merges `origin/`, 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/` 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`. diff --git a/skills/kvb-git-hotfix/scripts/hotfix.py b/skills/kvb-git-hotfix/scripts/hotfix.py index 4643ee2..0273232 100755 --- a/skills/kvb-git-hotfix/scripts/hotfix.py +++ b/skills/kvb-git-hotfix/scripts/hotfix.py @@ -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[^/:]*)(?::\d*)?(?P/.*)?$") SCP = re.compile(r"^(?:[^@/:]+@)?(?P[^/:]+):(?P.*)$") 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 diff --git a/skills/kvb-git-orphans-find/SKILL.md b/skills/kvb-git-orphans-find/SKILL.md deleted file mode 100644 index b91ad45..0000000 --- a/skills/kvb-git-orphans-find/SKILL.md +++ /dev/null @@ -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 --default - --stale-days 7 --top 5 --full /tmp/orphans--.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/`, 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 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 archive/` and `git - worktree remove ` 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 `, the command, - the reason, `--check absent:` for a worktree, and for a - branch `absent:/refs/heads/` 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 ` (`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. diff --git a/skills/kvb-git-orphans-find/scripts/orphans.py b/skills/kvb-git-orphans-find/scripts/orphans.py deleted file mode 100755 index c1609c4..0000000 --- a/skills/kvb-git-orphans-find/scripts/orphans.py +++ /dev/null @@ -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/. - --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/, - `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/ stays unmerged. - dirty: changed and untracked files of the worktree. - unpushed: commits outside origin/ 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/ 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"(? 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()) diff --git a/skills/kvb-git-worktree-cleanup/SKILL.md b/skills/kvb-git-worktree-cleanup/SKILL.md new file mode 100644 index 0000000..c75453c --- /dev/null +++ b/skills/kvb-git-worktree-cleanup/SKILL.md @@ -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-`, 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 `, output to `/tmp/worktree-cleanup-.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 + `//`). 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 `, + one `--branch` per picked item, `| tee + /tmp/worktree-cleanup-apply-.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/` mount. Never remove a checkout + or its metadata by hand. A `done` item drops only `worktree:` from the + register. +- **Dirty or unpushed worktrees** reach step 5; the `kvb-git-worktree-orphans` + skill finds the abandoned ones among them. diff --git a/skills/kvb-git-worktree-orphans/SKILL.md b/skills/kvb-git-worktree-orphans/SKILL.md new file mode 100644 index 0000000..4aab71b --- /dev/null +++ b/skills/kvb-git-worktree-orphans/SKILL.md @@ -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 `, 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 abandoned items now?". + No ends here. Yes: rerun with `--json` into `/tmp/orphans--.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 archive/` and `git worktree remove + ` 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 `, the + command, the reason, `--check absent:` for a worktree, and + for a branch + `absent:/refs/heads/` while that loose ref file + exists, else `manual`. A `delete` is `pkgmgr branch purge --branch `, + 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 ` (`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. diff --git a/skills/kvb-git-worktree-up/SKILL.md b/skills/kvb-git-worktree-up/SKILL.md new file mode 100644 index 0000000..a640c94 --- /dev/null +++ b/skills/kvb-git-worktree-up/SKILL.md @@ -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/` 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 + `/.worktrees/` 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 --path --json +``` + +`--base ` 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 `**: pkgmgr ran no init, + because only the operator's pkgmgr config pins a hook (`worktree_hooks`, + keyed `//`); 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 --workdir --branch --artifact +worktree:`. It goes later through the `kvb-git-worktree-cleanup` skill, +never by hand. diff --git a/skills/kvb-org-swarm/SKILL.md b/skills/kvb-org-swarm/SKILL.md index 3f1aa68..54e5f57 100644 --- a/skills/kvb-org-swarm/SKILL.md +++ b/skills/kvb-org-swarm/SKILL.md @@ -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 ""`, 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 --branch --base --workdir `. 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:`. 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 --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 diff --git a/skills/kvb-org-unify/SKILL.md b/skills/kvb-org-unify/SKILL.md index 6642789..efde7f5 100644 --- a/skills/kvb-org-unify/SKILL.md +++ b/skills/kvb-org-unify/SKILL.md @@ -61,7 +61,7 @@ row. Per merged branch and for `unify/`, 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 `) and the +the dry runs of `kvb-git-branch-cleanup` (`--merged-into `) 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 diff --git a/tests/test_dream.py b/tests/test_dream.py index 4c906c4..1ae4332 100644 --- a/tests/test_dream.py +++ b/tests/test_dream.py @@ -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): diff --git a/tests/test_hotfix.py b/tests/test_hotfix.py index c2c40ed..5d95ac8 100644 --- a/tests/test_hotfix.py +++ b/tests/test_hotfix.py @@ -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" diff --git a/tests/test_orphans.py b/tests/test_orphans.py deleted file mode 100644 index 6bae9a1..0000000 --- a/tests/test_orphans.py +++ /dev/null @@ -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()