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:
|
no predicate at all, or the predicate ran and returned non-zero |
|
the predicate ran and returned |
|
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:
Only owners you named. A predicate is run only when the issue’s repository owner is in
trusted_owners=, which defaults to the sameowners=the search was scoped to. A row outside that set reads?— neveropen, because nothing checked it, and neverdischarged, because nothing ran.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.
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.Every evaluation is time-bounded, by
predicate_timeout=. A timeout isunknown, 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.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
The predicate ran and returned 0. |
|
Nothing could be checked. |
|
The three states, in the order a reader cares about them. |
|
Every key on every row, whatever the issue happened to say. |
Functions
|
Whose repositories to search, resolved once. |
|
The default |
|
The open |
|
|
|
The default |
Classes
|
One open |
|
What evaluating one predicate produced. |
Exceptions
The world could not be read: no |
- openloops.obligations.DISCHARGED = 'discharged'#
The predicate ran and returned 0. The ask is done; the issue is just still open.
Bases:
RuntimeErrorThe 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 becomeslisted: Falseplus anerror, 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.
openisopenloops.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:
objectOne open
manual-taskissue, with the verdict its own predicate returned.verifyis the**Verify:**field exactly as the issue wrote it andpredicateis the runnable command extracted from it — both are carried so a reader can disagree withstaterather than absorb it.>>> Obligation('acme/widget', 7, 't', 'u', '2026-01-01T00:00:00Z', 3).state 'open'
- class openloops.obligations.PredicateOutcome(status, output='')[source]#
Bases:
objectWhat evaluating one predicate produced.
statusis the shell exit status, orNonefor “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 thegh owealias 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 loginghis authenticated as. The resolved list is reported back on every result, so a partial answer is a visible one rather than a silent one.>>> 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 openmanual-taskissues, fromgh.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
ghreturned, unparsed and unjudged: a list of dicts withcreatedAt,number,repository,title,urlandbody.
- 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-taskobligations, each re-checked against the world.verify=Falselists without executing anything; every row that carries a predicate then readsunknown, because that is what is true about it.owners=scopes the search (default:configured_owners()).trusted_owners=scopes what may execute and defaults toowners, 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 togh_issues(); a list of dicts substitutes) andrun_predicate=(defaults toshell_predicate(); any callable from command to exit status substitutes). With both injected this reaches no network and needs nogh.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”:
listedFalsewhen the listing itself failed. Every surface must render that as?. The counts are zeros and they mean nothing.checkedwhether predicates were evaluated at all.
truncatedthe result set saturated its own cap, so the count is a floor.
countsopen/discharged/unknown/with_predicate/total.rowsone 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.
>>> 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
ghandtruenaturally, 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 isunknown, which is?.- Return type:
>>> shell_predicate('exit 0').status 0 >>> shell_predicate('exit 3').status 3 >>> shell_predicate(' ').status is None True