Your repo is keeping notes you will never read again — and it will not tell you which ones.
loose-ends finds every one of them, including the ones ls and git ls-files cannot see,
explains in plain words why each is loose, and turns the pile into a short list of decisions.
read-only until you say otherwise · no dependencies · no network · no API key · no model in the scan
One decision per row. The verdict is already filled in. The pane at the bottom says why.
- Thirty seconds on why this exists
- What one real run found
- Try it on your own repo — it cannot change anything
- How it works, in one picture
- How it works, step by step
- The four verdicts
- Nothing is judged twice
- Two front ends, same verdicts
- Nothing happens until you say so
- Reference
- Working with an agent
- Questions people actually ask
- Why the colours
Work gets interrupted. You need information you don't have yet, or the shape of the
problem changes, or it's late. The responsible thing is to write down where you got
to — handoff.md, plan.md, parked-findings.md, NIGHT-REPORT.md,
task-7-brief.md — so the thinking isn't lost.
That's good practice. It is also, exactly, how the pile forms.
Every one of those files was worth writing. Almost none of them are worth keeping forever. None of them announce which kind they are. And the way modern work is organised makes each one multiply:
- Worktrees overlap. A linked worktree is a whole second checkout. Its
docs/is not yourdocs/. A note written in one is invisible from the other, and worktrees outside the repo directory are invisible from everywhere. - Branches hold notes hostage. The plan you wrote on
spike/new-parseris still there, on a branch you moved off in March. It doesn't appear in your editor, your file search, orgit ls-filesonmain. - Task lists drift apart.
todo.mdon the branch andtodo.mdonmainare now different files with the same name and 60% of the same headings, and the merge that reconciles them is one nobody schedules. - Notes go stale silently. A plan naming
Persistence.swift,AppStore.swiftandSTATUS.mdstill reads as authoritative long after two of those files stopped existing. Nothing marks a document as describing a repo that has moved on. - Coding agents made all of this an order of magnitude worse. One overnight run leaves thirty briefs and reports in a gitignored directory you never open. Ten runs leave three hundred.
So the problem was never that these files exist. It's that you cannot tell them apart without opening all of them — so nobody opens any of them, and the pile compounds, and the one paragraph you would genuinely want back is somewhere in it.
loose-ends is the thing that opens all of them for you and hands back a short
list of decisions.
This is the repo the tool was built in — a Mac + iOS app, four worktrees, an
.superpowers/ directory full of agent output, months of plans and reports:
loose ends — delightful_note_experience on main
────────────────────────────────────────────────────
221 files read across worktree, branches, scratch dirs
28 want a decision (fold·14, later·14)
5 of them cover 76 files at once
91 filed — something in the repo points at them
0 already judged 27 not work notes
────────────────────────────────────────────────────
221 notes in. 28 decisions out. Every number on this page comes from that run.
Here is the part that makes the case better than any adjective could. Of the 99 files it flagged as loose, this is where they were living:
| where it was | how many | would you have found it? |
|---|---|---|
| a gitignored agent scratch directory | 76 | git ls-files returns zero of these. git status doesn't mention them. |
inside a linked worktree, not on HEAD |
22 | one of those worktrees is a sibling directory outside the repo entirely |
| untracked in the working tree | 1 | one git clean from gone, with 8 open items in it |
git ls-files '*.md' in that repo returns 125 files. The scan read 221. Ninety-six
markdown notes existed that git's own file list did not contain — and the
highest-scoring file that wasn't part of an agent run was that single untracked
one: nothing in the repo pointed at it, no commit had ever recorded it, and it
claimed eight things were still outstanding.
That's the demonstration. Not "your repo is probably messy" — this is what a normal, well-run repository looks like underneath, and no tool you already have shows it to you.
The scan reads git objects and files. It never checks out a branch, never touches
the index, never writes into your repo. The only file it produces is the one you
name with --out. So the honest way to evaluate this is to just point it at
something and look:
git clone https://github.com/seyonv/loose-ends ~/.claude/skills/loose-ends
cd /your/repo
python3 ~/.claude/skills/loose-ends/scripts/scan.py --repo . --out /tmp/candidates.jsonTen seconds later you get the summary block above, for your repository. If it says zero, your repo is tidy and you've lost ten seconds. If it says forty, you now know something about your project that you did not know a minute ago.
Then review them:
python3 ~/.claude/skills/loose-ends/scripts/tui.py --in /tmp/candidates.json --out /tmp/verdicts.jsonStill nothing changed — that writes a list of decisions, not the changes. Applying them is a separate command that is a dry run by default. See Reference for the whole four-line pipeline, and Nothing happens until you say so for why it's built that way.
Using Claude Code or a compatible agent? Cloning into ~/.claude/skills/ is all
the installation there is — then just say /loose-ends.
This is the whole idea, in order. None of it involves a language model. The scan is deterministic: two runs against an unchanged repo produce byte-identical output, which is what makes it safe to wire into a habit.
A plain ls or find misses most of the pile. loose-ends reads:
- The working tree — tracked and untracked.
- Every local branch, looking for files that exist there but not on
HEAD. This is the note you wrote on a branch you never merged. It is read as a git blob: no branch is ever checked out, and the index is never touched. - Agent scratch directories —
.superpowers/,.agent/,.cursor/,.aider/. These are usually gitignored, which meansgit ls-filescannot see them at all, so they are walked directly from disk. - Every linked worktree in
git worktree list, including ones outside the repo directory entirely. .claude/worktrees/*— where overnight and parallel-lane agent runs leave their reports.
Note
A trap worth naming, because the first version fell into it. A linked
worktree is a complete second checkout of the repo. Walking into one naïvely
reports every file in your project twice. Version one did exactly that: 442
candidates, 324 of them duplicates of each other, which is worse than useless
because it teaches you to distrust the list. Worktree prefixes are now skipped
during the main walk and handled separately, contributing only the files that
are not already on HEAD — which is precisely the set that is invisible from
where you're standing.
Plenty of markdown isn't a loose end. A README is documentation. A marketing page is a deliverable. A changelog is a record. None of them is waiting on a decision.
So each file earns a work signal from what it contains, not merely where it sits: unticked checkboxes; phrases like next steps, deferred, follow-ups, working notes, handoff, where we left off, acceptance criteria, phase 2; a date-stamped filename; the shape of a plan, spec, report or queue. Anything scoring below the threshold is set aside as a document, not a candidate. On the real run that removed 27 files before any question was asked.
Files whose names a project depends on — README.md, CLAUDE.md, CHANGELOG.md,
LICENSE, CONTRIBUTING.md — are never candidates, whatever they contain.
This is the part that makes the tool work, and it is worth stating plainly, because it is the opposite of what tools in this space normally do.
Most "find your TODOs" tools grep for the string TODO. That is why they hand you
five hundred results and you close the window. Searching for unfinished-looking
text finds every file that has ever been unfinished — which, in a working repo, is
nearly all of them. The signal is drowned by the thing it's made of.
The better question isn't about a file's contents at all. It's about its position:
Does anything else in this repository refer to this file?
A plan that STATUS.md links to is filed. It has a reader, a place in some
structure, and a reason to still exist — someone will arrive at it by walking the
thing that points to it. A plan that nothing mentions is an orphan. It is not
part of any structure. It survives purely because deleting files feels risky.
Mechanically: every tracked text file in the repo is concatenated into one string, and each candidate is checked for mentions of its own path and filename — with its self-references subtracted, so a file quoting its own name cannot vouch for itself.
That single test did most of the work. It moved 91 of 221 notes out of the way
before a single question was asked. Those 91 are not deleted or hidden — --all
brings them back — they are simply not what you're being asked about, because
something in the repo already looks after them.
Orphanhood is the strongest signal but not the only one, so looseness is scored, and the reasons are shown to you in plain words rather than as a number you're asked to trust. Each signal adds:
| signal | weight | what it tells you |
|---|---|---|
not on HEAD |
+5 | it isn't part of the current repo at all |
| exists only on another branch | +5 | it was parked with a branch you moved off |
| untracked | +4 | never committed; one git clean from gone |
| nothing points at it | +4 | no reader, no place — see step 3 |
| only inside a linked worktree | +4 | invisible from the main checkout |
| in an agent scratch directory | +3 | machine-written paperwork, rarely re-read |
| near-duplicate of a newer file | +3 | the same content already exists, fresher |
| has unticked checkboxes | +2 | it claims work is outstanding |
| names 3+ files that no longer exist | +2 | it describes a repo that moved on |
carries an unreviewed Parked banner |
+2 | parked once, never revisited |
| 45 days cold and unreferenced | +1 | time alone, but only when nobody reads it |
Default cutoff is 5. --min-loose N moves it; --all shows everything.
Two files from the real run, to make this concrete.
tasks/2026-08-05-seven-lanes.md — not on HEAD (+5), untracked (+4), nothing
points at it (+4), 8 unticked boxes (+2) = 15. Top of the list, and correctly
so: it is one git clean away from vanishing and it claims eight things are
outstanding.
docs/superpowers/plans/2026-07-24-phase-1-editor-core.md — on HEAD, tracked,
and STATUS.md links to it = 0. Never shown. It is an archived plan sitting
exactly where the index says it is. Nothing is wrong with it, and nobody needs to
be asked about it.
Same kind of file. Completely different situations. Only the second question tells them apart.
An agent run that produced fourteen task briefs and fourteen reports is one
thing you have an opinion about, not twenty-eight. Any scratch-run directory with
four or more sibling notes collapses into a single row carrying all of them,
labelled with what's inside (8 report, 3 queue, 1 plan). Judge the run; the
verdict applies to every file in it.
On the real repo, 5 grouped rows covered 76 files. That is the difference between a review you finish in four minutes and one you abandon on row nine.
The same note often exists twice — written once, copied to a branch, edited slightly on one side. Comparing full text won't catch it, because the words drift as soon as anyone edits either copy.
Headings drift much less. So each pair of candidates is compared on the set of their headings: how many they share, over how many they have between them. At 55% or higher they are flagged as near-duplicates, and the older one is recommended for folding into the newer. The card names its twin, so you can see the pairing and judge it rather than take it on faith.
Every row arrives with a suggested verdict and a sentence saying why, so a fast pass is mostly pressing Enter. The rules are checked in this order, first match wins:
| # | if the file… | → | why that default |
|---|---|---|---|
| 1 | already carries a Parked banner |
park | it's a decision someone already made; don't quietly re-litigate it |
| 2 | lives in an agent scratch directory | later | that directory belongs to another tool — deleting there is a real deletion, not repo tidying, and should be deliberate |
| 3 | is a near-duplicate of something newer | fold | the fresher copy is the one to keep |
| 4 | has every box ticked and is 21+ days cold | delete | the work is behind it |
| 5 | is a report or night report, 21+ days cold | delete | it described a moment that has passed |
| 6 | names 3+ files that are gone, most of what it names | delete | it describes a repo that no longer exists |
| 7 | is a plan or spec, 21+ days old, boxes still unticked | park | see below — this one matters |
| 8 | has unticked boxes | fold | something in it is still live |
| 9 | exists only on a branch | fold | nothing on HEAD leads to it |
| 10 | is 60+ days old with nothing outstanding | park | park it or lose it |
| 11 | — anything else — | later | no strong signal, and pretending otherwise would be worse than saying so |
Rule 7 is the one that earns its keep. An old plan with 71 open boxes almost always describes work that shipped and was never re-ticked. Folding those 71 lines into your standing queue would be vandalism — you'd resurrect finished work as a backlog. So the default is to park it, not fold it, and the case for reading the file before folding it is made in Working with an agent.
A suggestion is only a suggestion. Every row takes any of the four verdicts.
| means | what actually happens | |
|---|---|---|
| Delete | spent | git rm if tracked, plain remove if not |
| Fold in | the live part belongs on a standing list | appends the unticked lines — under the headings they sat below — into a destination file, beneath a dated ## Folded in from … heading, then deletes the source |
| Park | keep it, but say so out loud | writes > Parked 2026-08-08 — <reason> under the title, so next time it reads as a decision instead of a loose end |
| Later | not now | nothing, and — importantly — no ledger entry, so it comes back next run |
Fold destinations are discovered, not configured. Files matching todo / status / backlog / queue / roadmap / lessons are ranked by how many checkboxes they already carry, on the theory that a file which collects work looks like a file full of checkboxes. Press t to cycle between them. A destination file is never itself offered as a candidate — a standing list is where loose ends go, so it is never one.
A fold with nothing live in it is refused, with the reason printed. A file with no unticked lines is a delete or a park; silently folding an empty block would hide that fact from you and quietly delete the source.
.loose-ends/ledger.json records the path, a content hash, the verdict, the
date and the reason. The next scan skips anything already judged at that same
hash.
The hash is what makes this behave the way you'd want. Edit a parked file and it returns for a fresh look, because it is genuinely a different file now. Leave it alone and it stays quiet forever. (Parking writes a banner, which changes the file — so the ledger records the hash of the file as it will be, not as it was, or the tool would immediately hand you back the note it had just annotated.)
The ledger is committed on purpose. A fresh clone, a teammate, and any agent working in the repo should all be able to find out what has already been decided, rather than proposing it again.
The terminal reviewer at the top of this page is the fast one — built for a pass where most rows are Enter. The browser page is the one for actually reading the notes before you decide, which is the part that matters when the decision is "delete".
Left is the queue, one line per decision, which you fly down with
j/k. Right is the note itself — the whole thing,
rendered: headings, checkboxes, tables, code. For a grouped agent run, the strip
of filenames across the top flips between the twelve files inside it without
leaving the row. The header of each note carries the facts that put it in the
list (0 references, 24 open boxes, never committed) as tags, so why is this
even here never requires a second window.
Nothing arrives pre-decided. The suggestion sits as one quiet line under the
buttons — suggested Delete — a report of work already landed, 43d old — and the
suggested button is outlined in dashes. Enter takes it, a
takes every remaining one in the current view (and asks first). But the loud thing
on the screen is always your call, and until you make it, that line reads
— undecided —. A verdict you didn't choose should never look like one you did.
And you can leave in the middle. Press s and everything decided so
far goes back to the terminal and is written to verdicts.json immediately;
those rows lock, and the queue carries on. The footer keeps a running count —
12 sent · 6 ready to send · 10 to judge — and the terminal prints each batch as
it lands. A 40-note pile does not have to be one sitting, and quitting halfway
through never costs you the half you did.
← 6 verdicts 6 rows, 31 files so far → verdicts.json
← 4 verdicts 10 rows, 47 files so far → verdicts.json
--serve opens the page and waits for Finish & apply before handing back to
the terminal. With no server listening the page downloads verdicts.json instead,
so you can build the page, mail it to yourself, and decide on a train. It is one
self-contained HTML file, light and dark, no network of any kind.
apply.py is a dry run unless you pass --apply. It prints every step it would
take, and separately, everything it cannot do.
- It never rewrites a branch's history. A file living only on a dead branch is reported under needs you, with the one command to run there. It does not check out fourteen branches in order to be helpful.
- It never switches branches or touches the index while scanning. Branch content is read as blobs.
- Applying is one commit, listing what was deleted, folded and parked, with the
ledger updated in the same change — so the whole thing is one
git revertaway from never having happened. - Folding is destructive to the source. It appends, then deletes. If you are not sure the append captured what mattered, park it instead; park is free.
Install
git clone https://github.com/seyonv/loose-ends ~/.claude/skills/loose-endsThen in any repo, /loose-ends — or run it by hand:
S=~/.claude/skills/loose-ends/scripts
W=$(mktemp -d)
python3 $S/scan.py --repo . --out $W/candidates.json # read-only
python3 $S/tui.py --in $W/candidates.json --out $W/verdicts.json # your judgment
python3 $S/apply.py --repo . --verdicts $W/verdicts.json # dry run
python3 $S/apply.py --repo . --verdicts $W/verdicts.json --apply --commitBrowser instead of terminal:
python3 $S/build_page.py --in $W/candidates.json --out $W/loose-ends.html \
--verdicts $W/verdicts.json --serveTip
Work in a scratch directory, not in the repo. candidates.json carries an
excerpt of every file it found — that is your own writing, and it belongs
nowhere near a commit.
Keys — the same letters in both front ends
| terminal | page | |
|---|---|---|
| j k | move | move |
| d f p l | delete · fold · park · later | same |
| Enter | accept the suggestion | same |
| a | accept every remaining suggestion | same, but asks first |
| t | cycle the fold destination | same |
| u | — | undo the last verdict |
| s | — | send what is decided so far |
| [ ] | — | previous / next file inside a run |
| 1–4 | — | to judge · ready · sent · all |
| / | — | filter the queue by path |
| ? | — | the key list, on screen |
| v | open the file in your pager | — (the note is already open) |
| w | write the verdicts and quit | — |
| q | quit without writing a thing | — |
Flags
--min-loose N |
move the looseness cutoff (default 5) |
--min-signal N |
move the work-note threshold (default 3) |
--all |
include the filed files too |
--remotes |
also scan origin/* branches |
--no-ledger |
re-ask about everything, ignoring past verdicts |
Python 3.8+, standard library only.
Run as a skill, the split is clean: the mechanical parts stay mechanical, and the agent does the part that requires actually reading — checking that each fold's extracted lines are really what should move.
That check is the whole reason rule 7 exists. A file with 71 unticked boxes can be 71 live commitments or 71 lines of finished work nobody re-ticked, and no amount of pattern matching can tell those apart — but something that reads the plan and looks at the repo can. The skill's instruction is explicit about it: never apply a fold of more than about twenty lines without having read the source, and when the extraction is wrong, say so and recommend park or delete instead.
Everything before --apply is read-only, which is exactly what makes it safe to
hand an agent a repository you have not looked at in a month.
Is this just grep -r TODO?
No, and that difference is the entire design. Grepping for unfinished-looking text
finds every file that has ever been unfinished. This asks whether anything in the
repo is reading the file — a question about position, not contents. On the real
run that one test set aside 91 of 221 files as already looked-after. See
step 3.
Will it delete something I wanted?
It cannot delete anything without --apply, and it deletes nothing you did not
personally give a verdict to. The dry run prints every step first, and the applied
change is a single commit.
Do I have to review all of them? No — that's what later is for, and later is deliberately not recorded, so those files come back next run. A pass where you judge the obvious ten and defer the rest is a successful pass; the ten are gone for good.
Can I stop halfway through? Yes, and it costs nothing. In the browser page, s sends everything decided so far and the terminal writes it to disk on arrival, batch by batch. Close the tab at row twelve and the twelve are still there. Even Ctrl-C in the waiting terminal keeps whatever already landed.
What if my notes live somewhere unusual? The five sources cover the common cases including gitignored agent directories and worktrees outside the repo. Anything in the working tree is read regardless of where it sits, tracked or not.
Does it phone home / call a model / need a key? None of the three. The scan is pure Python over git and the filesystem, and it is deterministic — same repo, same output, every time.
Is it safe on a repo with uncommitted work? Yes. Scanning never switches branches and never touches the index; branch content is read as blobs. Applying only ever touches the files you gave verdicts to.
Amber, sage, bark. Nothing turns red, because none of this is an error. It's just work you haven't decided about yet — and a tool that shouts at you about your own honest notes is a tool you will run exactly once.
MIT.



