> built 2026-09-26 07:43 UTC from 87a40aa (main) · openloops 0.1.15. Details: build_info.json

# index.html.md

<!-- generated by epythet -->

# openloops

An **open loop** is a commitment that outlived the session that made it and that
nothing is watching. An agent said it would come back to something and the message
scrolled away. An agent got blocked on you and filed an issue you never re-read. An
agent found the real fix belonged in another repository, filed it there, wrote a
workaround here, and nobody ever told this repository the blocker was closed.

`openloops` finds those and re-checks them. It answers three questions and nothing
else. It never writes: it does not close, reopen, relabel or comment on anything,
anywhere, and there is no local database — the transcript, the label and the
dependency edge *are* the records.

**The surface most people want is not a command.** If you delegate development to
agents, what you want is an agent that runs the commands and tells you what matters.
That is what to install first; the `ol` command underneath it is plumbing, and it is
documented further down for when you want it directly.

## Start here: install the skills, then ask your agent

```bash
pip install openloops        # Python 3.10+. Puts an `ol` command on your PATH.
ol install-skills            # link the skills and the subagent into ~/.claude
                             # --only openloops,openloops-sweep to skip the capture skill
```

```default
installed into ~/.claude
  method: symlink   install: 3   ok: 0   conflict: 0

install  skill  openloops                 not present
                  -> ~/.claude/skills/openloops
install  skill  openloops-needs-human     not present
                  -> ~/.claude/skills/openloops-needs-human
install  agent  openloops-sweep           not present
                  -> ~/.claude/agents/openloops-sweep.md
```

Then open a coding session and ask, in whatever words you would have used anyway:

> **what needs my attention?**
```default
## Needs you now
- CI secrets are not provisioned on acme/widget — acme/widget#1, 23 days old. One
  `gh secret set PYPI_PASSWORD --repo acme/widget` and the publish job stops
  failing on merge.
- Decide whether the parser ships in 2.0 or waits — acme/parser#4, 2 days old.
  A judgement call, so no check can ever discharge it; it sits here until you answer.
- Unchecked: acme/widget#9. Its predicate runs the doctests against a live
  third-party fetch and timed out at 20s. Not open, not done — nothing observed it.

## Free to proceed
- acme/widget#109 — every blocker closed. acme/engine#15 has been closed for 5 days
  and nothing has said so. The rename workaround in widget can come out.
- acme/engine#7 — the deploy key is there and the check now passes. The issue is
  merely still open. I have not closed it.

## In flight
- widget: two sessions, the 2.0 parser and an egress scrubbing pass.
- parser, infra, engine: one session each, none of them waiting on you.
- Two widget issues wait on acme/engine and widgets/aix. Nothing for you there.

2 need you, 2 are free to start, 1 could not be checked.
```

That answer is a **synthesis, not a paste** — the skill asks for exactly that, and an
agent that hands you three screens of command output has failed to use it. Every row
behind it is one the `ol` commands printed; you can see the same rows raw, in the
plumbing sections below.

Ask again tomorrow and it is a different answer, because `ol owed` and `ol blocked`
re-run each obligation’s check and re-resolve each blocker edge before answering. That
re-check is the whole product; everything else is a list you could have got from `gh`.

### Two things to set up once

**The GitHub CLI, logged in.** Two of the three questions are answered out of GitHub, so
they need `gh` on your PATH and authenticated. The session-digest half needs nothing at
all — no model, no network, no account, no configuration.

**Which repositories you mean.** Left alone, the fleet is whatever login `gh` is
authenticated as. If you work across an organisation, that is a count that looks clean
and is wrong, so say so once:

```bash
export OPENLOOPS_OWNERS="your-login some-org another-org"
```

Every result prints the owners it used, and the read skill is told to check that line
and tell you when it looks too narrow. More on the resolution order below.

### What got installed

| Name                    | Kind     | What it is for                                                                                                                                                                                   |
|-------------------------|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `openloops`             | skill    | **the read side.** Teaches an agent which of the three commands answers which question, how to synthesise the three into one page, and — most of all — never to round a `?` into a clean answer. |
| `openloops-needs-human` | skill    | **the capture side.** Teaches an agent to file a `manual-task` issue, with a re-checkable predicate, at the moment it gets blocked on you. Without it, `ol owed` has nothing to read.            |
| `openloops-sweep`       | subagent | the same sweep, run in a fresh context, returning a page instead of three screens. For the session you keep open all day.                                                                        |

`~/.claude` is Claude Code’s config directory, and `CLAUDE_CONFIG_DIR` is honoured if
you have moved it. Any other agent host: `ol install-skills --target DIR`. They are
plain markdown files with YAML front matter; nothing about them is Claude-specific
except where they get installed.

**Re-running it changes nothing.** Each asset is a symlink into the installed package,
so `pip install -U openloops` upgrades the skills too, and a second `ol install-skills`
reads `ok: 3`. Where symlinks are unavailable — Windows without Developer Mode — it
copies instead, and the report says which happened rather than pretending they are the
same thing.

**Nothing already there is ever overwritten.** A destination holding something that is
not ours is reported and left exactly as it was:

```default
installed into ~/.claude
  method: symlink   install: 2   ok: 0   conflict: 1

conflict skill  openloops                 something else with this name is already there
                  -> ~/.claude/skills/openloops
install  skill  openloops-needs-human     not present
                  -> ~/.claude/skills/openloops-needs-human
install  agent  openloops-sweep           not present
                  -> ~/.claude/agents/openloops-sweep.md

conflict means nothing was written there. Look at the file, then re-run with --force to replace it.
```

```bash
ol install-skills --dry-run     # print the plan, touch nothing
ol install-skills --copy        # copy instead of link (stops tracking upgrades)
ol install-skills --force       # replace what is there — after you have looked at it
ol install-skills --target DIR  # some other agent host's config directory
```

## The sweep subagent, for the session you keep open all day

The session you want to have this conversation in is a long one: the master session you
leave open and come back to. Running the sweep inline fills that session’s context with
command output that is stale ten minutes later, and does it again every time you ask.

`openloops-sweep` is the fix, and it is one file. It runs the three commands in its own
fresh context and returns only the synthesis, so the tenth “what needs my attention?” of
the day costs the main session the same as the first — about a page — no matter how long
the conversation has run.

The read skill dispatches to it when it is installed. Inline is still right for a one-off
question, or when you are about to drill into one row.

## The capture skill, and why `ol owed` has anything to read

`ol owed` reads open issues labelled `manual-task`. On a fresh machine there are none,
and there is no amount of reading that will produce any: **something has to file them.**

That something is `openloops-needs-human`, installed above. It fires at the moment an
agent gets blocked on you — a secret it cannot write, a permission it does not have, a
decision that is yours, a recorded decision that now looks wrong — and files the blocker
as a labelled issue in the affected repository *before* writing the hand-back message
you were never going to re-read.

> **Did openloops file this issue?** No. **openloops never writes to GitHub.** The
> `manual-task` issues it reads are filed by whatever you have told your coding agents to
> do when they get blocked on you, running under your own `gh` credentials; the capture
> skill is one such thing and you can point yours at anything else. If an issue appeared
> that you did not write, an agent wrote it, and that is the system working. openloops
> only reads them back.

The label is the whole storage design. It outlives the session, it is queryable from a
terminal or a phone with every session stopped, and it needs no database to disagree
with:

```bash
gh search issues --owner acme --label manual-task --state open
```

What `openloops` adds to that one-liner is the re-check — and the re-check only works if
the issue carries something to run.

### The field that decides whether an obligation can ever be closed

**An obligation without a predicate can never be re-checked.** It will sit at `open`
forever, correctly, because nothing openloops can run would ever observe it as done. The
capture skill writes these; it is worth two minutes to know what a good one looks like,
because you are the one who will read the count.

Obligations get discharged **out of band.** Somebody adds a deploy key in a web UI,
pays an invoice, answers in chat. None of that emits an event anyone is listening to,
so the issue sits open for months describing something that was done in five minutes.
A stale row annoys; a phantom row destroys the count, and the count is the whole
product.

So each obligation carries its own answer, in its body, as a shell command whose **exit
status is the question**:

```markdown
**Verify:** `gh secret list --repo OWNER/REPO --json name -q '.[].name' | grep -qx PYPI_PASSWORD`
```

The rules the parser actually applies, in the order they bite:

- The field is `Verify:` at the start of a line. The `**` bold markers are optional —
  the field is the contract, not its markup.
- **The predicate is the first backtick code span in the field.** Everything outside it
  is prose.
- **Fenced and indented code blocks are ignored** when looking for the field, so an
  issue that quotes this format in a `<details>` block does not get the example run
  instead of its own predicate.
- **A field that begins `none possible`, `none`, `n/a`, `no predicate` or `not possible` yields no command, even if the prose that follows contains a code span.**
  This matters: the natural way to write that sentence mentions ``gh`` or
  ``true``, and both exit `0`, which would report a live obligation as done. Use it
  for genuine judgement calls.
  Such a field reads `open`, with its prose as the reason. A field that merely *starts
  like* one — `nonexistent: …`, `none of the files remain: …`, `n/a until …` — followed
  by a code span is ambiguous: nothing in it is run, and it reads `?`.
- Otherwise, a field with a backtick but no closed code span is *malformed*, and reads
  `?` rather than `open` — a typo should not look like an answer.

What makes a good one:

- **It observes the world, not the issue.** `gh secret list …`, `curl -fsS https://…`,
  `gh api repos/…`. Never something that reads the issue itself: closing an issue is not
  doing the thing.
- **`0` means done and nothing else.** Non-zero for anything short of done.
- **It fails loudly when it cannot check.** openloops already catches the common
  shapes — exit `126`/`127`, death by signal, and stderr saying `command not found`,
  `bad credentials`, `HTTP 401`, `could not resolve host` and friends — and turns them
  into `?` rather than `open`, because a check that never reached the world has not
  observed anything. Write yours so it lands in one of those rather than silently
  exiting `1`.
- **It finishes fast.** Every evaluation is time-bounded (20s by default) and a timeout
  is `?`, not an answer.

One honest limit: predicates are POSIX shell. On a shell that cannot parse one, the
command exits non-zero and the row reads `open`. That is the safe direction to be wrong
in, and it is why the exit status is always on screen.

## `?` means nothing was checked — read this once

`ol owed` and `ol blocked` each report **three** states, never two, and the third is
`unknown`, printed `?`.

`?` is not a rounding error. A tool that says “nothing owed” because `gh` was missing,
or “nothing is waiting” because the network was down, is worse than no tool: it hands
you a clean board you did not earn, and you stop looking. So `unknown` is never
collapsed into either of the other states — not into the alarming one, not into the
reassuring one — and every row that reads `?` prints *why* on the next line: a timeout,
a missing `gh`, an untrusted owner, a malformed predicate.

When the listing itself fails there are no rows to mark, so the whole command says so
and refuses to print a count:

```default
owed ?  could not check - gh: not logged in
```

That refusal is the single most important design decision in the package, and the read
skill spends more words on it than on anything else: an agent that answers “nothing
owed” when rows read `?` has told the exact lie this package exists to prevent.

One disambiguation, because the character appears in two places: the `?` in the second
column of `ol`’s own output is **not** this. It means low confidence — the session
never said it was finished — and it is explained under `ol` below. The digest half
checks nothing against the world by design, so it has nothing to be unable to check.

## Before you run it: this executes text from an issue body

Evaluating a predicate means **running a command that came out of a GitHub issue** —
whether you typed `ol owed` or an agent did. That is a real capability, and it is
bounded in five ways, all of them visible:

- **Only owners you configured.** A predicate runs only when the issue’s repository
  owner is trusted, which defaults to exactly the owners you searched — so widening the
  search never quietly widens what runs. A row outside that set reads `?`: never
  `open`, because nothing checked it, and never `done`, because nothing ran. In Python
  it is `trusted_owners=`; on the CLI it follows `--owners`.
- **The command is always printed next to its verdict**, in full and never abbreviated,
  so nothing executes invisibly and you can disagree with the answer.
- **`ol owed --no-verify` lists without executing anything.** Every row that has a
  predicate then reads `?`, because that is what is true about it. The read skill is
  told to reach for it on a machine that is not yours, or after you have just added an
  owner whose issues neither of you has read.
- **Every evaluation is time-bounded**, and a timeout is `?` rather than an answer. On
  POSIX the predicate runs in its own process group and the timeout kills the group, so
  anything it started dies with it. On Windows only the shell itself is killed — a
  predicate that backgrounds work can outlive its timeout there. That is a platform
  limit worth knowing rather than a promise broken quietly.
- **`run_predicate=` replaces the evaluator entirely**, and `issues_source=` the reader.

The default is to check, and that is deliberate rather than an oversight: not checking
has a silent failure mode (a count quietly full of things you finished weeks ago) and
checking has a loud one (a command you can see on screen).

---

## The plumbing: the `ol` command

This is what the skills call. It is a perfectly good thing to type yourself — it is how
the author uses it half the time — but if you came here wanting an overview of what your
agents are doing, the section at the top is the one you want.

### Which command answers which question

| You want to know                                            | Run          | Needs `gh`?   |
|-------------------------------------------------------------|--------------|---------------|
| What did my sessions leave open?                            | `ol`         | no            |
| What do I still owe my agents?                              | `ol owed`    | yes           |
| What is waiting on another repo — and what is not any more? | `ol blocked` | yes           |

Everything else is a variation on those:

```bash
ol ls --state all           # every digest, newest first
ol ls --confidence high     # drop the ones open only because nothing said otherwise
ol show 2b1f                # one digest, by session id or a unique prefix
ol status                   # where the digests are, how many, how stale the cache is
ol dashboard --out b.html   # all three answers as one self-contained HTML page
ol install-job              # run the digest sync every 15 minutes (macOS launchd)
```

Every block of command output below came out of the shipped renderers. Repository,
project and machine names have been replaced with generic ones; nothing else has been
touched.

## `ol` — what your sessions left open

Claude Code keeps a full JSONL transcript of every session, then garbage-collects it
after about a month. Meanwhile the thing you actually wanted from that session — what
it did, what it left for you, how to get back to it — was one paragraph at the end.
`ol` reads the transcripts, writes one short dated markdown digest per session, and
keeps the digests. It is a **retention** device, not a dashboard.

Bare `ol` syncs and then prints what is open:

```default
2026-08-27 10:05   015dcf33  widget                ship the 2.0 parser
2026-08-27 09:53   f8c0b9e2  engine                cross-repo blocker harvest
2026-08-27 09:47 ? 7f400fcb  parser                skill_management
2026-08-26 21:42 ? 8437d111  infra                 sync the staging box
2026-08-26 12:35   8cad34f9  widget                egress scrubbing pass

acme-laptop: 170 sessions scanned, 0 digests written, 170 unchanged, 0 moved; 170 digests in store.
```

Columns: the session’s last turn, a `?` for low confidence, the session id (`ol show`
takes any unique prefix), the project directory, and the session’s own title for
itself.

**The gotcha: `open` here is loop state, never process state.** A `?` in the second
column does not mean the session is running or crashed. It means the session’s own
closing words never said it was finished, so the classifier defaulted to `open`. Five
rules decide it, and the digest always prints which one fired and what cues it saw, so
you can disagree:

| Rule                                                       | Reads as                      |
|------------------------------------------------------------|-------------------------------|
| the last turn is a usage-limit or API-error notice         | `open` — it was cut off       |
| the transcript stops before the assistant’s turn completed | `open`                        |
| the closing line is a question put to you                  | `open`                        |
| the latest cue in the closing lines defers something       | `open`                        |
| the latest cue in the closing lines declares it finished   | `archive`                     |
| nothing decided it, but the session’s own recap does       | either                        |
| nothing decided it at all                                  | `open`, marked low confidence |

**`archive` is earned, `open` is the default.** A session only reaches `archive` by
saying it finished. Against hand-labelled sessions the cue rules never produced a false
`archive` but caught only about half the genuinely finished ones — and that is the right
direction to be wrong in, because a false `archive` buries a loop this tool exists to
surface, while a false `open` only lengthens a list already sorted by recency.

**Conflicts resolve at sentence granularity, and inside a sentence an open cue always
beats a close cue.** Real closing paragraphs carry both kinds:  *“say the word and I’ll
remove it. Nothing is blocking; safe to close”* is finished, and  *“you’re safe to exit …
the two threads waiting for you are”* is not — the last cue-bearing sentence decides
those. But  *“**Needs you** (nothing blocking, all tracked): the #146 decision”* puts the
close cue inside a parenthetical of the sentence that hands work back, so within one
sentence the asymmetry decides instead of position.

Expect most sessions to land in `open/`. That is what agentic sessions do, and a split
that came out balanced would be a split that was lying. `ol ls --confidence high` drops
the ones that are open only because nothing said otherwise — which is also why the read
skill is told to prefer those rows when it summarises what is in flight.

If `open` ever came to mean “a process is running”, this would be a session dashboard.
It is not one, and `claude` already has that view.

**For the live half, there is [crowsnest](https://github.com/thorwhalen/crowsnest).** It reads the session registry and the tail of the same transcripts to say which sessions are running right now and which of them is busy, idle, or stopped waiting on you — and it uses openloops’ own `parse_session` to read them, so the two agree about what a session said. The split is the one this section is about: crowsnest answers *what is running now*, openloops answers *what sessions left open and what you owe them*. A session crowsnest calls idle may still be carrying three open loops, and a loop openloops surfaces may belong to a session that exited a week ago.

### What a digest says — and what it never says

A digest is a dated record of **what one session said**. It carries the session’s last
turn, the prompt that provoked it, a context-compaction summary if the session made
one, and any pull-request links the session recorded for itself. Every heading carries
the timestamp of the thing under it.

It never says that anything is *currently* true. There is no “still open”, no “more to
do”, no “waiting on you” — because `ol` has checked nothing against the world, and a
snapshot presented as a live fact is worse than no snapshot at all. The front matter
says `verified: false` for exactly that reason.

This is the constraint the whole design is arranged around. A tool that quietly tells
you a loop is open when you closed it out of band destroys the count, and the count is
the product. It is also why the other two commands exist: they are the ones that *can*
re-check, and they do.

### Where the digests are stored

```default
~/.local/share/openloops/digests/{source}/open/{session}.md
                                          /archive/{session}.md
~/.local/state/openloops/sync-state.json      ← a cache, not data
```

`{source}` is this machine. Two machines syncing digests into one git repository never
write the same path, so there is no merge to reconcile — the directory layout *is* the
answer to cross-machine sync. The label seeds from the machine’s short hostname and is
then **sticky**, remembered in the state directory: macOS renames a host that collides on
a network, and a label that drifted would silently fork the store into two copies. Every
sync prints the label it is writing under, and `OPENLOOPS_SOURCE` overrides it.

The cache is deliberately somewhere else. Deleting it re-reads every transcript and
must produce byte-identical digests; deleting the digests loses whatever the
transcripts no longer hold. Those are very different operations and they should not
live in the same folder.

Override with `OPENLOOPS_DATA_DIR`, `OPENLOOPS_STATE_DIR`, `OPENLOOPS_SOURCE`.

### Keeping it up to date

```bash
ol install-job              # a launchd StartInterval agent, every 15 minutes
ol job-status               # installed? loaded? when did it last actually write?
ol uninstall-job
```

It is a periodic job, not a daemon, and that is a decision rather than an
implementation detail: a tick that crashes is repaired by the next one, whereas a
resident process that dies leaves its last output behind with nothing scheduled to
correct it. `ol job-status` reports when a tick last *wrote*, because “the job is
registered” and “the job is working” are not the same claim.

The installer is macOS-only. On Linux, put `ol sync` in a systemd user timer or a
crontab line — `openloops` itself is cross-platform.

---

## `ol owed` — what you still owe your agents

When an agent gets blocked on something only you can do — a secret it cannot write, a
permission it does not have, a decision that is yours — the ask usually dies in a final
message nobody re-reads. The fix is not a new database: it is a `manual-task` label on
an issue in the affected repo, filed by the capture skill above.

`ol owed` lists those issues, and runs each one’s check before printing it:

```default
2 open, 1 discharged, 1 unknown
  owners: acme  |  predicates: 3 of 4

open   23d  acme/widget#1              CI secrets are not provisioned on this repo
          verify: gh secret list --repo acme/widget --json name -q '.[].name' | grep -qx PYPI_PASSWORD
                  -> exit 1
open    2d  acme/parser#4              Decide whether the parser ships in 2.0 or waits
          verify: (none)
                  -> none possible - a judgement call.
?      23d  acme/widget#9              Doctests depend on a live third-party HTTP fetch
          verify: python3 -m pytest -q --doctest-modules --pyargs widget.remote
                  -> the predicate timed out after 20s
done   30d  acme/engine#7              Add a deploy key so CI can clone the fixture repo
          verify: [ "$(gh api repos/acme/engine/keys --jq length)" -gt 0 ]
                  -> exit 0
```

| Printed   | State        | Means                                                                                               |
|-----------|--------------|-----------------------------------------------------------------------------------------------------|
| `open`    | `open`       | no predicate at all, or the predicate ran and returned non-zero                                     |
| `done`    | `discharged` | the predicate returned `0` — the ask is finished, the issue is merely still open                    |
| `?`       | `unknown`    | nothing could be checked: no `gh`, no network, a timeout, an untrusted owner, a malformed predicate |

`done` rows are reported, never acted on. A passing predicate is evidence, not
authority; you close the issue, or you don’t. The read skill is told the same thing in
the same words, because a model that closes an issue on a shell exit status has written
a record nobody can audit back.

```bash
ol owed                     # list them, and re-check each one against the world
ol owed --no-verify         # list them, executing nothing — every predicate row reads ?
ol owed --owners acme,widgets
ol owed --limit 100
```

Writing the `**Verify:**` predicate is covered above, under the capture skill; so is
what it means that running one executes text out of an issue body.

---

## `ol blocked` — what is waiting on another repo, and what is not any more

The same shape, pointed at a repository instead of at a person.

An agent working in repo X finds that the real fix belongs in repo Y. It files in Y,
writes a workaround in X, and moves on. Y gets fixed. **X is never told.** Nobody was
blocking, nothing waited on a human, so the loop is invisible to `ol owed` — and the
workaround in X quietly becomes architecture, noticed months later only when somebody
asks why the code looks like that.

GitHub already models the edge, and it already crosses repositories: an issue’s
**blocked-by dependencies** name each blocker’s own repository, number and state. They
are a first-class GitHub field, set on the issue itself in GitHub’s own UI, not a
convention openloops invented — the representation costs nothing and exists today.
**What is missing is the harvest**: nothing notices when a blocker closes, because the
edge is queryable, not eventful.

`ol blocked` is that harvest:

```default
1 unblocked, 2 blocked, 0 unknown
  scope: acme, widgets  |  candidates: 4  |  1 had no dependency edge at all

ready    8d  acme/widget#109              Drop the rename workaround once the engine lands it
        blocked by: acme/engine#15 [closed]
                    -> free for 5d, and nothing has said so
waits    8d  acme/widget#27               Criteria carry a structured definition, not free text
        blocked by: acme/engine#61 [open]
waits    8d  acme/widget#34               Track the six facade gaps, and which phase each one blocks
        blocked by: widgets/aix#40 [open] widgets/aix#41 [open]
```

| Printed   | State       | Means                                                                                                                                                                       |
|-----------|-------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `ready`   | `unblocked` | every blocker is closed. The work can proceed and nobody has been told — **this is the row the command exists for.** It sorts first and says how many days it has been free |
| `waits`   | `blocked`   | at least one blocker is still open, and the row names which, with the foreign repository                                                                                    |
| `?`       | `unknown`   | the edges could not be resolved                                                                                                                                             |

`ready` is a finding, not an instruction. The workaround in X may still be the right
code; only you know that.

```bash
ol blocked                                  # search a fleet, resolve every edge
ol blocked --owners acme,widgets
ol blocked --repos acme/widget,acme/engine  # the audit path: enumerate these exactly
ol blocked --no-resolve                     # spend nothing; every row reads ?
ol blocked --limit 100
```

### The gotcha: discovery is a candidate list, not an answer

Finding the blocked issues has two implementations, and which one runs depends on
whether you passed `--repos`.

**Across a fleet** (the default): one `gh search issues` call carrying GitHub’s own
`is:blocked` qualifier. One request answers for every repository, which is what makes
this cheap enough to run at the start of a session. But the qualifier is a *search
index*, and a search index is not the dependency graph. Measured on one real fleet on
2026-08-27, scoped to three owners: `is:blocked` returned 15 open issues, of which **10
carried a blocked-by edge and 5 carried no dependency edge of any kind** — not a stale
edge, and four of the five carry no sub-issue relation either, simply none. Recall was perfect
on that fleet: all 10 were found, checked against a full per-repository enumeration of
821 open issues across 148 repositories. Precision was 10/15.

So every candidate is re-resolved against the dependency graph itself, and one with no
edge is dropped and *counted* — that is the `1 had no dependency edge at all` note in
the header. A discovery step that quietly disagrees with reality is the same class of
defect as a count that cannot say `?`.

**Per repository** (`--repos owner/name,owner/other`): one paginated listing per
repository, filtered on the dependency counts GitHub already returns on every row of
it. This reads the counts themselves rather than an index of them, so it cannot
over-report and cannot miss. It is the audit, not the daily command.

### What it costs

Resolving edges is **one API call per candidate** — there is no batch form. `--limit`
is that bound and it defaults to 50; one more than it is requested, so a candidate list
that saturates its own cap comes back marked `TRUNCATED` and the counts are then a
**floor**, never a total. At the default that is at most 51 requests against a
5000/hour limit. A 15-candidate fleet run took 41-48 s wall-clock across several runs here.

`--no-resolve` skips all of it and every row reads `?`, which is honest and free.

---

## `ol dashboard` — the same four registers as one page

```bash
ol dashboard --out board.html
```

All three answers rendered as one self-contained HTML document: no stylesheet, no
script, no font, no request to anywhere. It is the page you leave open on a second
monitor, or publish somewhere your phone can reach.

Being self-contained is what makes it publishable, and it is also its one limit: **a
page cannot re-check anything.** So it stamps the moment it was made and says
“snapshot” in its largest type, and its fourth register is `Unknown` — every `?`, with
why, including the envelope-level failures. An `owed` that could not list is not
“nothing owed”, and rendering that as a `0` would make the page worse than no page.
When the count really is zero the section names which checks earned it.

Everything printed goes through the same scrubber that guards the digests, plus HTML
escaping and a scheme allowlist on every link. `--fragment` drops the document scaffold
for a host that supplies its own `<head>`.

---

## Turning it off

Nothing here runs unless you type it, or an agent you installed a skill for does. There
is one exception, and it is opt-in: `ol install-job` puts the digest sync on a timer.
Check with `ol job-status`; if it says the plist is absent, nothing of openloops is
running in the background at all.

```bash
ol uninstall-job            # stop the timer, if you ever installed it
rm -rf ~/.claude/skills/openloops ~/.claude/skills/openloops-needs-human \
       ~/.claude/agents/openloops-sweep.md      # the skills are three symlinks
pip uninstall openloops     # remove the package
```

`pip uninstall` leaves your digests where they are — `~/.local/share/openloops/` by
default, or wherever `OPENLOOPS_DATA_DIR` points — because deleting a record is not part
of removing a tool. Delete that directory yourself if you want the data gone too.

Nothing needs undoing on GitHub. openloops never wrote anything there: no issue was
created, closed, relabelled or commented on, and the `**Verify:**` lines in your issue
bodies were put there by whoever wrote them, not by this package.

## Which repositories all this is about

`ol owed` and `ol blocked` answer about the same fleet, resolved the same way, so the
two can never quietly disagree about which repositories exist. Three sources, first one
wins:

1. `OPENLOOPS_OWNERS` — comma- or space-separated (or `--owners` on either command).
2. The owners named by a `gh` alias called `owe`, if you have one. A fleet of several
   organisations is usually already written down there, and disagreeing with the thing
   you actually type is how a count goes quietly wrong.
3. The login `gh` is authenticated as.

Whichever it was is printed on every result, so a partial answer is a visible one. It is
worth setting explicitly: the fallback is your personal login, and if you work across an
organisation, a count that only ever saw your own repositories looks clean and is wrong.
The read skill is told to check that line and say so.

## Python API

```python
import openloops

openloops.sync()  # read transcripts → write digests
for row in openloops.ls(state="open"):  # what your sessions left open
    print(row["session"], row["title"])

print(openloops.show("2b1f")["text"])  # one digest, in full

report = openloops.owed()  # open `manual-task` issues, each re-checked
print(report["counts"])  # {'open': 2, 'discharged': 1, 'unknown': 1, ...}

report = openloops.blocked()  # cross-repo blocker edges, every one resolved
print(
    [
        r["repo"] + "#" + str(r["number"])
        for r in report["rows"]
        if r["state"] == "unblocked"
    ]
)

openloops.install_skills(dry_run=True)  # the plan, having touched nothing
```

`owed()` and `blocked()` return an **envelope, never a bare list** — because a caller
has to be able to tell “nothing is owed” from “I could not find out”:

```python
{
    "listed": True,  # False ⇒ the listing/discovery itself failed; the counts mean nothing
    "checked": True,  # blocked() calls this "resolved"
    "error": "",
    "truncated": False,  # the result set hit its cap: the counts are a floor
    "owners": [...],
    "counts": {...},
    "rows": [...],
}  # every row carries every documented key, present and empty if unset
```

Check `listed` before you read `counts`. That is the whole contract.

What a person asked, and what came back, is read out of a transcript as facts, with no model:

```python
from openloops.exchanges import exchanges, load_records

for e in exchanges(load_records(path)):  # one per prompt, in time order
    if e.questions:  # the sentences of a person's prompt that ask something
        print(e.questions, "->", e.reply[:80])  # the reply its turn ended with
```

`origin` says who started the turn: `human`, `peer` (another session’s message, `sender` names it) or `system` (a notification or a headless run). Only a human prompt is searched for questions, and requests phrased as questions (“can you fix it?”), tags, headings, quotes and code are skipped. Deciding what a doubtful sentence means, or finding an answer given in another session, is left to the consumer ([crowsnest](https://github.com/thorwhalen/crowsnest) does both).

`openloops.tools` is the single dispatch list every surface goes through — the `ol`
command today, an MCP server or an HTTP endpoint later. Operations go there, never
straight into the CLI, which is what stops two surfaces from drifting apart.
`install_skills` is deliberately *not* in it: “symlink files into this machine’s agent
config” is not an operation a remote surface could honestly offer.

## The seams

Each is one keyword argument, and each defaults to a real implementation rather than a
stub wearing a keyword argument as a disguise:

```python
openloops.sync(
    transcript_source=my_sessions,  # any Mapping[str, Session]
    digests_store=my_store,  # any MutableMapping[str, str]
)

openloops.owed(
    issues_source=[...],  # a list of dicts, or any callable
    run_predicate=lambda cmd: 0,  # any callable from command to exit status
)

openloops.blocked(
    issues_source=[...],
    blockers_source={"acme/widget#12": [...]},  # or any callable of (repo, number)
)
```

`transcript_source=` defaults to a direct reader of Claude Code’s on-disk layout.
`digests_store=` defaults to a `dol` store over a directory of markdown files, with its
encoding pinned to UTF-8 and its delete made a real delete. Point it at an S3-backed
store, or at a git-synced directory shared between machines, and nothing else changes.

`issues_source=` and `run_predicate=` default to a `gh` search and a time-bounded
subshell; `blockers_source=` to the dependency-graph call. **With them injected, the
GitHub half needs no network and no `gh` on PATH** — which is how the whole of it is
tested.

Pass `state_dir=` too when you swap a digest seam in a test: the change-detection cache
is separate from both, and left alone it would record your fixture’s revisions in the
real one.

## Nothing leaves without being scrubbed

Transcripts are the highest-entropy secret source on a developer machine: pasted
tokens, `.env` contents, tracebacks full of absolute paths. A digest store can be a
synced git repository, so **a written digest is an export surface**, and the discipline
belongs before the first byte is written rather than before the first push.

- Absolute paths under your home directory are rewritten to `~`-relative.
- Credential-shaped text **raises**. It is never silently redacted, because a silent
  redaction teaches nobody that a secret was there. That session is skipped, the run
  says so and exits non-zero, and the error names the pattern class and offset without
  ever quoting the match.

The same two rules are what the capture skill applies to an issue title and body before
it files anything, through the same `openloops.egress` functions — so an agent that
gets blocked while holding a token in its context cannot publish it by accident, and
cannot mask it and file anyway either.

## Why not just read the transcripts, or use `gh` directly?

For the digests: `claude` shows you your live sessions, which is a different question —
and a better answer to it than this could be. The transcripts themselves answer the
question `ol` answers, right up until they are deleted. A digest is a few hundred bytes
and outlives the megabyte it came from.

For the other two: the query is one line of `gh` and always was. The whole of what
openloops adds is **the re-check** — running the predicate, resolving the edge — and
the refusal to round an unchecked row into an answer. That is the entire difference
between a list and a count you can act on.

There is one caveat worth knowing before you read a digest as a diary: a Claude Code
*session* is not a sitting. Sessions get resumed, so a single session can span days,
and a digest keyed on one is a digest of a thread of work rather than of an afternoon.

## The name

Two live collisions, both deliberate, both worth knowing before you search for it:

- **OpenLoops** is an established particle-physics one-loop amplitude library
  ([openloops.hepforge.org](https://openloops.hepforge.org)). It is not on PyPI, and it
  is what a web search will find first.
- **openloops** is also [a browser-history tool](https://github.com/sholajegede/openloops)
  that groups your browsing into what you were trying to do — an adjacent product using
  the same metaphor, in a different distribution channel.

Neither blocks `pip install openloops`. The command is `ol` because a read path that
costs eighteen characters to reach is a read path that does not get reached.

## Design tests

These are tests in the suite, not aspirations:

- **A digest is derived, never authored.** Delete the digest store and the cache, sync
  again, and every digest whose transcript still exists comes back byte-for-byte.
  Digests whose transcripts have since been collected are *retained* — that is the
  point of the tool — and `ol status` counts them separately.
- **The suite passes with no network, no credentials, and no `~/.claude` present.**
- **`import openloops` does not import the CLI library**, and opens no socket. The core
  has no opinion about how it is called, and a job on a plane still has to run.
- **Nothing in this repository carries an absolute home path or credential-shaped
  text** — checked mechanically, by the same code that scrubs your digests. The shipped
  skills are in the repository, so they are checked by it too.
- **No surface exposes an operation that writes.** Enforcement is by omission: there is
  no `close`, no `comment`, no `POST`. A test walks the source and fails the build if a
  mutating verb or a mutating `gh` argument appears anywhere in the package.
- **`ol install-skills` never overwrites anything.** An occupied destination reads
  `conflict` and is left exactly as it was; a second run of a clean install reads `ok`
  and writes nothing.
- **`unknown` never becomes `open`, `discharged`, `blocked` or `unblocked`**, and a
  listing that failed prints `?` rather than a count. Both are tested directly, because
  they are the two ways a tool like this becomes a liar.

## License

Apache-2.0.

<p class="epythet-aggregates">This documentation as a single file: <a href="openloops.md">openloops.md</a> (Markdown, for agents).</p>


# _autosummary/openloops.base.html.md

# openloops.base

Data structures shared across openloops: what a session is, and what a digest is.

Nothing here does I/O and nothing here makes a judgement. [`openloops.transcripts`](_autosummary/openloops.transcripts.html.md#module-openloops.transcripts)
reads sessions, `openloops._classify` judges them, [`openloops.digest`](_autosummary/openloops.digest.html.md#module-openloops.digest) renders
them, and all three speak in the types defined here.

The types are deliberately flat and JSON-shaped. A session read from Claude Code’s
on-disk state and a session supplied by some other reader are the same object to the
rest of the package — that is what makes `transcript_source=` a one-argument swap.

### Module Attributes

| [`OPEN`](_autosummary/openloops.base.html.md#openloops.base.OPEN)    | the session's own last word left something for the human.   |
|----------------------------------------------------------|-------------------------------------------------------------|
| [`ARCHIVE`](_autosummary/openloops.base.html.md#openloops.base.ARCHIVE) | the session's own last word closed out.                     |
| [`STATES`](_autosummary/openloops.base.html.md#openloops.base.STATES)  | The two loop states, in the order they are shown.           |

### Classes

| [`Digest`](_autosummary/openloops.base.html.md#openloops.base.Digest)(key, text, session_key, source, state)   | One rendered digest: its store key and its markdown text.                   |
|--------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------|
| [`Locator`](_autosummary/openloops.base.html.md#openloops.base.Locator)(type[, url, text, at])                  | A typed, human-readable pointer to something outside the digest.            |
| [`Session`](_autosummary/openloops.base.html.md#openloops.base.Session)(key[, title, ai_title, cwd, ...])       | What one Claude Code session's persisted state says, parsed but not judged. |
| [`Verdict`](_autosummary/openloops.base.html.md#openloops.base.Verdict)(state, reason[, cues, at, confidence])  | A loop-state judgement, with the rule that produced it and the cues it saw. |

### openloops.base.ARCHIVE *= 'archive'*

the session’s own last word closed out.

* **Type:**
  Loop state

### *class* openloops.base.Digest(key, text, session_key, source, state, verdict=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One rendered digest: its store key and its markdown text.

`text` is a pure function of the [`Session`](_autosummary/openloops.base.html.md#openloops.base.Session) it was built from. It carries
no generation timestamp, which is what lets the regeneration test in
`tests/test_sync.py` compare bytes rather than fields.

#### as_dict()

JSON-ready form (without the markdown body).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### *class* openloops.base.Locator(type, url='', text='', at='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A typed, human-readable pointer to something outside the digest.

The exportable half of ADR-015’s evidence split: a short typed reference that
another tool (or a person) can resolve, never a content hash and never a byte
offset into a file that is garbage-collected after thirty days.

```pycon
>>> Locator("pr", url="https://github.com/o/r/pull/1").as_dict()["type"]
'pr'
```

#### as_dict()

JSON-ready form.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### openloops.base.OPEN *= 'open'*

the session’s own last word left something for the human.

* **Type:**
  Loop state

### openloops.base.STATES *= ('open', 'archive')*

The two loop states, in the order they are shown.

### *class* openloops.base.Session(key, title='', ai_title='', cwd='', project='', git_branches=(), started_at='', ended_at='', last_turn_at='', last_user_prompt='', last_prompt_at='', last_assistant_text='', recap='', recap_at='', compaction='', compaction_at='', turn_count=0, model='', ended_mid_turn=False, ended_with_error=False, locators=())

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

What one Claude Code session’s persisted state says, parsed but not judged.

Every field is a *fact read from the transcript*, never an inference about the
world. `last_assistant_text` is what the session said last; it is not a claim
that the thing it describes is still true.

`key` is the session id. It is the only identifier openloops uses, and per
ADR-014 nothing in the model hangs off it: a digest is a view keyed by session,
not a record owned by one.

#### as_dict()

JSON-ready form (`asdict` already flattens the nested locators).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

#### compaction *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)* *= ''*

The context-compaction summary, a different and much longer thing than the recap,
covering only the part of the session that preceded it.

#### ended_mid_turn *: [bool](https://docs.python.org/3/builtins/functions.html#bool)* *= False*

The transcript’s final conversational record is an unanswered human prompt, or an
assistant tool call whose result never arrived — i.e. the session stopped mid-turn.

#### ended_with_error *: [bool](https://docs.python.org/3/builtins/functions.html#bool)* *= False*

The last thing the session said is a usage-limit or API-error banner rather than
the assistant’s own words: it was cut off, not finished.

#### *property* git_branch *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

The branch the session opened on — the one that names it, when several ran.

A third of sessions touch more than one branch, so a single value is a summary
rather than a fact; `git_branches` is what carries the truth.

#### *property* heading *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

its description, else its label.

* **Type:**
  What to call this session in prose

#### recap *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)* *= ''*

Claude Code’s own end-of-turn recap (its `away_summary` record) — one to three
sentences it wrote itself, usually naming what it thought came next. Reading it
is retention, not duplication: it was generated and billed once already, and it
disappears with the transcript.

### *class* openloops.base.Verdict(state, reason, cues=(), at='', confidence='high')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A loop-state judgement, with the rule that produced it and the cues it saw.

`reason` and `cues` exist so a reader can disagree. A classification whose
grounds are not shown is an assertion, and openloops does not make assertions
about sessions — it reports what they said and why it read them that way.

#### as_dict()

JSON-ready form.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

#### at *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)* *= ''*

When the text this verdict was read from was written. Usually the closing turn,
but the recap rule reads text written later, and a heading dated to the wrong
moment is exactly the thing ADR-005 forbids.

#### confidence *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)* *= 'high'*

`"high"` when a rule actually fired, `"low"` when the state is the default
and nothing in the transcript supported it. A classifier that hides which of the
two it did is claiming knowledge it does not have.


# _autosummary/openloops.blockers.html.md

# openloops.blockers

The other kind of open loop: the one that points at a repository, not at a person.

An agent working in repo X finds that the real fix belongs in repo Y. It files in Y,
writes a workaround in X, and moves on. Y gets fixed. **X is never told.** Nobody was
blocking, nothing waited on a human, so the loop is invisible to [`openloops.owed()`](_autosummary/openloops.html.md#openloops.owed)
— and the workaround in X quietly becomes architecture, noticeable months later only
when somebody asks why the code looks like that.

GitHub already models the edge, and it is already cross-repo: the
`dependencies/blocked_by` collection on an issue answers with each blocker’s own
repository, number and state. The representation costs nothing and exists today.
**What is missing is the harvest** — nothing notices when a blocker is resolved,
because the edge is queryable, not eventful. Closing an issue in Y emits no event that
anything in X is listening to, exactly as setting a secret emits none that
[`openloops.obligations`](_autosummary/openloops.obligations.html.md#module-openloops.obligations) could hear. This module is that harvest and nothing else.

[`blocked()`](_autosummary/openloops.blockers.html.md#openloops.blockers.blocked) lists the open issues across a fleet that carry a `blocked_by` edge,
resolves every edge, and reports **three** states — never two:

| `unblocked`   | every blocker is closed. *This is the row that matters*: the work<br/>can proceed and nobody has been told. It sorts first, and it<br/>carries `unblocked_days` — how long it has been free.   |
|---------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `blocked`     | at least one blocker is still open. The row names which, with the<br/>foreign repository, because “who am I waiting on” is the second<br/>question a reader has.                               |
| `unknown`     | the edges could not be resolved. Displayed as `?`.                                                                                                                                             |

`?` is not a rounding error, it is the whole point. A run that could not reach the
dependency graph must not report a clean board, so nothing here is allowed to collapse
`unknown` into `unblocked` or into `blocked`.

**Nothing here mutates anything.** An `unblocked` row is a finding, not an
instruction: the workaround in X may still be the right code, and only a human knows.
You are shown the edge, the blocker’s state and the date it closed, and you decide.

## Discovery is a candidate list, not an answer

Finding the blocked issues in the first place has two implementations here, and which
runs is decided by whether `repos=` is given.

**Across a fleet** (`owners=`, the default): one `gh search issues` call carrying
GitHub’s own `is:blocked` qualifier. One request answers for every repository, which
is what makes this cheap enough to run at the start of a session. But the qualifier is
a *search index*, and a search index is not the dependency graph. Measured on one real
fleet on 2026-08-27, scoped to three owners: `is:blocked` returned 15 open issues, of
which \*\*10 carried a `blocked_by` edge and 5 carried no dependency edge of any kind\*\*
— not a stale edge, and four of the five carry no sub-issue relation either (the fifth, checked, has two sub-issues, which is a correlation rather than an explanation, since its parent has no dependency edges), simply none. Recall
was perfect on that fleet: all 10 were found, checked against a full per-repository
enumeration of 821 open issues across 148 repositories. Precision was 10/15.

So a candidate is a *candidate*. Every one is re-resolved against
`dependencies/blocked_by`, which is the graph rather than an index of it, and one
with no edge is dropped and counted as `without_edges` — visible in the envelope and
printed by the CLI, because a discovery step that quietly disagrees with reality is the
same class of defect as a count that cannot say `?`. The recall figure is one
measurement on one fleet on one day and nothing here treats it as a guarantee, which
is what the second implementation is for.

**Per repository** (`repos=`): one paginated REST listing per repository, filtered on
the `issue_dependencies_summary` GitHub already returns on every row of it — its
`blocked_by` count is the blockers still open and `total_blocked_by` is all of
them. This reads the dependency counts themselves rather than an index of them, so it
cannot over-report and cannot miss; it is the per-repo enumeration ADR-005
`github-authoritative` requires of anything that must not lose a row, since the
search API is hard-capped at 1000 results. It costs one request per hundred open
issues in each repository you name, so it is the audit, not the daily command.

## The N+1, and what bounds it

Resolving edges is **one API call per candidate** — there is no batch form. `limit=`
is that bound and it defaults to `DFLT_CANDIDATE_LIMIT`; one more than it is
requested, so a candidate list that saturates its own cap comes back `truncated:
True` and every surface says so. When the bound is hit the count is a **floor**, never
a total: rows past the cap are not resolved, not guessed at, and not counted. At the
default that is at most 51 requests against a 5000/hour core limit, which per ADR-005
is read from each response’s own `X-Ratelimit-*` headers rather than from the
rate-limit endpoint — a claim this module keeps by never asking for the number at all.

**Kill criterion** (this ships on an argument, so it names in advance what would retire
it): if, ninety days after this ships, `counts['unblocked']` has never once been
non-zero on a real run, delete it. Its entire reason to exist over `gh search issues
-- 'is:blocked'` is finding the row that became free while nobody watched; with no
such row it is a slower alias that draws a table. Removal is one command — delete this
module and `tests/test_blockers.py`, and drop `blocked` from
`openloops.tools._dispatch_funcs`, from `openloops.__main__._commands` and from
`openloops.__init__`. Nothing imports it, nothing persists, and no other module
changes.

**What this is not.** It is a *reader*, like everything else here. No store, no schema,
no history, no event log, no local copy of the dependency graph: the edge on GitHub
**is** the record. Deleting this module loses no data.

```pycon
>>> from datetime import datetime, timezone
>>> candidates = [
...     {'number': 12, 'title': 'Drop the workaround once the engine lands',
...      'url': 'https://github.com/acme/widget/issues/12',
...      'createdAt': '2026-07-01T00:00:00Z',
...      'repository': {'nameWithOwner': 'acme/widget'}},
...     {'number': 13, 'title': 'Waiting on the parser rewrite',
...      'url': 'https://github.com/acme/widget/issues/13',
...      'createdAt': '2026-08-01T00:00:00Z',
...      'repository': {'nameWithOwner': 'acme/widget'}},
... ]
>>> edges = {
...     'acme/widget#12': [{'number': 15, 'state': 'closed',
...                         'closed_at': '2026-08-20T00:00:00Z',
...                         'repository': {'full_name': 'acme/engine'}}],
...     'acme/widget#13': [{'number': 7, 'state': 'open',
...                         'repository': {'full_name': 'acme/parser'}}],
... }
>>> report = blocked(                    # a dict and a dict: no gh, no network
...     issues_source=candidates,
...     blockers_source=edges,
...     now=datetime(2026, 8, 27, tzinfo=timezone.utc),
... )
>>> report['counts']['unblocked'], report['counts']['blocked']
(1, 1)
>>> for row in report['rows']:
...     print(row['state'], f"{row['repo']}#{row['number']}",
...           ' '.join(b['ref'] for b in row['blockers']))
unblocked acme/widget#12 acme/engine#15 [closed]
blocked acme/widget#13 acme/parser#7 [open]
```

The unblocked row says how long it has been free — the number nobody currently has:

```pycon
>>> report['rows'][0]['unblocked_days']
7
```

A candidate the dependency graph does not agree with is dropped, and the disagreement
is counted rather than hidden:

```pycon
>>> quiet = blocked(issues_source=candidates, blockers_source={},
...                 now=datetime(2026, 8, 27, tzinfo=timezone.utc))
>>> quiet['counts']['without_edges'], quiet['counts']['total']
(2, 0)
```

A run that could not resolve is never a clean board:

```pycon
>>> def unreachable(repo, number):
...     raise GhUnavailable('gh: not logged in')
>>> dark = blocked(issues_source=candidates, blockers_source=unreachable,
...                now=datetime(2026, 8, 27, tzinfo=timezone.utc))
>>> dark['counts']['unknown'], dark['counts']['unblocked']
(2, 0)
```

### Module Attributes

| [`UNBLOCKED`](_autosummary/openloops.blockers.html.md#openloops.blockers.UNBLOCKED)      | every blocker is closed.                                            |
|-----------------------------------------------------------------|---------------------------------------------------------------------|
| [`BLOCKED`](_autosummary/openloops.blockers.html.md#openloops.blockers.BLOCKED)        | at least one blocker is still open, and the row names which.        |
| [`BLOCKER_STATES`](_autosummary/openloops.blockers.html.md#openloops.blockers.BLOCKER_STATES) | The three states, in the order a reader cares about them.           |
| [`BLOCKED_FIELDS`](_autosummary/openloops.blockers.html.md#openloops.blockers.BLOCKED_FIELDS) | Every key on every row, whatever the issue's edges happened to say. |

### Functions

| [`blocked`](_autosummary/openloops.blockers.html.md#openloops.blockers.blocked)(\*[, resolve, owners, repos, query, ...])   | Open issues that carry a blocker edge, with every edge resolved.          |
|------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------|
| [`gh_blocked_by`](_autosummary/openloops.blockers.html.md#openloops.blockers.gh_blocked_by)(repo, number, \*[, timeout])          | The default `blockers_source`: the blocker edges out of one issue.        |
| [`gh_blocked_candidates`](_autosummary/openloops.blockers.html.md#openloops.blockers.gh_blocked_candidates)(\*[, owners, repos, ...])     | The default `issues_source`: open issues that *may* carry a blocker edge. |

### Classes

| [`BlockedIssue`](_autosummary/openloops.blockers.html.md#openloops.blockers.BlockedIssue)(repo, number, title, url, ...)    | One open issue that carries blocker edges, with the verdict those edges imply.   |
|-------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------|
| [`Blocker`](_autosummary/openloops.blockers.html.md#openloops.blockers.Blocker)(repo, number[, state, url, closed_at]) | One edge: the issue, in whatever repository it lives, that blocks another.       |

### Exceptions

| [`GhUnavailable`](_autosummary/openloops.blockers.html.md#openloops.blockers.GhUnavailable)   | The world could not be read: no `gh`, no credentials, no network, a timeout.   |
|------------------------------------------------------------------|--------------------------------------------------------------------------------|

### openloops.blockers.BLOCKED *= 'blocked'*

at least one blocker is still open, and the row names which.

* **Type:**
  Loop state

### openloops.blockers.BLOCKED_FIELDS *= ('repo', 'number', 'title', 'url', 'created', 'age_days', 'state', 'blockers', 'cross_repo', 'unblocked_days', 'evidence')*

Every key on every row, whatever the issue’s edges happened to say. A row shape that
varies by issue makes `row["blockers"]` a coin flip and a JSON schema a lie.

### openloops.blockers.BLOCKER_STATES *= ('unblocked', 'blocked', 'unknown')*

The three states, in the order a reader cares about them. `unknown` is
[`openloops.obligations.UNKNOWN`](_autosummary/openloops.obligations.html.md#openloops.obligations.UNKNOWN): not-checked means the same thing either way.

### *class* openloops.blockers.BlockedIssue(repo, number, title, url, created, age_days, state='unknown', blockers=(), cross_repo=False, unblocked_days=0, evidence='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One open issue that carries blocker edges, with the verdict those edges imply.

`blockers` is carried in full next to `state` so a reader can disagree with the
verdict rather than absorb it — the same rule `openloops.obligations.
Obligation` follows for its predicate.

```pycon
>>> BlockedIssue('acme/widget', 12, 't', 'u', '2026-01-01T00:00:00Z', 3).state
'unknown'
```

#### as_dict()

JSON-ready form, blockers included.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

#### cross_repo *: [bool](https://docs.python.org/3/builtins/functions.html#bool)* *= False*

At least one blocker lives in another repository — the shape this module is for.

#### unblocked_days *: [int](https://docs.python.org/3/builtins/functions.html#int)* *= 0*

how long the work
has been free while nobody was told. `0` for every other state.

* **Type:**
  Days since the last blocker closed, for an `unblocked` row

### *class* openloops.blockers.Blocker(repo, number, state='', url='', closed_at='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One edge: the issue, in whatever repository it lives, that blocks another.

`repo` is the *blocker’s* repository, and carrying it is the whole point — an
edge that did not name it would answer only the easy half of the question.

```pycon
>>> Blocker('acme/engine', 15, 'closed').ref
'acme/engine#15 [closed]'
>>> Blocker('acme/engine', 15).ref
'acme/engine#15 [?]'
>>> Blocker('', 15, 'closed').ref
'?#15 [closed]'
```

#### as_dict()

JSON-ready form, with [`ref`](_autosummary/openloops.blockers.html.md#openloops.blockers.Blocker.ref) materialised for surfaces that print it.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

#### *property* ref *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

`owner/name#number [state]` — how a blocker is written down.

A `?` in either half is a fact about what could be read, never a shrug: an
edge whose repository the payload did not name says so rather than borrowing
the blocked issue’s own repository and inventing a same-repo dependency.

### *exception* openloops.blockers.GhUnavailable

Bases: [`RuntimeError`](https://docs.python.org/3/builtins/exceptions.html#RuntimeError)

The world could not be read: no `gh`, no credentials, no network, a timeout.

Raised only by the listing path, and never propagated to a caller of `owed()` —
it becomes `listed: False` plus an `error`, which every surface must render as
`?`. Turning it into an empty list would be the one unrecoverable bug here.

### openloops.blockers.UNBLOCKED *= 'unblocked'*

every blocker is closed. The work can proceed and nobody has been told.

* **Type:**
  Loop state

### openloops.blockers.blocked(, resolve=True, owners=None, repos=(), query='is:blocked', limit=50, timeout=30.0, now=None, issues_source=None, blockers_source=None)

Open issues that carry a blocker edge, with every edge resolved.

The row that matters sorts first: `unblocked` — every blocker closed, the work
free to proceed, and nothing anywhere has said so. `blocked` rows name what they
are waiting on, foreign repository included. `unknown` means the edges could not
be read, and it is never rounded into either of the others.

`owners=` scopes the fleet-wide search (default: `openloops.obligations.
configured_owners()`, the same fleet `ol owed` answers about, so two commands never
quietly disagree about which repositories exist). `repos=` instead enumerates the
repositories you name exactly, which is the audit path.

`resolve=False` lists candidates without spending one API call each; every row
then reads `unknown`, because that is what is true about it.

Two seams, each one keyword argument, each defaulting to a real implementation:
`issues_source=` (defaults to [`gh_blocked_candidates()`](_autosummary/openloops.blockers.html.md#openloops.blockers.gh_blocked_candidates); a list of dicts
substitutes) and `blockers_source=` (defaults to [`gh_blocked_by()`](_autosummary/openloops.blockers.html.md#openloops.blockers.gh_blocked_by); a mapping
from `'owner/name#number'` to edges, or any callable of `(repo, number)`,
substitutes). With both injected this reaches no network and needs no `gh`.

Returns one envelope, never a bare list, because a reader has to be able to tell
“nothing is waiting” from “I could not find out”:

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

`listed`
: `False` when *discovery itself* failed. Every surface must render that as
  `?`. The counts are zeros and they mean nothing.

`resolved`
: whether edges were resolved at all.

`truncated`
: the candidate list saturated `limit`, so the counts are a floor.

`counts`
: `unblocked` / `blocked` / `unknown`, plus `cross_repo`, `candidates`
  (how many discovery offered), `without_edges` (how many of those the
  dependency graph disagreed about) and `total` (how many rows).

`rows`
: one dict per issue, with every key in [`BLOCKED_FIELDS`](_autosummary/openloops.blockers.html.md#openloops.blockers.BLOCKED_FIELDS).

```pycon
>>> report = blocked(issues_source=[], blockers_source={})
>>> report['listed'], report['counts']['total'], report['rows']
(True, 0, [])
```

Discovery that could not run is not “nothing is waiting”:

```pycon
>>> def broken(**query):
...     raise GhUnavailable('gh: not logged in')
>>> report = blocked(issues_source=broken)
>>> report['listed'], report['error'], report['counts']['total']
(False, 'gh: not logged in', 0)
```

### openloops.blockers.gh_blocked_by(repo, number, , timeout=30.0)

The default `blockers_source`: the blocker edges out of one issue.

One request per issue — the N+1 [`blocked()`](_autosummary/openloops.blockers.html.md#openloops.blockers.blocked) bounds with `limit=`. Returns the
rows GitHub sends, unparsed and unjudged; `_verdict()` is what reads a state
out of them. An issue with no edges answers `[]`, which is an answer.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]

### openloops.blockers.gh_blocked_candidates(, owners=(), repos=(), query='is:blocked', limit=50, timeout=30.0)

The default `issues_source`: open issues that *may* carry a blocker edge.

`repos=` chooses the exact per-repository enumeration; otherwise `owners=`
drives one fleet-wide search. Both return rows shaped the way `gh search issues`
shapes them — `createdAt`, `number`, `repository`, `title`, `url` — so
everything downstream reads one shape. Which one to use, and what each costs and
misses, is the “Discovery is a candidate list” section of this module’s docstring.

A large repository can take longer than `timeout` to paginate; that is a
[`GhUnavailable`](_autosummary/openloops.blockers.html.md#openloops.blockers.GhUnavailable), which reads `?`, not an empty answer.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]


# _autosummary/openloops.dashboard.html.md

# openloops.dashboard

One page you can look at instead of reading three command outputs.

[`render_dashboard()`](_autosummary/openloops.dashboard.html.md#openloops.dashboard.render_dashboard) takes what [`openloops.owed()`](_autosummary/openloops.html.md#openloops.owed), [`openloops.blocked()`](_autosummary/openloops.html.md#openloops.blocked)
and [`openloops.ls()`](_autosummary/openloops.html.md#openloops.ls) returned and renders a single self-contained HTML document —
no stylesheet, no script, no font and no request to anywhere. That is not decoration:
the page is meant to be published, and a published page runs no `gh`, shells out to
nothing and reaches no network.

So **the page is a snapshot, and it says so in its largest type.** This module inverts
the one rule [`openloops.digest`](_autosummary/openloops.digest.html.md#module-openloops.digest) holds to — a digest deliberately stamps no
generation time so that regenerating it is byte-stable — because here the generation
time is the whole claim. `made_at` is a required-in-practice argument rather than a
hidden `now()`, which is also what lets a test compare bytes.

The four registers are ordered the way a person needs them, and the fourth is the one
the package exists for:

1. **Needs you now** — obligations still open, each printing the predicate that decided
   it, unabbreviated, so a reader can disagree with the verdict rather than absorb it.
2. **Free to proceed** — blocker edges that have all closed, led by how many days the
   work has been free while nothing anywhere said so.
3. **In flight** — what the sessions left open, newest last turn first.
4. **Unknown** — every `?`, with why. It carries the envelope-level failures too: an
   `owed` that could not list is not “nothing owed”, and rendering it as `0` would
   make this page worse than no page. When the count really is zero the section says
   which checks earned that, because a clean board with no provenance is the same lie
   told quietly.

Everything this module prints goes through [`Sanitizer`](_autosummary/openloops.dashboard.html.md#openloops.dashboard.Sanitizer), which is
[`openloops.egress.scrub()`](_autosummary/openloops.egress.html.md#openloops.egress.scrub) plus HTML escaping plus a scheme allowlist on every link.
The one carve-out is the shared kit below: [`register()`](_autosummary/openloops.dashboard.html.md#openloops.dashboard.register) and [`rail()`](_autosummary/openloops.dashboard.html.md#openloops.dashboard.rail) \*\*escape
nothing\*\*. Every argument they take is markup, interpolated as given and some of it into
an unquoted attribute, because that is what a markup builder is; a caller that passes
anything it did not write itself puts it through [`Sanitizer`](_autosummary/openloops.dashboard.html.md#openloops.dashboard.Sanitizer) first. This module
passes literals. A page that carries a
repository name and an issue title is fine; one that carries a home path or a token is
the failure `openloops.egress` exists to prevent, and this renderer scrubs its input
rather than trusting it. A credential-shaped field is *withheld and counted*, never
silently dropped — the count is printed in the footer.

`CSS`, [`Sanitizer`](_autosummary/openloops.dashboard.html.md#openloops.dashboard.Sanitizer), [`register()`](_autosummary/openloops.dashboard.html.md#openloops.dashboard.register) and [`rail()`](_autosummary/openloops.dashboard.html.md#openloops.dashboard.rail) are public for
sibling renderers — a tool that shows a different half of the same picture and wants to
look like this page — so that the look, the egress rule and the markup the stylesheet
dresses stay in one place instead of being copied and drifting. They are the shared kit:
the stylesheet here is what makes `register--needs` a colour and `rail` a column, so
a package that writes those class names by hand is one rename away from a broken page.

```pycon
>>> html = render_dashboard({}, {}, [], made_at='2026-01-01T00:00:00Z')
>>> '<title>' in html and 'snapshot' in html
True
```

### Module Attributes

| [`DFLT_TITLE`](_autosummary/openloops.dashboard.html.md#openloops.dashboard.DFLT_TITLE)        | What the page is called — in the tab, in a gallery, and in its own masthead.   |
|--------------------------------------------------------------------|--------------------------------------------------------------------------------|
| [`DFLT_MAX_SESSIONS`](_autosummary/openloops.dashboard.html.md#openloops.dashboard.DFLT_MAX_SESSIONS) | How many session rows are printed.                                             |
| [`GAUGE_FULL_DAYS`](_autosummary/openloops.dashboard.html.md#openloops.dashboard.GAUGE_FULL_DAYS)   | Where the age gauge tops out.                                                  |

### Functions

| [`headline_counts`](_autosummary/openloops.dashboard.html.md#openloops.dashboard.headline_counts)(owed, blocked, sessions)           | The four figures across the top of the page.                                    |
|-----------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------|
| [`rail`](_autosummary/openloops.dashboard.html.md#openloops.dashboard.rail)(chip, tone, age[, unit, extra])               | The fixed left column of every row: what state it is in, and for how long.      |
| [`register`](_autosummary/openloops.dashboard.html.md#openloops.dashboard.register)(\*, ident, name, figure, tone, rule, ...) | One band: a heading, the count in the largest figure on the page, and its rule. |
| [`render_dashboard`](_autosummary/openloops.dashboard.html.md#openloops.dashboard.render_dashboard)(owed, blocked, sessions, \*)      | The three envelopes as one self-contained HTML page.                            |
| [`unchecked_count`](_autosummary/openloops.dashboard.html.md#openloops.dashboard.unchecked_count)(owed, blocked)                     | How many OBLIGATIONS could not be checked against the world.                    |
| [`unknown_count`](_autosummary/openloops.dashboard.html.md#openloops.dashboard.unknown_count)(owed, blocked, sessions)             | How many things read `?`, or `None` when even that cannot be counted.           |

### Classes

| [`Sanitizer`](_autosummary/openloops.dashboard.html.md#openloops.dashboard.Sanitizer)([aliases])   | Scrub, then escape.   |
|-------------------------------------------------------------------------|-----------------------|

### openloops.dashboard.DFLT_MAX_SESSIONS *= 40*

How many session rows are printed. The register states the true total either way; a
page that showed all 150-odd would bury the two registers above it.

### openloops.dashboard.DFLT_TITLE *= 'Open Loops Board'*

What the page is called — in the tab, in a gallery, and in its own masthead.

### openloops.dashboard.GAUGE_FULL_DAYS *= 90*

Where the age gauge tops out. Ninety days is the point past which the length of the
bar stops being informative and the number beside it is doing all the work.

### *class* openloops.dashboard.Sanitizer(aliases=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Scrub, then escape. The single path from an envelope to the document.

Two failures are possible and they are treated differently, exactly as
[`openloops.egress`](_autosummary/openloops.egress.html.md#module-openloops.egress) prescribes. A home path is *rewritten* — it is an identifier
and the tail is the part a reader needs. A credential is *withheld and counted*: the
field is replaced by a visible notice naming the pattern class, never the text, and
`withheld` is printed in the footer so the run reports it rather than quietly
losing a field.

```pycon
>>> s = Sanitizer()
>>> s.text('a < b')
'a &lt; b'
>>> s.text('token=' + 'ghp_' + 'A' * 36)
'[withheld: credential-shaped text (github_token)]'
>>> s.withheld
['github_token']
```

#### text(value)

One field, safe to place in the document.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

#### url(value)

A link target, or `''` when it is not one this page will follow.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### openloops.dashboard.headline_counts(owed, blocked, sessions)

The four figures across the top of the page. `None` is `?`, never a zero.

Shared by the masthead and by [`openloops.tools.dashboard()`](_autosummary/openloops.tools.html.md#openloops.tools.dashboard), so the number a
caller reads back is the number the page printed.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`int`](https://docs.python.org/3/builtins/functions.html#int) | [`None`](https://docs.python.org/3/builtins/constants.html#None)]

```pycon
>>> headline_counts({'listed': True, 'counts': {'open': 2}, 'rows': []},
...                 {'listed': False}, [])['free_to_proceed'] is None
True
```

### openloops.dashboard.rail(chip, tone, age, unit='d', , extra='')

The fixed left column of every row: what state it is in, and for how long.

Shared kit (see the module docstring): `CSS` styles `rail`, `chip`,
`chip--<tone>` and `age`, so a sibling renderer that builds this by hand breaks
the next time a class name here changes.

`age` is the figure beside the unit — a number, or `None` for the `?` that
means nobody knows. It is not required to be a day count: pass a string and a
`unit` of your own for a page whose durations run from seconds to days.

`extra` is further markup placed between the state chip and the age — a second
chip a sibling page needs and this one has no equivalent of.

Nothing here is escaped, `extra` least of all: see the module docstring.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> rail("owed", "needs", 3)
'<div class="rail"><span class="chip chip--needs">owed</span><span class="age"><b>3</b><i>d</i></span></div>'
```

### openloops.dashboard.register(, ident, name, figure, tone, rule, body, folds=False, start_open=False, extra='')

One band: a heading, the count in the largest figure on the page, and its rule.

Shared kit (see the module docstring): `CSS` styles `register`,
`register--<tone>` and `register-head`, laying the head out as `auto 1fr` —
the figure in the first column, the heading and the rule in the second.

`folds` renders the band as a `<details>` a person can close, `start_open`
opening it anyway; both default off, which is the plain `<section>` this page has
always rendered. A `<summary>` may hold phrasing content and a heading only, so
the folding head carries the figure and the rule as `<span>``s rather than
``<p>``s. :data:`CSS` places those three flat children into the same ``auto 1fr`
grid a plain section’s head uses (figure down column one across both rows, heading
and rule down column two) and restores the disclosure affordance a
`display:grid` summary would otherwise cost it (#13).
`start_open` is ignored when `folds` is false — there is no disclosure to open —
because a caller decides `folds` from whether it has rows and passes both.

A band with nothing in it is not worth folding — there is nothing to hide — so that
decision belongs to the caller, which knows whether its body is rows.

`extra` is markup that heads the band’s body. A `<summary>` may not hold
interactive content and what a caller puts here is usually a control, so in the
`<details>` it goes inside the body; in the `<section>` it stays in the head,
under the rule. That is above the head’s hairline in one form and below it in the
other, which is how the caller that needs it already reads.

Nothing here is escaped: see the module docstring. `ident` and `tone` reach an
unquoted attribute, so they are the two that must be literals or already safe.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> register(ident="x", name="Needs you", figure="2", tone="needs", rule="now", body="")
'<section class="register register--needs" id="x"><div class="register-head"><p class="figure">2</p><div><h2>Needs you</h2><p class="rule">now</p></div></div></section>'
>>> register(ident="x", name="Quiet", figure="9", tone="done", rule="why", body="",
...          folds=True, start_open=True)
'<details class="register register--done" id="x" open><summary class="register-head"><span class="figure">9</span><h2>Quiet</h2><span class="rule">why</span></summary></details>'
```

### openloops.dashboard.render_dashboard(owed, blocked, sessions, , made_at=None, source='', title='Open Loops Board', max_sessions=40, standalone=True, aliases=None)

The three envelopes as one self-contained HTML page. No network, no script.

`owed` and `blocked` are the envelopes [`openloops.owed()`](_autosummary/openloops.html.md#openloops.owed) and
[`openloops.blocked()`](_autosummary/openloops.html.md#openloops.blocked) return; `sessions` is the list [`openloops.ls()`](_autosummary/openloops.html.md#openloops.ls)
returns. All three may be empty mappings, and an envelope whose `listed` is
`False` renders as `?` throughout rather than as zero.

`made_at` is the moment the snapshot was taken and is printed in the largest type
on the page. It defaults to now, but a caller that wants a byte-stable page passes
it — this is the one module in openloops that stamps a generation time, and it does
so because the whole claim of the page is *when*.

`standalone` wraps the output in a document scaffold for a file you open yourself.
Pass `False` for a host that supplies its own `<head>` — a published artifact
does — and the same page comes back as title, styles and content only.

`max_sessions` bounds the in-flight register; the true total is stated either way.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> page = render_dashboard(
...     {'listed': False, 'error': 'gh: not logged in', 'counts': {}},
...     {'listed': True, 'counts': {'unblocked': 0, 'total': 0}, 'rows': []},
...     [], made_at='2026-01-01T00:00:00Z')
>>> 'gh: not logged in' in page and 'could not' in page
True
```

### openloops.dashboard.unchecked_count(owed, blocked)

How many OBLIGATIONS could not be checked against the world. `None` if unknowable.

Deliberately narrower than [`unknown_count()`](_autosummary/openloops.dashboard.html.md#openloops.dashboard.unknown_count), and the two must not be merged in
the masthead. A `?` on an obligation means *the world could not be reached* — a
timeout, a missing `gh`, an untrusted owner. A low-confidence session digest means
something else entirely: the digest half checks nothing against the world by design,
so it has nothing it was unable to check. Rolling forty of the second into the first
reports a crisis of forty when the real number is one, and in the alarming direction.

* **Return type:**
  [`int`](https://docs.python.org/3/builtins/functions.html#int) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

```pycon
>>> unchecked_count({'listed': True, 'rows': [{'state': 'unknown'}]},
...                 {'listed': True, 'rows': []})
1
>>> unchecked_count({'listed': False}, {'listed': True, 'rows': []}) is None
True
```

### openloops.dashboard.unknown_count(owed, blocked, sessions)

How many things read `?`, or `None` when even that cannot be counted.

A register that never listed could be hiding any number of unknown rows, so the
headline figure for unknowns is itself unknown. Reporting `2` there — the two
failures we happen to know about — is the exact shape of the lie this page is
against, and it is a lie a reader has no way to spot.

It counts the rows that read `?`, not the entries in the list below: one entry
can stand for forty sessions, and reporting `1` for those would be the tidier
number rather than the true one.

* **Return type:**
  [`int`](https://docs.python.org/3/builtins/functions.html#int) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

```pycon
>>> unknown_count({'listed': True, 'rows': []}, {'listed': True, 'rows': []}, [{}])
0
>>> lows = [{'confidence': 'low'}] * 5
>>> unknown_count({'listed': True, 'rows': []}, {'listed': True, 'rows': []}, lows)
5
>>> unknown_count({'listed': False}, {'listed': True, 'rows': []}, [{}]) is None
True
```


# _autosummary/openloops.digest.html.md

# openloops.digest

Rendering one session into one dated markdown digest. No model, no network.

The whole of the release’s honesty rule lives in this module:

> **A digest states what the session said, dated — never what is currently true.**

So every heading here carries the timestamp of the thing under it, the loop state is
phrased as a reading of the transcript rather than a claim about the world, and the
front matter says `verified: false` because nothing has been checked against
anything. ADR-015’s failure mode — a snapshot of local state that was already wrong on
two of four fields, published and handed to a consumer that acted on it — is what these
rules exist to prevent, and the way to prevent it is to never make the claim.

[`render()`](_autosummary/openloops.digest.html.md#openloops.digest.render) is a pure function of its [`Session`](_autosummary/openloops.base.html.md#openloops.base.Session) and
[`Verdict`](_autosummary/openloops.base.html.md#openloops.base.Verdict). It stamps no generation time, which is what allows
`tests/test_sync.py` to delete the entire store, regenerate, and compare bytes.

### Module Attributes

| [`SECTION_LIMITS`](_autosummary/openloops.digest.html.md#openloops.digest.SECTION_LIMITS)   | How much of each section is kept.   |
|-------------------------------------------------------------------|-------------------------------------|

### Functions

| [`render`](_autosummary/openloops.digest.html.md#openloops.digest.render)(session, verdict, \*, source)             | The markdown for one digest.       |
|---------------------------------------------------------------------------------------------------|------------------------------------|
| [`make_digest`](_autosummary/openloops.digest.html.md#openloops.digest.make_digest)(session, verdict, \*, source[, ...]) | Render, scrub, and key one digest. |

### openloops.digest.SECTION_LIMITS *: [Mapping](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [int](https://docs.python.org/3/builtins/functions.html#int)]* *= {'compaction': 3000, 'last_assistant_text': 4000, 'last_user_prompt': 1200, 'recap': 1500}*

How much of each section is kept. Bounds are deterministic so regeneration is
byte-stable; they exist because a digest is meant to be read, not archived whole.

### openloops.digest.make_digest(session, verdict, , source, aliases=None)

Render, scrub, and key one digest.

Raises [`CredentialFound`](_autosummary/openloops.egress.html.md#openloops.egress.CredentialFound) when the rendered text matches a
credential pattern — deliberately, so the caller skips that session loudly rather
than writing a secret into a store that may be synced.

* **Return type:**
  [`Digest`](_autosummary/openloops.base.html.md#openloops.base.Digest)

```pycon
>>> from openloops.base import Session, Verdict
>>> d = make_digest(Session(key='s1'), Verdict('open', 'why'), source='demo')
>>> d.key
'demo/open/s1.md'
```

### openloops.digest.render(session, verdict, , source)

The markdown for one digest. Pure, dated, and bounded.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> from openloops.base import Session, Verdict
>>> s = Session(key='s1', project='proj', started_at='T0', last_turn_at='T1',
...             last_assistant_text='Shipped it.', turn_count=3)
>>> verdict = Verdict('archive', 'its closing lines declare it finished')
>>> text = render(s, verdict, source='demo')
>>> text.splitlines()[0]
'---'
>>> 'state: archive' in text
True
>>> render(s, verdict, source='demo') == text     # pure: same in, same out
True
```


# _autosummary/openloops.egress.html.md

# openloops.egress

The egress choke point: nothing leaves openloops carrying a home path or a secret.

A digest is derived from a transcript, and transcripts are the highest-entropy secret
source on a developer’s machine — pasted tokens, `.env` contents, tracebacks full of
absolute paths. A digest store can be a git-synced private repository, so \*\*a written
digest is an export surface\*\*, and the moment to apply that discipline is before the
first byte is written rather than before the first push.

Two rules, and they are deliberately asymmetric:

- **Paths are rewritten**, never raised on. A path is an identifier, not a secret, and
  the tail of it is the part a reader needs. This machine’s home becomes `~`; \*any
  other\* home — a server’s root home, a colleague’s, a CI runner’s — becomes
  `~other`, keeping the tail and dropping the identity.
- **Credentials raise.** [`scrub()`](_autosummary/openloops.egress.html.md#openloops.egress.scrub) never silently redacts a secret, because a silent
  redaction teaches nobody that a secret was there. It raises [`CredentialFound`](_autosummary/openloops.egress.html.md#openloops.egress.CredentialFound),
  the caller skips that one session, and the run reports it — loudly, and without ever
  quoting the matched text.

Two shapes are easy to miss and both were, at first. Claude Code encodes a working
directory into a directory name by turning `/`, `_` and `.` into `-`, so a home
path appears throughout transcripts in a dashed form — trivially reversible and just
as identifying. And a transcript from an ssh session names *that* machine’s home,
not this one’s, so rewriting only `$HOME` leaves every foreign home intact. Both forms
are handled here, and [`scrub()`](_autosummary/openloops.egress.html.md#openloops.egress.scrub) asserts its own postcondition afterwards rather than
trusting that it did.

The same rules apply to openloops’ own repository, which is why
`tests/test_egress_repo.py` runs [`scan_files()`](_autosummary/openloops.egress.html.md#openloops.egress.scan_files) over everything the build ships.
One implementation, two configurations.

```pycon
>>> scrub("see /nowhere/at/all/x.py", aliases={"/nowhere/at/all": "~/code"})
'see ~/code/x.py'
>>> scrub("token=" + "ghp_" + "A" * 36)
Traceback (most recent call last):
    ...
openloops.egress.CredentialFound: credential-shaped text (github_token) at offset 6
```

### Module Attributes

| [`CREDENTIAL_PATTERNS`](_autosummary/openloops.egress.html.md#openloops.egress.CREDENTIAL_PATTERNS)   | Named credential patterns, checked in order.   |
|------------------------------------------------------------------------|------------------------------------------------|

### Functions

| [`apply_aliases`](_autosummary/openloops.egress.html.md#openloops.egress.apply_aliases)(text[, aliases])       | Replace each alias at a path boundary, longest key first.                                                             |
|---------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|
| [`default_aliases`](_autosummary/openloops.egress.html.md#openloops.egress.default_aliases)()                    | The rewrites applied when a caller supplies none: this machine's home → `~`.                                          |
| [`find_credentials`](_autosummary/openloops.egress.html.md#openloops.egress.find_credentials)(text)               | Every credential-pattern hit, as `(pattern_name, offset)` pairs.                                                      |
| [`find_absolute_paths`](_autosummary/openloops.egress.html.md#openloops.egress.find_absolute_paths)(text[, aliases]) | Home paths — literal or encoded — still present after rewriting, with offsets.                                        |
| [`rewrite_paths`](_autosummary/openloops.egress.html.md#openloops.egress.rewrite_paths)(text[, aliases])       | Apply the aliases, then rewrite every home path they did not cover.                                                   |
| [`scan`](_autosummary/openloops.egress.html.md#openloops.egress.scan)(text, \*[, aliases, where])     | Report egress violations instead of raising — for auditing many files at once.                                        |
| [`scan_files`](_autosummary/openloops.egress.html.md#openloops.egress.scan_files)(paths, \*[, aliases])     | Run [`scan()`](_autosummary/openloops.egress.html.md#openloops.egress.scan) over each readable text file, accumulating every problem. |
| [`scrub`](_autosummary/openloops.egress.html.md#openloops.egress.scrub)(text, \*[, aliases, where])    | Rewrite paths and raise on credentials.                                                                               |

### Exceptions

| [`CredentialFound`](_autosummary/openloops.egress.html.md#openloops.egress.CredentialFound)(pattern_name, offset, \*[, where])   | Raised when text about to be written matches a credential pattern.   |
|-------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------|

### openloops.egress.CREDENTIAL_PATTERNS *: [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [Pattern](https://docs.python.org/3/library/re.html#re.Pattern)], ...]* *= (('aws_access_key_id', re.compile('(?<![A-Z0-9])(?:AKIA|ASIA)[0-9A-Z]{16}(?![A-Z0-9])')), ('aws_secret_access_key', re.compile('(?i)aws_secret_access_key\\\\s\*[:=]\\\\s\*\\\\S{20,}', re.IGNORECASE)), ('github_token', re.compile('\\\\bgh[pousr]_[A-Za-z0-9]{36,}\\\\b')), ('github_pat', re.compile('\\\\bgithub_pat_[A-Za-z0-9_]{22,}\\\\b')), ('gitlab_token', re.compile('\\\\bglpat-[A-Za-z0-9_\\\\-]{20,}\\\\b')), ('anthropic_api_key', re.compile('\\\\bsk-ant-[A-Za-z0-9_\\\\-]{20,}\\\\b')), ('openai_api_key', re.compile('\\\\bsk-(?!ant-)[A-Za-z0-9_\\\\-]{20,}\\\\b')), ('stripe_key', re.compile('\\\\b[srp]k_(?:live|test)_[A-Za-z0-9]{16,}\\\\b')), ('npm_token', re.compile('\\\\bnpm_[A-Za-z0-9]{30,}\\\\b')), ('pypi_token', re.compile('\\\\bpypi-[A-Za-z0-9_\\\\-]{32,}')), ('huggingface_token', re.compile('\\\\bhf_[A-Za-z0-9]{30,}\\\\b')), ('sendgrid_key', re.compile('\\\\bSG\\\\.[A-Za-z0-9_\\\\-]{20,}\\\\.[A-Za-z0-9_\\\\-]{20,}\\\\b')), ('slack_token', re.compile('\\\\bxox[baprs]-[A-Za-z0-9\\\\-]{10,}\\\\b')), ('google_api_key', re.compile('\\\\bAIza[0-9A-Za-z_\\\\-]{35}\\\\b')), ('google_oauth_refresh_token', re.compile('\\\\b1//0[A-Za-z0-9_\\\\-]{20,}\\\\b')), ('json_web_token', re.compile('\\\\beyJ[A-Za-z0-9_\\\\-]{10,}\\\\.eyJ[A-Za-z0-9_\\\\-]{10,}\\\\.')), ('private_key_block', re.compile('-----BEGIN [A-Z ]\*PRIVATE KEY-----')), ('url_inline_password', re.compile('\\\\b[a-z][a-z0-9+.\\\\-]\*://[^/\\\\s:@]+:[^/\\\\s@]{6,}@')), ('secret_assignment', re.compile('(?i)(?<![A-Za-z0-9])(?:[A-Za-z0-9]+[_-])\*(?:api[_-]?key|secret[_-]?\\\\w\*|passwd|password|access[_-]?token|auth[_-]?token|token|credential\\\\w\*)\\\\b\\\\s\*[:=]\\\\s\*[\\'\\\\"]?((?=[^\\'\\\\"\\\\s]\*[A-Za-z])(?=[^\\'\\\\", re.IGNORECASE)))*

Named credential patterns, checked in order. Most are well-known vendor prefixes,
which give near-zero false positives; the assignment rule at the end is the only
heuristic among them, and the URL rule catches the shape a prefix list cannot.

### *exception* openloops.egress.CredentialFound(pattern_name, offset, , where='')

Bases: [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError)

Raised when text about to be written matches a credential pattern.

The message names the pattern class and the offset. It never contains the matched
text: an exception that quotes a secret has moved the secret into a log file.

### openloops.egress.apply_aliases(text, aliases=None)

Replace each alias at a path boundary, longest key first. Nothing else.

Longest-first matters: a home directory and a projects directory beneath it are both
aliases, and the more specific one must win. Boundary-anchoring matters for the
opposite reason: a bare `str.replace` turns a *longer* sibling name into a
half-rewritten mess — a home of `…/bob` would mangle `…/bobby` into `~by`,
corrupting the path and leaving half the other name behind.

Separate from [`rewrite_paths()`](_autosummary/openloops.egress.html.md#openloops.egress.rewrite_paths) because auditing and scrubbing want different
things: an audit must still be able to *see* a foreign home path in order to report
it, which it could not if every caller had already rewritten one away.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> apply_aliases("/a/b/c and /a", {"/a": "~", "/a/b": "$B"})
'$B/c and ~'
>>> apply_aliases("/a2/x", {"/a": "~"})
'/a2/x'
```

### openloops.egress.default_aliases()

The rewrites applied when a caller supplies none: this machine’s home → `~`.

Both spellings of it, because the encoded form appears in transcripts as often as
the literal one.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

```pycon
>>> sorted(set(default_aliases().values()))
['~']
```

### openloops.egress.find_absolute_paths(text, aliases=None)

Home paths — literal or encoded — still present after rewriting, with offsets.

The example builds its path by concatenation and compares rather than printing,
because this module’s own source is scanned by the rule it implements: a literal
home-rooted path in a docstring would violate the thing being documented.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`int`](https://docs.python.org/3/builtins/functions.html#int)]]

```pycon
>>> home = "/Us" + "ers/someone"
>>> [p for p, _ in find_absolute_paths(home + "/x", aliases={})] == [home]
True
>>> find_absolute_paths(home + "/x", aliases={home: "~"})
[]
```

### openloops.egress.find_credentials(text)

Every credential-pattern hit, as `(pattern_name, offset)` pairs.

The matched text is never returned — only where it is and what it looked like.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`int`](https://docs.python.org/3/builtins/functions.html#int)]]

```pycon
>>> find_credentials("nothing to see here")
[]
>>> find_credentials("AKIA" + "B" * 16)
[('aws_access_key_id', 0)]
```

### openloops.egress.rewrite_paths(text, aliases=None)

Apply the aliases, then rewrite every home path they did not cover.

A transcript from an ssh session names *that* machine’s home, and a CI traceback
names a runner’s; neither is covered by an alias derived from this process. They are
rewritten to `FOREIGN_HOME`, which keeps the tail of the path and drops the
part that identifies somebody.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> rewrite_paths("/ro" + "ot/py/x", aliases={})
'~other/py/x'
>>> rewrite_paths("-Us" + "ers-someone-proj", aliases={})
'~other-proj'
```

### openloops.egress.scan(text, , aliases=None, where='')

Report egress violations instead of raising — for auditing many files at once.

Returns a list of human-readable problem descriptions, empty when the text is clean.
[`scrub()`](_autosummary/openloops.egress.html.md#openloops.egress.scrub) is what production code calls; this is what the repository’s own egress
test calls, because a test that stopped at the first violation would take one run per
problem to converge.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

```pycon
>>> scan("all clear")
[]
```

### openloops.egress.scan_files(paths, , aliases=None)

Run [`scan()`](_autosummary/openloops.egress.html.md#openloops.egress.scan) over each readable text file, accumulating every problem.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### openloops.egress.scrub(text, , aliases=None, where='')

Rewrite paths and raise on credentials. The only way text leaves openloops.

`where` is a caller-supplied label (a session id, a file name) carried into the
exception so a failed run can say *which* input tripped, without quoting it.

The path check runs on the **output**, not the input, because `aliases` is
caller-supplied: a rewrite can in principle produce a home path as easily as remove
one, and a postcondition that is only true of benign inputs is not a postcondition.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> scrub("built /nowhere/proj/x", aliases={"/nowhere/proj": "$PROJ"})
'built $PROJ/x'
```


# _autosummary/openloops.html.md

# openloops

What your Claude Code sessions were doing — kept after the transcripts are gone.

Claude Code writes a JSONL transcript for every session and deletes it after about a
month. openloops reads those transcripts, writes one short dated markdown digest per
session, and keeps the digests. Nothing here calls a model, reaches the network, or
needs an account: it reads files you already have and writes files you already own.

```pycon
>>> import openloops
>>> report = openloops.sync()
>>> for row in openloops.ls(state='open'):
...     print(row['session'], row['ai_title'] or row['title'])
```

The whole design rests on one sentence, and everything else follows from it:

> **A digest says what a session said, dated. It never says what is true now.**

So a digest is filed under `open/` or `archive/` by what the session’s own last
turn *read* like — not by whether a process is running, which would make this a session
dashboard rather than a record of open loops. Nothing is asserted to be still
outstanding, because openloops has checked nothing against the world.

The two modules whose primary export shares their name — the sync engine and the
classifier — are private (`openloops._sync`, `openloops._classify`), because a
module and a function cannot both answer to `openloops.sync`. Everything they export
is re-exported here, including the classifier’s cue tables.

Two seams, both one keyword argument, both defaulting to something that works out of
the box: `transcript_source=` (a reader of Claude Code’s on-disk layout) and
`digests_store=` (a directory of markdown files under `~/.local/share/openloops/`).
Swap either for a test fixture, a git-synced directory, or blob storage without
touching anything else.

There is a second thing here, added later and kept deliberately small:
`openloops.owed()` lists the open `manual-task` issues your agents filed when they
got blocked on you, and — this is its whole reason to exist — re-runs the shell
predicate each issue carries before showing it, so an obligation discharged out of band
reads *discharged* rather than sitting open for months. It reads and reports; it never
closes, relabels or writes anything, and it keeps no copy of anything: the label is the
record. Its trust boundary (evaluating a predicate executes text from an issue body)
and its kill criterion are both written out in [`openloops.obligations`](_autosummary/openloops.obligations.html.md#module-openloops.obligations).

```pycon
>>> report = openloops.owed()
>>> report['counts']
{'open': 6, 'discharged': 1, 'unknown': 2, 'with_predicate': 7, 'total': 9}
```

And a third thing, which is the same thing pointed at a repository instead of at a
person: `openloops.blocked()`. When an agent working in repo X finds the real fix
belongs in repo Y, it files in Y and moves on — and X is never told when Y is fixed, so
the workaround in X quietly becomes architecture. GitHub’s issue dependencies already
record that edge across repositories; what nothing does is notice when the blocker
closes, because the edge is queryable rather than eventful. `blocked()` resolves every
edge and reports `unblocked` (every blocker resolved: the work is free and nobody has
been told), `blocked` (naming the foreign repository) and `unknown`. It reads and
reports; it writes nothing. What its discovery step is measured to over-report, what
bounds its one-call-per-issue resolution, and its kill criterion are all in
[`openloops.blockers`](_autosummary/openloops.blockers.html.md#module-openloops.blockers).

```pycon
>>> report = openloops.blocked()
>>> [r['repo'] + '#' + str(r['number'])
...  for r in report['rows'] if r['state'] == 'unblocked']
['acme/widget#109']
```

All three of those are plumbing, and the surface most people actually want is an agent
that knows how to use the plumbing and tells them what matters. So the package also
ships that agent, as files: a `openloops` skill that answers “what is being done, and
what needs my attention?”, a `openloops-needs-human` skill that files the
`manual-task` issues `owed()` later reads back, and an `openloops-sweep` subagent
that runs the whole sweep in a fresh context. `install_skills()` links them into an
agent host; see [`openloops.skills`](_autosummary/openloops.skills.html.md#module-openloops.skills) for why it links rather than copies and why it
never overwrites.

```pycon
>>> plan = openloops.install_skills(dry_run=True)
>>> [row['name'] for row in plan['actions']]
['openloops', 'openloops-needs-human', 'openloops-sweep']
```

And a page to look at instead of reading three command outputs:
`openloops.render_dashboard()` turns those same three answers into one self-contained
HTML document — no stylesheet, no script, no font, nothing fetched from anywhere.
Because it fetches nothing it also *checks* nothing, so it is a **snapshot**: it stamps
the moment it was made and says so in its largest type, and any figure it could not
establish reads `?` rather than `0`. `ol dashboard` writes one.

```pycon
>>> page = openloops.render_dashboard(
...     openloops.owed(), openloops.blocked(), openloops.ls(limit=0))
```

**Still not here, and deliberately:** a ledger. No store, no schema, no history, no
event log, no cross-repo links, no session model, and no write path of any kind.

### Functions

| [`exchanges`](_autosummary/openloops.html.md#openloops.exchanges)(records, \*[, key])                      | Every prompt of a transcript's main thread, in order, each with its turn's reply.                                     |
|-----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|
| [`question_sentences`](_autosummary/openloops.html.md#openloops.question_sentences)(text)                           | The sentences of a person's prompt that ask something, in order, at most `MAX_QUESTIONS`.                             |
| [`agents_dir`](_autosummary/openloops.html.md#openloops.agents_dir)()                                       | The bundled subagent definitions, inside the installed package.                                                       |
| [`asks_the_human`](_autosummary/openloops.html.md#openloops.asks_the_human)(text, \*[, ask_cues, chars])        | The ask cues in the closing lines, if those lines put a question to the reader.                                       |
| [`blocked`](_autosummary/openloops.html.md#openloops.blocked)(\*[, resolve, owners, repos, query, ...])  | Open issues that carry a blocker edge, with every edge resolved.                                                      |
| [`classify`](_autosummary/openloops.html.md#openloops.classify)(session, \*[, ask_cues, defer_cues, ...]) | Read a session's loop state from its own last turn.                                                                   |
| [`data_dir`](_autosummary/openloops.html.md#openloops.data_dir)()                                         | The project's data root.                                                                                              |
| [`default_source`](_autosummary/openloops.html.md#openloops.default_source)([directory])                        | This machine's label for its digest folder.                                                                           |
| [`digest_key`](_autosummary/openloops.html.md#openloops.digest_key)(source, state, session_key)             | The store key for one session's digest.                                                                               |
| [`digests_store`](_autosummary/openloops.html.md#openloops.digests_store)([rootdir])                           | The default `digests_store`: markdown files under the data root.                                                      |
| [`gh_blocked_by`](_autosummary/openloops.html.md#openloops.gh_blocked_by)(repo, number, \*[, timeout])         | The default `blockers_source`: the blocker edges out of one issue.                                                    |
| [`gh_blocked_candidates`](_autosummary/openloops.html.md#openloops.gh_blocked_candidates)(\*[, owners, repos, ...])    | The default `issues_source`: open issues that *may* carry a blocker edge.                                             |
| [`gh_issues`](_autosummary/openloops.html.md#openloops.gh_issues)(\*, owners[, label, state, limit, ...])  | The default `issues_source`: the open `manual-task` issues, from `gh`.                                                |
| [`headline_counts`](_autosummary/openloops.html.md#openloops.headline_counts)(owed, blocked, sessions)           | The four figures across the top of the page.                                                                          |
| [`install_skills`](_autosummary/openloops.html.md#openloops.install_skills)(\*[, target, only, copy, ...])      | Make the bundled skills and subagent visible to an agent host.                                                        |
| [`ls`](_autosummary/openloops.html.md#openloops.ls)(\*[, state, source, project, confidence, ...])  | The digests in the store, newest last-turn first.                                                                     |
| [`make_digest`](_autosummary/openloops.html.md#openloops.make_digest)(session, verdict, \*, source[, ...])   | Render, scrub, and key one digest.                                                                                    |
| [`owed`](_autosummary/openloops.html.md#openloops.owed)(\*[, verify, owners, trusted_owners, ...])    | The open `manual-task` obligations, each re-checked against the world.                                                |
| [`parse_session`](_autosummary/openloops.html.md#openloops.parse_session)(records, \*[, key])                  | Read one transcript's records into a [`Session`](_autosummary/openloops.base.html.md#openloops.base.Session). |
| [`parse_verify`](_autosummary/openloops.html.md#openloops.parse_verify)(body)                                 | `(predicate, verify_text)` for an issue body.                                                                         |
| [`render`](_autosummary/openloops.html.md#openloops.render)(session, verdict, \*, source)               | The markdown for one digest.                                                                                          |
| [`render_dashboard`](_autosummary/openloops.html.md#openloops.render_dashboard)(owed, blocked, sessions, \*)      | The three envelopes as one self-contained HTML page.                                                                  |
| [`retained`](_autosummary/openloops.html.md#openloops.retained)(store, sessions, \*[, source])            | Digest keys whose session no longer has a transcript — the retention surplus.                                         |
| [`scrub`](_autosummary/openloops.html.md#openloops.scrub)(text, \*[, aliases, where])                  | Rewrite paths and raise on credentials.                                                                               |
| [`shell_predicate`](_autosummary/openloops.html.md#openloops.shell_predicate)(command, \*[, timeout])            | The default `run_predicate`: run the command in a subshell, bounded in time.                                          |
| [`show`](_autosummary/openloops.html.md#openloops.show)(session, \*[, source, digests_store])         | One digest in full, found by session id or by a unique prefix of one.                                                 |
| [`skills_dir`](_autosummary/openloops.html.md#openloops.skills_dir)()                                       | The bundled skills directory, inside the installed package.                                                           |
| [`state_dir`](_autosummary/openloops.html.md#openloops.state_dir)()                                        | Where per-machine caches and job logs go.                                                                             |
| [`status`](_autosummary/openloops.html.md#openloops.status)(\*[, source, since_days, ...])              | Where everything is, how much of it there is, and how stale the cache is.                                             |
| [`sync`](_autosummary/openloops.html.md#openloops.sync)(\*[, transcript_source, digests_store, ...])  | Bring the digest store up to date with the sessions, and say what changed.                                            |

### Classes

| [`Exchange`](_autosummary/openloops.html.md#openloops.Exchange)(session, uuid, origin, asked_at, prompt)   | One prompt a session received and the reply its turn ended with.               |
|------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------|
| [`BlockedIssue`](_autosummary/openloops.html.md#openloops.BlockedIssue)(repo, number, title, url, ...)         | One open issue that carries blocker edges, with the verdict those edges imply. |
| [`Blocker`](_autosummary/openloops.html.md#openloops.Blocker)(repo, number[, state, url, closed_at])      | One edge: the issue, in whatever repository it lives, that blocks another.     |
| [`ClaudeCodeTranscripts`](_autosummary/openloops.html.md#openloops.ClaudeCodeTranscripts)([root, since_days, ...])      | Claude Code's persisted sessions, as a `Mapping[str, Session]`.                |
| [`Digest`](_autosummary/openloops.html.md#openloops.Digest)(key, text, session_key, source, state)       | One rendered digest: its store key and its markdown text.                      |
| [`Locator`](_autosummary/openloops.html.md#openloops.Locator)(type[, url, text, at])                      | A typed, human-readable pointer to something outside the digest.               |
| [`Obligation`](_autosummary/openloops.html.md#openloops.Obligation)(repo, number, title, url, ...[, ...])    | One open `manual-task` issue, with the verdict its own predicate returned.     |
| [`PredicateOutcome`](_autosummary/openloops.html.md#openloops.PredicateOutcome)(status[, output])                  | What evaluating one predicate produced.                                        |
| [`Session`](_autosummary/openloops.html.md#openloops.Session)(key[, title, ai_title, cwd, ...])           | What one Claude Code session's persisted state says, parsed but not judged.    |
| [`Verdict`](_autosummary/openloops.html.md#openloops.Verdict)(state, reason[, cues, at, confidence])      | A loop-state judgement, with the rule that produced it and the cues it saw.    |

### Exceptions

| [`CredentialFound`](_autosummary/openloops.html.md#openloops.CredentialFound)(pattern_name, offset, \*[, where])   | Raised when text about to be written matches a credential pattern.           |
|-------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------|
| [`GhUnavailable`](_autosummary/openloops.html.md#openloops.GhUnavailable)                                        | The world could not be read: no `gh`, no credentials, no network, a timeout. |

### *class* openloops.BlockedIssue(repo, number, title, url, created, age_days, state='unknown', blockers=(), cross_repo=False, unblocked_days=0, evidence='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One open issue that carries blocker edges, with the verdict those edges imply.

`blockers` is carried in full next to `state` so a reader can disagree with the
verdict rather than absorb it — the same rule `openloops.obligations.
Obligation` follows for its predicate.

```pycon
>>> BlockedIssue('acme/widget', 12, 't', 'u', '2026-01-01T00:00:00Z', 3).state
'unknown'
```

#### as_dict()

JSON-ready form, blockers included.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

#### cross_repo *: [bool](https://docs.python.org/3/builtins/functions.html#bool)* *= False*

At least one blocker lives in another repository — the shape this module is for.

#### unblocked_days *: [int](https://docs.python.org/3/builtins/functions.html#int)* *= 0*

how long the work
has been free while nobody was told. `0` for every other state.

* **Type:**
  Days since the last blocker closed, for an `unblocked` row

### *class* openloops.Blocker(repo, number, state='', url='', closed_at='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One edge: the issue, in whatever repository it lives, that blocks another.

`repo` is the *blocker’s* repository, and carrying it is the whole point — an
edge that did not name it would answer only the easy half of the question.

```pycon
>>> Blocker('acme/engine', 15, 'closed').ref
'acme/engine#15 [closed]'
>>> Blocker('acme/engine', 15).ref
'acme/engine#15 [?]'
>>> Blocker('', 15, 'closed').ref
'?#15 [closed]'
```

#### as_dict()

JSON-ready form, with [`ref`](_autosummary/openloops.html.md#openloops.Blocker.ref) materialised for surfaces that print it.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

#### *property* ref *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

`owner/name#number [state]` — how a blocker is written down.

A `?` in either half is a fact about what could be read, never a shrug: an
edge whose repository the payload did not name says so rather than borrowing
the blocked issue’s own repository and inventing a same-repo dependency.

### *class* openloops.ClaudeCodeTranscripts(root=None, , since_days=None, projects=None, skip_scratchpads=True)

Bases: [`Mapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping)

Claude Code’s persisted sessions, as a `Mapping[str, Session]`.

`since_days` bounds the scan by file modification time (`None` scans
everything); `projects` keeps only project directories whose name contains one of
the given substrings; `skip_scratchpads` drops the throwaway directories the CLI
creates under a temp root, which hold agent scratch sessions rather than work.

ADR-010’s revision shape lives here: [`revision()`](_autosummary/openloops.html.md#openloops.ClaudeCodeTranscripts.revision) returns an opaque token —
a file’s modification time for one session, a hash over all of them for the
collection — and [`changed_since()`](_autosummary/openloops.html.md#openloops.ClaudeCodeTranscripts.changed_since) compares against it. mtime is a coarse
signal that both misses content-preserving rewrites and fires on touches; that
trade is accepted rather than reprocessing several thousand transcripts a tick.

```pycon
>>> src = ClaudeCodeTranscripts(root='/nonexistent-dir-for-doctest')
>>> list(src), src.changed_since('0')
([], False)
```

#### changed_since(token, key=None)

Whether the current [`revision()`](_autosummary/openloops.html.md#openloops.ClaudeCodeTranscripts.revision) differs from *token*.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

#### path_of(key)

The transcript file backing *key*.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

#### revision(key=None)

An opaque change token: for one session, or for the whole collection.

For a single key it is the transcript’s modification time in nanoseconds. For
the collection it is a hash over every `(key, mtime)` pair, so a token can
be compared without holding the whole index.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

#### scratchpad_marker *= '-private-tmp'*

Project-directory names starting with this are scratchpads, not work.

### *exception* openloops.CredentialFound(pattern_name, offset, , where='')

Bases: [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError)

Raised when text about to be written matches a credential pattern.

The message names the pattern class and the offset. It never contains the matched
text: an exception that quotes a secret has moved the secret into a log file.

### *class* openloops.Digest(key, text, session_key, source, state, verdict=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One rendered digest: its store key and its markdown text.

`text` is a pure function of the [`Session`](_autosummary/openloops.html.md#openloops.Session) it was built from. It carries
no generation timestamp, which is what lets the regeneration test in
`tests/test_sync.py` compare bytes rather than fields.

#### as_dict()

JSON-ready form (without the markdown body).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### *class* openloops.Exchange(session, uuid, origin, asked_at, prompt, reply='', replied_at='', sender='', questions=())

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One prompt a session received and the reply its turn ended with.

`uuid` is the prompt record’s own id, stable for the life of the transcript.
`origin` is `HUMAN`, `PEER` (`sender` names the session) or
`SYSTEM` (a notification, a headless `-p` run, or nothing typed).
`reply` is the last assistant text before the next prompt, `''` while the turn
is still running or ended without words. `questions` is filled for a human
prompt only.

#### as_dict()

JSON-ready form.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### *exception* openloops.GhUnavailable

Bases: [`RuntimeError`](https://docs.python.org/3/builtins/exceptions.html#RuntimeError)

The world could not be read: no `gh`, no credentials, no network, a timeout.

Raised only by the listing path, and never propagated to a caller of [`owed()`](_autosummary/openloops.html.md#openloops.owed) —
it becomes `listed: False` plus an `error`, which every surface must render as
`?`. Turning it into an empty list would be the one unrecoverable bug here.

### *class* openloops.Locator(type, url='', text='', at='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A typed, human-readable pointer to something outside the digest.

The exportable half of ADR-015’s evidence split: a short typed reference that
another tool (or a person) can resolve, never a content hash and never a byte
offset into a file that is garbage-collected after thirty days.

```pycon
>>> Locator("pr", url="https://github.com/o/r/pull/1").as_dict()["type"]
'pr'
```

#### as_dict()

JSON-ready form.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### *class* openloops.Obligation(repo, number, title, url, created, age_days, state='open', verify='', predicate='', evidence='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One open `manual-task` issue, with the verdict its own predicate returned.

`verify` is the `**Verify:**` field exactly as the issue wrote it and
`predicate` is the runnable command extracted from it — both are carried so a
reader can disagree with `state` rather than absorb it.

```pycon
>>> Obligation('acme/widget', 7, 't', 'u', '2026-01-01T00:00:00Z', 3).state
'open'
```

#### as_dict()

JSON-ready form.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### *class* openloops.PredicateOutcome(status, output='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

What evaluating one predicate produced.

`status` is the shell exit status, or `None` for “could not be run” — the
difference between an answer and the absence of one.

```pycon
>>> PredicateOutcome(0).status, PredicateOutcome(None, 'timed out').output
(0, 'timed out')
```

### *class* openloops.Session(key, title='', ai_title='', cwd='', project='', git_branches=(), started_at='', ended_at='', last_turn_at='', last_user_prompt='', last_prompt_at='', last_assistant_text='', recap='', recap_at='', compaction='', compaction_at='', turn_count=0, model='', ended_mid_turn=False, ended_with_error=False, locators=())

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

What one Claude Code session’s persisted state says, parsed but not judged.

Every field is a *fact read from the transcript*, never an inference about the
world. `last_assistant_text` is what the session said last; it is not a claim
that the thing it describes is still true.

`key` is the session id. It is the only identifier openloops uses, and per
ADR-014 nothing in the model hangs off it: a digest is a view keyed by session,
not a record owned by one.

#### as_dict()

JSON-ready form (`asdict` already flattens the nested locators).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

#### compaction *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)* *= ''*

The context-compaction summary, a different and much longer thing than the recap,
covering only the part of the session that preceded it.

#### ended_mid_turn *: [bool](https://docs.python.org/3/builtins/functions.html#bool)* *= False*

The transcript’s final conversational record is an unanswered human prompt, or an
assistant tool call whose result never arrived — i.e. the session stopped mid-turn.

#### ended_with_error *: [bool](https://docs.python.org/3/builtins/functions.html#bool)* *= False*

The last thing the session said is a usage-limit or API-error banner rather than
the assistant’s own words: it was cut off, not finished.

#### *property* git_branch *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

The branch the session opened on — the one that names it, when several ran.

A third of sessions touch more than one branch, so a single value is a summary
rather than a fact; `git_branches` is what carries the truth.

#### *property* heading *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)*

its description, else its label.

* **Type:**
  What to call this session in prose

#### recap *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)* *= ''*

Claude Code’s own end-of-turn recap (its `away_summary` record) — one to three
sentences it wrote itself, usually naming what it thought came next. Reading it
is retention, not duplication: it was generated and billed once already, and it
disappears with the transcript.

### *class* openloops.Verdict(state, reason, cues=(), at='', confidence='high')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A loop-state judgement, with the rule that produced it and the cues it saw.

`reason` and `cues` exist so a reader can disagree. A classification whose
grounds are not shown is an assertion, and openloops does not make assertions
about sessions — it reports what they said and why it read them that way.

#### as_dict()

JSON-ready form.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

#### at *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)* *= ''*

When the text this verdict was read from was written. Usually the closing turn,
but the recap rule reads text written later, and a heading dated to the wrong
moment is exactly the thing ADR-005 forbids.

#### confidence *: [str](https://docs.python.org/3/builtins/stdtypes.html#str)* *= 'high'*

`"high"` when a rule actually fired, `"low"` when the state is the default
and nothing in the transcript supported it. A classifier that hides which of the
two it did is claiming knowledge it does not have.

### openloops.agents_dir()

The bundled subagent definitions, inside the installed package.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### openloops.asks_the_human(text, , ask_cues=('want me to', 'do you want', 'would you like', 'shall i', 'should i', 'let me know', 'say the word', 'your call', 'up to you', 'over to you', 'which would you', 'tell me which', "if you'd prefer", 'if you would prefer', 'needs your input', 'needs your review', 'needs your decision', 'needs your go-ahead', 'needs your call'), chars=1200)

The ask cues in the closing lines, if those lines put a question to the reader.

The primitive a retrospective measurement uses to ask “did this session end with a
question directed at the human” — defined once, here, rather than twice.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

```pycon
>>> asks_the_human('I fixed it. Want me to open the PR?')
('want me to',)
>>> asks_the_human('Was it broken? Yes, and I fixed it.')
()
>>> asks_the_human('All done.')
()
```

### openloops.blocked(, resolve=True, owners=None, repos=(), query='is:blocked', limit=50, timeout=30.0, now=None, issues_source=None, blockers_source=None)

Open issues that carry a blocker edge, with every edge resolved.

The row that matters sorts first: `unblocked` — every blocker closed, the work
free to proceed, and nothing anywhere has said so. `blocked` rows name what they
are waiting on, foreign repository included. `unknown` means the edges could not
be read, and it is never rounded into either of the others.

`owners=` scopes the fleet-wide search (default: `openloops.obligations.
configured_owners()`, the same fleet `ol owed` answers about, so two commands never
quietly disagree about which repositories exist). `repos=` instead enumerates the
repositories you name exactly, which is the audit path.

`resolve=False` lists candidates without spending one API call each; every row
then reads `unknown`, because that is what is true about it.

Two seams, each one keyword argument, each defaulting to a real implementation:
`issues_source=` (defaults to [`gh_blocked_candidates()`](_autosummary/openloops.html.md#openloops.gh_blocked_candidates); a list of dicts
substitutes) and `blockers_source=` (defaults to [`gh_blocked_by()`](_autosummary/openloops.html.md#openloops.gh_blocked_by); a mapping
from `'owner/name#number'` to edges, or any callable of `(repo, number)`,
substitutes). With both injected this reaches no network and needs no `gh`.

Returns one envelope, never a bare list, because a reader has to be able to tell
“nothing is waiting” from “I could not find out”:

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

`listed`
: `False` when *discovery itself* failed. Every surface must render that as
  `?`. The counts are zeros and they mean nothing.

`resolved`
: whether edges were resolved at all.

`truncated`
: the candidate list saturated `limit`, so the counts are a floor.

`counts`
: `unblocked` / `blocked` / `unknown`, plus `cross_repo`, `candidates`
  (how many discovery offered), `without_edges` (how many of those the
  dependency graph disagreed about) and `total` (how many rows).

`rows`
: one dict per issue, with every key in `BLOCKED_FIELDS`.

```pycon
>>> report = blocked(issues_source=[], blockers_source={})
>>> report['listed'], report['counts']['total'], report['rows']
(True, 0, [])
```

Discovery that could not run is not “nothing is waiting”:

```pycon
>>> def broken(**query):
...     raise GhUnavailable('gh: not logged in')
>>> report = blocked(issues_source=broken)
>>> report['listed'], report['error'], report['counts']['total']
(False, 'gh: not logged in', 0)
```

### openloops.classify(session, , ask_cues=('want me to', 'do you want', 'would you like', 'shall i', 'should i', 'let me know', 'say the word', 'your call', 'up to you', 'over to you', 'which would you', 'tell me which', "if you'd prefer", 'if you would prefer', 'needs your input', 'needs your review', 'needs your decision', 'needs your go-ahead', 'needs your call'), defer_cues=('still open', 'still owed', 'still outstanding', 'still pending', 'next step', 'next session', 'left off', 'waiting for you', 'waiting on', 'blocked on', 'blocked by', 'handoff', 'hand-off', 'pick it up', 'pick this up', 'pick that up', 'i left it', 'not yet done', 'not yet merged', 'not yet posted', 'open question', 'unresolved', 'i could not', "i couldn't", 'was unable to', 'were unable to', 'did not finish', "didn't finish", 'needs a human', 'needs you', 'still needs', "you'll need to", 'you will need to', 'requires you to'), close_cues=('nothing half-finished', 'nothing pending', 'nothing is pending', 'nothing left', 'nothing is blocking', 'nothing blocking', 'nothing to clean', 'nothing else outstanding', 'nothing outstanding', 'nothing further', 'no next action', 'no further action', 'no action needed', 'safe to close', 'safe to exit', "you're all set", 'you are all set', 'all done', 'everything is done', 'everything is green', 'everything is verified', 'not unfinished business', 'ready to close'), chars=1200)

Read a session’s loop state from its own last turn.

* **Return type:**
  [`Verdict`](_autosummary/openloops.base.html.md#openloops.base.Verdict)

```pycon
>>> from openloops.base import Session
>>> classify(Session(key='s', ended_with_error=True)).state
'open'
>>> v = classify(Session(key='s', last_assistant_text='Blocked on your deploy key.'))
>>> v.state, v.cues, v.confidence
('open', ('blocked on',), 'high')
```

### openloops.data_dir()

The project’s data root. Override with `OPENLOOPS_DATA_DIR`.

Per-kind subdirectories hang off this; nothing is written directly into it, so a
later kind of data needs no second migration.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### openloops.default_source(directory=None)

This machine’s label for its digest folder. Override with `OPENLOOPS_SOURCE`.

A short, filename-safe name. It exists so several machines can sync digests into one
place without colliding — not to identify anybody, though on a machine whose
hostname was never changed it may well do; every `ol sync` prints it, and
`OPENLOOPS_SOURCE` overrides it.

**It is sticky.** The hostname is a *seed*, written once to a file under the state
directory and read thereafter. macOS rewrites the hostname when it joins a network
where the name collides — appending `-2`, `-3` — and a label that moved would
fork the store into two complete copies with no dedup and no warning.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### openloops.digest_key(source, state, session_key)

The store key for one session’s digest.

Both segments are validated, not just the state. `transcript_source=` is a seam
whose documented purpose is “another machine’s synced transcripts”, so a session id
can come from a listing this process did not produce — and a key containing `..`
would make a file-backed store write outside its own root while `sync` reported
success. Validating here means every backend inherits the check.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> digest_key('mac', 'archive', 's1')
'mac/archive/s1.md'
>>> digest_key('mac', 'open', '../../etc/passwd')
Traceback (most recent call last):
    ...
ValueError: session key must be a single safe path segment, got '../../etc/passwd'
```

### openloops.digests_store(rootdir=None)

The default `digests_store`: markdown files under the data root.

Any `MutableMapping[str, str]` works in its place — a plain `dict` for tests,
a git-synced directory, an S3-backed store. The keys are what carry the layout, so
a different backend gets the same `{source}/{state}/{session}.md` structure for
free.

* **Return type:**
  [`MutableMapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.MutableMapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

```pycon
>>> store = digests_store(rootdir='/tmp/openloops-doctest-store')
>>> store['demo/open/s1.md'] = '# hi'
>>> sorted(store)
['demo/open/s1.md']
>>> del store['demo/open/s1.md']
```

### openloops.exchanges(records, , key='')

Every prompt of a transcript’s main thread, in order, each with its turn’s reply.

Records are ordered by timestamp (a transcript’s lines are not always written in
order), ties kept in file order. Sub-agent sidechains are left out: their “prompts”
were written by the session, not to it.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`Exchange`](_autosummary/openloops.html.md#openloops.Exchange), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

```pycon
>>> recs = [
...     {"type": "user", "sessionId": "s", "uuid": "u1", "timestamp": "T1",
...      "message": {"content": "Ship it. Is the cache still warm?"}},
...     {"type": "assistant", "timestamp": "T2", "message": {"content": [
...         {"type": "text", "text": "Shipped. Yes, warm for an hour."}]}},
... ]
>>> [(e.uuid, e.questions, e.reply) for e in exchanges(recs)]
[('u1', ('Is the cache still warm?',), 'Shipped. Yes, warm for an hour.')]
```

### openloops.gh_blocked_by(repo, number, , timeout=30.0)

The default `blockers_source`: the blocker edges out of one issue.

One request per issue — the N+1 [`blocked()`](_autosummary/openloops.html.md#openloops.blocked) bounds with `limit=`. Returns the
rows GitHub sends, unparsed and unjudged; `_verdict()` is what reads a state
out of them. An issue with no edges answers `[]`, which is an answer.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]

### openloops.gh_blocked_candidates(, owners=(), repos=(), query='is:blocked', limit=50, timeout=30.0)

The default `issues_source`: open issues that *may* carry a blocker edge.

`repos=` chooses the exact per-repository enumeration; otherwise `owners=`
drives one fleet-wide search. Both return rows shaped the way `gh search issues`
shapes them — `createdAt`, `number`, `repository`, `title`, `url` — so
everything downstream reads one shape. Which one to use, and what each costs and
misses, is the “Discovery is a candidate list” section of this module’s docstring.

A large repository can take longer than `timeout` to paginate; that is a
[`GhUnavailable`](_autosummary/openloops.html.md#openloops.GhUnavailable), which reads `?`, not an empty answer.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]

### openloops.gh_issues(, owners, label='manual-task', state='open', limit=50, timeout=30.0)

The default `issues_source`: the open `manual-task` issues, from `gh`.

This is a *filtered* search, not an enumeration — the label is applied server-side
and the result is capped — which is the only form of search this package is allowed
to use. Walking a whole fleet’s issues through the search API silently returns wrong
answers past its first thousand results; that job belongs to per-repo listing.

Returns whatever `gh` returned, unparsed and unjudged: a list of dicts with
`createdAt`, `number`, `repository`, `title`, `url` and `body`.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]

### openloops.headline_counts(owed, blocked, sessions)

The four figures across the top of the page. `None` is `?`, never a zero.

Shared by the masthead and by [`openloops.tools.dashboard()`](_autosummary/openloops.tools.html.md#openloops.tools.dashboard), so the number a
caller reads back is the number the page printed.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`int`](https://docs.python.org/3/builtins/functions.html#int) | [`None`](https://docs.python.org/3/builtins/constants.html#None)]

```pycon
>>> headline_counts({'listed': True, 'counts': {'open': 2}, 'rows': []},
...                 {'listed': False}, [])['free_to_proceed'] is None
True
```

### openloops.install_skills(, target=None, only=None, copy=False, force=False, dry_run=False)

Make the bundled skills and subagent visible to an agent host. Idempotent.

Links each one into `target` (default: `CLAUDE_CONFIG_DIR` or `~/.claude`) so
that upgrading the package upgrades the skill. `copy=True` takes a copy instead,
which is also what happens by itself where symlinks are unavailable.

Returns the plan it carried out – one row per asset, each carrying its verdict and
the reason for it. `dry_run=True` returns the same plan having touched nothing,
which is the only way to see what a run would do *before* it does it.

Nothing that is already there is overwritten: an occupied destination reads
`conflict` and is left exactly as it was until `force=True`.

`only=` installs a subset by name. The case it exists for: you already have your
own capture skill, adapted to your own fleet, and a second one competing for the same
triggers is worse than either alone – so take the reader and the subagent and leave
the capture skill out.

* **Return type:**
  dict[str, Any]

```pycon
>>> plan = install_skills(target='/nonexistent/host', only=['openloops'], dry_run=True)
>>> [row['name'] for row in plan['actions']]
['openloops']
```

```pycon
>>> plan = install_skills(target='/nonexistent/host', dry_run=True)
>>> plan['counts']['install'], plan['dry_run']
(3, True)
>>> sorted({row['action'] for row in plan['actions']})
['install']
```

### openloops.ls(, state='open', source=None, project=None, confidence=None, limit=20, digests_store=None)

The digests in the store, newest last-turn first.

`state` is `open`, `archive` or `all`. `source`, `project` and
`confidence` narrow by the digest’s own header fields. Each row is that front
matter plus its key.

`confidence='high'` is the one worth knowing about: most sessions land in `open`,
and a good few of those are open only because nothing said otherwise. Filtering to
`high` leaves the ones where the session itself said something.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]

```pycon
>>> rows = ls(digests_store={'m/open/s1.md':
...     '---\nsession: s1\nstate: open\nlast_turn: T9\n---\n'})
>>> rows[0]['session'], rows[0]['key']
('s1', 'm/open/s1.md')
```

### openloops.make_digest(session, verdict, , source, aliases=None)

Render, scrub, and key one digest.

Raises [`CredentialFound`](_autosummary/openloops.egress.html.md#openloops.egress.CredentialFound) when the rendered text matches a
credential pattern — deliberately, so the caller skips that session loudly rather
than writing a secret into a store that may be synced.

* **Return type:**
  [`Digest`](_autosummary/openloops.base.html.md#openloops.base.Digest)

```pycon
>>> from openloops.base import Session, Verdict
>>> d = make_digest(Session(key='s1'), Verdict('open', 'why'), source='demo')
>>> d.key
'demo/open/s1.md'
```

### openloops.owed(, verify=True, owners=None, trusted_owners=None, label='manual-task', limit=50, timeout=30.0, predicate_timeout=20.0, now=None, issues_source=None, run_predicate=None)

The open `manual-task` obligations, each re-checked against the world.

`verify=False` lists without executing anything; every row that carries a
predicate then reads `unknown`, because that is what is true about it.

`owners=` scopes the search (default: `configured_owners()`).
`trusted_owners=` scopes what may *execute* and defaults to `owners`, so
widening the search never quietly widens what runs — pass it explicitly to search
wider than you trust.

Two seams, each one keyword argument, each defaulting to a real implementation:
`issues_source=` (defaults to [`gh_issues()`](_autosummary/openloops.html.md#openloops.gh_issues); a list of dicts substitutes) and
`run_predicate=` (defaults to [`shell_predicate()`](_autosummary/openloops.html.md#openloops.shell_predicate); any callable from command
to exit status substitutes). With both injected this reaches no network and needs no
`gh`.

Returns one envelope, never a bare list, because a reader has to be able to tell
“nothing is owed” from “I could not find out”:

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

`listed`
: `False` when the *listing itself* failed. Every surface must render that as
  `?`. The counts are zeros and they mean nothing.

`checked`
: whether predicates were evaluated at all.

`truncated`
: the result set saturated its own cap, so the count is a floor.

`counts`
: `open` / `discharged` / `unknown` / `with_predicate` / `total`.

`rows`
: one dict per obligation, with every key in `OBLIGATION_FIELDS`.

```pycon
>>> report = owed(issues_source=[], run_predicate=lambda command: 0)
>>> report['listed'], report['counts']['total'], report['rows']
(True, 0, [])
```

A listing that could not run is not “nothing owed”:

```pycon
>>> def broken(**query):
...     raise GhUnavailable('gh: not logged in')
>>> report = owed(issues_source=broken)
>>> report['listed'], report['error'], report['counts']['total']
(False, 'gh: not logged in', 0)
```

### openloops.parse_session(records, , key='')

Read one transcript’s records into a [`Session`](_autosummary/openloops.base.html.md#openloops.base.Session).

Pure: same records in, same session out. Everything it reports is a fact about the
document — no inference, no judgement, and nothing about running processes.

Three details are corpus-driven rather than obvious, and getting them wrong
mislabels a large fraction of real sessions:

- \*\*Start and end come from `min`/`max` over every timestamp\*\*, not from the
  first and last line. Transcript lines are not written in timestamp order; on real
  data the first line holds the earliest timestamp only about four times in five.
- \*\*The project comes from the *first* record’s `cwd`.\*\* Four sessions in five
  visit more than one working directory (scratchpads, sibling repos, task dirs), and
  the last or most-frequent one names a different repository about half the time.
- **Branches are a tuple.** A third of sessions touch more than one.

```pycon
>>> recs = [
...     {"type": "user", "sessionId": "s1", "cwd": "/w/proj", "timestamp": "T1",
...      "gitBranch": "main",
...      "message": {"role": "user", "content": [{"type": "text", "text": "do it"}]}},
...     {"type": "assistant", "sessionId": "s1", "timestamp": "T2",
...      "message": {"role": "assistant", "model": "m",
...                  "content": [{"type": "text", "text": "done"}]}},
... ]
>>> s = parse_session(recs)
>>> s.key, s.project, s.turn_count, s.last_assistant_text
('s1', 'proj', 1, 'done')
>>> s.started_at, s.ended_at, s.git_branch, s.ended_mid_turn
('T1', 'T2', 'main', False)
```

* **Return type:**
  [`Session`](_autosummary/openloops.base.html.md#openloops.base.Session)

### openloops.parse_verify(body)

`(predicate, verify_text)` for an issue body. Both are `''` when absent.

The predicate is the first code span in the field. Prose with no code span – the
documented “no predicate is possible here” answer – yields no command, and the
prose is kept so the row can say *why* rather than merely say nothing.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

```pycon
>>> parse_verify('**Verify:** `test -f x`')
('test -f x', '`test -f x`')
>>> parse_verify('no such field here')
('', '')
```

A field that says no predicate is possible yields no command \*\*even when its prose
contains a code span\*\* – the documented wording mentions `gh` and `true` naturally,
and both exit 0, which would report a live obligation as done:

```pycon
>>> parse_verify('**Verify:** none possible - no `gh` query observes a decision.')
('', 'none possible - no `gh` query observes a decision.')
```

A quoted example of the format is not this issue’s own predicate:

```pycon
>>> body = chr(10).join(['```', '**Verify:** `quoted`', '```', '**Verify:** `real`'])
>>> parse_verify(body)[0]
'real'
```

An unterminated code span is not a command – it is a malformed field, and saying so
is the difference between a row that reads `?` and one that silently reads open:

```pycon
>>> parse_verify('**Verify:** `echo one &&' + chr(10) + 'echo two`')
('', '`echo one &&')
```

### openloops.question_sentences(text)

The sentences of a person’s prompt that ask something, in order, at most
`MAX_QUESTIONS`.

A sentence asks when it ends with `?` or opens the way a question does (“why”,
“is there”, “I wonder”), and is not a request phrased as one (“can you fix it?”).
Code blocks, quoted lines and table rows are skipped.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

```pycon
>>> question_sentences("Can you add a test? Could you explain why it failed?")
('Could you explain why it failed?',)
>>> question_sentences("> Why is it slow?\nok?\nwhy does the page load twice")
('why does the page load twice',)
```

### openloops.render(session, verdict, , source)

The markdown for one digest. Pure, dated, and bounded.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> from openloops.base import Session, Verdict
>>> s = Session(key='s1', project='proj', started_at='T0', last_turn_at='T1',
...             last_assistant_text='Shipped it.', turn_count=3)
>>> verdict = Verdict('archive', 'its closing lines declare it finished')
>>> text = render(s, verdict, source='demo')
>>> text.splitlines()[0]
'---'
>>> 'state: archive' in text
True
>>> render(s, verdict, source='demo') == text     # pure: same in, same out
True
```

### openloops.render_dashboard(owed, blocked, sessions, , made_at=None, source='', title='Open Loops Board', max_sessions=40, standalone=True, aliases=None)

The three envelopes as one self-contained HTML page. No network, no script.

`owed` and `blocked` are the envelopes [`openloops.owed()`](_autosummary/openloops.html.md#openloops.owed) and
[`openloops.blocked()`](_autosummary/openloops.html.md#openloops.blocked) return; `sessions` is the list [`openloops.ls()`](_autosummary/openloops.html.md#openloops.ls)
returns. All three may be empty mappings, and an envelope whose `listed` is
`False` renders as `?` throughout rather than as zero.

`made_at` is the moment the snapshot was taken and is printed in the largest type
on the page. It defaults to now, but a caller that wants a byte-stable page passes
it — this is the one module in openloops that stamps a generation time, and it does
so because the whole claim of the page is *when*.

`standalone` wraps the output in a document scaffold for a file you open yourself.
Pass `False` for a host that supplies its own `<head>` — a published artifact
does — and the same page comes back as title, styles and content only.

`max_sessions` bounds the in-flight register; the true total is stated either way.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> page = render_dashboard(
...     {'listed': False, 'error': 'gh: not logged in', 'counts': {}},
...     {'listed': True, 'counts': {'unblocked': 0, 'total': 0}, 'rows': []},
...     [], made_at='2026-01-01T00:00:00Z')
>>> 'gh: not logged in' in page and 'could not' in page
True
```

### openloops.retained(store, sessions, , source=None)

Digest keys whose session no longer has a transcript — the retention surplus.

These are what makes openloops a retention device rather than a view: Claude Code
garbage-collects transcripts, and a digest outlives the thing it was derived from.
[`sync()`](_autosummary/openloops.html.md#openloops.sync) never removes them, and they are the one part of the store that a
from-scratch rebuild does not reproduce.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

```pycon
>>> retained({'m/open/a.md': '', 'm/open/b.md': ''}, {'a': None})
['m/open/b.md']
```

### openloops.scrub(text, , aliases=None, where='')

Rewrite paths and raise on credentials. The only way text leaves openloops.

`where` is a caller-supplied label (a session id, a file name) carried into the
exception so a failed run can say *which* input tripped, without quoting it.

The path check runs on the **output**, not the input, because `aliases` is
caller-supplied: a rewrite can in principle produce a home path as easily as remove
one, and a postcondition that is only true of benign inputs is not a postcondition.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> scrub("built /nowhere/proj/x", aliases={"/nowhere/proj": "$PROJ"})
'built $PROJ/x'
```

### openloops.shell_predicate(command, , timeout=20.0)

The default `run_predicate`: run the command in a subshell, bounded in time.

The exit status is the answer and nothing else is interpreted. Anything that is not
an exit status — a timeout, a shell that would not start — comes back as
`status=None`, which is `unknown`, which is `?`.

* **Return type:**
  [`PredicateOutcome`](_autosummary/openloops.obligations.html.md#openloops.obligations.PredicateOutcome)

```pycon
>>> shell_predicate('exit 0').status
0
>>> shell_predicate('exit 3').status
3
>>> shell_predicate('   ').status is None
True
```

### openloops.show(session, , source=None, digests_store=None)

One digest in full, found by session id or by a unique prefix of one.

An exact id always wins over a prefix, so a session whose id happens to be a prefix
of another stays reachable. `source` narrows a store that several machines write
into, where the same session can legitimately appear more than once.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> store = {'m/open/abcdef.md': '---\nsession: abcdef\n---\nbody'}
>>> show('abc', digests_store=store)['key']
'm/open/abcdef.md'
```

### openloops.skills_dir()

The bundled skills directory, inside the installed package.

`Path(__file__).parent` rather than a configured root, so this answers correctly
from a wheel, an editable install, a zip and a virtualenv without being told which.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### openloops.state_dir()

Where per-machine caches and job logs go. Override with `OPENLOOPS_STATE_DIR`.

Deliberately not under [`data_dir()`](_autosummary/openloops.html.md#openloops.data_dir): everything here is disposable, and keeping
it elsewhere means “clear the cache” can never be mistyped into “delete the digests”.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### openloops.status(, source=None, since_days=None, digests_store=None, transcript_source=None)

Where everything is, how much of it there is, and how stale the cache is.

Reports the cache’s age because a read served from a cache that nothing has
refreshed is the failure openloops is built to avoid — a periodic job that died
leaves a confident, months-old answer behind, and the only defence is saying how
old the answer is.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### openloops.sync(, transcript_source=None, digests_store=None, source=None, since_days=None, state_dir=None, force=False)

Bring the digest store up to date with the sessions, and say what changed.

`force` ignores the cache and re-derives everything; the result must be identical,
which is what makes it safe to suggest when someone suspects a stale digest.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> from openloops.base import STATES, Session
>>> sessions = {'s1': Session(key='s1',
...                               last_assistant_text='Shipped it. Nothing is pending.')}
>>> store = {}
>>> r = sync(transcript_source=sessions, digests_store=store, source='demo',
...          state_dir='/tmp/openloops-doctest-state', force=True)
>>> r['written'], sorted(store)
(1, ['demo/archive/s1.md'])
>>> sync(transcript_source=sessions, digests_store=store, source='demo',
...      state_dir='/tmp/openloops-doctest-state', force=True)['written']
0
```

### Modules

| [`base`](_autosummary/openloops.base.html.md#module-openloops.base)               | Data structures shared across openloops: what a session is, and what a digest is.       |
|-------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------|
| [`blockers`](_autosummary/openloops.blockers.html.md#module-openloops.blockers)       | The other kind of open loop: the one that points at a repository, not at a person.      |
| [`dashboard`](_autosummary/openloops.dashboard.html.md#module-openloops.dashboard)     | One page you can look at instead of reading three command outputs.                      |
| [`digest`](_autosummary/openloops.digest.html.md#module-openloops.digest)           | Rendering one session into one dated markdown digest.                                   |
| [`egress`](_autosummary/openloops.egress.html.md#module-openloops.egress)           | The egress choke point: nothing leaves openloops carrying a home path or a secret.      |
| [`job`](_autosummary/openloops.job.html.md#module-openloops.job)                 | The periodic job: launchd runs `ol sync`, writes, and exits.                            |
| [`obligations`](_autosummary/openloops.obligations.html.md#module-openloops.obligations) | What you still owe your agents — re-checked against the world before it is shown.       |
| [`skills`](_autosummary/openloops.skills.html.md#module-openloops.skills)           | The agent-facing surface: two skills, one subagent, and the command that installs them. |
| [`store`](_autosummary/openloops.store.html.md#module-openloops.store)             | Where digests live, and where the change-detection cache lives — two different things.  |
| [`tools`](_autosummary/openloops.tools.html.md#module-openloops.tools)             | The package's single list of operations: plain functions, JSON-ready dicts.             |
| [`transcripts`](_autosummary/openloops.transcripts.html.md#module-openloops.transcripts) | The default `transcript_source`: a direct reader of Claude Code's on-disk state.        |


# _autosummary/openloops.job.html.md

# openloops.job

The periodic job: launchd runs `ol sync`, writes, and exits. No daemon.

ADR-003’s decision, applied to digests. A resident process would have to be kept alive,
and when it dies its output latches at whatever it last wrote — a confident, months-old
answer with nothing scheduled to correct it. A `StartInterval` job is self-healing by
construction: a tick that crashes is repaired by the next one, and the only thing to
supervise is launchd itself.

The environment is captured at install time and pinned into the plist, because launchd
hands a job a nearly empty environment. The interpreter is invoked directly rather than
by console-script name, for the same reason: a name resolved against a minimal `PATH`
is a lottery whose losing ticket is a job that dies instantly, every tick, silently.

macOS only. On Linux the equivalent is a systemd user timer or a crontab line running
the same command, and [`install()`](_autosummary/openloops.job.html.md#openloops.job.install) says so rather than pretending.

### Module Attributes

| [`DFLT_LABEL`](_autosummary/openloops.job.html.md#openloops.job.DFLT_LABEL)    | launchd job label.     |
|----------------------------------------------------------------|------------------------|
| [`DFLT_INTERVAL`](_autosummary/openloops.job.html.md#openloops.job.DFLT_INTERVAL) | Seconds between ticks. |

### Functions

| [`install`](_autosummary/openloops.job.html.md#openloops.job.install)(\*[, label, interval, dry_run])      | Install (or replace) the periodic sync job.                                  |
|-----------------------------------------------------------------------------------------------|------------------------------------------------------------------------------|
| [`uninstall`](_autosummary/openloops.job.html.md#openloops.job.uninstall)(\*[, label])                       | Unload the job and remove its plist.                                         |
| [`job_status`](_autosummary/openloops.job.html.md#openloops.job.job_status)(\*[, label])                      | Installed? loaded? and — the question that matters — when did it last write? |
| [`plist_xml`](_autosummary/openloops.job.html.md#openloops.job.plist_xml)(\*, label, program_args, env, ...) | The launchd property list, as text.                                          |

### openloops.job.DFLT_INTERVAL *= 900*

Seconds between ticks. A digest is a record of what a session said, so being a
quarter of an hour behind costs nothing; running every few seconds would cost a
full scan of the transcript directory for no gain.

### openloops.job.DFLT_LABEL *= 'openloops.sync'*

launchd job label. Also the plist filename and the log filename.

### openloops.job.install(, label='openloops.sync', interval=900, dry_run=False)

Install (or replace) the periodic sync job. Returns the paths it wrote.

A smoke run follows the bootstrap, because a job that cannot start produces exactly
the same silence as a job with nothing to do.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### openloops.job.job_status(, label='openloops.sync')

Installed? loaded? and — the question that matters — when did it last write?

`launchctl list` says the job is registered, which is not the same as the job
doing anything. The cache’s modification time is the only evidence that a tick
completed, so it is reported alongside.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`object`](https://docs.python.org/3/builtins/functions.html#object)]

### openloops.job.plist_xml(, label, program_args, env, interval, log_path)

The launchd property list, as text. Pure, so it can be tested on any platform.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> xml = plist_xml(label='x', program_args=['a'], env={'HOME': '/h'},
...                 interval=60, log_path=Path('/tmp/x.log'))
>>> '<key>StartInterval</key>' in xml and '<integer>60</integer>' in xml
True
```

### openloops.job.uninstall(, label='openloops.sync')

Unload the job and remove its plist. Idempotent.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]


# _autosummary/openloops.obligations.html.md

# openloops.obligations

What you still owe your agents — re-checked against the world before it is shown.

When an agent gets blocked on something only you can do — a secret it cannot write, a
permission it does not have, a decision that is yours — it files a `manual-task` issue
in the affected repo and says so in its closing message. The closing message dies with
the session. The issue does not, and one query lists every one of them across a fleet.

That query has one failure mode, and it is the one that matters: \*\*obligations get
discharged out of band.\*\* Someone adds a deploy key in a web UI, pays an invoice,
answers in chat. None of that emits an event anyone is listening to, so the issue sits
open for months describing something that was done in five minutes. A stale increment
annoys; a phantom row destroys the count, and the count is the product.

So each obligation carries its own answer, in its body, as a shell command whose \*\*exit
status is the question\*\*:

```default
**Verify:** `gh secret list --repo OWNER/REPO --json name -q '.[].name' | grep -qx NAME`
```

[`owed()`](_autosummary/openloops.obligations.html.md#openloops.obligations.owed) lists the open ones, runs each predicate, and reports **three** states —
never two:

| `open`       | no predicate at all, or the predicate ran and returned non-zero                                   |
|--------------|---------------------------------------------------------------------------------------------------|
| `discharged` | the predicate ran and returned `0`: the ask is done, the issue<br/>is merely still open           |
| `unknown`    | the predicate could *not* be run, or errored in a way that is not<br/>an answer. Displayed as `?` |

`?` is not a rounding error, it is the whole point. A surface that says “nothing
owed” because it failed to check is worse than no surface at all, so nothing here is
ever allowed to collapse `unknown` into `open` or into `discharged`.

**Nothing here mutates anything.** A passing predicate is evidence, not authority: it
never closes, reopens or relabels an issue, and it never will — the standing rule is
that a human obligation is not closed on a model’s judgement. You are shown the
command, its exit status and what it printed, and you decide.

## Trust boundary — stated here because it should be read, not discovered

Evaluating a predicate means **executing text that came from a GitHub issue body**.
That is a real capability and it is bounded here in five ways, each of them visible:

1. **Only owners you named.** A predicate is run only when the issue’s repository owner
   is in `trusted_owners=`, which defaults to the same `owners=` the search was
   scoped to. A row outside that set reads `?` — never `open`, because nothing
   checked it, and never `discharged`, because nothing ran.
2. **The command is always shown next to its verdict.** Every row carries the predicate
   verbatim, so nothing executes invisibly and you can disagree with the answer.
3. **An explicit way not to execute at all.** `owed(verify=False)` (`ol owed
   --no-verify`) lists without running anything; every row that has a predicate then
   reads `?`, because that is the truth about it.
4. **Every evaluation is time-bounded**, by `predicate_timeout=`. A timeout is
   `unknown`, never an answer. On POSIX the predicate runs in its own process group
   and the timeout kills the group, so anything it started dies with it. On Windows only
   the shell itself is killed — a predicate that backgrounds work can outlive its
   timeout there, which is a platform limit worth knowing rather than a promise broken
   quietly.
5. **The whole thing is one keyword argument away from being someone else’s code.**
   `run_predicate=` replaces the evaluator; `issues_source=` replaces the reader.

One honest limit: predicates are POSIX shell. On a shell that cannot parse one, the
command exits non-zero and the row reads `open`. That is the safe direction to be
wrong in — a stale increment annoys, a wrong decrement destroys the count — and it is
why the exit status and the command text are always on screen.

**Kill criterion** (this module ships on an argument, so it names in advance what would
retire it): if, ninety days after this ships, `counts['with_predicate']` is below half
of `counts['total']` on a normal run, or nobody has run `ol owed` in a fortnight,
delete it. Its entire reason to exist over a one-line `gh search issues` alias is the
predicate; with no predicates to run it is a slower alias that draws a table. Removal is
one command — delete this module and `tests/test_obligations.py`, and drop `owed`
from `openloops.tools._dispatch_funcs` and `openloops.__main__._commands`. Nothing
imports it, nothing persists, and no other module changes.

**What this is not.** It is a *reader*. There is no store, no schema, no history, no
event log, no cross-repo linking, no session model and no local copy of anything: the
`manual-task` label **is** the record, and a second writable home for the same record
type is the drift this deliberately does not build. State lives in first-class GitHub
fields that a plain `gh` command can query, so deleting this module loses no data.

```pycon
>>> from datetime import datetime, timezone
>>> issues = [
...     {'number': 7, 'title': 'Add a deploy key so CI can clone',
...      'url': 'https://github.com/acme/widget/issues/7',
...      'createdAt': '2026-08-01T00:00:00Z',
...      'repository': {'nameWithOwner': 'acme/widget'},
...      'body': '**Verify:** `[ "$(gh api repos/acme/widget/keys --jq length)" -gt 0 ]`'},
...     {'number': 8, 'title': 'Decide whether to publish this at all',
...      'url': 'https://github.com/acme/widget/issues/8',
...      'createdAt': '2026-08-10T00:00:00Z',
...      'repository': {'nameWithOwner': 'acme/widget'},
...      'body': '**Verify:** none possible - a judgement call.'},
... ]
>>> report = owed(
...     issues_source=issues,               # a list of dicts: no gh, no network
...     run_predicate=lambda command: 0,    # canned: the ask turns out to be done
...     trusted_owners=('acme',),
...     now=datetime(2026, 8, 20, tzinfo=timezone.utc),
... )
>>> report['counts']['discharged'], report['counts']['open']
(1, 1)
>>> for row in report['rows']:
...     print(row['state'], row['age_days'], f"{row['repo']}#{row['number']}")
open 10 acme/widget#8
discharged 19 acme/widget#7
```

The discharged row is reported, not closed:

```pycon
>>> report['rows'][1]['predicate']
'[ "$(gh api repos/acme/widget/keys --jq length)" -gt 0 ]'
```

An owner you did not name is never executed, and never silently believed:

```pycon
>>> quiet = owed(issues_source=issues, run_predicate=lambda command: 0,
...              trusted_owners=(), now=datetime(2026, 8, 20, tzinfo=timezone.utc))
>>> [(row['number'], row['state']) for row in quiet['rows']]
[(8, 'open'), (7, 'unknown')]
>>> next(r['evidence'] for r in quiet['rows'] if r['number'] == 7)
"not evaluated: owner 'acme' is not in trusted_owners"
```

### Module Attributes

| [`DISCHARGED`](_autosummary/openloops.obligations.html.md#openloops.obligations.DISCHARGED)        | The predicate ran and returned 0.                           |
|--------------------------------------------------------------------|-------------------------------------------------------------|
| [`UNKNOWN`](_autosummary/openloops.obligations.html.md#openloops.obligations.UNKNOWN)           | Nothing could be checked.                                   |
| [`OBLIGATION_STATES`](_autosummary/openloops.obligations.html.md#openloops.obligations.OBLIGATION_STATES) | The three states, in the order a reader cares about them.   |
| [`OBLIGATION_FIELDS`](_autosummary/openloops.obligations.html.md#openloops.obligations.OBLIGATION_FIELDS) | Every key on every row, whatever the issue happened to say. |

### Functions

| [`configured_owners`](_autosummary/openloops.obligations.html.md#openloops.obligations.configured_owners)(\*[, timeout])                  | Whose repositories to search, resolved once.                                 |
|----------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------|
| [`gh_issues`](_autosummary/openloops.obligations.html.md#openloops.obligations.gh_issues)(\*, owners[, label, state, limit, ...]) | The default `issues_source`: the open `manual-task` issues, from `gh`.       |
| [`owed`](_autosummary/openloops.obligations.html.md#openloops.obligations.owed)(\*[, verify, owners, trusted_owners, ...])   | The open `manual-task` obligations, each re-checked against the world.       |
| [`parse_verify`](_autosummary/openloops.obligations.html.md#openloops.obligations.parse_verify)(body)                                | `(predicate, verify_text)` for an issue body.                                |
| [`shell_predicate`](_autosummary/openloops.obligations.html.md#openloops.obligations.shell_predicate)(command, \*[, timeout])           | The default `run_predicate`: run the command in a subshell, bounded in time. |

### Classes

| [`Obligation`](_autosummary/openloops.obligations.html.md#openloops.obligations.Obligation)(repo, number, title, url, ...[, ...])   | One open `manual-task` issue, with the verdict its own predicate returned.   |
|-----------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------|
| [`PredicateOutcome`](_autosummary/openloops.obligations.html.md#openloops.obligations.PredicateOutcome)(status[, output])                 | What evaluating one predicate produced.                                      |

### Exceptions

| [`GhUnavailable`](_autosummary/openloops.obligations.html.md#openloops.obligations.GhUnavailable)   | The world could not be read: no `gh`, no credentials, no network, a timeout.   |
|------------------------------------------------------------------|--------------------------------------------------------------------------------|

### openloops.obligations.DISCHARGED *= 'discharged'*

The predicate ran and returned 0. The ask is done; the issue is just still open.

### *exception* openloops.obligations.GhUnavailable

Bases: [`RuntimeError`](https://docs.python.org/3/builtins/exceptions.html#RuntimeError)

The world could not be read: no `gh`, no credentials, no network, a timeout.

Raised only by the listing path, and never propagated to a caller of [`owed()`](_autosummary/openloops.obligations.html.md#openloops.obligations.owed) —
it becomes `listed: False` plus an `error`, which every surface must render as
`?`. Turning it into an empty list would be the one unrecoverable bug here.

### openloops.obligations.OBLIGATION_FIELDS *= ('repo', 'number', 'title', 'url', 'created', 'age_days', 'state', 'verify', 'predicate', 'evidence')*

Every key on every row, whatever the issue happened to say. A row shape that varies
by issue makes `row["predicate"]` a coin flip and makes a JSON schema a lie.

### openloops.obligations.OBLIGATION_STATES *= ('open', 'discharged', 'unknown')*

The three states, in the order a reader cares about them. `open` is
[`openloops.base.OPEN`](_autosummary/openloops.base.html.md#openloops.base.OPEN): a loop that is still open is the same idea either way.

### *class* openloops.obligations.Obligation(repo, number, title, url, created, age_days, state='open', verify='', predicate='', evidence='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One open `manual-task` issue, with the verdict its own predicate returned.

`verify` is the `**Verify:**` field exactly as the issue wrote it and
`predicate` is the runnable command extracted from it — both are carried so a
reader can disagree with `state` rather than absorb it.

```pycon
>>> Obligation('acme/widget', 7, 't', 'u', '2026-01-01T00:00:00Z', 3).state
'open'
```

#### as_dict()

JSON-ready form.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### *class* openloops.obligations.PredicateOutcome(status, output='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

What evaluating one predicate produced.

`status` is the shell exit status, or `None` for “could not be run” — the
difference between an answer and the absence of one.

```pycon
>>> PredicateOutcome(0).status, PredicateOutcome(None, 'timed out').output
(0, 'timed out')
```

### openloops.obligations.UNKNOWN *= 'unknown'*

Nothing could be checked. Rendered `?`. Never collapsed into either other state.

### openloops.obligations.configured_owners(, timeout=30.0)

Whose repositories to search, resolved once.

Three sources, in order, first one wins: `OPENLOOPS_OWNERS` (comma- or
space-separated); the owners named by the `gh owe` alias if one is installed,
because a fleet is usually already written down there and disagreeing with it is
how a count goes quietly wrong; and failing both, the login `gh` is authenticated
as. The resolved list is reported back on every result, so a partial answer is a
visible one rather than a silent one.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

```pycon
>>> import os
>>> os.environ['OPENLOOPS_OWNERS'] = 'acme, widgets co'
>>> configured_owners()
('acme', 'widgets', 'co')
>>> del os.environ['OPENLOOPS_OWNERS']
```

### openloops.obligations.gh_issues(, owners, label='manual-task', state='open', limit=50, timeout=30.0)

The default `issues_source`: the open `manual-task` issues, from `gh`.

This is a *filtered* search, not an enumeration — the label is applied server-side
and the result is capped — which is the only form of search this package is allowed
to use. Walking a whole fleet’s issues through the search API silently returns wrong
answers past its first thousand results; that job belongs to per-repo listing.

Returns whatever `gh` returned, unparsed and unjudged: a list of dicts with
`createdAt`, `number`, `repository`, `title`, `url` and `body`.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]

### openloops.obligations.owed(, verify=True, owners=None, trusted_owners=None, label='manual-task', limit=50, timeout=30.0, predicate_timeout=20.0, now=None, issues_source=None, run_predicate=None)

The open `manual-task` obligations, each re-checked against the world.

`verify=False` lists without executing anything; every row that carries a
predicate then reads `unknown`, because that is what is true about it.

`owners=` scopes the search (default: [`configured_owners()`](_autosummary/openloops.obligations.html.md#openloops.obligations.configured_owners)).
`trusted_owners=` scopes what may *execute* and defaults to `owners`, so
widening the search never quietly widens what runs — pass it explicitly to search
wider than you trust.

Two seams, each one keyword argument, each defaulting to a real implementation:
`issues_source=` (defaults to [`gh_issues()`](_autosummary/openloops.obligations.html.md#openloops.obligations.gh_issues); a list of dicts substitutes) and
`run_predicate=` (defaults to [`shell_predicate()`](_autosummary/openloops.obligations.html.md#openloops.obligations.shell_predicate); any callable from command
to exit status substitutes). With both injected this reaches no network and needs no
`gh`.

Returns one envelope, never a bare list, because a reader has to be able to tell
“nothing is owed” from “I could not find out”:

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

`listed`
: `False` when the *listing itself* failed. Every surface must render that as
  `?`. The counts are zeros and they mean nothing.

`checked`
: whether predicates were evaluated at all.

`truncated`
: the result set saturated its own cap, so the count is a floor.

`counts`
: `open` / `discharged` / `unknown` / `with_predicate` / `total`.

`rows`
: one dict per obligation, with every key in [`OBLIGATION_FIELDS`](_autosummary/openloops.obligations.html.md#openloops.obligations.OBLIGATION_FIELDS).

```pycon
>>> report = owed(issues_source=[], run_predicate=lambda command: 0)
>>> report['listed'], report['counts']['total'], report['rows']
(True, 0, [])
```

A listing that could not run is not “nothing owed”:

```pycon
>>> def broken(**query):
...     raise GhUnavailable('gh: not logged in')
>>> report = owed(issues_source=broken)
>>> report['listed'], report['error'], report['counts']['total']
(False, 'gh: not logged in', 0)
```

### openloops.obligations.parse_verify(body)

`(predicate, verify_text)` for an issue body. Both are `''` when absent.

The predicate is the first code span in the field. Prose with no code span – the
documented “no predicate is possible here” answer – yields no command, and the
prose is kept so the row can say *why* rather than merely say nothing.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

```pycon
>>> parse_verify('**Verify:** `test -f x`')
('test -f x', '`test -f x`')
>>> parse_verify('no such field here')
('', '')
```

A field that says no predicate is possible yields no command \*\*even when its prose
contains a code span\*\* – the documented wording mentions `gh` and `true` naturally,
and both exit 0, which would report a live obligation as done:

```pycon
>>> parse_verify('**Verify:** none possible - no `gh` query observes a decision.')
('', 'none possible - no `gh` query observes a decision.')
```

A quoted example of the format is not this issue’s own predicate:

```pycon
>>> body = chr(10).join(['```', '**Verify:** `quoted`', '```', '**Verify:** `real`'])
>>> parse_verify(body)[0]
'real'
```

An unterminated code span is not a command – it is a malformed field, and saying so
is the difference between a row that reads `?` and one that silently reads open:

```pycon
>>> parse_verify('**Verify:** `echo one &&' + chr(10) + 'echo two`')
('', '`echo one &&')
```

### openloops.obligations.shell_predicate(command, , timeout=20.0)

The default `run_predicate`: run the command in a subshell, bounded in time.

The exit status is the answer and nothing else is interpreted. Anything that is not
an exit status — a timeout, a shell that would not start — comes back as
`status=None`, which is `unknown`, which is `?`.

* **Return type:**
  [`PredicateOutcome`](_autosummary/openloops.obligations.html.md#openloops.obligations.PredicateOutcome)

```pycon
>>> shell_predicate('exit 0').status
0
>>> shell_predicate('exit 3').status
3
>>> shell_predicate('   ').status is None
True
```


# _autosummary/openloops.skills.html.md

# openloops.skills

The agent-facing surface: two skills, one subagent, and the command that installs them.

The `ol` command is plumbing. What most people actually want is an agent that knows
how to use the plumbing and tells them what matters – “what is being done, and what
needs my attention?” – without their ever typing `ol owed`. That agent is a file:
a `SKILL.md` an agent host loads when the question comes up. So openloops ships three
of them, inside the package, and this module is how they get from the wheel to the host.

| `openloops`             | the **read** skill. Runs the three commands, and<br/>synthesises rather than pastes. The one to load.                                                                  |
|-------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `openloops-needs-human` | the **capture** skill. Without something filing<br/>`manual-task` issues, `ol owed` reads an empty<br/>list forever – which is the state a stranger<br/>installs into. |
| `openloops-sweep`       | a subagent: the same sweep in a fresh context,<br/>returning a page instead of three screens of output.                                                                |

**Nothing here is openloops-specific machinery.** A skill is a markdown file in a
directory an agent host reads; installing one is a symlink. The whole module is a
hundred lines because that is genuinely all it is, and the alternative – a copy of
each skill pasted into every user’s config – is a copy that goes stale the first time
`pip install -U openloops` lands.

Two decisions worth stating, because both are the kind that get quietly reversed:

**A symlink, not a copy.** The point is that upgrading the package upgrades the skill.
Copying is the *fallback*, taken only where symlinks are unavailable (Windows without
Developer Mode, where creating one needs a privilege an ordinary process lacks), and
the plan says which happened rather than pretending they are the same thing.

**Nothing already there is ever overwritten.** A destination holding something that is
not ours reads `conflict`: it is reported, not replaced, and `force=True` is the
only way past. Someone’s hand-written skill of the same name is theirs, and silently
eating it would be a worse failure than not installing at all.

This is deliberately *not* in [`openloops.tools`](_autosummary/openloops.tools.html.md#module-openloops.tools). That module is the list every
surface dispatches from – an MCP server, an HTTP endpoint – and “symlink files into
this machine’s agent config” is not an operation a remote surface could honestly offer.
It belongs beside [`openloops.job`](_autosummary/openloops.job.html.md#module-openloops.job), the other installer, which the CLI also wraps
directly.

```pycon
>>> skills_dir().name, agents_dir().name
('skills', 'agents')
>>> sorted(asset.name for asset in bundled())
['openloops', 'openloops-needs-human', 'openloops-sweep']
```

### Module Attributes

| [`HOST_ENV_VAR`](_autosummary/openloops.skills.html.md#openloops.skills.HOST_ENV_VAR)   | Where Claude Code keeps skills and subagents, and the variable that relocates it.   |
|-----------------------------------------------------------------|-------------------------------------------------------------------------------------|

### Functions

| [`agents_dir`](_autosummary/openloops.skills.html.md#openloops.skills.agents_dir)()                                  | The bundled subagent definitions, inside the installed package.                   |
|------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------|
| [`bundled`](_autosummary/openloops.skills.html.md#openloops.skills.bundled)()                                     | Every skill and subagent this package ships, sorted by name.                      |
| [`host_dir`](_autosummary/openloops.skills.html.md#openloops.skills.host_dir)([target])                            | The agent host's config directory: `target`, `CLAUDE_CONFIG_DIR`, or `~/.claude`. |
| [`install_skills`](_autosummary/openloops.skills.html.md#openloops.skills.install_skills)(\*[, target, only, copy, ...]) | Make the bundled skills and subagent visible to an agent host.                    |
| [`skills_dir`](_autosummary/openloops.skills.html.md#openloops.skills.skills_dir)()                                  | The bundled skills directory, inside the installed package.                       |

### Classes

| [`Asset`](_autosummary/openloops.skills.html.md#openloops.skills.Asset)(kind, name, source)   | One installable thing: a skill directory or a subagent file.   |
|------------------------------------------------------------------------------|----------------------------------------------------------------|

### *class* openloops.skills.Asset(kind, name, source)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One installable thing: a skill directory or a subagent file.

`name` is what the host will call it, and it is the file or folder name too –
the agent-skill spec requires the folder name to equal the skill’s `name:`, so
deriving one from the other cannot drift.

#### destination(host)

Where this asset goes under *host*.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### openloops.skills.HOST_ENV_VAR *= 'CLAUDE_CONFIG_DIR'*

Where Claude Code keeps skills and subagents, and the variable that relocates it.
Honoured rather than reimplemented: a user who has moved their config has done so
for a reason, and an installer that ignores it writes to a directory nobody reads.

### openloops.skills.agents_dir()

The bundled subagent definitions, inside the installed package.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### openloops.skills.bundled()

Every skill and subagent this package ships, sorted by name.

Discovered from the directories rather than listed here: a list would be a second
place to update, and the one that gets forgotten.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Asset`](_autosummary/openloops.skills.html.md#openloops.skills.Asset)]

### openloops.skills.host_dir(target=None)

The agent host’s config directory: `target`, `CLAUDE_CONFIG_DIR`, or `~/.claude`.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

```pycon
>>> import os
>>> host_dir('/somewhere/else').name
'else'
```

### openloops.skills.install_skills(, target=None, only=None, copy=False, force=False, dry_run=False)

Make the bundled skills and subagent visible to an agent host. Idempotent.

Links each one into `target` (default: `CLAUDE_CONFIG_DIR` or `~/.claude`) so
that upgrading the package upgrades the skill. `copy=True` takes a copy instead,
which is also what happens by itself where symlinks are unavailable.

Returns the plan it carried out – one row per asset, each carrying its verdict and
the reason for it. `dry_run=True` returns the same plan having touched nothing,
which is the only way to see what a run would do *before* it does it.

Nothing that is already there is overwritten: an occupied destination reads
`conflict` and is left exactly as it was until `force=True`.

`only=` installs a subset by name. The case it exists for: you already have your
own capture skill, adapted to your own fleet, and a second one competing for the same
triggers is worse than either alone – so take the reader and the subagent and leave
the capture skill out.

* **Return type:**
  dict[str, Any]

```pycon
>>> plan = install_skills(target='/nonexistent/host', only=['openloops'], dry_run=True)
>>> [row['name'] for row in plan['actions']]
['openloops']
```

```pycon
>>> plan = install_skills(target='/nonexistent/host', dry_run=True)
>>> plan['counts']['install'], plan['dry_run']
(3, True)
>>> sorted({row['action'] for row in plan['actions']})
['install']
```

### openloops.skills.skills_dir()

The bundled skills directory, inside the installed package.

`Path(__file__).parent` rather than a configured root, so this answers correctly
from a wheel, an editable install, a zip and a virtualenv without being told which.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)


# _autosummary/openloops.store.html.md

# openloops.store

Where digests live, and where the change-detection cache lives — two different things.

The digest store is **data**: a local directory of markdown files under
`~/.local/share/openloops/digests/`, addressed as a `MutableMapping[str, str]` so
that swapping it for a git-synced directory or an S3-backed store is one keyword
argument rather than a rewrite. Nothing in openloops opens a file by path.

The sync cache is **not data**: it is a per-machine record of which transcript
revisions have already been read, and deleting it is a distinct operation from deleting
digests. That separation is ADR-010’s rule — a cache purge must not be able to reach
the thing being cached — and it is what makes the regeneration test meaningful: wipe
both, sync again, and every digest comes back byte-identical.

Keys are `{source}/{state}/{session}.md`. The source segment is the machine (or
person) that wrote the digest, so two machines syncing into one git repository never
write the same path and there is nothing to reconcile. The state segment is `open` or
`archive` — **loop state, not process state**. If it ever came to mean “a process is
running”, this would be a session dashboard rather than a record of what sessions said.

```pycon
>>> digest_key('laptop', 'open', 'abc-123')
'laptop/open/abc-123.md'
>>> parse_digest_key('laptop/open/abc-123.md')
('laptop', 'open', 'abc-123')
```

### Functions

| [`data_dir`](_autosummary/openloops.store.html.md#openloops.store.data_dir)()                             | The project's data root.                                         |
|-----------------------------------------------------------------------------------------|------------------------------------------------------------------|
| [`state_dir`](_autosummary/openloops.store.html.md#openloops.store.state_dir)()                            | Where per-machine caches and job logs go.                        |
| [`default_source`](_autosummary/openloops.store.html.md#openloops.store.default_source)([directory])            | This machine's label for its digest folder.                      |
| [`digests_store`](_autosummary/openloops.store.html.md#openloops.store.digests_store)([rootdir])               | The default `digests_store`: markdown files under the data root. |
| [`digest_key`](_autosummary/openloops.store.html.md#openloops.store.digest_key)(source, state, session_key) | The store key for one session's digest.                          |
| [`parse_digest_key`](_autosummary/openloops.store.html.md#openloops.store.parse_digest_key)(key)                  | Split a store key back into `(source, state, session_key)`.      |
| [`load_sync_state`](_autosummary/openloops.store.html.md#openloops.store.load_sync_state)([directory])           | Session id → the transcript revision last read.                  |
| [`save_sync_state`](_autosummary/openloops.store.html.md#openloops.store.save_sync_state)(state[, directory])    | Write the mtime cache, creating its directory.                   |
| [`sync_state_path`](_autosummary/openloops.store.html.md#openloops.store.sync_state_path)([directory])           | Where the mtime cache lives.                                     |

### Classes

| [`Utf8TextFiles`](_autosummary/openloops.store.html.md#openloops.store.Utf8TextFiles)(\*args[, delete_func])   | `dol`'s text-file store with the encoding pinned rather than inherited.   |
|-----------------------------------------------------------------------------------------|---------------------------------------------------------------------------|

### *class* openloops.store.Utf8TextFiles(\*args, delete_func=None, \*\*kwargs)

Bases: `PrefixRelativizationMixin`, `Store`

`dol`’s text-file store with the encoding pinned rather than inherited.

`dol` opens text with `encoding=None`, i.e. whatever the locale says. Every
digest contains an em dash, so on a machine where the locale resolves to ASCII — a
container or a cron job with `LANG` unset, an Alpine image — writing one raises,
and in a store two machines share it is worse: one writes bytes the other cannot
read back. Pinning UTF-8 is the whole fix.

#### is_valid_key(k, \*args, \_\_name='is_valid_key', \*\*kwargs)

`is_valid_key` on the inner key – see `mk_relative_path_store`.

#### validate_key(k, \*args, \_\_name='validate_key', \*\*kwargs)

`validate_key` on the inner key – see `mk_relative_path_store`.

### openloops.store.data_dir()

The project’s data root. Override with `OPENLOOPS_DATA_DIR`.

Per-kind subdirectories hang off this; nothing is written directly into it, so a
later kind of data needs no second migration.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### openloops.store.default_source(directory=None)

This machine’s label for its digest folder. Override with `OPENLOOPS_SOURCE`.

A short, filename-safe name. It exists so several machines can sync digests into one
place without colliding — not to identify anybody, though on a machine whose
hostname was never changed it may well do; every `ol sync` prints it, and
`OPENLOOPS_SOURCE` overrides it.

**It is sticky.** The hostname is a *seed*, written once to a file under the state
directory and read thereafter. macOS rewrites the hostname when it joins a network
where the name collides — appending `-2`, `-3` — and a label that moved would
fork the store into two complete copies with no dedup and no warning.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### openloops.store.digest_key(source, state, session_key)

The store key for one session’s digest.

Both segments are validated, not just the state. `transcript_source=` is a seam
whose documented purpose is “another machine’s synced transcripts”, so a session id
can come from a listing this process did not produce — and a key containing `..`
would make a file-backed store write outside its own root while `sync` reported
success. Validating here means every backend inherits the check.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

```pycon
>>> digest_key('mac', 'archive', 's1')
'mac/archive/s1.md'
>>> digest_key('mac', 'open', '../../etc/passwd')
Traceback (most recent call last):
    ...
ValueError: session key must be a single safe path segment, got '../../etc/passwd'
```

### openloops.store.digests_store(rootdir=None)

The default `digests_store`: markdown files under the data root.

Any `MutableMapping[str, str]` works in its place — a plain `dict` for tests,
a git-synced directory, an S3-backed store. The keys are what carry the layout, so
a different backend gets the same `{source}/{state}/{session}.md` structure for
free.

* **Return type:**
  [`MutableMapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.MutableMapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

```pycon
>>> store = digests_store(rootdir='/tmp/openloops-doctest-store')
>>> store['demo/open/s1.md'] = '# hi'
>>> sorted(store)
['demo/open/s1.md']
>>> del store['demo/open/s1.md']
```

### openloops.store.load_sync_state(directory=None)

Session id → the transcript revision last read. Missing or corrupt reads empty.

A corrupt cache is treated as an absent one on purpose: the only cost of being
wrong here is re-deriving digests that were already correct.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### openloops.store.parse_digest_key(key)

Split a store key back into `(source, state, session_key)`.

Both separators are accepted. A file-backed store hands keys back joined with the
platform’s separator, so on Windows every key would fail to parse and every read
path would quietly return nothing while `sync` reported digests written.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

```pycon
>>> parse_digest_key('mac/archive/s1.md')
('mac', 'archive', 's1')
>>> parse_digest_key('mac' + chr(92) + 'archive' + chr(92) + 's1.md')
('mac', 'archive', 's1')
```

### openloops.store.save_sync_state(state, directory=None)

Write the mtime cache, creating its directory. Returns the path written.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### openloops.store.state_dir()

Where per-machine caches and job logs go. Override with `OPENLOOPS_STATE_DIR`.

Deliberately not under [`data_dir()`](_autosummary/openloops.store.html.md#openloops.store.data_dir): everything here is disposable, and keeping
it elsewhere means “clear the cache” can never be mistyped into “delete the digests”.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### openloops.store.sync_state_path(directory=None)

Where the mtime cache lives. Deleting this file forces a full re-read.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)


# _autosummary/openloops.tools.html.md

# openloops.tools

The package’s single list of operations: plain functions, JSON-ready dicts.

Every surface openloops has or might grow — the `ol` command today, an MCP server or
an HTTP endpoint later — dispatches from this module and nothing else. The functions
here know nothing about argument parsers, transports or agent hosts; they take flat
serialisable arguments and return flat serialisable results, and printing is somebody
else’s job.

Keeping one list is what stops two surfaces from drifting apart. A parity test between
two surfaces is a sign that there are two implementations, and there is one here.

### Module Attributes

| [`ROW_FIELDS`](_autosummary/openloops.tools.html.md#openloops.tools.ROW_FIELDS)   | The keys every [`ls()`](_autosummary/openloops.tools.html.md#openloops.tools.ls) row carries, whatever the digest happens to record.   |
|---------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------|

### Functions

| [`sync`](_autosummary/openloops.tools.html.md#openloops.tools.sync)(\*[, source, since_days, force, ...])        | Read what changed in the sessions and write the digests.                               |
|----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------|
| [`ls`](_autosummary/openloops.tools.html.md#openloops.tools.ls)(\*[, state, source, project, confidence, ...]) | The digests in the store, newest last-turn first.                                      |
| [`show`](_autosummary/openloops.tools.html.md#openloops.tools.show)(session, \*[, source, digests_store])        | One digest in full, found by session id or by a unique prefix of one.                  |
| [`status`](_autosummary/openloops.tools.html.md#openloops.tools.status)(\*[, source, since_days, ...])             | Where everything is, how much of it there is, and how stale the cache is.              |
| [`owed`](_autosummary/openloops.tools.html.md#openloops.tools.owed)(\*[, verify, owners, trusted_owners, ...])   | What you still owe your agents, with each obligation re-checked against the world.     |
| [`blocked`](_autosummary/openloops.tools.html.md#openloops.tools.blocked)(\*[, resolve, owners, repos, query, ...]) | What your repositories are waiting on — and what is no longer waiting.                 |
| [`dashboard`](_autosummary/openloops.tools.html.md#openloops.tools.dashboard)(\*[, verify, resolve, owners, ...])     | All three answers as one self-contained HTML page, for looking at rather than reading. |

### openloops.tools.ROW_FIELDS *= ('session', 'source', 'state', 'title', 'ai_title', 'project', 'branches', 'started', 'ended', 'last_turn', 'turns', 'model', 'confidence', 'verified')*

The keys every [`ls()`](_autosummary/openloops.tools.html.md#openloops.tools.ls) row carries, whatever the digest happens to record.

### openloops.tools.blocked(, resolve=True, owners=None, repos=None, query='is:blocked', limit=50, timeout=30.0, issues_source=None, blockers_source=None)

What your repositories are waiting on — and what is no longer waiting.

The sibling of [`owed()`](_autosummary/openloops.tools.html.md#openloops.tools.owed): an obligation points at a person, a blocker edge
points at another repository, and both are commitments nothing is watching. Lists
the open issues carrying a `blocked_by` dependency across `owners` (or exactly
the `repos` you name), resolves every edge, and reports three states:
`unblocked` (every blocker closed — the work is free and nobody has been told),
`blocked` (naming the foreign repository it waits on) and `unknown` (nothing
could be resolved). Nothing is ever closed, relabelled or written; see
[`openloops.blockers`](_autosummary/openloops.blockers.html.md#module-openloops.blockers) for what discovery costs and what it is measured to miss.

The result is an envelope rather than a list, because `listed=False` — discovery
itself failed — must not be readable as “nothing is waiting”.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> report = blocked(issues_source=[], blockers_source={})
>>> report['listed'], report['counts']['total']
(True, 0)
```

### openloops.tools.dashboard(, verify=True, resolve=True, owners=None, repos=None, limit=50, candidate_limit=50, max_sessions=40, title='Open Loops Board', standalone=True, path=None, made_at=None, owed_report=None, blocked_report=None, sessions=None, digests_store=None)

All three answers as one self-contained HTML page, for looking at rather than reading.

Runs [`owed()`](_autosummary/openloops.tools.html.md#openloops.tools.owed), [`blocked()`](_autosummary/openloops.tools.html.md#openloops.tools.blocked) and [`ls()`](_autosummary/openloops.tools.html.md#openloops.tools.ls), then renders
[`openloops.dashboard.render_dashboard()`](_autosummary/openloops.dashboard.html.md#openloops.dashboard.render_dashboard) over what they returned. The page is a
**snapshot**: it stamps the moment it was made and states, in its largest type, that
it has checked nothing since — because a published page cannot. Nothing is written
to GitHub, here or anywhere in openloops.

`path` also writes the page there, scrubbed, and reports where it went.
`standalone=False` returns the same page without the document scaffold, for a host
that supplies its own `<head>`.

Each of the three reads is a seam: pass `owed_report=`, `blocked_report=` or
`sessions=` and that one is not performed. With all three passed this reaches no
network and needs no `gh`, which is how the tests run.

`counts` comes back alongside the page and carries `null` — not `0` — for any
figure that could not be established, so a caller reading the JSON is held to the
same three-state rule as a reader looking at the page.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> result = dashboard(owed_report={'listed': True, 'counts': {'open': 0}, 'rows': []},
...                    blocked_report={'listed': False, 'error': 'no gh'},
...                    sessions=[], made_at='2026-01-01T00:00:00Z')
>>> result['counts']['free_to_proceed'] is None, result['counts']['unknown'] is None
(True, True)
```

### openloops.tools.ls(, state='open', source=None, project=None, confidence=None, limit=20, digests_store=None)

The digests in the store, newest last-turn first.

`state` is `open`, `archive` or `all`. `source`, `project` and
`confidence` narrow by the digest’s own header fields. Each row is that front
matter plus its key.

`confidence='high'` is the one worth knowing about: most sessions land in `open`,
and a good few of those are open only because nothing said otherwise. Filtering to
`high` leaves the ones where the session itself said something.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]

```pycon
>>> rows = ls(digests_store={'m/open/s1.md':
...     '---\nsession: s1\nstate: open\nlast_turn: T9\n---\n'})
>>> rows[0]['session'], rows[0]['key']
('s1', 'm/open/s1.md')
```

### openloops.tools.owed(, verify=True, owners=None, trusted_owners=None, label='manual-task', limit=50, timeout=30.0, predicate_timeout=20.0, issues_source=None, run_predicate=None)

What you still owe your agents, with each obligation re-checked against the world.

Lists the open `manual-task` issues across `owners` and evaluates the
`**Verify:**` predicate each one carries, reporting three states: `open`,
`discharged` (the predicate returned 0 — done, but the issue is still open) and
`unknown` (nothing could be checked). Nothing is ever closed, relabelled or
written; see [`openloops.obligations`](_autosummary/openloops.obligations.html.md#module-openloops.obligations) for the trust boundary that evaluating a
predicate crosses, and `verify=False` for the way to read without executing.

The result is an envelope rather than a list, because `listed=False` — the query
itself failed — must not be readable as “nothing owed”.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> report = owed(issues_source=[], run_predicate=lambda command: 0)
>>> report['listed'], report['counts']['total']
(True, 0)
```

### openloops.tools.show(session, , source=None, digests_store=None)

One digest in full, found by session id or by a unique prefix of one.

An exact id always wins over a prefix, so a session whose id happens to be a prefix
of another stays reachable. `source` narrows a store that several machines write
into, where the same session can legitimately appear more than once.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

```pycon
>>> store = {'m/open/abcdef.md': '---\nsession: abcdef\n---\nbody'}
>>> show('abc', digests_store=store)['key']
'm/open/abcdef.md'
```

### openloops.tools.status(, source=None, since_days=None, digests_store=None, transcript_source=None)

Where everything is, how much of it there is, and how stale the cache is.

Reports the cache’s age because a read served from a cache that nothing has
refreshed is the failure openloops is built to avoid — a periodic job that died
leaves a confident, months-old answer behind, and the only defence is saying how
old the answer is.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### openloops.tools.sync(, source=None, since_days=None, force=False, transcript_source=None, digests_store=None, state_dir=None)

Read what changed in the sessions and write the digests. Returns a summary.

`since_days` bounds the scan to recently-modified transcripts; `force`
re-derives everything, which must produce an identical store.

`state_dir` scopes the change-detection cache. It matters because the seams do
not isolate on their own: swapping `transcript_source` for a fixture and leaving
the cache alone writes the fixture’s revisions into the caller’s real one.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]


# _autosummary/openloops.transcripts.html.md

# openloops.transcripts

The default `transcript_source`: a direct reader of Claude Code’s on-disk state.

Claude Code persists one JSONL transcript per session under
`~/.claude/projects/<encoded-cwd>/<session-uuid>.jsonl`. [`ClaudeCodeTranscripts`](_autosummary/openloops.transcripts.html.md#openloops.transcripts.ClaudeCodeTranscripts)
is a `Mapping` from session id to [`Session`](_autosummary/openloops.base.html.md#openloops.base.Session), so a caller who
wants a different reader — a test fixture, a copy synced from another machine, an
in-house parser — passes any mapping of the same shape and nothing downstream changes.

Two things this reader deliberately does not do.

It never reverses a project directory name back into a working directory. The encoding
is lossy (`/`, `_` and `.` all become `-`), so `cwd` is read from the records
themselves or left empty.

It never looks at whether a process is running. A session’s transcript is a document;
what openloops reports is what the document says. Liveness is a different tool’s job,
and mixing the two is how a digest store becomes a session dashboard. That tool is
[crowsnest](https://github.com/thorwhalen/crowsnest), which reads the session registry
and the tail of these same transcripts to say who is busy, idle or waiting on you.

```pycon
>>> src = ClaudeCodeTranscripts(root='/nonexistent-dir-for-doctest')
>>> len(src)
0
>>> src.revision()
'0'
```

### Functions

| [`claude_projects_dir`](_autosummary/openloops.transcripts.html.md#openloops.transcripts.claude_projects_dir)([root])       | Where Claude Code keeps per-session transcripts.                                                                      |
|------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|
| [`parse_session`](_autosummary/openloops.transcripts.html.md#openloops.transcripts.parse_session)(records, \*[, key]) | Read one transcript's records into a [`Session`](_autosummary/openloops.base.html.md#openloops.base.Session). |

### Classes

| [`ClaudeCodeTranscripts`](_autosummary/openloops.transcripts.html.md#openloops.transcripts.ClaudeCodeTranscripts)([root, since_days, ...])   | Claude Code's persisted sessions, as a `Mapping[str, Session]`.         |
|---------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
| [`Revisioned`](_autosummary/openloops.transcripts.html.md#openloops.transcripts.Revisioned)(source)                               | Give any mapping of sessions a total `revision` / `changed_since` pair. |

### *class* openloops.transcripts.ClaudeCodeTranscripts(root=None, , since_days=None, projects=None, skip_scratchpads=True)

Bases: [`Mapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping)

Claude Code’s persisted sessions, as a `Mapping[str, Session]`.

`since_days` bounds the scan by file modification time (`None` scans
everything); `projects` keeps only project directories whose name contains one of
the given substrings; `skip_scratchpads` drops the throwaway directories the CLI
creates under a temp root, which hold agent scratch sessions rather than work.

ADR-010’s revision shape lives here: [`revision()`](_autosummary/openloops.transcripts.html.md#openloops.transcripts.ClaudeCodeTranscripts.revision) returns an opaque token —
a file’s modification time for one session, a hash over all of them for the
collection — and [`changed_since()`](_autosummary/openloops.transcripts.html.md#openloops.transcripts.ClaudeCodeTranscripts.changed_since) compares against it. mtime is a coarse
signal that both misses content-preserving rewrites and fires on touches; that
trade is accepted rather than reprocessing several thousand transcripts a tick.

```pycon
>>> src = ClaudeCodeTranscripts(root='/nonexistent-dir-for-doctest')
>>> list(src), src.changed_since('0')
([], False)
```

#### changed_since(token, key=None)

Whether the current [`revision()`](_autosummary/openloops.transcripts.html.md#openloops.transcripts.ClaudeCodeTranscripts.revision) differs from *token*.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

#### path_of(key)

The transcript file backing *key*.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

#### revision(key=None)

An opaque change token: for one session, or for the whole collection.

For a single key it is the transcript’s modification time in nanoseconds. For
the collection it is a hash over every `(key, mtime)` pair, so a token can
be compared without holding the whole index.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

#### scratchpad_marker *= '-private-tmp'*

Project-directory names starting with this are scratchpads, not work.

### *class* openloops.transcripts.Revisioned(source)

Bases: [`Mapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping)

Give any mapping of sessions a total `revision` / `changed_since` pair.

ADR-010’s rule is that no caller may branch on whether a backend happens to
implement change detection — capability probing is backend leakage by another name.
So the probe happens exactly once, here, at the boundary: a wrapped source that
supplies its own cheap revision token keeps it, and one that does not gets a total
default (a hash of the value). `sync()` therefore always has
the methods and never asks.

The default is honest rather than fast: hashing requires loading the session, so a
source with no cheap token gains correctness and no speed. That is the right way
round — a plain `dict` of sessions in a test costs nothing to hash, and the
on-disk reader supplies mtime.

```pycon
>>> from openloops.base import Session
>>> src = Revisioned({'a': Session(key='a')})
>>> src.revision('a') == Revisioned({'a': Session(key='a')}).revision('a')
True
>>> src.changed_since(src.revision('a'), 'a')
False
```

#### changed_since(token, key=None)

Whether the current [`revision()`](_autosummary/openloops.transcripts.html.md#openloops.transcripts.Revisioned.revision) differs from *token*.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

#### revision(key=None)

An opaque token that changes when the underlying session changes.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### openloops.transcripts.claude_projects_dir(root=None)

Where Claude Code keeps per-session transcripts.

Resolution order: the *root* argument, then `CLAUDE_PROJECTS_DIR`, then
`~/.claude/projects`.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

```pycon
>>> claude_projects_dir('/tmp/x').name
'x'
```

### openloops.transcripts.parse_session(records, , key='')

Read one transcript’s records into a [`Session`](_autosummary/openloops.base.html.md#openloops.base.Session).

Pure: same records in, same session out. Everything it reports is a fact about the
document — no inference, no judgement, and nothing about running processes.

Three details are corpus-driven rather than obvious, and getting them wrong
mislabels a large fraction of real sessions:

- \*\*Start and end come from `min`/`max` over every timestamp\*\*, not from the
  first and last line. Transcript lines are not written in timestamp order; on real
  data the first line holds the earliest timestamp only about four times in five.
- \*\*The project comes from the *first* record’s `cwd`.\*\* Four sessions in five
  visit more than one working directory (scratchpads, sibling repos, task dirs), and
  the last or most-frequent one names a different repository about half the time.
- **Branches are a tuple.** A third of sessions touch more than one.

```pycon
>>> recs = [
...     {"type": "user", "sessionId": "s1", "cwd": "/w/proj", "timestamp": "T1",
...      "gitBranch": "main",
...      "message": {"role": "user", "content": [{"type": "text", "text": "do it"}]}},
...     {"type": "assistant", "sessionId": "s1", "timestamp": "T2",
...      "message": {"role": "assistant", "model": "m",
...                  "content": [{"type": "text", "text": "done"}]}},
... ]
>>> s = parse_session(recs)
>>> s.key, s.project, s.turn_count, s.last_assistant_text
('s1', 'proj', 1, 'done')
>>> s.started_at, s.ended_at, s.git_branch, s.ended_mid_turn
('T1', 'T2', 'main', False)
```

* **Return type:**
  [`Session`](_autosummary/openloops.base.html.md#openloops.base.Session)


# about-this-build.html.md

<!-- generated by epythet -->

# About this build

This documentation was built on **2026-09-26 07:43 UTC** from commit <a href="https://github.com/thorwhalen/openloops/commit/87a40aa222cfd76f6bc862e9fbff3b45c5dfb48c"><code>87a40aa</code></a> on branch <code>main</code>, for **openloops 0.1.15** (from <code>pyproject.toml</code>).

#### NOTE
Nothing suggests a mismatch: the tree was clean at the commit above, and the documented version is the one on PyPI.

## Source

|                     |                                                                                                                                                             |
|---------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Commit              | <a href="https://github.com/thorwhalen/openloops/commit/87a40aa222cfd76f6bc862e9fbff3b45c5dfb48c"><code>87a40aa222cfd76f6bc862e9fbff3b45c5dfb48c</code></a> |
| Branch              | <code>main</code>                                                                                                                                           |
| Tags at this commit | none                                                                                                                                                        |
| Working tree        | clean                                                                                                                                                       |
| Remote              | <code>https://github.com/thorwhalen/openloops</code>                                                                                                        |

## Continuous integration

|              |                                                                                            |
|--------------|--------------------------------------------------------------------------------------------|
| Repository   | <code>thorwhalen/openloops</code>                                                          |
| Run          | <a href="https://github.com/thorwhalen/openloops/actions/runs/36227561665">36227561665</a> |
| Ref          | <code>refs/heads/main</code>                                                               |
| Event commit | <code>87a40aa222cfd76f6bc862e9fbff3b45c5dfb48c</code> (in the history of the built commit) |

## Tools

|          |         |
|----------|---------|
| epythet  | 0.2.12  |
| Sphinx   | 9.1.0   |
| docutils | 0.22.4  |
| Python   | 3.12.14 |

## Configuration as resolved

|               |                                                                   |
|---------------|-------------------------------------------------------------------|
| theme         | <code>auto</code> (Sphinx theme <code>pydata_sphinx_theme</code>) |
| accent        | <code>#006d67</code>                                              |
| api_generator | <code>autosummary</code>                                          |
| ignore        | <code>tests/</code>, <code>scrap/</code>, <code>examples/</code>  |
| agent_outputs | <code>true</code>                                                 |
| aggregates    | <code>md</code>                                                   |
| ai_artifacts  | <code>true</code>                                                 |

## Package on PyPI

Latest release: <a href="https://pypi.org/project/openloops/0.1.15/">0.1.15</a>, the same as the documented version.

## Reproduce

```bash
git clone https://github.com/thorwhalen/openloops && cd openloops
git checkout 87a40aa222cfd76f6bc862e9fbff3b45c5dfb48c
pip install "epythet==0.2.12"
epythet quickstart . --ignore tests/ scrap/ examples/
```

The same data, for machines: <a href="build_info.json"><code>build_info.json</code></a> (schema version 1).


# ai-agents.html.md

<!-- generated by epythet -->

# For AI agents

`openloops` ships artifacts for coding agents alongside its code. This page lists
them, says where each lives in the repository, and points at the
machine-readable copies of this documentation.

## Skills

Skills are folders holding a `SKILL.md` (the [Agent Skills](https://agentskills.io) format): a description that tells an agent when to use it and a body with the procedure. Install one into your agent with `gh skill` (any host: `--agent claude-code`, `copilot`, `cursor`, `codex`, `gemini`), or use the copy bundled in the wheel.

### `openloops`

Answer “what is being done, and what needs my attention?” across every coding session and every repository, by running the `ol` command and synthesising its answers rather than pasting them. Use when the user asks what needs their attention, what their agents have been doing or left unfinished, what they missed while they were away, what they still owe an agent, what is blocked or waiting on another repo, or what quietly became unblocked and nobody noticed. Triggers on: ‘what needs my attention’, ‘anything waiting on me’, ‘catch me up’, ‘what did I miss’, ‘where are we’, ‘status across my sessions’, ‘what are my agents doing’, ‘what did my sessions leave open’, ‘what do I owe’, ‘what am I blocked on’, ‘what can I start now’, ‘give me the overview’, ‘morning briefing’, ‘what should I look at first’, ‘is anything stuck’. Also use before planning a day’s work, so the plan starts from what is outstanding rather than from memory. Read-only: it never closes, reopens or relabels anything.

```bash
gh skill install thorwhalen/openloops openloops --agent claude-code
```

Source: [`openloops/data/skills/openloops`](https://github.com/thorwhalen/openloops/tree/HEAD/openloops/data/skills/openloops) (bundled with the pip package).

### `openloops-needs-human`

Use the moment you are blocked on the human and cannot finish on your own: a secret or API key only they can set; a command only they can run (a deploy, a `sudo`, a step your permissions refuse); access you do not have; a purchase, an approval, a decision only they can make; or a decision ALREADY RECORDED – an ADR, a convention, a default – that is now in your way or looks wrong given what you now know. Files the blocker as a `manual-task` GitHub issue in the affected repo, carrying a shell predicate, so the obligation outlives the session instead of dying in a message nobody re-reads and `ol owed` can re-check it later. Triggers on: ‘I can’t do this without you’, ‘you’ll need to run this’, ‘this needs your credentials’, ‘blocked on’, ‘someone has to decide’, ‘I don’t have access to’, ‘the ADR says X but’, or any point where the honest next step is to hand the work back. File it BEFORE writing the hand-back message, not instead of it. Skip when you could do the thing yourself.

```bash
gh skill install thorwhalen/openloops openloops-needs-human --agent claude-code
```

Source: [`openloops/data/skills/openloops-needs-human`](https://github.com/thorwhalen/openloops/tree/HEAD/openloops/data/skills/openloops-needs-human) (bundled with the pip package).

The bundled skills are also on disk after `pip install openloops`, under the package’s `data/skills/` directory; link them into an agent without network access with `skill link-skills <that directory>`.

## Subagents

Subagents are Markdown files with a frontmatter (`name`, `description`, `tools`) and a system prompt as the body. Copy one into your project’s `.claude/agents/` (or your agent host’s equivalent) to delegate the task it describes.

### `openloops-sweep`

Runs the openloops sweep (`ol`, `ol owed`, `ol blocked`) in a fresh context and returns a short synthesis of what needs the user’s attention, what is now free to proceed, and what is in flight — never the raw command output. Invoke whenever the user asks what needs their attention, what their agents left open, what they owe, what is blocked or newly unblocked, or for a catch-up across their sessions. Read-only: it never closes, reopens, relabels or comments on anything.

Source: [`openloops/data/agents/openloops-sweep.md`](https://github.com/thorwhalen/openloops/tree/HEAD/openloops/data/agents/openloops-sweep.md); tools: `Bash`.

## Instruction files

Files agents read before working in this repository.

- [`.claude/CLAUDE.md`](https://github.com/thorwhalen/openloops/tree/HEAD/.claude/CLAUDE.md): read by Claude Code

## Machine-readable documentation

This site publishes the same documentation in forms that fit an agent’s context window:

- [`llms.txt`](https://thorwhalen.github.io/openloops/llms.txt): an index of every page with a one-line description ([llms.txt](https://llmstxt.org) format)
- [`openloops.md`](https://thorwhalen.github.io/openloops/openloops.md): the whole documentation as one Markdown file
- `<page>.html.md`: a rendered Markdown twin of every page, advertised from each page’s `<head>` with `<link rel="alternate" type="text/markdown">`
- [`objects.inv`](https://thorwhalen.github.io/openloops/objects.inv): the Sphinx inventory: a symbol-to-URL index (`sphobjinv convert plain objects.inv -`)


# api.html.md

# API reference

| [`openloops`](_autosummary/openloops.html.md#module-openloops)   | What your Claude Code sessions were doing — kept after the transcripts are gone.   |
|-------------------------------------------------------------------------------|------------------------------------------------------------------------------------|


