thread-ledger

v2026.09.24

Records a session's open work as append-only events in the session-memory store, and renders the watchable view. Use when a session's progress must be visible outside it — an orchestrator tracking parallel threads, or a worker or review run reporting where it got to.

GitHub
Install command
npx skhub add pandoscope/thread-ledger
Markdown
SKILL.md

Thread Ledger

A session's record of what is open, how far along it is, and what it waits on. Append-only events in the session-memory store named by SESSION_MEMORY_URL, rendered as a page anyone can watch.

Where this is in force it replaces the native task list. Do not maintain both: two trackers drift, and the one being watched is the one that lies.

What a thread is, by session

The schema does not change by role; what counts as a thread does.

SessionA thread is
orchestratorone line of work the principal opened — usually several at once, with deps between them
workerthe ticket it was fired on, plus anything it split off (parent)
reviewthe PR under review; blocked on: external while it waits for the fix session

A worker run typically opens one thread and closes it. That is worth doing anyway: it is the only place its progress is visible while it is running, and the only record of why it stopped once it is gone.

When to append

Whenever thread state actually changes — then republish at the end of the turn. Where heartbeat.mjs is installed a turn that changed a declared thread and did not append cannot end; where it is not, the harness's task-tool reminder is the cue, and priming is the fallback that has already been measured insufficient here.

MomentEvent
A thread startsopened
Measurable movementprogress with pct and a one-line note
Waiting on somethingblocked with on and what
That something arrivesunblocked
Deferred deliberatelyparked with a named trigger
A conversation thread became workpromoted with its ticket
The ticket no longer says what the session knowsstale with what changed
The ticket has been brought back in linesynced
The priorities on a live thread were wrongreprioritized with the corrected deps, urgency or importance
Finishedcompleted
Abandoneddropped with why
Finished thing needs more workreopened

sealed is the one kind you never write: heartbeat.mjs appends it when a turn's checks are green. It describes the log rather than any thread, so it carries no thread at all. Each seal carries a diligence digest the hook computes from its own compliance log — the stretch since the previous seal, never a window anyone chooses — and the stretch's raw per-Stop records flush to diligence/<session>.jsonl in the store beside it. The digest is computed, never typed: a seal composed by hand about the turn's own conduct would be self-report, and the recorder has no flag that reaches the field. The hook then pushes the store itself (seal phase 3): the checks gated the seal — the rendered page among them — and the seal gates the push, so no manual store push belongs in any turn. A store that is not a git clone, or a push the network refuses, is left for the next seal's push to sweep; the SessionStart clone report and the store's CI tail guard observe that gap.

Splitting a thread: opened the children with parent set to the original's slug. The parent keeps its own lifecycle.

Rules the recorder enforces

It rejects rather than warns, so a mistake fails at write time instead of surfacing as a wrong page later.

  • Every thread references a forge ticket or is tagged conversation_only — exactly one. The board is where work lives; the ledger is conversation state. Promotion runs one way only.
  • parked requires a named trigger. A revisit condition nobody can check is a revisit that never happens — and the rendered page is what observes it.
  • Illegal transitions fail loudly. completed → completed is rejected; completed → reopened → completed is fine. Validation runs against every session's events, so a later session can continue a thread an earlier one opened.
  • stale needs a ticket and a what. A conversation-only thread has nothing to be out of date, and the prompt the marker generates is only as useful as the sentence naming what the ticket is missing. Marking stale twice, or syncing a current ticket, is rejected.
  • promoted, stale, synced and reprioritized are metadata, not moves. The first three record what happened to the ticket; reprioritized corrects the thread's own deps, urgency or importance. Neither kind says anything about whether the work is blocked, so all four are legal wherever the thread is live and leave the work state untouched. Inside the state machine, filing a ticket for a blocked thread would have forced a false unblocked into the log — and priorities, which fold only on opening events, could never be corrected at all.
  • at and anchor are recorder-owned. Supply them and they are overwritten — code determines them more accurately than an agent estimating.
  • The store is worked on its default branch. An append pushes HEAD there, so a clone left on any other branch would publish that branch's commits unreviewed the moment a routine append ran (measured: skills#76). The recorder refuses before writing anything; reconcile the clone onto the default branch and re-run.

The heartbeat

heartbeat.mjs sits beside ledger.mjs and runs as a Stop hook. It checks a finished turn against observed state only — files on disk, git, the ledger log — and either seals the turn or blocks it once. There is no self-report tier: a checklist filled in by the agent that did the work is another claim from the context that already believed the work happened.

Your part is one declaration per turn, written ONLY through the validated writer — never by editing any file by hand; where the declaration lives is the writer's business, and a malformed one fails here with the correction in the error instead of as a Stop-hook block:

node ledger.mjs declare --reviews none --tickets my-org/skills#56

--rulings slug-a adds a rulings declaration; each --no-update "<target> <reason>" adds a per-ticket waiver (a target without a reason is refused). The verb needs no store or identity — it is the one command that must work when everything else is misconfigured.

Threads are not declared: the heartbeat observes which threads got events this turn from the ledger itself, so the observation IS the claim. Append the event and the turn's threads follow; a turn that committed to a clone while appending nothing fails ledger-event on that observation alone.

Every check is a mechanical diff of the declaration and the observed state against what was actually written and pushed. When one fails, the block reason states the completion criterion and the exact command — run it and end the turn; it is never a prompt to start new work.

The hook blocks once per reason, not once per turn. A re-fire that fails a check the block never named earns one more block, up to three per turn, because that is a wall the model has not been told about rather than a reason it just ignored. Past that the turn is released unsealed and says so on stderr: a released turn is not a passed one, and the store's unsealed tail is the durable record of it.

The reviews: line is the declared half of a standing habit: wherever a check has a blind spot, the model declares and the observer cross-checks. Its states are none, read, persisted and nothing-to-persist. A declaration can widen detection — declaring none over a transcript that fetched footer-less comment bodies fires, and so does claiming persisted over untouched stores — but it never greens the check: only an observed memory write does that. The explicit waiver, nothing-to-persist, passes as a logged claim, so declining to persist is a visible act rather than a silence.

The seal, the verdict log and the environment contract are code, documented where they live: the header of heartbeat.mjs. The check list is checks/index.mjs — the priority order in one place, each check with its own module beside it. The installer configures them; a misconfiguration is logged, never passed.

A clone behind its branch is reconciled, never forced. The command the hook offers is merge --ff-only: it succeeds for a plain rollback and fails loudly for a real divergence. Forcing the push instead is the step that turns a recoverable state into lost work.

Nothing outgoing carries a blocked term. scan.mjs is the shared scanner (skills#46, check 7): built-in terms are the store URL values, taken from the environment automatically; user terms come from PUSH_BLOCKLIST, |-separated and optional by design (a literal | in a term is not expressible — reserved). The scan covers what would LEAVE — commits on no remote, tracked changes a commit would sweep up, the rendered page — never untracked files or the environment: a term may live there, it must only never leave. It runs before the pushed check so a hit blocks before any push instruction, and every report names the term's SOURCE, never its value — the confirm commands count matches rather than printing them.

What a review decided is persisted, not just read. The truth source is the attribution-footer contract (skills#46, check 14): a fetched comment body without the footer was written by a human, and a human's review answers must not live only in a transcript the container discards. Either memory store counts as persisted — "not lost" beats "right cabinet" — and the match is coarse by ruling: human comments in, zero memory writes out, fires once. The footer heuristic alone only observes; what blocks is the mechanical side — the reviews: declaration against the stores, or a declaration the transcript contradicts.

With AGENT_ACCOUNTS set (comma-separated forge logins the agent posts as), authorship beats the footer as the discriminator, and the contract itself is guarded: a footer on a foreign account, or an agent account posting bare, fails loudly — every footer-based reading is suspect while either holds. Opt-in by construction: no variable, no account check.

Every ticket the turn declared heard about it. The declared tickets: set diffs against issue-writing tool calls in the transcript (skills#46, check 4) — reading a ticket is not updating it. The per-ticket escape is a no-update: <owner/repo#n> <why> line in the same summary file: logged as a claim, never verified, so declining to update is a visible act rather than a silence.

Every ruling the turn declared is a record. A rulings: line names the slugs the principal ruled on (skills#46, check 8); each one must appear in a decisions/ filename that arrived this turn. Mechanical, so it blocks; the accepted blind spot (ruling E10) is the ruling never declared. The grilling check (13) stays observe-first: the invocation is mechanical, but answers arrive in waves over later turns and records legitimately land when the rulings settle — a blocking check would fire between waves, so it only logs what it sees until the compliance data earns it more.

The remind tier (checks 10 and 11). Work that completes with a PR owes the corpus a kata: the trigger is mechanical, the adequacy is not, so the hook reminds exactly once per thread — the fresh-incident moment is when a kata is cheap — and afterwards records only the claim. A question-shaped close without a blocked event is observed and never blocked on: the detector is imperfect by admission, and an imperfect detector is measured before it may nag.

A ledger conflict is resolved by union — --ours/--theirs are never valid. The log is append-only and both sides are real events, so the only correct merge keeps every line in stamp order; the recorder does exactly that on a lost push. Picking a side deletes someone's published event, and git checkout --theirs . deletes them wholesale (measured: skills#79). The store's CI guard (ledger guard) rejects any push that removes a ledger or diligence line, and the recorder refuses a push whose merge would land a transition the union forbids — when that happens, the event is withdrawn and the message says what to re-append. Deleting a published line is never a legal edit; re-appending is the only legal repair.

A decision marked this turn is recorded this turn. When a commit adds a DECISION marker, the decision store gets its record before the turn ends: the reasoning is free to write while you still hold it and cannot be reconstructed later, and a reconstructed prediction scores nothing. A marker already in the tree is an earlier turn's debt, not this one's — and this turn is measured by when a commit was AUTHORED, which survives rebase and merge, so landing an old branch never bills its markers to the turn that merged it. Only the marked half is mechanized — judging what deserves a marker stays with you.

Never re-open a recorder session that is already open. record.py open mints a new session branch every time it runs, stranding the records committed on the branch it replaces. The hook reads the recorder's own state and offers the command that matches it.

A spawned session is checked against what it was spawned for. With REINSET_ANSWERS naming the composer's answers file (skills#179 §3), a ticket the turn declares outside the spawner's passed.tickets is printed as drift — declared ticket, passed list, origin — and never blocked: the passed list is the spawner's claim, the ledger is the record (D5). A principal-origin session has no passed list and no drift; a named file that does not exist yet is the ordinary first Stop and reads as nothing. A session whose resolved role is probe is exempt from ledger-event and artifact-fresh: it does one commit and one answers file, and the ledger and the artifact are its orchestrator's (skills#181).

The shape of the work is checked, shadowed first (skills#192). Three checks read the working-branch rules from the judged clone's own .github/reference-keywords.json — the branch pattern, the canonical keywords, the forge's native ones — and from commitlint's grammar: a clone committed to this turn sits on a claude/<code><ticket>…-<desc> branch, its commits this turn carry conventional headers (fixup!, squash! and a git-made Revert included) with no merge on the branch, and every tracker body the transcript shows posted renders as written — canonical keywords only, no angle-bracket placeholder outside code, no hard-wrapped paragraph, every branch ticket referenced. The stores are never judged by these rules. Each ships shadowed: a failure lands in the compliance log as shadow, is never blocked on, never seals a fired count into a digest, and the diligence report counts it per check as the rate that argues for arming it. Arming is dropping the flag in the check table, with the kata that pins the block's wording. A check enters the table only with its accepted forms — the spellings its rule allows, in tests/original/reminder-heartbeat/test_forms.mjs — because the driver lab refused correct work three times over forms the rule text permitted.

Republish the page after appending. The rendered file is compared against the newest event, so a stale artifact blocks the turn. Render with --session-url: a store holding several conversations refuses without one.

Responses follow the reference style, and mistakes are corrected as an exercise. In prose, tickets and PRs are linked shortcode refs — XXX#n for tickets, XXX!n for PRs, each a markdown link to the page its sigil implies — with the shortcodes defined once, in the store's config/shortcodes.json. That file is either a flat shortcode → owner/repo map (GitHub assumed) or structured — {forge, patterns: {ticket, pr}, repos}, patterns interpolating {base}/{repo}/{n} — so the forge is org configuration in the store, and no vendor is named in code. A thread opened this turn is announced as new thread: <slug>, and every thread the turn summary declares is named in the prose that discusses it. Code spans are quoted material and exempt; a bare owner/repo#n belongs in PR bodies and ledger events, where the forge autolinks it, never in prose. When the check fires, it names the canonical forms and the rewrite must contain them verbatim — deleting the offending refs is not correcting them, and the re-fire grades exactly that. No map in the store means the check reports unconfigured and declines, like every other check.

Preflight — the same checks as an in-turn linter

Before ending a turn, run the heartbeat over the draft instead of waiting for the Stop hook to grade the posted reply:

node heartbeat.mjs --preflight --draft <file> [--fix] <<< '<hook JSON>'

Preflight runs every check against observed state with the draft standing in for the response, prints every verdict, and exits 1 when anything would fail — a report, never a gate. It does not seal, does not block, and writes no ledger events, summaries, or waivers: preflight reports, the agent does the work. Each round lands in the compliance log with outcome preflight, excluded from cycle counting, so lint runs neither spend the Stop hook's block budget nor displace the cycle-1 baseline; the dojo mines those traces for katas rather than any round auto-minting one. Iterate until clean or converged — same bound as the block cap — and on non-convergence post anyway, naming what is still failing.

--fix is the one write preflight owns, and it edits notation only, in the draft file and nowhere else: bare refs become their canonical linked shortcode forms, and commit hashes resolve across the session's clones — a hash git cat-file -e finds in exactly one clone becomes a link to that repo's commit page via the shortcode map; anything else is left in place and reported. Inline code IS scanned for commit hashes — prose habitually backticks a SHA, so there the code-span exemption would be the escape hatch rather than the protection — while fenced blocks stay quoted material, exempt as everywhere else.

Diligence

diligence.mjs reads the compliance log and reports what the reminders cost against what they buy — per turn, per check, per model. Cycle 1 is the unprompted baseline, and everything above it is the reminder's price in round-trips. The report prints its own limits beside its numbers. HEARTBEAT_OBSERVE runs every check and logs every verdict while surfacing nothing: the unobserved arm, for measuring what the reminder itself changes.

A check defect that fires wrongly still lands in the corpus as model non-compliance. The correction is a dispute: a line in the store's diligence/disputes.jsonl naming the check, the window, and the ticket that filed the defect, written at diagnosis time. The report counts matching failures apart — billed to neither side — while the records themselves stay immutable; a dispute without a filed ticket is rejected, because an eraser the recorder could reach for is exactly what the corpus must not have.

The rendered page makes the same numbers legible per stretch: a sessions section leads the page — one chip per session, and the chip both unfolds that session's stretches and filters the thread lists. Every seal is a thin rule carrying when, the threads touched, the checks that fired (ignored ones marked), the reminder count, and total tokens and wall-span weighted against the session's own median. Clean stretches stay quiet; reminders go amber; a stretch that was reminded and still did not finish goes red. A compaction reset renders as an explicit gap, never as zero, and seals from before the digest existed collapse to one counted line.

Reconciling against the world

The checks are turn-local by design; nothing in the hook queries the tracker, and a merge usually happens outside any turn. Reconciliation therefore lives in two report-only tools, and neither can write:

  • merged-report --repos <dir> — the SessionStart twin of the clone report. A thread that records its --branch beside its ticket makes "is this work merged" a pure git question; live threads whose branch is an ancestor of the default branch are named on stdout. Reports, never gates: a missing clone, an unknown ref or a network failure is silence, and the exit is always 0.
  • reconcile — the API-priced half, on demand where gh is authenticated. Prints both directions of divergence: a live thread whose ticket is closed, and a completed thread whose --pr never merged. Deliberately forge-specific: it is gh-bound by declaration and refuses without it, unlike the render path, which builds every ticket link through the store's config/shortcodes.json forge config and names no vendor (skills#102).

Deciding what event to append stays with the reader — a reconciler that wrote events would be a second author of the log it audits.

Ordering

Rendering order is computed from the events, so nothing here needs setting — but it is worth knowing that deps and urgency are what move a thread up the page, since those are fields you supply. Dependency ranks first: a cluster is ordered by the thread that must move first, so a thread behind an urgent blocker sits directly under it rather than sinking down the list.

Usage

ledger.mjs sits next to this file, wherever the skill is installed. Node only, no packages.

LEDGER="$(dirname "$0")/ledger.mjs"   # or the skill directory's own path

node "$LEDGER" append --ev opened --thread pilot-trigger \
    --title "Create the Issues:Labeled trigger" \
    --ticket my-org/meta#35 --urgency high --importance high
node "$LEDGER" append --ev progress --thread pilot-trigger \
    --pct 40 --note "trigger drafted; awaiting model-selection check"
node "$LEDGER" state          # the folded JSON, to inspect before appending
node "$LEDGER" render --out ledger.html --title "Thread ledger"

Each event kind requires only its own fields — progress wants pct, blocked wants on and what, synced wants nothing. The fold carries everything else forward, so nothing is restated per append.

Then publish ledger.html as an artifact, reusing the same URL so the principal's bookmark stays valid.

render fast-forwards the store before it reads; --no-pull skips that. A store that cannot fast-forward still renders: the reason goes to stderr, and the page itself carries a "possibly outdated" banner so its reader knows too. Two habits stay yours, since they happen around the publish call: never read the published page first — the conflict error is cheaper, so pay it when it happens and republish — and concede to a session visibly publishing the same store, since its next render carries your events.

Identity

The conversation's URL is the log's identity. Pass --session-url once; it is recorded in the store, names the log file, and is stamped onto later events so every thread links back to where it was discussed.

Nothing else is stable. The transcript filename is local to one machine, and a name the session is merely called can differ between two runs of this tool — which is how one conversation ended up with two valid logs that folded in beside each other and looked entirely healthy. Deriving the name from the URL removes that, rather than guarding against it.

The fallbacks — the store's single recorded URL, then the transcript stem — belong to reads only: CI renders with no transcript and no way to know the session, and a read folds every log anyway. An append without --session-url (or LEDGER_SESSION_URL) or --session refuses outright: an append pushes immediately, so a guessed identity is published before any warning can be acted on — measured twice (skills#51), both times filing events under an identity that exists nowhere. --session remains for a store holding several conversations.

One implementation

core.mjs holds the schema, the state machine and the fold. One consumer is an HTML page. It carries raw events, not rendered rows and computes state at load. If its script fails, the page shows a failure banner carrying a ready-to-paste debugging prompt.

Store location

SESSION_MEMORY_URL names the store events are written to. Unset, every command fails.

Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.24

Published

Sep 24, 2026

Category

Uncategorized

License

MIT

Source path

original/thread-ledger

Default branch

main

Latest commit

c37e9a0

Tree SHA

71b121b