SuperAPI AI Directory

session-harvest

Office & productivity

View repository

Updated 10-16 02:02

About this skill

Session harvest

Reviews a conversation for what's worth persisting before it's compacted or ends, and makes sure each candidate lands in the right place — not just memory by default because memory is the easiest tool at hand. Primary invocation is explicit (/session-harvest); the description above also matches natural-language phrasing, but explicit invocation is the one to rely on — description matching for something this consequential (it writes files) has been reported unreliable by prior art (see references/rationale.md).

On-demand only. Installing hooks, wiring settings.json, or running on any schedule is explicitly out of scope — that's a different, heavier tool (see references/rationale.md for the prior art considered and rejected).

What this skill reads, runs and writes

  • Reads: Claude Code's transcripts and background-job state under ~/.claude (read-only, another harness has nothing there); git state of every repo the session touched; CI runs through gh; the process and listening-socket tables; docker images; both plans stores; the installed skills hub and the source checkout skills-state resolves. The socket step checks whether credential-named files (.env, .netrc, …) exist under a directory a listener serves — names only, never contents.
  • Runs: git (including fetch to each touched repo's own upstream unless --no-fetch), gh and docker when installed, ps/ss on Linux or PowerShell/netstat on Windows, and plans.py absorb read-only. Nothing through a shell. It never installs, re-installs or removes a skill: skills-state compares the installed copy with its source and reports the difference, and deploying a fix is skill-authoring-taudelta's sequence, not this skill's.
  • Writes: the script writes nothing. The procedure writes, through the agent, only inside the set stated at the top of it — the session repo, its plans/, and the plans store through plan-conveyor. It never edits, moves or deletes a plan somebody else filed for a different repo; evidence for one is filed as a new plan that names it. Never a deployed instructions file, never an installed skill copy, never another repo's tree. Killing an orphaned process the sweep found is the one non-file side effect, and it is proposed, not done.
  • Network: git fetch and gh only, to the repos' own remotes. Nothing is sent anywhere; every report goes to stdout.

The script

scripts/harvest.py carries every mechanical step: the boundary, the installed-vs-checkout comparison, resolving this session's transcript, extracting the turns and answers, the live-state sweep, the green-claim count, and what an earlier harvest in this session already filed. Run it rather than composing the commands by hand — measured 2026-09-02 across 24,429 Bash calls in 1,134 transcripts, those commands were re-invented every run and drifted, so answers that ought to be comparable across harvests were not. Six documented failures are now code the script cannot repeat (the upstream branch is read rather than typed, nothing runs through a shell so no pipe can eat an exit code, CI is read as JSON, depends_on is anchored, a job's transcript comes from its state.json, answers are matched by tool-use id).

H=~/.agents/skills/session-harvest/scripts/harvest.py   # or <checkout>/skills/session-harvest/scripts/harvest.py
python3 $H boundary                                     # step 0, first command of the run
python3 $H transcript --expect '<a command this session ran>'
python3 $H turns                                        # step 4
python3 $H skills-state                                 # step 0 — needs a checkout, see below
python3 $H sweep --boundary <instant>                   # step 5
python3 $H claims --until <instant>                     # step 5, the exit-masked rule
python3 $H filed --until <instant>                      # step 8, every harvest — see below

The bare turns, sweep and claims lines resolve the transcript from $CLAUDE_CODE_SESSION_ID, which Claude Code exports into every Bash call and which is the transcript's own filename stem; a background job's state.json still takes precedence, and --session <id|path> is for a harness that exports no id. Until 2026-09-05 they resolved from nothing — transcript --expect printed the right path and the next line exited 1 with no transcript resolved — and three harvests in two days re-typed --session by hand before the script was taught to read the variable that had been in its environment all along.

skills-state resolves its own --since, and prints the value it used. Until 2026-09-08 the block above read --since <session start> — a placeholder with no stated source. The value exists (transcript's started: line) but nothing said to take it from there, and the two commands are independent, so a session batching its calls had to supply the flag before transcript had answered. Six harvests substituted a guess; one was ninety minutes early and got the right verdict anyway, which is how a placeholder survives. Too early prescribes the procedure's most expensive step on evidence that does not support it; too late drops a genuinely superseding commit out of the window and fails closed behind a clean report. The flag remains, as an override for a harness exporting no id or to audit a window that is not this session's — and the since: line says which it used either way, because the harm was never the wrong window but that a wrong one was indistinguishable from a right one. A supplied value now also says how far it sits from the real start and which way the window moved, since a guess and a deliberate audit look identical until the two instants are printed side by side.

transcript prints the session's longest idle gap, and one over a day means a resumed session. Every window anchored at started: then spans the gap, so skills-state, filed and sweep fill with rows correctly attributed to other sessions — measured 2026-09-27, ~190 store commits to find this session's 3, and a re-read prescribed on nine commits of which seven were not its own. Read each row's attribution before its prescription there. The anchor itself stays at session start, since a same-day session, the case every other measurement here came from, gains nothing from moving it.

The moved-since check baselines each skill on when its own body entered context, not on session start. Those differ by hours for a skill invoked late, and a harvest is loaded last by construction — so session start gives this skill a false positive on itself every run, in the step whose whole purpose is deciding whether to trust its own instructions. Confirmed 2026-09-07: a harvest invoked in a session's last minutes was told its held copy might be superseded by three commits that landed before the copy was ever read. The load instant comes from the session's own Skill calls; a skill with no such call falls back to session start, and each row says which of the two it used.

skills-state compares the install against a checkout, and finds the checkout itself. In order: --checkout <path>, $SESSION_HARVEST_CHECKOUT, the repo above the script it is running from, and then the one git checkout under plan-conveyor' configured projects_root (default ~/projects, walked three levels deep, symlinks never followed) that holds skills/session-harvest/SKILL.md. So the installed copy answers step 0 on any machine that has the source somewhere, whatever the repo layout; a fork counts, and two candidates make it ask rather than pick. It also prints the command step 6 files a skill fix with, checkout path included, so no skill body has to name where any author keeps repos. With no checkout on the machine it exits 1 saying so — the reader's normal case, an installed copy with no source repo — and the right report line is that the comparison was unavailable, not that nothing had moved. Until 2026-09-05 the install-side copy could never answer this without --checkout, confirmed by a harvest that ran the block above verbatim and got the error on its first step-0 command.

Every subcommand is read-only and takes --json. Two things are deliberately not in it: the gate re-run (that is the repo's own command, and hard-coding one would be wrong in every repo that spells it differently) and anything that writes — a script that both measures and writes is one an agent runs without reading. The judgement below is the rest of the skill, and none of it moves.

Procedure

What a harvest may write, stated once. Three places and nothing else: the repo the session is running in (ordinary edits and commits), that repo's plans/ through plan-conveyor, and the plans store through plan-conveyor — new plans, including plans filed --for another repo, and corrections to plans this session wrote there. A plan somebody else filed for a different repo is read, cited and never edited. Outside it: a deployed instructions file, an installed skill copy, any other repo's working tree, and the sources of a generated ~/.agents/AGENTS.md when they live in a repo the session is not in. A candidate for any of those is filed as a plan for the repo that owns it. Being in the owning repo is what makes an edit ordinary work, so the skills repo and the instructions-fragment repo are not exceptions to this rule; they are the rule applied to a session that happens to be there. Stated by the user 2026-09-03: "harvest should exclusively edit things in the repo where the session is happening and, via plan docs, to the plans in the current session's and the plans in the central store repo" — and "instructions" there means the deployed file, not its fragment sources from inside their own repo. Killing an orphaned process the sweep found is a side effect, not a write, and step 8 governs it.

0. Check the copy you are running is the current one

python3 $H boundary is the harvest's first command, before anything else this run does. Keep the value; step 5 passes it to --until so the adherence figure describes the session's work rather than this harvest's own sweep. Every step below adds Bash calls of a different character to the transcript, so a boundary taken later has already lost some of them.

The running skill is a file copy dropped at install time, so a harvest can silently execute a version older than the source — skipping exactly the checks most recently added, and reporting a clean run because it never looked. python3 $H skills-state compares the installed copy against the checkout for this skill and the others a harvest leans on, plus every skill this session wrote to (printed as changed this session:), since changing a source is exactly what puts its install behind; add --skill <name> for one it used without writing, or changed through a shell command, which the transcript's edit-tool writes cannot show. In a checkout holding none of those three — any skills repo but this one's — it reports that checkout's own skills instead, and prints a scope: line saying so, because the default's whole purpose is that naming another skill cannot drop this one, and where this one is absent there is nothing to protect. If they differ, say so; a stale harvest is worse than no harvest, because its report reads identical. Added 2026-08-29 after the user asked for a harvest "with the latest versions" — behaviour the skill did not have, and could not have confirmed if asked.

A difference has four causes, and only one of them is a stale install. The subcommand prints the verdict; report the row it names. A harvest reports install state and never deploys — pushing, installing and reloading a skill are skill-authoring-taudelta's sequence, including why each row below needs a different move — so the report says which row applies and whose move it is:

the checkout is what it means what the report says
clean, level with the remote the install is stale the installed copy is behind a published fix
clean, ahead by commits unpushed skill work the fix is unpublished, and whose commits it is
dirty work in progress nothing to deploy; another session may be mid-edit
a linked worktree edits on a branch the fix is on a branch no install reads from

The last row reads as the first: skills-state prints a worktree: line naming the checkout it belongs to, because nothing else in its output distinguishes them. Say plainly that the last three are not install problems. A dirty checkout in particular is far more often another session's live restructure than install hygiene, and describing it as the second invites exactly the cross-repo interference the global rules forbid. Confirmed 2026-08-29 on plan-conveyor, mid two-tier-store-split: 672 uncommitted insertions, nothing ahead of the remote — git log showed a settled history, and only status --short saw it. Confirmed in the other direction 2026-08-30: same non-empty diff, clean checkout level with the remote, two commits pushed minutes earlier — row one exactly as written. The same diff meant opposite things a day apart.

The verdict is a reading, not a fact, and re-running it costs one call. Which row you are on can change while the harvest is still going, because another session commits or pushes into the same checkout — so quote a verdict taken at the top of a run and you may hand the user the previous answer. Confirmed twice on plan-conveyor, and the interval is the point: thirteen hours apart on 2026-09-03, where "unpushed skill work" became "install is stale against a clean, pushed checkout", the row changing from one no install could fix to the one an install does; and then inside ninety seconds on 2026-09-05, two rows of the table apart, with a parallel session's commit landing at 23:44:21 in the gap between two calls at 23:43 and 23:45. So this is not a long-interval precaution — a fast run that resolves at the top and reports at the bottom is exposed too, and is exactly the run where nobody would think to re-check. The git ahead-count already carries this rule under step 5; skills-state is the check whose output most directly becomes advice, so it needs it more.

A dirty checkout does not block the run. Both sessions above harvested correctly, because every command they ran was against the committed version. Confirm in passing that the commands this run needs are not themselves inside the uncommitted diff, and — only if this session is working in the skills repo — treat it as a finding about step 6's fold-back, since that is the one case where the fold-back is an edit into a checkout another session may be holding. From anywhere else it blocks nothing: step 6 files a plan rather than editing, and a filing needs nothing from that checkout.

A clean diff is not the whole answer, and this is the outcome it cannot see. The comparison comes back identical whenever the installer has already run — while the copy frozen in this session's context at load time is still the old one. No filesystem comparison reaches that. The consequence is worse than running stale code: it produces confident, specific, wrong statements about the very skills the harvest is auditing, in the report nobody re-checks. That is what --since <session start> answers: it lists every commit under the skill since the instant this session began, each marked with the parts it touched, and names each part that moved with its own remedy. If SKILL.md moved after the session started, re-read it from disk before relying on it, whatever the diff said — from the checkout when the checkout is the one that is ahead. Re-reading the installed copy is the fix for a stale context against a current install; when the install is itself behind, it hands back the same superseded text the session already holds, and the instruction reads as satisfied. Confirmed 2026-08-30: a harvest ran fourteen minutes after a commit to this very skill, re-read the installed copy as written, and got its own stale wording back; the checkout was clean, pushed, and two paragraphs ahead.

One case makes the whole branch a no-op, and it is the common one in this repo: the commits that moved the skill are this session's own. The subcommand lists them; when every one of them is this run, the context holding the newest text on the machine is not stale — nothing was superseded, because nothing was written by anyone else. Say so in one line and move on; do not re-read four files to confirm that the session remembers what it just wrote. The re-read exists for a different session's commit landing under this one's feet, and treating the two alike is how the most expensive step in this procedure fires on the case it was never about. Confirmed 2026-09-02: a harvest in this repo found all four skills it leaned on moved after session start, all four by its own commits. What still pays is the second half — checking whether anything already done ran under superseded wording — because a script committed mid-session does change what an earlier call returned.

And that half has its own carve-out, in the same case: a session working in the skills repo usually called the checkout all along. The scripts/ line warns that an earlier call may have run the installed copy, which is true from anywhere else and near-impossible here — a repo-relative skills/<name>/scripts/<file> is what a session in that repo naturally types, and it is current by construction. Confirm it from the transcript rather than assuming either way; the question is only which path the calls used. Confirmed 2026-09-04 in this repo: three skills' scripts/ all differed between install and checkout, all three were reported as possible stale execution, and every call in the session had gone to the checkout — the whole branch was moot.

The mirror case is stated by the subcommand itself: scripts/ commits that are all unpushed. The installer clones from the remote, so no install carries them, and a session that called the installed copy ran unchanged code; the line says all unpushed, so no install carries them and narrows the read-the-diff remedy to calls made to the checkout. Confirmed 2026-09-28: an invoke-stubs harvest was told to read the diff for two plan-conveyor commits its own row listed as unpushed.

The cheapest trigger for all of this is free and arrives unprompted: the available-skills listing changing mid-session. A skill present that was not there before, or a description reworded, is direct evidence that the installer has run since the session began — which is exactly when the timestamp check is worth paying for on the skills this run leans on. It costs nothing, it needs no command, and it covers a case the diff cannot reach: a re-install that touched skills this run was not using still tells you the install state moved. Confirmed 2026-08-31, and it is what triggered a re-check that the prescribed checks would not have: both changed skills' installed copies matched the checkout, so the diff said "same" for everything and would have prompted nothing.

The three subdirectories fail differently, which is why the subcommand reports SKILL.md and the rest apart. Only SKILL.md is held in this session's context, so only it can go stale there. Diff it rather than re-reading it, the same technique the next paragraph prescribes for scripts/ — diff -u <installed>/SKILL.md <checkout>/skills/<name>/SKILL.md returns the handful of lines that changed where a re-read returns the whole file. Confirmed 2026-09-07: a stale harvest diffed instead of re-reading and got four hunks, ~40 lines, against ~700 — and every hunk changed what the run then did. The one case that needs the full re-read is a re-install that ran mid-session, after the skill was loaded: the diff is only sound when the copy held in context is one side of it, and there the held text is neither. skills-state prints the install's mtime, so that case is recognisable rather than something to assume; the listing changing mid-session (above) is the other tell. For a skill whose commands are a script, probe before re-reading: --help on each subcommand this session actually called answers whether those calls meant what was assumed and whether a better one now exists, and skills-state prints the exact probe on the rows where this case fires. Re-read SKILL.md only when the session leaned on its prose rather than its commands, or when the probe shows an interface that moved under a call already made. Confirmed 2026-09-26: plan-conveyor ten commits past a held ~700-line copy, and two --help calls showed commit -m unchanged — so nothing done earlier was owed a correction — plus a new --body flag and push subcommand.

scripts/ is shelled out to, so the next call already runs the new code — but a call made earlier in the session ran the old one. Read the diff before deciding a note is enough. A change that reworks output is a one-line note; a change that adds or widens a check means the earlier call answered a question the current code would have answered differently, and the remedy is to re-run that command from the checkout. diff -u on the one script is cheap, and it is the difference between "this result is stale" and "this result was never computed". Confirmed 2026-09-03: a session had run plans.py list several times against an installed copy in which the status-drift check ran at family scope only, while the checkout ran it at every scope; the re-run came back clean, which is what a benign instance looks like, and a note alone would have left that unknown. references/ is read on demand and is inert. Confirmed 2026-08-30: a directory-scoped query fired the re-read-and-audit branch on a commit that touched only a references/ page, and the audit was empty because the held wording had never changed. It stays inert for the verdict, and stops being inert for a page this run actually opened — the difference changes nothing about the install's state, so it must not move the verdict, but a reference read from a stale copy was read stale like anything else. Diff that one file, the same as the other two.

boundary, transcript and skills-state all ran from the copy under test, and no ordering fixes that — resolving the checkout is skills-state's own job, so some call is always made before the staleness answer exists. The subcommand therefore reports its own exposure: when the stale script is the one executing, its verdict names which of its subcommands actually differ between install and checkout, and says whether the three above are among them. Usually they are not — confirmed 2026-09-07, six commits stale with every change in sweep, so all three pre-check answers were current and only a hand-written diff could show it. When they are among them, re-run them from the checkout before trusting what you already collected; when the two copies cannot be compared at all, the verdict says that instead of reporting no difference.

When the checkout is ahead, name whose unpushed commit closes the staleness. The push is outward-facing and belongs to whoever authored it, per step 5's rule about unpushed commits, so the report ends on that fact rather than on a remedy. Confirmed 2026-08-30: the checkout was ahead 1, and that one commit was exactly the one carrying the wording the run had just re-read. Note the asymmetry that makes this easy to miss — the session is not blocked, since reading the checkout is enough to run correctly. Only the next step is in question, which is the part nobody re-checks.

And when the session has already acted on that skill, re-reading is only half the fix. Ask whether anything already done was done under superseded wording. Re-reading corrects the next call; it does nothing about the pushes already made. Confirmed 2026-08-30: a session had run plan-conveyor' scan --mode tree before pushing a store, while a commit landing four hours after it loaded that skill had changed the pre-push gate to --mode history. Re-reading would have left the pushed history unchecked; running the superseding command retroactively is what closed it, and it came back clean.

The failure this whole branch exists for, confirmed 2026-08-29: a session held plan-conveyor from load time, was told by a later commit that the store's pre-push gate is scan --mode history rather than --mode tree, never saw it, and filed the opposite claim into a plan — a confidentiality gate, reasoned about from stale wording.

1. Significance test first

Re-read the conversation for candidates. For each one, before anything else: if this were lost, would a future session go wrong? Anything that fails this is dropped (optionally noted in the report as "considered, not worth persisting"), not proposed. This is the actual noise filter — apply it upstream of routing, not after.

Then, for anything shaped like a convention — "always do X", "never do Y" — check it is actually true before proposing it. Every filter below assumes the candidate is correct and only decides where it goes. A convention inferred from what this session happened to do, or from a sample of one or two sibling repos, is a hypothesis: check it against the tool's own documentation and real community practice first, and say which you checked. Losing a candidate costs one rediscovery; writing a wrong one into a shared doc costs every future reader, and the shared doc is exactly where nobody re-derives it. Confirmed 2026-08-27: a harvest proposed recording "don't make tests/ a package" from two sibling repos that happen not to — pytest's own docs say the opposite for the default prepend import mode ("highly recommended to arrange your test modules as packages"), and the session had actually backed out of __init__.py to satisfy a shared type-checker config. The true finding was a different claim with a different destination, and only the user pushing back surfaced it.

2. Routing filters

For what survives the significance test:

  • Plan-specific content → plans/*.md, never memory. If the session touched or produced work that the plan-conveyor skill would track — a design, an idea, in-progress implementation — it does not get a memory entry. Check whether the relevant plans/YYYY-MM-DD-*.md already captures it; if the repo uses plan-conveyor and it doesn't, say so and offer to create/update the plan file instead of saving to memory. Memory has no retirement mechanism, so a plan snapshot parked there would just rot silently — plans/ already owns that lifecycle. If a plan file already exists but its status is now stale (e.g. planned/in-progress when the session just finished landing and verifying the work), invoke the plan-conveyor skill directly to apply its own status-bump/retirement procedure — don't improvise an AskUserQuestion about whether to retire it. Confirmed as friction 2026-08-23: asked the user a retirement judgment call that plan-conveyor's own "Retiring a plan" section already answers (default: preserve unless the rationale is already covered elsewhere in the repo); the user's correction was "why isn't the plan docs skill kicking in?"

  • Repo-specific durable knowledge → AGENTS.md/docs//contributing/, never memory. Use this split, not a flat "put it in AGENTS.md":

    • AGENTS.md (or equivalent instructions file) — only operating instructions an agent needs on every task in that repo (commands, conventions, gotchas that change behavior). Keep it minimal: it's loaded into every session unconditionally, so this is the actual bloat-avoidance lever, not a place to write everything learned.
    • docs/*.md — usage-facing reference material, linked from AGENTS.md, read on demand.
    • contributing/*.md (or a skill's own references/*.md, if the knowledge is about a skill itself rather than the repo) — design rationale, prior art, implementation gotchas, also on demand.
  • Cross-repo/personal preference (not tied to one project) → ~/.agents/AGENTS.md, never memory either. Same logic as the repo-specific split above, one level up — version-controlled via its real source. A harness that reads its own path instead (Claude Code's ~/.claude/CLAUDE.md) holds a copy or symlink of that file, never a second home to write to. If ~/.agents/AGENTS.md is generated (assembled from fragments, deployed by a dotfile manager, symlinked out of a repo), the source is what changes, never the deployed file — that copy is silently overwritten on the next run, so an edit there is lost and reaches no other machine. And the source changes only from a session in the repo that holds it; from anywhere else the candidate is filed for that repo with plans.py new --for, per the write set at the top of the procedure. On this author's machine the source is a fragments directory in a separate setup repo, whose own README.md says which fragment owns what. Read the canonical source before drafting an addition — the deployed copy loaded into a session's context can be structurally stale against it. Confirmed 2026-08-24: a session held a ~20 flat-section ~/.agents/AGENTS.md while the source had been restructured to 7 clustered ones, so an addition drafted against the section names in context would have targeted headings that no longer existed. grep -n '^## ' <source> first. A candidate that's a variant of a rule already there extends that rule's existing section — it doesn't get a new one. "Already covered → skip" (below) is for an exact duplicate; this is the near-miss case, where the principle is written down but this particular shape of it isn't. Default to appending a short paragraph to the section that already frames it, because that file is loaded into every session in every repo, so a new heading costs context everywhere and a reader who sees three instances under one principle generalizes better than one holding three unrelated rules. Reach for a new section only when the trigger and the detection signal are both genuinely different from anything already there. Resolved 2026-08-23: "don't characterize a multi-file diff from one sampled file" was folded into "Verify what actually happened, not what output looks like" — which already covered clean-stdout-vs-exit-code and test-suite-vs-throwaway-script, both the same "the convenient surface signal isn't the real signal" shape.

  • A skill that already owns the topic beats a new always-loaded rule. ~/.agents/AGENTS.md is not the default home for every cross-repo finding. Whatever document states that file's admission criteria is the gate (on this author's machine, power-user-linux-setup/contributing/global-agents-md.md's "Admitting a new rule"); where no such document exists, the tier test below still applies — a rule whose miss is silent and expensive belongs in the always-loaded file, while one with a sharp trigger whose miss is cheap and recoverable belongs in a skill. Check the file's current size against its own reference points (grep -c '^### ', wc -l; ≤15 rules / ≤200 lines) before proposing, and say the numbers out loud when asking — admission is a real cost once it is over them, and the user should decide with that in view. Resolved 2026-08-25: pgrep -f matching the harness's own zsh -c … eval wrapper (a false positive that reads as a real process) went to session-bash-audit — which already invites newly noticed Bash anti-patterns and can measure the rate — rather than becoming a 34th rule in a file already at 33 rules / 390 lines. Counter-example, resolved 2026-08-28 the other way: a non-terminating CI-poll loop went to the always-loaded file despite it standing at 37 rules / 446 lines, because the tier test is decided by the miss, not by the budget — this miss is silent by construction (a loop that cannot fail emits nothing, so "still waiting" and "will never finish" look identical) and it had already made a session report a result it could never observe. Size pressure argues for a skill; it does not overrule "silent and expensive". Two levers keep the cost honest when the always-loaded file wins: extend an existing section instead of adding a heading (rule count unchanged), and end the rule on the command that replaces the habit rather than on the warning. Report the before/after line count either way.

  • Destination mid-restructure → the plan reshaping it, not the file. When a candidate's correct home is currently the subject of an open plans/*.md that is reshaping it — especially one that defines its own criteria for what may be added — record the candidate in that plan, as a [NEEDS CLARIFICATION: ...] item stating its trigger, rather than appending to the file. Appending bypasses the criteria that plan exists to enforce, risks the addition being restructured away unread, and conflicts with whatever session is doing the restructuring. Applies to any destination with an open plan owning its shape, not just ~/.agents/AGENTS.md. Resolved 2026-08-23: two cross-repo rules routed to ~/.agents/AGENTS.md while the (since retired) leanness pass was actively cutting it from 30 sections and adding admission rules of its own; both candidates were parked in that plan instead of appended, and were decided at its close.

  • A candidate belonging to another repo is filed there, not queued here. As of 2026-08-29 plan-conveyor has plans.py new <topic> --for <repo>, which writes the plan into that repo's store mirror outside every working tree: no commit crosses, and the session that next works in that repo is offered it by plans.py absorb. Prefer it over a depends_on plan in the current repo, which was the workaround before the mechanism existed and leaves the candidate somewhere the owning repo never looks. Commit the filed plan in the store immediately — a dirty store forces every other session into the add-a-new-file fallback for as long as it lasts. depends_on keeps its own, different meaning: this work cannot land until that repo changes, which is a dependency rather than a delivery.

  • A skill or an instructions file that was misused, misread or ignored → filed immediately, against the repo that owns it. Not held for the report, not described in prose at the end: use plan-conveyor' own routing — plans.py new <topic> when this repo owns the skill, plans.py new <topic> --for <repo> when another does, committed in the store straight away — and then have the report name the finding and the plan filename. The rule is "file first, report second", because a finding that exists only in the report dies with the terminal.

    This covers three shapes, and all three are easy to mistake for narration rather than findings:

    • a rule that was followed and still produced the wrong outcome — the rule is wrong or aimed at the wrong case;
    • a rule that was reasoned around — the wording explains a mechanism rather than stating a constraint, and a mechanism can be argued with;
    • a rule that was simply not followed, repeatedly — the wording is fine and something else is failing, which is a measurement question rather than a rewording one.

    Say which of the three it is, since each has a different fix, and attach whatever the session can count. Confirmed 2026-08-30: one session produced all three — a ~/.agents/AGENTS.md memory rule reasoned around, a status gate bypassed by editing frontmatter instead of calling set-status, and a head/tail prohibition violated in 28% of the session's own Bash calls. Each became a filed plan against the repo owning the rule; none would have survived as a paragraph.

    Check whether the misuse is already filed before filing it, and search the store as well as the repo. On a machine running parallel sessions the likeliest explanation for a rule being broken is that somebody has already noticed — the same reasoning as step 5's already-owned bullet, which is written for the live-state sweep and is easy not to apply here. When it is owned, the session's numbers are evidence for that plan, and where they go depends on whose plan it is. A plan of this repo's own takes them directly — in its plans/, or in its store mirror when the repo keeps its plans there — as ordinary work in the session's repo; plan-conveyor prefers one plan per topic, and a second one splits the corpus the first is accumulating. Confirmed 2026-08-30: a session measured its own Bash calls, found 36% piped through head/tail and was about to file it — two plans already owned the contradiction, one of them citing "25–36% for two other sessions the same day". What survived as genuinely new was one row nobody had measured, and it landed in the existing plan as a fourth sample.

    A plan filed for another repo, which this session did not write, is never edited: file the evidence --for that repo as a new plan whose opening line names the plan it belongs to. absorb pairs a filing with the plan it cites, and the session working in the owning repo merges the two there. Confirmed 2026-09-13: a repo-tasks session filed a second occurrence this way, asking to be merged, and the absorbing session folded it into its owner in one commit. Editing in place was the earlier rule, and it cost twice over: it wrote into a directory several sessions share, where absorb --apply could take the file between the edit and the commit (confirmed 2026-09-04, 76 deletions and 0 insertions recorded under a message announcing an addition), and it made the skill able to change durable files it had not written, which a filing gets for free by other means.

    Correcting a plan this session wrote is still an edit, and still races absorption, so commit it in the same breath. commit names where the file went when that happens, so a (removed) result is information rather than an investigation, but the interval is yours to keep short.

  • Already covered → skip. If an existing doc already says this, don't write a duplicate — check first.

  • Meta-conventions about how to build things in this ecosystem (e.g. "skills should do X by default") → the relevant existing skill's own docs, not a feedback memory — even though on the surface "how to approach work" sounds like the feedback bucket. Resolved via AskUserQuestion during this skill's own design: a preference about how new skills should be authored belongs in the skill-authoring-taudelta skill (durable, version-controlled, visible to every contributor/tool), not this harness's private memory store. Use that as the default for similar cases rather than re-asking each time.

3. There is no memory tier

Every survivor lands in a plain file, and step 2 is the whole of the routing. A harness's own memory store is never a destination — not for durable content, not for perishable content, not as a staging area, not "just this once".

[DECISION: stated by the user 2026-08-29 — no memories, for any harness, for any project, for any reason, because project data and user-wide practices must not be vendor-locked. The carve-out is harness configuration (settings.json, hooks, keybindings), which is expected to differ per harness because it describes the tool rather than the work. The sorting rule: configuration describes the harness; anything describing the work is a plain file any agent can open.]

State the ban, not the mechanism. Earlier versions of this step sent "genuinely temporary" survivors to the harness's memory store, and explaining why that store is a poor home is precisely what let a session reason its way to an exception — see references/rationale.md, "Why no memory tier at all".

If a candidate fits no filter in step 2, that is a signal to add a filter there (step 6's self-update) — never a reason to reach for a harness feature. The gap is usually smaller than it looks: an in-progress plan is already sorted above everything else by plans.py list.

4. Loose-ends pass

Separate from the memory scan: is there in-progress state in this conversation that isn't memory-worthy (failed step 1) and isn't covered by a plan file either, that compaction would still lose track of? Surface it explicitly — recommend a plans/*.md entry if it's real design/idea work worth resuming, or say plainly that it's fine to let go if it's genuinely ephemeral task state.

Read the conversation, not only the summary. A compacted session hands you someone else's précis: intermediate summaries drop exactly the loose ends this step exists to catch, and their confident tone reads as completeness. python3 $H turns prints the real user turns and every AskUserQuestion answer from the transcript, in order — re-read the original instructions rather than the recap of them. Confirmed 2026-08-28: the brief's own "this needs a full tier run afterwards, only one common cause was established" survived into no summary, and neither did an explicitly-declined consumer sweep.

Confirm the transcript is yours before reading anything into it. In a background job the session id is not the transcript id, and every guess at it lands on a real file belonging to someone else — confirmed 2026-09-01, when a harvest read a stranger's session and reported 386 calls, none of them the job's, without a single sign anything was wrong. The script resolves this from the job's state.json and prints the path it settled on; --expect '<a command this session definitely ran>' is the check that costs nothing, selecting the transcript by content when no id is to hand and verifying it when one is. Never reconstruct a path from an id you inferred — a wrong id that names nothing errors out, while one that names the wrong file cannot be told from a right one by reading the output.

The brief arrives in three ways, and a run that finds two has not been told so. A user turn is one; an AskUserQuestion answer is the second; the third is a message the user sends while a turn is still running, which the harness records as a queue-operation rather than as a user turn. turns prints all three, labelled, with the harness's own noise (task notifications, subagent hand-backs, interruption markers, slash-command wrappers) counted separately so "six user turns" cannot mean three. A subagent's report arrives in the mid-turn population and is not the user: it was counted as one until 2026-09-29, 6 of 6 "mid-turn" entries across two filed sessions. Confirmed 2026-09-02: a session's richest instruction — new scope, roughly its last third, two plans and six commits — was sent mid-turn and appeared in none of the six turns the extraction reported. The miss is invisible exactly where it costs most, because a mid-turn message is what a user sends when they think of something while the agent is working, so it is disproportionately new scope with no earlier trace to recover it from.

The answers are half the brief, and on a tool-driven session they are most of it. A user turn is type == "user" with text; an answer to an AskUserQuestion is not, so a scan written for the first finds none of the second. Confirmed 2026-08-31: a five-hour session's extraction returned ten "user turns", of which most were slash-command wrappers and one was /clear — while every substantive instruction in the session had arrived as an answer and appeared in none of them. A harvest reading only the turns would have concluded the user said almost nothing and harvested against its own summary. The two populations differ in content, which is why an incomplete filter is worse than a miscount: a listed option is a word or two a harvest could infer from what the session then did, while a typed answer is the user writing prose about what they want, which is exactly what this step exists to recover.

[PITFALL: this filter has been wrong in both directions, and both times the wrong version looked right. First a heuristic looking for "question" and "answers" anywhere in a tool result, which returned Read outputs alongside real answers. Then a narrowing that named Your questions have been answered: as the marker and missed every typed answer — those open The user answered: — on a session where three typed answers carried the whole second half of the brief. The narrowing's own confirming note predicted its successor's failure without recognising it: "harmless here because both real ones were found, and precisely the shape that returns a plausible, incomplete set on a session with more of them". turns now matches by tool-use id — it asks the transcript what the tool was, rather than what its output looks like — and prints a raw preamble count beside it so a disagreement is visible rather than silently resolved. A disagreement is normal: this skill's own text quotes both markers, so any transcript that loaded it counts itself.]

5. Live-state sweep

The parts of "dangling" that are not in the conversation at all. The transcript says what was intended; these say what is actually true now. python3 $H sweep --boundary <instant> runs them — processes, listening sockets, disk artifacts, git state for every repo the session wrote to, CI, both stores, the absorb queue, depends_on plans, files written outside every repository, and paths named in edits that do not exist. Add --repo <path> for a repo the transcript cannot show. Run it even when the session felt tidy, because every one of these has been wrong at least once.

Two of those steps shell out to the platform's own tools: processes come from ps -eo and listeners from ss -ltnp on Linux, and from Get-CimInstance Win32_Process and netstat -ano on Windows. The Linux pair is what every measurement in this skill was made with; the Windows pair is reasoned from documented output and tested against fixture text only, so read a first Windows sweep as a claim to verify rather than a result. On macOS, or wherever a listing does not run, the step reports itself unavailable with the reason — the honest answer and not a clean bill of health. Everything else in the sweep is git, the harness's own files, and Python, and works anywhere.

What the script cannot do is decide what a finding means. That is this list:

Every row here names a consequence, and a row that names only a state will have one invented for it. A report has to say why an item is in it, so a bullet that reports a fact and stops leaves the reporting session to supply the reason — and it will, plausibly and out of the nearest thing it remembers. Confirmed 2026-09-08: the store's unpushed-commits row said to report the count and not what it cost, and one harvest filled the gap with "no future session gets offered them by absorb", stated twice and unchallenged both times because it sounds like the mechanism working. It is false; absorb reads a local directory. Every other row below names what goes wrong — an orphaned server serves .env to the LAN, a masked exit invalidates a green claim — and the one that did not is the one that produced a fabricated consequence. So when a row's cost is small, say that it is small rather than leaving it unsaid: a stated small cost cannot be inflated, and an unstated one can.

  • Processes and what they serve. A backgrounded poll outlives the turn that spawned it — confirmed 2026-08-28, four CI-poll loops 36 hours old, still polling, whose exit condition could never be true; the harvest was the only thing that would ever have found them. For anything holding a listening socket the bind address and the directory it serves are the finding, not liveness: a long-running dev server is supposed to be running. A development server's default bind is usually every interface, and that default is invisible locally, since bound or unbound every local run behaves identically and only the reachable audience differs. Confirmed 2026-08-31: python3 -m http.server --directory <repo root>, 24 hours up, on 0.0.0.0, serving that repository's .env and .git to the whole LAN.

    A loopback bind narrows the audience; it does not close the finding. An orphaned server is still an orphan, and the directory it serves is still readable to everything running as this user — which on a machine with several agent sessions and a browser is not a small set. Report an unowned listener over a repository root whatever its bind address, and say what is under it; the sweep names the served directory and any secret-shaped file in it, and curl on one gitignored path turns that into a status code. Confirmed 2026-09-02: an http.server deliberately bound to 127.0.0.1 by the repo's own task — the 2026-08-31 hazard designed out — three and a half hours old, orphaned by a one-shot command, whose gitignored .env answered 200. The run nearly stopped at "bound to loopback, safe". Both cases belong in the report side by side, so the next reader does not learn "check the bind address" as the whole rule.

    An orphan that comes back is a different finding, and killing it again is not the answer. Re-check the pid before reporting a kill as done: a fresh pid over the same port and directory means something restarts it, so the kill closed one instance and nothing else, and a report saying "killed" describes a state that no longer holds. Confirmed 2026-09-05: the same http.server over the same repo root was killed at the user's request, and a sweep half an hour later found it back under a new pid, sixteen minutes old, reparented to systemd --user exactly as before — orphaned again rather than held by a live session, which is the check that tells the two apart. Say that the kill did not hold and let the lifetime question own the fix; killing on a loop inside one session is the shape this skill refuses everywhere else.

    The sweep answers both of those questions itself now, so read its line rather than re-deriving it. Every process and every listener carries its parent pid and that parent's command, and says ORPHANED when the parent is systemd/init/launchd, held by a live process when it is anything else, and holder unknown when the parent is not in the listing — the third is an honest gap, never a quiet orphan. A row also says when the process started after this session's last activity, which is what separates this session's litter from somebody else's process; on a machine running parallel sessions age alone never establishes that. Both were prose rules costing two ps -o pid,ppid calls per run until 2026-09-06 — the "a correction a script can simply not make belongs in the script" case that step 6 names.

  • Disk artifacts outside any repo. Container images and build caches, throwaway interpreters, volumes — none of which ps, git status or either store can see. Report the sizes with a proposed removal line the user can approve; do not delete unasked, because the build cache is shared with every other project on the machine and an image another session is about to reuse costs a rebuild. Say which were one-off verification artifacts and which a committed README names — the session is the only party that can still tell those apart. Confirmed 2026-09-01: a run that had verified a container fix left a 4.11 GB image, 445 MB of others and a ~58 GB machine-wide build cache, while the processes check reported clean and correctly so.

    An artifact created during the window is not this session's, and the same parallel-sessions caveat the git bullet carries applies here just as hard. The sweep now cross-checks the images against whether this session ran docker at all — zero docker calls means nothing is attributable, whatever the timestamps say — and prints the unattributable rows under their own heading rather than dropping them, because the sizes are worth seeing whoever made them. Never propose a removal line for a row under that heading: it is another session's work, possibly a live one mid-run, and this bullet's own reason for not deleting unasked is that an image another session is about to reuse costs a rebuild. Confirmed 2026-09-06: a harvest reported twenty images, 2.4 GB, as "new this session" for a session whose 183 Bash calls contained no docker at all. It read as authoritative in the one direction nobody checks — a specific, plausible number the reading session had no cheap way to falsify.

  • Files this session edited that no repository and no store covers. The sweep subtracts every write path inside a git repo and reports what is left; the work is saying what would recover it. Every other check here asks whether a store was left tidy, and a file belonging to no store is not untidy, it is unseen. An example file in a repo plus a validator that rejects a bad one is a real recovery path and not an obvious one; "no copy anywhere" is a finding worth stating plainly. Confirmed 2026-09-01: a session edited an unversioned config outside every working tree — no diff, no history, no backup, and operational data of the kind the repo that reads it exists to protect. The edit was correct and user-approved, which is what makes it the right instance: nothing went wrong and the sweep still could not see it, so a wrong edit would have been equally invisible.

    Recovering one is often a command, not a sentence, and which command turns on whether the file's question recurs. Three dispositions:

    • Evidence for one finding a plan records — a probe or repro in the scratchpad: plan-conveyor' attach copies it beside the plan and records it there, and it retires with the plan. Confirmed 2026-09-18: a harvest reported a reproducer as having no copy anywhere, correctly by the text above and one command short; attach … --commit closed it.
    • A diagnostic the next incident will want — promote it into the repo as a script by the repo's own convention. An attachment is deleted with its plan, and "filed somewhere stable" is easy to mistake for "still present": confirmed 2026-09-20, the same session pulled one probe back out of its retiring plan into the repo, because the question it answers recurs.
    • A live config outside every tree — neither. A copy is a stale duplicate of a file that keeps changing, so the finding is stated plainly, as the 2026-09-01 instance was.

    Which attach flag applies, and why a local attachment is invisible to scan, is plan-conveyor' to say; read it there before attaching a file you have not read.

    Rows in a harness scratch directory print as their own group — a session scratchpad, or a background job's tmp/ — because they are ephemeral by design, and the first disposition above is the only one that can apply to them. They filled whole lists before (9 of 9 rows, 2026-09-09) and were kept listed rather than counted, since the reproducer attach exists for lives there. Plain /tmp is not grouped: a file written there directly is the original finding.

    This check and its neighbour — paths written into files that do not exist — read nothing but the transcript, so both print none or skipped and never nothing at all. Until 2026-09-08 they vanished from the report whenever no transcript resolved: absent rather than empty, in a report whose every other section printed normally, so there was no gap for a reader to notice. Confirmed twice — 2026-09-03, where the same session's degraded run covered one repo against the resolved run's three and produced no CORRECTION? lines at all; and 2026-09-04, by a harvest that read the whole sweep, moved on, and found the hole only when re-reading this file for a later step. The header now also says when the repo set collapsed to the working directory for the same reason.

    Each also prints its own limit, and the limit they share is the tool call. Every count here is derived from the transcript, so whatever a script does inside one Bash call is out of scope — and that seam has three independent instances by 2026-09-08, none reachable by fixing the others: a live config rewritten by a repo's own inv task rather than by Edit, so this very check came back empty on exactly the class of file it exists for; a retrofit that re-cloned seven library entries from a list held inside a script, where argv named two; and a truncation counter blind to the SIGPIPE its own verification script produced. A reader who learns that once for one row will still read the next row as complete, which is why the rows say it rather than this page alone.

  • This session's own rule adherence, which nothing else in the sweep reaches:

    python3 ~/.agents/skills/session-bash-audit/scripts/audit.py --session <session-id> \
      --until <the step 0 boundary> \
      --compare ~/.local/state/session-bash-audit/<baseline>.json
    

    The baseline is one you saved with that skill's --save-baseline, which writes to ~/.local/state/session-bash-audit/ by default. Without one, run the audit anyway and drop --compare — the rates and exit-masked stand on their own, and only the comparison needs a baseline. Never compare against a baseline measured on somebody else's machine: that reports how your session differs from their setup rather than from your rules.

    And check whether the comparison straddles a change to the instrument itself, which is the same error one level down: a baseline written by a different audit.py attributes a pattern-definition change to your session's behaviour, in whichever direction happens to flatter it. A baseline records the script's own commit in its instrument field; a null there, or its absence in a file written before the field existed, means the answer has to come from elsewhere — the writing session's transcript and the mtime of the script it called. A straddling comparison is not automatically void, and deciding that needs the diff rather than the commit: read which rows the intervening commits actually moved, and say so. Confirmed 2026-09-05, in the session that both anchored the patterns and then compared against a baseline saved thirty-six minutes earlier — the verdict was a clean 11/11 and stayed readable, because none of the eleven judged rows was one the anchoring touched. That sentence is the honest form of the result, and nothing in the JSON could have produced it.

    [PITFALL: the first wording of this said "skip this step", which reads as skip the audit — and the step is the audit. Filed by a session that took the narrow reading on 2026-09-04, ran it anyway, and got its most useful number that way: 23% exit-masked across 189 calls, with five messages telling the user a gate was green, every one from a | tail-ed run. All five held on re-run, which is what makes the wording easy to dismiss — the session could not have known that without running the audit the skip would have cancelled.]

    Get <session-id> from the transcript the script resolved, not from a path you happen to have — in a background job the id in the task-output path names a different transcript, the audit runs clean against it, and the verdict describes a session that is not yours.

    --until is not optional, and the reason is not that the sweep looks bad. A figure that includes this sweep measures the sweep: its calls are inspections, a different population from the session's working commands, and they drag the rate toward their own shape in whichever direction that happens to lean. Measured 2026-09-01 on two runs the same day — one rose 37% → 40% on head/tail, the other fell 22% → 17% on git -C. "The harvest inflates its own number" is the wrong claim and would have made the second run look like a refutation. Report both numbers, one line: "6% during the work, 14% including this sweep." The first is the session; the second is the honesty.

    claims and audit.py count exit-masked differently, and a mismatch between them is expected. claims matches the raw command, so a pipeline quoted inside a string or a heredoc body counts; audit.py strips quoted spans and heredoc bodies, so it does not. Measured over the week to 2026-09-06: of 390 calls claims tagged and audit.py did not, 388 were a bug in audit.py (since fixed) and 2 were claims counting quoted prose. Expect claims to run slightly higher now, and do not read either number as the other's check — 7 against 4 on one session, the three extras all 2>&1 inside a probe's heredoc body.

    Read the gate/listing split before reasoning about any of this. The audit line prints exit-masked = n m wrapped a gate, k a listing, and when m is zero the greens were never at risk, whatever the shell does: no claim rested on a masked exit code, because no masked call was a gate. That is a shorter and stronger answer than the pipefail check below, it needs no extra command, and it is the common shape for a session that runs its gate unpiped and pipes only listings. Confirmed 2026-09-06 on the first harvest to have the split available: 4 masked calls, 0 of them a gate, and 8 green-gate claims that had all come from unpiped runs. Only when m is above zero does the shell's state decide anything.

    m is a word list's answer, not a fact, so read the samples before taking the cheap exit. audit.py's GATE_RE recognises test, lint and type-check runners, and every gate nobody had in mind when that list was written scores as a listing. Confirmed 2026-09-13: a harvest was handed 0 wrapped a gate, 3 a listing for three calls that were all plans.py scan behind a head -5 — the confidentiality gate, whose failure publishes a client's name irreversibly, and the one gate in this family whose exit code matters most. The samples print directly underneath the verdict, which is what made it two seconds of reading rather than a command; the zero was caught by the rule three paragraphs down about checking a zero you have reason to doubt, not by this one. GATE_RE matches plans.py scan since 2026-09-28, and the rule stands: the next unlisted gate scores as a listing the same way. plans/2026-09-13-gate-re-has-no-term-for-the-confidentiality-scan.md owns the wider question.

    If a masked call did wrap a gate, ask the shell before paying for a re-run. One call:

    setopt | rg pipefail          # zsh; `set -o | rg pipefail` under bash
    

    With pipefail in force a pipeline reports the rightmost non-zero status, so inv quality.precommit 2>&1 | tail -3 already exits non-zero on a red gate and every green this session read was a real exit code. Report the count and say the guarantee was in force; do not re-run. Without it, the re-run below is the only thing standing between the session and a false green. Confirmed 2026-09-06 on the first session where m was above zero — 5 masked calls, 4 of them a gate or suite, three green claims resting on them, setopt answering pipefail, no gate re-run. The branch above it had been confirmed the same day on a session where m was zero, so both sides of the split have now been walked.

    Ask the shell, not a config file. A file says what should be set; the option can be guarded on a harness environment variable (on this author's machine ~/.zshenv sets it only when CLAUDECODE is), so the answer differs between the session's own shell and any other on the same box. The check has to run as a Bash call in the session being audited.

    The guarantee covers the exit code and nothing else. | head -20 still discards output, so a claim resting on reading the gate's output rather than on its status is still unverified; and the guard means the same command loses its status in cron, in CI, in a container, or on any machine without that snippet. A session that learns the shape is harmless here writes it into a script that runs somewhere else — which is why exit-masked stays a finding and this paragraph is a reason to skip the re-run, never a reason to skip the row.

    When pipefail is off, this session's own green results are unverified — re-run the gate before believing any of them. A masked exit code is then not a style finding: it means a command reported success through a filter that discarded the real answer. Confirmed 2026-08-31: a session ran inv quality.precommit 2>&1 | grep -Ei "…" | tail -3 perhaps twenty times, read the absence of a match as success, and pushed three red commits — basedpyright printed 0 errors, 6 warnings and the gate exited 1, but the pipe reported grep's exit code. Re-run it unpiped — inv quality.check > log 2>&1, then read the log — and make the gate conditional on the number so a slow gate is not re-run for nothing. This is the one command the script deliberately does not run for you: it is the repo's, not this skill's.

    The re-run settles whether the greens were true. It does not touch the fact that they were asserted. A session with a non-zero exit-masked has usually told the user "gate green" several times over the run, each time on evidence a filter had already discarded, and those sentences stand in the conversation whatever the re-run comes back with. python3 $H claims --until <the boundary> counts them against the masked calls. Report the count with the re-run's verdict attached. "Said the gate was green 15 times on masked calls; re-run exits 0, so the claims hold" is a footnote. The same sentence ending "re-run exits 1" is a live inaccuracy with a reader: the same shape as an unpushed commit correcting something already pushed, and it goes in "needs action now" for the same reason. The conversation is the record the user is actually working from, and it is the one artefact a later commit cannot amend — only a later message corrects it, and only if someone writes one. Confirmed 2026-09-02: a ten-hour session at 28% exit-masked had reported the gate green roughly fifteen times, every one from a | tail-ed run. The re-run exited 0 and all fifteen held — which is the outcome that makes this rule easy to skip, and the reason it is written as a count rather than as a warning.

    Report the two numbers and the comparison in the skill-and-instruction-misuse group rather than in the verdict; a self-flagellating report buries the findings the user needs. Confirmed 2026-08-30: a session that had spent the day authoring the rule against piping a gate through head/tail then produced that shape in a third of its own calls, and reported "went well, gate green throughout" — true, and beside the point. Authoring a rule is not evidence of following it, which is exactly why the number has to come from the transcript.

    A zero from any of these instruments is a claim, and you are holding the evidence to check it. Before reporting one, ask what a non-zero would have looked like and whether you saw it — the session's own messages and commands are right there, which is what makes this cheap and what makes skipping it careless rather than efficient. Every wrong zero found so far was found this way and none by reading the code first. Confirmed 2026-09-06, twice in one run: claims reported 0 green-gate messages in a session whose text says "both CI legs green" six times, because the matcher's vocabulary is the local gate's and has no term for CI; and the adherence line reported every rate at 0% while the session had typed rg -rn …, because rg-replace is in none of audit.py's display sets. Both are filed. A zero that agrees with what you hoped is the one to check, and the tell is a zero on a row you have a specific reason to expect a hit on. A count lower than you remember gets the same check, since one is not zero and nothing else prompts it. Confirmed 2026-09-26: claims reported 1 gate-green message for a session that had said it twice, because "Green, 707 tests" never uses the word "gate". Only the reading session remembering that it had said it more often caught the miss.

    And a count higher than the instrument's, taken off the transcript with a line grep, is an upper bound on a different question. A .jsonl line carries a command's echo in its tool result, the session quoting it, skill bodies loaded into context, and per-entry metadata that embeds prior context, so rg -c over it counts prose, not calls. Resolve by tool_use id, or use turns, claims and audit.py, which already do. Confirmed twice: 2026-09-18 a grep read 166 calls opening with cd into the session's repo where the deduped count was 5, and 2026-09-27 one read 135 where audit.py said 1 and was right. The first was one edit from a durable corpus row, as a cause that fitted a real observation; the tell was not the number but an instrument on the same screen disagreeing with it, unread.

  • Git state, every repo the session touched — not just the primary one. Touched means wrote into, or ran something other than a read against: a repo only read through git -C to write a filed plan accurately is not swept, nor is a cd the harness reset. If you changed a repo in a way the transcript cannot show, pass it with --repo. An unpushed commit is the most common real loose end, and a session that ends with one usually believes it pushed. The sweep reads the upstream branch rather than typing main (measured 2026-08-30: 22 of 71 clones were on main, fewer than were on master), runs the count unpiped so a wrong branch exits 128 rather than printing a calm 0, and checks the git fetch succeeded before trusting either answer — on this machine a fetch needs the Zenity SSH-passphrase dialog and fails with Permission denied (publickey) when nobody is at the keyboard, leaving origin/<branch> exactly where it was so the ahead-count still prints a plausible number against a stale ref. When that happens the machine's own diagnostic names the fix (inv ssh.check here) — do not reach for ssh-add, and apply the fix as a per-call environment prefix rather than an export, which does not survive to the next Bash call.

    Three judgements the count does not make for you:

    • Who wrote the unpushed commits. Where sessions run in parallel the ahead-count is not necessarily this session's work, and "you have two unpushed commits, push them" publishes another session's unfinished history under a recommendation that reads as routine. Name which are this session's and which are not, and let the user decide. Confirmed 2026-08-29: two commits from a parallel session appeared in the ahead-count between one push and the next, and asking rather than pushing was the only thing that surfaced them.

    • Whether any of them corrects something already pushed. That is not deferred work — it is a live inaccuracy with a reader — and reported flat it is indistinguishable from three plan updates in an ahead-count. The sweep lists paths that are both unpushed now and published earlier in this session; an overlap is not proof, but it is a short list to read and empty for most sessions. Name it in "needs action now", with what the remote currently claims. Confirmed 2026-08-30: a session pushed a package description arguing why the package existed, learned an hour later that the argument was false, and committed the correction without pushing — so the remote served a justification known to be wrong while its fix sat in the ahead-count alongside ordinary tidying. Keep it to the same-session case; "is anything this repo currently publishes known-wrong" is a different question no harvest can answer in bounded time.

      Read the diff before calling it a correction — the line no longer does. It printed CORRECTION? until 2026-09-08 and routed straight into "needs action now" on the strength of two path sets. Two of its three failure shapes were fixed by narrowing: a parallel session's work (gone once the check intersected with this session's own writes) and a repo this session never touched. The third cannot be: "touched before a push" and "touched after it" contains every file a long session keeps working on. Confirmed 2026-09-04, where all three flagged paths were this session's own writes in its own repo and every one was an addition to what was published — a new paragraph, appended incident recording, appended assertions. The remote was serving less, not serving wrong. So the line says what it measures, and the judgement that only a diff can make stays yours.

    • That the count is from now. Re-read it at report time; one taken earlier in the session is the session's memory wearing a measurement's clothes. Confirmed 2026-08-30: a session reported the plans store as "7 unpushed commits, 4 of them other sessions'", correct when counted; at harvest it was 27, of which 25 were other sessions'. On a machine this parallel the count moves fastest in the repositories several sessions share, which are the ones a harvest is most likely to be asked about.

  • Sibling repos this skill itself wrote to. A skill self-update (step 6) commits locally and reaches nothing until it is pushed and re-installed, so your skills repo — whichever one these skills were installed from — is a repo the session touched and the one most likely to be forgotten, because the edit felt done when it was committed.

  • Paths this session told other sessions to run. A rule written into an always-loaded instructions file, or a SKILL.md command block, names a path on this machine — usually an installed copy, not the checkout the session was editing. The sweep reports home-rooted paths written into files that do not exist; run one of the ones that do. Confirmed 2026-08-29: a session deployed a ~/.agents/AGENTS.md rule pointing at ~/.agents/skills/<name>/scripts/<file> while the installed skill still had no scripts/ directory, so a machine-wide rule instructed every future session to run a file that did not exist. The checkout worked perfectly throughout, which is why nothing surfaced it.

    Only AGENTS.md, CLAUDE.md and SKILL.md are read, because existence cannot tell an instruction from a description. Confirmed 2026-09-04: a harvest of a session whose subject was where each coding agent reads its instructions listed ten paths, all ten false positives — vendor directories for agents not installed here, which the installer deliberately does not create, and a docs table recording where three other agents look, which will never exist on this machine and is correct content regardless. The cost was never the noise: a section that has been all-false-positive once is one the next harvest skims, and the true positive above would have been the eleventh line. Spellings of one path now collapse to one row for the same reason.

  • CI, for anything this session pushed. A green local gate is not a green CI run. The sweep reads gh run list --json, and reading the conclusion as JSON is the point: --json has no exit code to lose, so there is nothing for a pipe to take away. Never hand-roll an until loop, and never watch through a filter — confirmed three times, each by a harvest executing this checklist with the warning in front of it, reporting tail's 0 as the run's.

  • Shared stores outside any repo, if you keep any. $RESEARCH_HOME — set it only if you use the research-library skill — holds clones, caches, anything the session added to a location no git status covers. The failure is a half-finished convention rather than a missing file — a clone without its SOURCE.md, or one that failed partway — and it is invisible to every other check here precisely because this store is not version-controlled. Also check the entries the session changed, not only the ones it added: a deliberate divergence from the store's shape reads as conformant, because the entry is present and its metadata is intact. Confirmed 2026-08-30: a session deepened a --depth 1 reference clone to ~436 commits to read a dependency's constraint history, which was the right call and left the store holding one entry that silently no longer matched its own convention. Record the divergence and why, in whatever file that store uses for per-entry metadata.

    A changed mtime is not this session's work, and this bullet is where that mistake is easiest to make, because the library is refreshed wholesale by one command and cloned into by any parallel session. The sweep attributes each changed entry from this session's own commands — the entry's directory name, or the <owner>/<repo> the URL that created it carried — and prints the rest as a bare count. Confirmed 2026-09-07: a sweep called 30 entries changed, including a whole dotfile-manager cluster that was another session's research topic; five were this session's, and its transcript said which. Do not report a count from this bullet as work the session did — the number that matters is the attributed one, and the convention checks above are the part that is true whoever made the entry.

  • The plans store, $PLANS_HOME — a separate bullet, for a different reason. Set by the plan-conveyor skill and absent if you do not use it, in which case the sweep skips this and says so. The sweep reports its dirty state and its unpushed commits, and runs plans.py absorb read-only. Two failures with separate owners: an uncommitted plan is this session's own mess, and a plan filed for this repo is another session's gift. The reason this went unnoticed in a skill whose whole subject is unswept state is that both neighbouring bullets appear to own it: the store is a git repository (so the git bullet seems to cover it) and it sits outside every working tree (so the shared-stores bullet seems to). Each framing hands it to the other. What the git bullet actually misses is uncommitted work: an uncommitted plan is not a commit, so no ahead-count sees it.

    Committing and pushing are two findings with two different costs, and only the first is urgent. An uncommitted plan is a live same-machine concurrency cost: plan-conveyor puts every other session into its add-a-new-file fallback instead of editing, for as long as the dirty window lasts. Report that every time, at full strength. An unpushed one costs off-machine backup and nothing else — the store is the only copy of plans that live in no repo, plus the in-transit copy of anything filed --for a repo that has not absorbed it, so a disk failure loses exactly those. Say that, in those words, and leave the decision with the user, who has already said they work from one machine at a time and push before starting elsewhere. Do not write "the store has no remote" either — the shareable tier has one and the sensitive tier deliberately does not; a push is gated by that skill's content scan.

    [PITFALL: do not reach for the handoff story, which is the wrong answer nearest to hand. absorb reads a local directory and never a remote — no fetch, no pull, no ls-remote anywhere in plans.py, and plan-conveyor says Network: none outright — so a filed plan is visible to every session on the machine the moment its file exists, whatever the push state. What makes the wrong version attractive is that a real cross-session handoff sits right beside it: new --for writes into the store and the next session in that repo is offered it. "The handoff needs the store pushed" is one plausible step from something true, which is how it got stated twice in one session without being checked, 2026-09-08.]

    The repo ahead-count above is not over-reported for the same reason and should keep its urgency: an unpushed repo commit means the change is not in the product and CI has never seen it, which is true whatever the machine count. Checked 2026-09-09 rather than assumed, because the two rows look alike and one habit applied twice would have demoted both.

    absorb runs here even though plan-conveyor already tells every session to run it first: the queue refills for as long as the session runs, because the sessions filing into it run concurrently. Measured 2026-08-30 in a session that followed the first-call rule correctly — 4 plans at session start, 4 more two hours in, and 1 at five hours, that last one a credential exposure that sat unread for half an hour. Report a mid-transaction store — uncommitted changes that are not yours — rather than working around it; it means another session is actively holding that directory.

  • depends_on plans whose blocker may have lifted. The routing filter above parks work owed to a mid-restructure repo in a depends_on-tagged plan — which stores it safely and gives it no trigger. Nothing watches the named repo, so a plan waiting on a repo that has been ready for days is indistinguishable from one waiting on a repo that is still busy. The sweep lists the tagged plans and their targets; sorting them is yours, because the tag carries two meanings that take opposite answers. Work parked because that repo was mid-restructure (this bullet's case, and a deprecated one now that new --for <repo> exists) is answered by that repo's current state. plan-conveyor' own documented meaning — "sibling repos this plan can't fully land without" — is not: the named repo being idle says nothing, because the blocker is a change that repo has not made yet, and only reading the plan answers whether it has. Report readiness only for the first kind; "seven plans ready" assembled from clean git status output is a claim the check never made. Confirmed 2026-08-29: eight tagged plans, three sibling repos all clean, and exactly one of the eight was queue-shaped. And verify against the working tree rather than the plan's prose — the same check that day read "surgery finished" from a clean git status and a landed plan, then found two modified files a few minutes later.

  • Work the session promised but never verified — a test tier it added to but never ran, a consumer it changed but never swept. "I'll report when it lands" in the last message is a promise the harvest has to either keep or retract.

  • Work handed off to another session, and what it blocks here. This user runs parallel sessions, so "another session is doing X, don't touch it" is a routine instruction — and it creates state no other check finds: not a process, not git state, not CI, not an unkept promise, but a dependency this session is stopped on and deliberately not solving. Name it in the report with what it blocks, so the handoff cannot fall between the two sessions. Do not re-probe the handed-off thing to report its status; that is the instruction being violated one call at a time. Confirmed 2026-08-28: a session finished its work, could not push because the ssh agent was empty after a reboot, was told another session owned that — and the only remaining record of the blocked push was the harvest report.

  • Whether a finding is already owned, before reporting it as new. A sweep that reaches back through history or across repos surfaces things this session did not cause, and on a machine running parallel sessions the likeliest explanation for a real finding is that someone else already found it. Check before escalating: the sibling repos' recent commits and their open plans. Report an already-owned finding as confirmed still open and name the plan that owns it — never as a discovery, and never by acting on it. Confirmed 2026-08-29: a confidentiality scan returned 55 hits in published history and was minutes from being reported as urgent, when another session had already rewritten both histories, force-pushed, opened a support request for the residue, and written all of it into a plan. Several probing calls, and nearly a duplicate alarm, for work that was done.

  • What a push here obliges elsewhere. Every other check is about this machine's own state; this is the only category a push creates rather than leaves behind, and it is invisible to all of them — the consumer's tree is clean and its CI is green precisely because nothing has changed there yet. Confirmed 2026-09-05: a session changed the module every gate step in a repo now calls, pushed it, and the sweep reported dirty 0, unpushed 0, CI green, nothing owed. By every check the skill ran, that session was finished; it was not. That repo's bootstrap is unpinned until a version tag exists, so every consumer's next CI run installs whatever main is at that moment, with no consumer-side action and no notice. The trigger was written down — in a file nobody had reason to open. The sweep now derives consumers from the machine, the same move scan makes for private terms: any checkout whose own manifest or bootstrap script names the repo, plus that repo's consumer-facing doc where it has one. Reporting is the whole action — sweeping a consumer means running its tasks in its tree, which is not this session's to do, so it goes in the report and the next-session prompt. The skill already knew this shape for exactly one repo, in the bullet above about a skill edit reaching nothing until pushed and re-installed; that was the mechanism written as a special case.

    Where that doc states its trigger, the sweep reads it, and the obligation line fires only when this session's changes match it or no trigger can be read. Confirmed 2026-09-18: five consumers and "report it and file it" for a push of a CI workflow, docs, plans and a test, none of it under the paths the repo's own "When to sweep" section lists. A warning that fires on every push to a widely installed repo is one the reader stops reading. The comparison covers files written with the edit tools, which the section's closing line says. And a repo under a temp or job directory is not swept at all — named on one line instead, since a throwaway git init in a scratchpad degraded three sections and matched its basename, clone, as installed by two unrelated repos.

  • Whether this session landed something one of this repo's open plans designed. The failure is a session that builds everything a plan specified, documents it, and never touches the plan: it goes on saying idea with open questions the code has answered, absorb never raises it because nothing is terminal, and the next session reading list sees live design work. Confirmed 2026-09-05: two plans were answered by six commits in one evening, the landing session ended with no status bump on either, and the next harvest nearly proposed building what already existed. The sweep lists this repo's open plans that name a source file this session wrote three times or more, or three or more such files at any count, and were last touched before the session began, ranked by how many they name. The second form is a design plan's shape — it lists what it will change, once each — and was added 2026-09-28 after a plan the session had answered outright was missed while an incidental conftest.py edit was listed. A prompt, never a gate — measured 2026-09-08 across 8 repos and 167 open plans, 43% name a source file that moved after them, which is noise, and the subject proxy only brings it to 14%. What is left is structural: a session that edits a file makes every plan about that file look stale. set-status stays the only thing that moves a status. A file that is the subject of 5+ open plans and 15%+ of them is folded: rows matching only such a file become one counted line, and --verbose lists them. Confirmed 2026-09-26: a setup-repo session's 15 rows became 4 rows and one line, because 11 matched only the repo's own config file. The same rule folds a genuinely central file (plans.py here, 20%) — accepted, since the line still names it, and the session that landed a design knows which plan it built.

  • What this session made stale somewhere else — the reactive bullet above turned around. Every other check here asks what is dangling for this session; a plan in another repo describing a mechanism this session just replaced is the inverse, and nothing else surfaces it: not dangling, not a process, not git state, and the session that wrote it is gone. The cost is not the stale prose but a scheduled measurement whose subject moved, which emits a confident wrong number rather than an error. Confirmed 2026-09-05: a session replaced a repo's gate-output mechanism while a plan elsewhere recorded that mechanism as its landed layer 2, with a comparison booked a week out against a baseline saved to isolate exactly that layer — a null result there would have read as "the change did nothing". The sweep lists plans in other repos and in the shareable store naming a source file this session wrote; they are candidates, not a verdict, on the same footing as depends_on, because a plan naming quality.py may be about something else. This repo's own plans/ are not searched — a session is already reading those, and including them turns an edit to a well-discussed file into a page of true-but-useless rows. Nor is a name every package has its own copy of (__init__.py, pyproject.toml, and the like, printed as not searched) — the lesson the written-paths check above learned first. Confirmed 2026-09-13: 22 of 24 rows matched only those two names, and the two real rows sat below them. And a plan elsewhere counts only if one of its paragraphs names the file's repo too, unless the plan lives in that repo's checkout or store mirror: the subject is this repo's ci.yml, not any file called that. Re-run 2026-09-26 on a session that changed setup.toml, ci.yml and util.py in the machine's setup repo: 19 rows by basename, 4 with the rule, and each of the 4 was about that repo's files. The trade is a plan that refers to the repo only obliquely, which the section's own limit: line states.

6. Improve the skill on every run

This is not optional, and not only for friction. The skill is actively dogfooded: each invocation is also a test of it, and the author has said catching and fixing issues immediately is the point. So before writing the report, ask explicitly: did this run show the skill to be wrong, incomplete, or unhelpful anywhere? Signals, in rough order of how often they are missed:

  • The user's invocation supplied behavior the skill lacks (see the third trigger below).
  • A step ran and produced nothing of value, or the run's best finding came from something no step asked for. Both mean the procedure is mis-aimed, and the second is the easier to overlook because the finding still got made.
  • A step was skipped as inapplicable — was that judgment right, or is the step written too narrowly?
  • Anything the user had to say twice, or ask about after the fact.
  • The skill contradicted itself, or an instruction turned out ambiguous when applied.

Act on it now rather than filing it for later; a deferred skill fix is a skill fix that does not happen. Keep the change small and additive. A correction that a script can simply not make belongs in the script, not in a new paragraph — six of them are already there, each one having been written as prose first and then recurred anyway. If a run genuinely surfaces nothing, say so in the report in one line — an explicit "no skill changes needed this run" is the evidence the check happened.

Where the session is decides whether "act on it now" means an edit or a filing, and this is not a preference:

  • In the repo that holds the skill's source — the checkout skills-state named in step 0: edit the source and commit it locally without asking — it is reversible, reviewable as a diff, and pausing for approval mid-run is what makes the fix get dropped.
  • Anywhere else, which is nearly every harvest: do not edit and do not commit. File it with the command skills-state printed — file a fix from another repo: python3 <plans.py> new <topic> --for <checkout> — commit the plan in the store, and name the filename in the report. Filing is the immediate action, not the deferral, so the "do it now" pressure is unchanged.
  • When skills-state found no checkout — an installed copy with no source repo on this machine, which is every reader who is not the author — there is nowhere to file, and a plan its reader cannot land is a plan that rots. Put the friction in the report under its own heading, one line per item, and file nothing. Reporting it back to the skill's author is a separate mechanism this skill does not have yet; do not improvise one.

[DECISION: the global rule wins over this skill's own instruction, and it is not close. ~/.agents/AGENTS.md says writing to another repo is out entirely, "however much a skill's own instructions tell you to" — a clause that reads as though written about this exact step. The skill's justification for committing directly was that a deferred fix does not happen; that was true when it was written and is not now, because plans.py new --for did not exist then and absorb gives a filed skill fix a real trigger in the next session working there. The reasons are also asymmetric: a silent commit in a parallel session's tree is a correctness problem, a deferred fix is a latency problem. Confirmed 2026-08-30 by the failure — a harvest run from an unrelated project made two correct, gate-green edits to this file and committed them in a repo it had no business writing to, left sitting in git log for whichever session pushed next.]

Deploying the skill edit is not a harvest step. The push is outward-facing and always asked, under step 5's rules; installing and reloading the skill afterwards are skill-authoring-taudelta's sequence, and the report names that as outstanding rather than performing it. Confirmed 2026-08-28: Bash(git commit:*) and Bash(git push:*) are both allowlisted on this machine, so no permission prompt guards the push — the discipline is entirely instruction-side, deliberately (see ~/.agents/AGENTS.md, "Proposing an enforcement mechanism for agent behavior"). Do not read the absence of a prompt as permission.

7. On friction, ask — then self-update the skill

Not just this session. Three triggers:

  • A candidate doesn't clearly fit any routing filter in step 2 (e.g. arguably both plan-specific and a durable cross-repo preference), or the significance test itself is a genuine toss-up.
  • The user corrects a routing decision this skill just made.
  • The user's own invocation asks for behavior this skill doesn't have. Arguments like "make sure you also check X, not sure the skill does that today" are a spec, not a one-off request. Applying them to the current run and stopping there is the failure this step exists to prevent — and it is easy to miss, because the run itself goes well. Confirmed 2026-08-28: every live-state check in step 5 arrived that way, was executed, produced the session's most valuable findings, and was nearly lost because nothing prompted writing it down. If the user has to ask "did you update the skill?", this trigger already fired and was missed.

In the first two cases use AskUserQuestion to resolve it for this item — never silently pick a side on a real ambiguity. In the third the user has already told you what they want; just fold it in. Either way the resolution goes back into this skill's source (see below) so the same friction doesn't recur. Resolving it for one session only defeats the point of a shared convention skill.

Do the fold-back before the final report, not after — which is why both skill steps sit ahead of it — and say in that report which destination was updated (this skill's source, or the always-loaded instructions file). A harvest whose whole subject is "what would be lost" should not end by losing its own lesson.

8. Harvest report

Last. Bullets throughout, indented into groups — never prose paragraphs. A harvest report is scanned, not read, and an indented list is what survives scanning.

A second harvest in one session re-runs the whole sweep and reports only the delta. Those pull in opposite directions and both are right: step 5 has to run again because live state moves between the two — confirmed 2026-08-30, twenty minutes apart, a parallel session had committed to the store in the gap and only the re-run saw it — while re-listing the findings the user read twenty minutes ago buries the two lines that are new. Say what changed since the last report and name the earlier one, rather than restating it.

And it corrects what the first one filed before writing that delta, which matters more than the delta does. The report is the cheap half: the user has read it and it dies with the terminal. The plans the first harvest wrote outlive the session, and nobody re-derives a number that is already in a file. A measurement filed mid-session is a prefix of the session, and a prefix of a session whose phases differ is not a smaller version of the whole. Confirmed 2026-09-07, two harvests of one session 2h40m apart: the first filed an adherence row reading n=211 chain=36% head/tail=20% sed-n=0%(1), and the same session at the second boundary measured n=306 chain=44% head/tail=27% sed-n=2%(7) — every rate moved the wrong way, because the last third was a different kind of work (reading five sibling repos' configs) and chained and sed -n'd far more than the phase before it. Ordinary, and it will recur. Nothing prompted the correction: the second harvest happened to remember it had filed the row, and had the user asked for one harvest instead of two, the corpus would have taken the prefix as the session's row.

python3 $H filed --until <the boundary> is what replaces the remembering, and it runs on every harvest rather than only a second one. It counts this session's own step-0 boundary calls, so "this is harvest #2" is read rather than recalled; lists the plan files this session wrote, each with the number-bearing lines this session wrote there and any others in the file marked (authorship unestablished); and lists every plans-store commit since session start as this session's, (authorship unestablished) or (not attributed) — the store is shared, so a commit inside the window is not yours by virtue of being there, and a marked row is reported, never edited. Only the newest ten (not attributed) rows print, the rest as a count, and --verbose lists them all: on a resumed session they were ~190 rows to find this session's 3 (2026-09-27). The counts line and every row that is this session's or (authorship unestablished) always print in full.

(not attributed) says the check could not tie the commit to this session, and no more. It used to read (another session), which asserted something the evidence never established and was wrong in the one direction the conservative reading was argued to be safe in: plans.py absorb --apply moves a plan out of the store, so the session writes nothing at the store path and a write-path test calls its own removal commit a stranger's. Confirmed 2026-09-07 — a session absorbed three plans, committed each removal minutes later, and filed reported 0 commit(s) this session, 20 from elsewhere with all three of its own among the twenty. Reading the session's commands as well as its writes fixed that and over-claimed the other way, through both doors: a filer was credited with the owning repo's absorption of its plan, with another session's correction of it, and — while running this step's own confirmation — with a commit it had only read the log of. A commit is now this session's by its receipt, the id its own commit command printed. A path match without one is (authorship unestablished): usually the owning repo absorbing or correcting your filing, which means it landed and is worth saying so. If a marked row is yours through a door the check cannot see — a commit piped through tail loses its receipt — say so in the report rather than assuming either way, and treat a 0 this session with the same suspicion as any other plausible number nobody can falsify.

On a first harvest it answers the report's opening groups instead, which is why the command block does not gate it on a second run. Those groups are "where did everything go", and assembling them from the session's own memory is the mistake this step already warns about one paragraph down: confirm the file is still there before naming it. Confirmed 2026-09-07 on the first harvest to have the subcommand — it listed five plan files written that session, correctly marked the one that had been retired as MISSING, and reported 0 store commits this session against 18 unattributed, a ratio no participant would have guessed and which decided whether an edit to a shared store plan was safe to make at all. That run predates the deletion fix above, so its 0 is a floor rather than a count; the argument it settled does not turn on which way it moves. Re-derive each unmarked measurement it prints and edit the file, then write the delta. Correcting a plan in the store is inside the write set at the top of this procedure, one filed --for another repo included. A row marked MISSING says only that the file is gone, and the cause decides the remedy: a plan this session retired needs nothing; one absorbed out of the store into the repo that owns it is corrected there by a new filing rather than an edit; anything else, find where it went before the report names it. The label used to read absorbed, or moved, and step 8 built its instruction on that. Confirmed 2026-09-20: a harvest listed four MISSING rows, three of them plans the same session had retired an hour earlier, and following the old wording would have filed three corrections against plans that were deliberately deleted. Retirement is the expected end of a plan a session lands, so a well-run session hits this more often than a sloppy one. So the row now names the two causes the deleting commit can establish — retired by this session (<sha>) when this session's own output shows the commit, absorbed (<sha>) when its subject says so — and keeps cause not determined for the rest. Added 2026-09-28, after a session that retired eight plans got nine undetermined rows to re-derive. A third cause comes from the session's own command rather than from git: a plans.py rename it ran reads renamed by this session to <new>, followed to what became of the new name, because the old name is often never committed — renaming a merged plan on absorption is the common case. A plan written in a linked worktree the session has since removed is looked up on main, at the same path under the repo's plans/, with a line naming the worktree path it was written as — before 2026-09-28 it dropped out of the list entirely, and a background session, which writes every plan through a worktree, got a count of one for five.

Two kinds of number are printed there, and only one of them goes stale by the session running longer. A measurement of the session — an adherence row, a call count, anything --until bounded — is a prefix by construction, so a later boundary changes it and it is re-derived every harvest. A measurement of the world — a corpus scanned, a spec read, a file's bytes — is not touched by this session continuing, and re-deriving it pays for a full re-scan to confirm a number nothing moved. Say which of the two each figure is, and re-derive the second kind only when its subject changed. Confirmed 2026-09-22: one harvest's filed printed both, the session-scoped row had moved on every rate, and the corpus row beside it — 46% of 144 READMEs — could not have.

A published verdict is the case that looks like the world and behaves like the session, because the thing that re-runs is somebody else's scanner rather than your session. Re-query it every time. Same harvest: a rating filed four days earlier had crossed two levels, on content whose only change was a printing fix.

Open with where everything went, as four groups, because "did this land somewhere durable, or is it still only in the chat?" is the question the whole report exists to answer:

  • To code — edits to source, tests, config that are committed.

  • To a plan in this repo — plans/*.md here, by filename.

  • To a plan in another repo — filed via plans.py new <topic> --for <repo> and committed in the store, by filename and target repo. Say only that it was filed. Do not restate what it asks for; the owning repo's session gets the whole thing from plans.py absorb.

    Confirm the file is still there before naming it, and check whether your store commit was pushed by someone else — ls the store directory, and git -C $PLANS_HOME branch -r --contains <sha>. A parallel session may have absorbed it into the target repo, which is normal and means the work arrived; say that instead. Confirmed 2026-08-30: a plan filed early in a session had been absorbed, merged into an existing plan there, and its store-side deletion committed and pushed hours before the harvest ran. A report written from the session's own memory would have named a path that no longer existed and called a published commit unpushed — two ordinary-looking status lines, neither of which reads as a guess.

  • Only in this conversation — everything not written to a file anywhere. This group is the point of the list: it is exactly what the user must either decide now or carry into the next session's prompt, and it disappears when the window closes. Keep it short by writing things down, not by leaving them out.

Then, least urgent first, because the final lines are what a skimmed report retains:

  • Settled — verified green/clean, no action. Say what was checked, so "fine" is a measurement and not an impression.
  • Skill and instruction misuse found — see the routing filter in step 2; each one is already filed by the time the report is written, so this section names the finding, the repo it was filed against, and the plan filename. Never a list of things to file later.
  • Skill changes — what step 6 changed to this skill, or one line saying it found nothing.
  • Decisions waiting — documented, not urgent. Name plans that must be decided together.
  • Risks carried — known and written down. For each, the falsifier: what observation would show the reasoning was wrong.
  • Needs action now — dangling state, and anything the user alone can decide (a push, a re-install, a destructive cleanup). Last, and specific.
  • Ends with a one-line verdict: "safe to compact" or "not yet — needs a decision on X."

The verdict is not the end of the run — step 9 is, and it runs every time. The report is what this session leaves behind; the prompt is what the next one starts from, and a harvest that stops at the verdict has done the expensive half and withheld the cheap one.

Fix what is cheap and unambiguous rather than only reporting it — kill the orphaned loops, write the lost measurement into the plan that owns it — and report it as done. Reserve the report's last zone for what genuinely needs the user. Anything outward-facing (a push) still gets asked.

9. The next-session prompt

Print it on every harvest, asked for or not. The heading said "if one is asked for" until 2026-09-04, which was a leftover from when this step was only a list of what to leave out — and it worked exactly as written: harvests ended at step 8, and the user asked "why doesn't session-harvest produce a prompt for the next session as the last item? i thought we did that". The feature had been built on 2026-09-01 precisely because they kept asking for it by hand.

It is never empty, so there is no silent case to design. The subtraction below removes most candidates on a well-harvested session, but ordering — what to do first, and why this rather than that — is not something any opening command prints, and there is always something to do next. A prompt that comes out with nothing but its opening line means the subtraction was done badly, not that the session had nothing to carry.

It is the next session's first move, not a summary of this one. Print it as a paste-ready block at the very end — never a file. The user pastes it into the next session within minutes, which is the only reason it may assert anything at all; a prompt written to a file rots on a shelf and reads identically when it does.

Build it by subtraction, and the subtraction is the whole design. The next session's own opening moves already print most of what a prompt is tempted to carry:

it already runs so it already knows
plans.py absorb every plan filed for this repo, incoming from elsewhere
plans.py list what is open here, grouped by status, retirements owed
git status, git log origin/<branch>..HEAD dirty tree, unpushed commits

Run those three, and include only the delta. Anything they print is not prompt content — it is noise that costs the reader attention and buys nothing. This is the mechanical test for "is this a marginal detail", and it is why the prompt is short: on a session that harvested properly, most candidates fail it.

What survives is three kinds, and only three:

  • Ordering. list says what is open; nothing on the machine says what to do first, or why this rather than that. Usually one sentence, and usually the most valuable line in the prompt.
  • Perishable state with a short fuse — a background process still running, a CI run in flight, a skill edited but not re-installed so the next session would run the old copy. Things that will be false in an hour and that no opening command reveals.
  • A decision made in this session that is not yet in any file. This one is a self-check: if it is not empty, step 2's routing failed and the fix is to write the thing down, not to carry it in a prompt.

Every item is verify-then-act, never an assertion: the command that re-derives the state, then what to do if it still holds. "Push 103b0b6" is a claim that may be false by morning; "git log origin/main..HEAD — if it still shows one commit, push it" cannot be. Stamp the block with the time it was written and say in it that anything older than a few hours should be re-derived rather than trusted.

Cap it at five items and one opening line. The opening line names the single next action and the file that carries its detail — a good prompt hands the next session one file to open, not a briefing. Past five it has become a plan without a status field, which is the shape this convention exists to prevent.

Never in the prompt, however tempting: anything the three commands print; anything already written into a plan (name the plan, never restate it); the reasoning behind a decision (that is plan content by step 2's routing); a narrative of what this session did (the report above already did that, and the next session does not need it).

It covers this repo only. Work filed for another repo is already routed — it sits in the store and plans.py absorb hands it to the session that can act on it, which is the first call that session makes. Repeating it here creates a second copy that goes stale independently and aims a reminder at the one session that cannot act on it. Confirmed 2026-08-30: a prompt carried two cross-repo items that were already filed and absorbable, which is duplication rather than a handoff. Same rule for a working-list plan in this repo — record that cross-repo work was filed, not what it says.

[PITFALL: the one carve-out, and it is narrow enough to state as a test. Another repo earns a line only when both hold: it is high-risk or irreversible (a history rewrite, a force-push, a destructive cleanup, a published credential), and it would change what the next session in this repo does. A pending history rewrite in a repo whose plans this repo's store mirrors is the shape that passes. An unpushed commit, an open plan, a failing test in another repo all fail the second half — that repo's own next session is handed those, and this one cannot act on them. When something does pass, one line: the risk, and the check that says whether it still stands.]

Everything this harvest writes into a repo — a plans/*.md entry, an AGENTS.md addition, a docs//contributing/ page, a skill's own source — goes through that repo's quality gate (inv quality.precommit, or the repo's equivalent) before committing, same as code. Markdown is not exempt: dprint reformats prose line-wrapping, and doc-only commits that skipped the gate were the one recurring CI-failure cause across these repos (confirmed 2026-08-23).

Self-update mechanics

Resolving friction (steps 6–7) means editing the skill's source, not whatever copy is in front of the current session:

  • The running copy — ~/.agents/skills/session-harvest/ (or a project-local .agents/skills/session-harvest/) — is a plain file copy dropped there at install time. Hand-editing it is silently clobbered by the next install and never reaches any other project or machine anyway. Never edit it directly.
  • The canonical source is the repo these skills were installed from — for this author, agent-skills; for you, your own checkout or fork. skills-state resolves it (--checkout, $SESSION_HARVEST_CHECKOUT, the repo above the script, else the one checkout under plan-conveyor' projects_root holding this skill's source) and asks when two qualify. Nothing about where any author keeps repos is written into the skill.
  • Only a session already working in that repo edits it. From anywhere else the fold-back is a filing, per step 6 — the --for <checkout> command skills-state prints, committed in the store. Do not locate the checkout in order to write to it: an edit there is a commit in another session's working tree, which is what the global rule forbids outright. The filing is not a weaker outcome; absorb hands it to the next session that works there. With no checkout on the machine there is no filing either — the friction goes in the report, per step 6.
  • When you are in that repo, edit skills/session-harvest/SKILL.md (or scripts/harvest.py, and its tests in tests/unit/test_harvest.py): a small, additive change. Rationale for why a resolution was made a particular way goes in references/rationale.md instead.
  • Run that repo's quality gate, then commit — locally, without asking, per step 7. Its own tests/unit/test_skill_layout.py is part of that gate and enforces real limits (the description cap among them), so run it rather than eyeballing the frontmatter. Then tell the user what changed.
  • A scripts/ edit does not reach this session's calls to the installed copy, and that bites during the run rather than after it. Every python3 ~/.agents/skills/<name>/scripts/<file> call keeps executing the installed copy, so after committing a script change the session is reading output from the code it just replaced. Confirmed 2026-08-30: a session renamed absorb's pairing output, committed it, then ran absorb --apply and got the old wording back — harmless there, and it would not have been if the change had altered behaviour rather than a string. Either call the checkout's copy for the rest of the run, or note which results predate the edit; do not re-derive the results from the new source and assume they match.
  • Say plainly that a committed edit reaches no installed copy yet, this session's and other projects' on the same machine included, and that deploying it is skill-authoring-taudelta's sequence. Then measure the install state before reporting what the machine runs — on a machine with parallel sessions another one may already have deployed it, so the state is shared. Diff it; that is what skills-state is for. Confirmed 2026-08-30: a harvest closed with "this one keeps running the old copy", the user asked, and the installed copy already carried the fix, deployed by another session twenty minutes earlier — a confident, specific, wrong sentence in the zone of the report reserved for what needs action.

Full rationale

references/rationale.md has the prior-art survey (why this isn't a PreCompact hook, what was borrowed from learning-loop-skill and what was deliberately left out, why plan-specific content is excluded from memory even though it's tempting mid-session) and the reasoning behind the self-update mechanism.