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**:

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

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 is merely still open

unknown

the predicate could not be run, or errored in a way that is not 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.

>>> 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:

>>> 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:

>>> 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

The predicate ran and returned 0.

UNKNOWN

Nothing could be checked.

OBLIGATION_STATES

The three states, in the order a reader cares about them.

OBLIGATION_FIELDS

Every key on every row, whatever the issue happened to say.

Functions

configured_owners(*[, timeout])

Whose repositories to search, resolved once.

gh_issues(*, owners[, label, state, limit, ...])

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

owed(*[, verify, owners, trusted_owners, ...])

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

parse_verify(body)

(predicate, verify_text) for an issue body.

shell_predicate(command, *[, timeout])

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

Classes

Obligation(repo, number, title, url, ...[, ...])

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

PredicateOutcome(status[, output])

What evaluating one predicate produced.

Exceptions

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[source]#

Bases: 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.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: 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='')[source]#

Bases: 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.

>>> Obligation('acme/widget', 7, 't', 'u', '2026-01-01T00:00:00Z', 3).state
'open'
as_dict()[source]#

JSON-ready form.

Return type:

dict[str, Any]

class openloops.obligations.PredicateOutcome(status, output='')[source]#

Bases: 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.

>>> 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)[source]#

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[str, ...]

>>> 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)[source]#

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[dict[str, 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)[source]#

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(); a list of dicts substitutes) and run_predicate= (defaults to 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[str, 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.

>>> 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”:

>>> 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)[source]#

(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[str, str]

>>> 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:

>>> 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:

>>> 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:

>>> parse_verify('**Verify:** `echo one &&' + chr(10) + 'echo two`')
('', '`echo one &&')
openloops.obligations.shell_predicate(command, *, timeout=20.0)[source]#

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

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