The merged-branch sweep is a standalone tool; sdlc consumes it and supplies the repositories
State: open/accepted
Summary
Section titled “Summary”- The git-side sweep is its own package. Discovery, gathering and
classification of branches, worktrees and remote branches moved out of
solutions/ontological/lib/services/project/cleanup/intopackages/ts/repo-cleaner(@sksizer/repo-cleaner, binaryrepo-cleaner), depending only on@sksizer/easy-git,@sksizer/easy-gh,@sksizer/command-seamand@sksizer/fleet. - Task linkage stays in sdlc. The tool classifies on git and GitHub evidence alone. sdlc post-processes the returned inventory with what it knows about task files. There is no callback into the classifier.
- The tool reads no sdlc config. Its library entry point takes resolved
repository paths.
sdlc project cleanupresolves them and passes them in. repos:is the input, under explicit fan-out. No new configuration key. A bare invocation still sweeps only the repository you are standing in; a flag is what puts the configured repositories in scope.repos:entries gain a second shape. An entry that is itself a checkout names that one repository. An entry that is not is scanned one level deep, as today.
Context
Section titled “Context”sdlc project cleanup already answers the question “what has been merged and
can go away” for one repository. It gathers local branches, worktrees and
remote branches, classifies each as safe-to-delete, safe-to-keep or
needs-investigation, and emits a markdown report plus the literal commands
that would clean each item up. It is strictly read-only; the judgment seam
lives in /sdlc:project-cleanup.
Two things are missing. It runs against one repository, and the capability is welded to sdlc even though almost none of it is about sdlc.
The second point is measurable. cleanup/index.ts imports six things. Three
are node: builtins or already-standalone packages. The remaining three —
scanEntities, extractTitle, splitFrontmatter — appear only inside
loadTasks and the task-linkage scope. One more, formatUtcFields, backs a
single timestamp. The engine is already close to dependency-free; nothing
about git merge-base --is-ancestor needs to know what a task file is.
Decision
Section titled “Decision”Extract the git-side sweep into packages/ts/repo-cleaner, published outward
on the snapshot-mirror channel alongside its dependencies. It ships a library
entry point and the repo-cleaner CLI.
sdlc project cleanup keeps its verb, its schema and its skill, and becomes a
thin consumer: resolve repositories, call the library, annotate the returned
rows with task linkage, render.
The repositories to sweep come from the existing repos: key in sdlc.yaml
and the machine config. No second key is introduced. Fan-out is opt-in at the
verb, so a bare invocation never walks the machine.
Extraction is cheap and the boundary is already drawn. The sdlc-specific code is confined to one function and one scope. Nothing has to be untangled.
The capability is general. Finding merged branches is not an SDLC concern. A tool that does it belongs where anyone can use it, and the repo’s placement gradient (D-0001-project-structure) already describes how a package earns its way outward.
Composition beats a hook. The verdict ladder in classifyLocalBranch
puts every git-derived delete above every task-derived rule. So sdlc never
needs to veto the tool. It only sharpens rows the tool left at keep or
needs-investigation, which is ordinary post-processing over the returned
inventory. An annotator port would add an extension point that nothing needs.
One configuration key is enough. repos: already answers “where are this
machine’s checkouts”. A second key holding the same paths under a different
name would be two lists to keep in step.
Options considered
Section titled “Options considered”Option A: extend sdlc project cleanup in place
Section titled “Option A: extend sdlc project cleanup in place”Add --repo and a worklist key to the existing op. Cheapest by far, and no
package to create. Rejected because the capability stays welded to sdlc, and
the thing most worth reusing is the part with no sdlc content in it.
Option B: extract with an annotator port
Section titled “Option B: extract with an annotator port”The tool exposes a hook so a host can contribute facts and override verdicts during classification. Rejected: the ordering analysis shows no override is needed, and the port would be an extension point built for a case that does not arise. Post-processing achieves the same result with a smaller surface.
Option C: a separate worklist key in sdlc.yaml
Section titled “Option C: a separate worklist key in sdlc.yaml”repo_sweep: { scan: [...] }, distinct from the search-path repos:.
Clearer to read, at the cost of two keys holding overlapping paths. Rejected
in favour of reusing repos: with opt-in fan-out, which preserves the
invariant that matters — no implicit fan-out — without a second list.
Proposed behaviour
Section titled “Proposed behaviour”Repository resolution
Section titled “Repository resolution”Each repos: entry resolves by inspection. No syntax distinguishes the kinds.
| Entry | Detected by | Resolves to |
|---|---|---|
~/Developer/dev | checkoutRoot(d) === d | that repository |
~/Developer | a directory that is not a checkout | every checkout one level under it |
| unreadable or missing | — | an unresolved row in the report |
Resolution order: expand ~, resolve a relative path against the project
root, machine-config entries before project entries, one level deep, hidden
children skipped, results sorted.
Candidates are then de-duplicated on dirname(gitCommonDir()). A linked
worktree collapses into its main checkout, so a container holding a repository
and three of its worktrees sweeps one repository, not four. Those worktrees
still appear — as worktree rows in that repository’s own inventory.
An entry that resolves to nothing is reported rather than dropped. A worklist that silently sweeps four of five named repositories is the worse failure.
Scope is opt-in
Section titled “Scope is opt-in”repo-cleaner # the repository you are standing inrepo-cleaner --all # every repository resolved from configrepo-cleaner ~/work/api ~/work/websdlc project cleanup # unchanged: this repository onlysdlc project cleanup --all # sweeps what repos: resolves tosdlc project cleanup --repo acme/api--repo and --all REPLACE the default target rather than adding to it,
matching sdlc pr survey: naming targets says what to sweep.
repos: remains a search path in its own right. The flag is what puts
repositories in scope, which preserves the rule that a bare invocation never
fans out across the machine.
Configuration
Section titled “Configuration”# sdlc.yaml — committedrepos: - ~/Developer # a container: every checkout one level under it - ~/work/api # a single checkout# <config-home>/sdlc/config.yaml — machine-local, never committedrepos: - /Volumes/scratch/clonesStandalone, the tool reads its own list from @sksizer/toolconfig’s machine
file — the host OS’s config home, so
~/Library/Application Support/repo-cleaner/config.yaml on macOS and
~/.config/repo-cleaner/config.yaml elsewhere. It never reads sdlc.yaml;
when sdlc is the caller, sdlc resolves the list and passes paths in.
Output
Section titled “Output”Default text mode renders one section per repository plus a roll-up. Each row carries a classification, a reason, the facts behind it, and the command that would act on it. Nothing is mutated.
$ repo-cleaner --all
/Users/me/work/api (acme/api, default main, origin) local_branch fix/login-redirect delete PR #412 merged git branch -d fix/login-redirect local_branch spike/cache review mixed or absent signals worktree wt/412 delete clean; PR #412 merged git -C /Users/me/work/api worktree remove wt/412 remote_branch origin/fix/login-… delete PR #412 merged (auto-delete didn't fire) git push origin --delete fix/login-redirect
/Users/me/work/web (acme/web, default main, origin) local_branch chore/deps delete fully merged into main git branch -d chore/deps
unresolved ~/work/old-thing not a directory
3 to delete, 1 to review, across 2 repositories (1 entry unresolved).--output json returns the full inventory: per-repository info, rows with
their facts, and the summary counts.
Degradation
Section titled “Degradation”- No
gh, orghnot authenticated: classification proceeds on git evidence alone and the report says so. This is today’shasGh/ghAuthenticatedprobe, unchanged. - A repository whose default branch cannot be determined becomes an error row
for that repository. The sweep continues. Today
discoverRepothrows, which is correct for one repository and wrong for a fan-out.
What sdlc adds
Section titled “What sdlc adds”sdlc matches branch names to task files and rewrites rows it has an opinion
about — a branch whose task is closed/done, a branch whose task is
in-progress. It also contributes the task-linkage rows that have no git
counterpart, such as a task with no branch anywhere.
For this to work the tool must emit complete facts on every row, including
rows whose verdict was obvious, and must return investigation_prompt as
composable parts rather than a joined string. Today that string contains the
sentence “No matching task file under docs/planning/tasks/”, which has to
leave the tool.
Consequences
Section titled “Consequences”repos:serves two intents. A repository added for sweeping is also a repositorysdlc pr reviewwill find. Benign —locateRepogains a candidate — but deliberate.- Three places state that
repos:is a search path and never a worklist: the Zod description inlib/config/load.ts, therepos:section ofconventions/sdlc-yaml.md, and theresolveTargetsdoc comment inpr/ops/survey.ts. Each is amended in place to say that a verb may fan out when explicitly asked. The invariant they protect is unchanged. - Accepting a direct checkout path in
repos:improveslocateRepo, which today cannot match an entry pointing straight at a checkout. @sksizer/easy-ghjoins the publish channel. Nothing on the channel has been released yet, so this is an entry inpublish-libs.json, not a gate.- A new package costs its
moon.yml, itstsconfigs, apublish-libs.jsonentry withneeds, an npm bin name that clears, and a tier-1 row indocs/LIBRARIES.md.
Migration
Section titled “Migration”- Add
@sksizer/easy-ghtopublish-libs.json(withfleet, which it needed too). - Create the package:
discoverRepo, the three gatherers, the classifiers and the renderers, with everymatchedTaskbranch dropped and the date helper inlined. - Swap the hand-rolled
gh pr list --jsonparse for@sksizer/easy-gh. - Add multi-repository resolution, per-repository error containment and the aggregate renderer. The fan-out is sequential; async is deferred.
- Add the CLI and its standalone config.
- Rewrite
services/project/cleanup/index.tsas an adapter: resolve, call, annotate, render. The op’s existing output-shape tests are the regression net. - Extend
sdlc project cleanupwith--repoand--all. - Amend the three
repos:descriptions and add the entry-kind table. - Add the tier-1 row to
docs/LIBRARIES.md.
Open questions
Section titled “Open questions”- Read-only, or a
--prunemode. Today’s op emits commands as strings and leaves execution to the skill’s approval flow. A standalone tool with no agent behind it may want to act, which changes the API. - One ordering rule changes under composition. Today a branch with a
closed/donetask and an open PR classifies as delete, because the task rule sits above the open-PR rule. Post-processing makes it keep-then-upgrade. The current precedence looks wrong — an open PR against a closed task is the definition of needs-investigation — but it should be decided, not inherited. - Whether
sdlc project cleanupkeeps ownership of the whole report or narrows to task linkage over the tool’s inventory. The proposal keeps the verb and narrows what it owns.