Where it is, what a record means, and how to answer “what happened?” from it alone.
~/.local/state/robot-army/logs/audit-YYYY-MM-DD.jsonl
JSON Lines: one record per line, opened in append mode, flushed per record. A record still sitting in a buffer when the process dies is not a durable record, which is why the flush is per line rather than per block.
Daily files, never deleted automatically. The constitution requires that any rotation policy not discard records silently; nothing here discards anything. Prune by hand when the directory gets large.
A partially written final line is possible — the process can die between the write and the
next flush. Readers skip unparseable lines and count them; robot-army log reports the
count at the end. Refusing to read the log because of one truncated line would be the wrong
trade.
robot-army log --since 10m # 30s, 10m, 2h, 1d
robot-army log --item 42 # everything about one work item
robot-army log --follow # tail the current day's file
robot-army log --json | jq . # machine-readable
Or directly, since it is just JSONL:
jq -c 'select(.action | startswith("github"))' ~/.local/state/robot-army/logs/audit-*.jsonl
Every record carries what Principle III enumerates: when, which component, what action, against what target, with what result.
{
"ts": "2026-08-23T14:07:11Z",
"component": "daemon",
"kind": "event",
"action": "state.work_item",
"outcome": "ok",
"entity_type": "work_item",
"entity_id": 42,
"detail": {"from": "dispatching", "to": "active", "reason": "session confirmed present"}
}
| Field | Meaning |
|---|---|
ts |
UTC ISO 8601, Z suffix. Always. |
component |
daemon, cli, or web — which process wrote it |
kind |
event, intent, or outcome |
action |
Dotted name: github.poll, git.fetch, hook.step, state.session, … |
outcome |
ok, error, or pending (only on an intent) |
entity_type / entity_id |
What it happened to |
target |
A path, URL, or socket, where one is more useful than an id |
action_id |
Present on intent/outcome pairs, shared between them |
dry_run / simulated |
Present and true only when the record concerns simulated work. Either one means it; robot-army log excludes such records unless given --include-simulated, and marks them [simulated] when it shows them |
detail |
Everything else, action-specific |
The record is UTC and always will be. Every ts above, every *_at column in the
database, the heartbeat, and every machine-readable response — robot-army <command> --json
and the web interface’s JSON — carry %Y-%m-%dT%H:%M:%SZ. That is what makes a record
comparable against another record, against GitHub, and against itself a year later. A log
whose timestamps depended on where the reader stood could not meet the constitution’s
reconstruction standard, which is why this half does not move.
What you read is local. Since milestone 010, every timestamp the CLI prints and every
timestamp the web interface renders is converted to this machine’s timezone and labelled
with its offset — 2026-08-29 21:31:07 -04:00 for the record 2026-08-30T01:31:07Z. Note
that these are the same instant on different calendar days; that is the point of the
feature, not a bug in it.
The two halves differ on purpose, and the difference is easy to mistake for an oversight in either direction. Some notes for whoever reads this next:
robot_army.timefmt, which is the only producer of a displayed
timestamp. It is display-only: nothing compares, sorts, stores, or parses back what it
returns, and timefmt.parse_stamp deliberately refuses to read a displayed value so that
a leak into a comparison fails loudly instead of quietly comparing two clocks.TZ if set, otherwise
/etc/localtime. There is no configuration key and should not be one: on a single-user
machine the setting would have exactly one correct value, which the OS already holds.detail payload is quoted verbatim in both interfaces, timestamps included.
It is the record shown as the record; rewriting values inside free-form JSON would make
the display disagree with the file it is quoting.web componentMilestone 002 added a third writer. Records from the web interface carry
"component": "web", which is what makes “which interface did this?” answerable from the
record alone rather than by inference from the timestamp.
jq -c 'select(.component == "web")' ~/.local/state/robot-army/logs/audit-*.jsonl
robot-army log --item 42 # both interfaces' records, interleaved in causal order
Every state-changing request produces an intent/outcome pair named for its route —
web.resume, web.abandon, web.cancel, web.dispatch.pause, web.poll — written
before the action runs, including before the checks that might refuse it. That ordering
is deliberate: it means a refused request, a crashed request, and a successful one all leave
a record, so an error response with no corresponding record is impossible by construction
rather than by discipline.
The operations underneath write their own records too, so a resume from the web reads as
web.resume (intent) → state.work_item → worktree.prepare → … → web.resume (outcome).
The two long actions, resume and restart, run on a worker thread and answer HTTP immediately;
what the operation actually returned lands later as web.resume.result.
web.start records the address and port the interface is actually listening on, on every
start. Under the exposure model in
002’s spec the bind address is the access policy, so it is
the one fact that is never allowed to be silent.
An append-only log cannot amend a record after the fact. The Operating Constraints require irreversible or outward-facing actions to be logged before execution, so “log before” plus “record the result” necessarily means two records:
{"ts":"…:11Z","kind":"intent","action":"github.comment","outcome":"pending","action_id":"a1b2","target":"jantman/demo#42"}
{"ts":"…:12Z","kind":"outcome","action":"github.comment","outcome":"ok","action_id":"a1b2","detail":{"comment_url":"https://…"}}
Those two records say a comment was written; dispatch.confirmed says what was in it.
Since #38 its detail carries host, session_name and attempt beside the session_id it
always carried, plus resumed_from or supersedes when the attempt replaced an earlier
session. Every one of those values also appears in the comment on the issue, which is the
point: the log is on the machine that ran the session, and the issue is the only place a
reader who is not on that machine can find out which machine it was.
jq -r 'select(.action == "dispatch.confirmed")
| [.detail.host, .detail.session_name, .detail.session_id, .detail.attempt] | @tsv' \
~/.local/state/robot-army/logs/audit-*.jsonl
The pairing is also the crash signature. An intent with no matching outcome means the
process died mid-action. Finding them:
jq -r 'select(.action_id) | "\(.action_id) \(.kind)"' ~/.local/state/robot-army/logs/audit-*.jsonl \
| sort | uniq -c | awk '$1 == 1'
A single redaction choke point applies to every record on its way to the file, keyed on
field name. There is no path to the log that bypasses it. Field names containing token,
secret, password, api_key, authorization, credential, or private_key become
<redacted>; an env object has its secret-looking members redacted individually so the
rest stays legible.
Issue titles and bodies are not redacted. They are the prompt, and reconstruction needs them.
grep -ri 'ghp_' ~/.local/state/robot-army/logs/ && echo "REDACTION FAILURE" || echo "clean"
trello.* actionsMilestone 003 adds one source and ten actions. Every one of them names the card id, and,
where one exists, the repository and issue — so a card’s whole life is grep-able by its id.
| Action | When | Notable detail |
|---|---|---|
trello.board.check |
Startup, and robot-army doctor |
Each precondition and its verdict, plus the board’s member list, which is recorded and never gated on |
trello.poll |
Once per board cycle | How many cards carry the tag, and how many were newly tracked |
trello.evaluated |
Per card, per evaluation | Whether it resolved, to what, the candidates it considered, and source — declaration if a robot-army: line on the card decided it, scan if the card’s text did, onboarding if nothing was onboarded and so nothing on the card could have resolved (issue #83) |
trello.needs_info |
A card is held | The reason, and the reason last written onto the card |
trello.issue.create |
An issue is filed from a card | Intent/outcome pair. The failure branch carries the attempt count |
trello.card.comment |
Any comment we write | Intent/outcome pair |
trello.card.move |
Any move we make | Intent/outcome pair, naming both lists |
trello.card.move_refused |
A move we declined | Where the card is, where we last put it, and what we would have done |
trello.recovered |
Any recovery path fires | Which path — issue listing, or marker comment — and what it found |
trello.dropped |
A card leaves the board | Whether the mapping was kept, and why |
A recovery that happened silently would be indistinguishable from nothing having gone wrong,
which is why trello.recovered exists at all: the absence of duplicates is not observable,
so the recovery that prevented one has to be.
Trello’s documented authentication is a query string. This project uses the header form
instead, and that is a security decision rather than a stylistic one: the redaction choke
point below is keyed on field names, so a secret embedded inside a string under a key
called url would sail straight through it. No trello.* record carries a full URL — method
and path only — and the boundary strips query strings and its own credential values from any
text it records, in case a remote quotes back what it was sent.
grep -c "$TRELLO_API_TOKEN" ~/.local/state/robot-army/log/*.jsonl # expect 0, always
Six new actions, and one changed one. Every one of them exists to answer a question that is asked long after the fact: why did nothing dispatch for twenty minutes? and why is this 499 MB still here?
| Action | When | Notable detail |
|---|---|---|
dispatch.at_capacity |
Changed — when a hold’s signature changes, not every pass | The counts, the cap, the ours/others split, and which item is at the head. Since issue #61 also simulated and in_flight, the sessions robot-army dispatched that the registry has not seen, so ours + others + simulated + in_flight equals live_sessions |
dispatch.hold_ended |
A capacity hold clears | How long it lasted, how many passes it spanned, and what freed it |
capacity.unobservable |
The registry and /proc both failed |
Which failed, and that dispatch is being withheld as a result. Also raises a de-duplicated anomaly of the same kind |
cleanup.considered |
An item is evaluated for cleanup | The decision and the guard that made it — including “not eligible”, so an item that was looked at and passed over is distinguishable from one nobody looked at |
cleanup.retained |
A guard refused a removal | Which guard, what it saw, and the worktree and branch it kept |
notify.send |
A message is attempted | The kind, the item, and whether the per-cycle bound suppressed it. Written whether or not it left the machine |
notify.channel |
A delivery to one channel is attempted | The channel name (webhook, pushover), the kind, the item, and our own reason on failure. One record per channel per message |
notify.suppressed |
The per-cycle bound was reached | How many were held back, and of which kinds |
health.notify |
The failed-health alert is sent to one channel | The channel name, the check’s own reason — which since issue #52 names the verdict, DIED and HUNG being different problems — and the outcome. One record per configured channel |
Since issue #106 a message and a delivery are different things: one message may go to
zero, one, or two channels. notify.send records the message and notify.channel records
each delivery, which is what lets the log answer “the webhook took it and Pushover did
not”. The per-cycle bound counts messages, not deliveries, so configuring a second channel
does not halve how many events you are told about.
Neither a Pushover application token nor a user key can appear in any of these. The credentials travel in the request body rather than the URL, and no upstream response body is ever recorded — the two habits that would otherwise leak one.
The existing git.remove_worktree and git.delete_branch gain a new caller and are
otherwise unchanged. git.delete_branch from cleanup always carries force: true, which
here means a stronger guard than git’s has already passed — containment against the remote
was proved — rather than that a guard was skipped.
dispatch.at_capacity retention ruleA hold is recorded when its signature changes — (total, others, cap, head item) — and
once more when it ends. Not once per pass.
This is a documented summarisation under Principle III’s retention clause, not an exception
to it. Everything about the hold is recorded: that it happened, what caused it, the counts
behind it, when it started, when it ended, and how many passes it spanned. What is not
written is 17,280 identical records a day at a five-second tick, which would not make the log
more reconstructible — it would make it less, by burying the records that carry information
under records that carry none. The same judgement already lives in this project as
raise_anomaly’s partial unique index.
The rule is written down here rather than improvised because the constitution requires a retention policy to be documented, and because milestone 004 makes holds routine rather than rare.
A NotificationEvent has exactly six fields — kind, item id, repository key, title, detail,
url — and every value is an identifier or a state name this system chose. There is no field
a secret could reach, which makes it a property of the shape rather than of the discipline of
whoever fills it in. A test asserts it across a run that includes an authentication failure,
because that is the case where a token would otherwise ride along inside an error string.
grep -c "$ROBOT_ARMY_GITHUB_TOKEN" ~/.local/state/robot-army/logs/*.jsonl # expect 0
One action changed, no new ones. repo.onboard was always an intent/outcome pair; what it
carries and when it is written both changed.
The detail grew to answer “which repository did I actually approve?” — the question the whole milestone exists to make answerable:
| Field | Meaning |
|---|---|
clone_path |
the location approved, absolute and with symlinks resolved |
path_source |
derived (from [paths] repo_root) or configured (from a [repos.*] section) |
remote |
which remote was consulted — origin, or the sole remote when there is no origin |
verified_origin |
the normalised host/owner/name found there. Never a raw URL |
owner_verdict |
owned, listed, or the owner found, from the one repository lookup |
base_ref_source arrived with issue #150, beside the base_ref this record already
carried. The ref alone said what was approved and not what decided it, and those had become
different questions once the base ref stopped being a configuration default:
| Field | Meaning |
|---|---|
base_ref |
the branch the settings were reviewed at, and that work will be cut from |
base_ref_source |
repo_config, detected (from <remote>/HEAD), worker_config, or default |
detected is the answer for any clone that has ever spoken to its remote. Anything else means
the clone could not say and configuration filled in — worth knowing when a later dispatch
reports a fingerprint that changed without anybody touching the repository:
jq -r 'select(.action == "repo.onboard" and .detail.base_ref_source != "detected")
| "\(.ts) \(.entity_id) \(.detail.base_ref) \(.detail.base_ref_source)"' \
~/.local/state/robot-army/logs/audit-*.jsonl
Two more fields arrived with issue #41, on the same record and for the same kind of reason — they say what was true of the repository at the moment it was approved:
| Field | Meaning |
|---|---|
speckit |
whether the clone was detected as a Spec Kit project |
speckit_numbering |
timestamp, scanned, unknown, or null when speckit is false and nothing was asked |
scanned is what the onboarding warning
fires on, so this answers “was I told, and did I approve anyway” for any past onboarding:
jq -r 'select(.action == "repo.onboard" and .detail.speckit_numbering == "scanned")
| "\(.ts) \(.entity_id)"' \
~/.local/state/robot-army/logs/audit-*.jsonl
null means not asked rather than no answer: the repository is not a Spec Kit project, so
nothing read the file. The configured value — sequential, or whatever it said — is on the
screen and in the --json document but deliberately not here. This record answers what was
approved, and the verdict is that.
Refusals are now written, and that is a bug fix rather than new behaviour. Before this
milestone onboard returned for a missing [repos.*] section before opening any audit
action, so a refusal was printed and forgotten. Under Principle III’s reconstruction standard
a refusal is a result, and one that leaves no trace is a gap. Every non-zero exit from
onboard now writes an outcome carrying a cause:
cause |
What was refused |
|---|---|
not_permitted |
neither owned-and-include_owned nor listed in extra_repos |
no_such_repository |
the source system has no such repository |
malformed_key |
not owner/name |
no_clone |
nothing at the resolved path |
linked_worktree |
the path is a linked worktree, not a primary clone |
inside_worktree_root |
the path is inside [paths] worktree_root |
no_remote / ambiguous_remote |
no remote, or several with none named origin |
unparseable_url |
the remote URL is not a repository URL |
wrong_repository |
a real clone of a different repository is there |
source_unreachable |
the source system could not be asked — a bad token, or no network |
unapproved_committed_settings |
--yes refused to skip an unreviewed settings change |
aborted_at_prompt |
I said no |
interrupted_at_prompt |
I pressed Ctrl-C at the prompt |
no_answer_available |
input ended before the prompt was answered |
jq -r 'select(.action == "repo.onboard" and .detail.refused) | "\(.entity_id) \(.detail.cause)"' \
~/.local/state/robot-army/logs/audit-*.jsonl
The last two are milestone 011, and are the same bug fix one milestone later. Onboarding
asked for approval before showing what was being approved (issue #17), so nobody reached that
prompt informed enough to walk away from it — and the two ways of walking away both exited
non-zero writing nothing. Ctrl-C propagated past onboard before its audit action opened;
end-of-input was an uncaught EOFError and a traceback. Now that the approval screen arrives
first, giving up after reading it is the expected second answer, so both are recorded like the
decline they resemble. Every terminating path through onboard now leaves exactly one outcome
record: an approval, or a refusal naming its cause.
Before creating anything, dispatch re-checks that the recorded clone is still there, is still a primary clone, and still has the same origin. A pass writes nothing.
That is a deliberate omission and it is not a gap. The worktree-creation record for the same item follows milliseconds later on the same code path, so the question “did the clone still check out?” is answered by the presence of the next record — which is exactly Principle III’s reconstruction standard, met by the log as a whole rather than by one line in it. Writing a line per dispatch to say “yes, still fine” would add volume and no information.
A failure writes the existing DispatchBlocked path — the item goes failed with the
reason, naming the recorded path — and the two failures that mean the machine changed
under an approval also raise an anomaly: clone_path_missing and clone_origin_changed.
Those two are distinguished from an ordinary gate refusal on purpose. An untrusted clone is a
setup step I have not done yet; a clone that moved is a fact about the world I probably do
not know.
A git remote URL may embed credentials, and milestone 005 is the first time this codebase
reads one at all. Normalisation strips the userinfo@ component before anything else, and
what is recorded and compared is the normalised triple — which cannot carry a secret
because it is three lowercase strings with no room for one. What is printed is the same
triple with owner and name as the remote spelled them, so a refusal names
ZoneMinder/zoneminder rather than a folded name nobody would recognise; it is taken from
the same parse, after the credential is gone. The refusal path is held
to the same rule, including the unparseable-URL refusal, which deliberately does not echo
the URL back even though that is the case where doing so would feel most helpful.
Two new actions, both cheap, and one of them writes far less often than you would expect.
| Action | When | Notable detail |
|---|---|---|
speckit.detect |
Once per dispatch, before the prompt is composed | detected, the reason verbatim, the command form found (skills, commands, mixed), whether the guidance was enabled, and suppressed_by naming the setting when it was turned off |
speckit.phase |
A lifecycle phase changes | from, to, and the feature_dir it was read from — plus previous_feature_dir when the directory itself changed |
The suppressed_by field is the one worth knowing about. “This repository has no Spec Kit”
and “this repository has Spec Kit and I turned the guidance off” produce the same behaviour
and must not produce the same record, or a deliberately quiet repository reads as a broken
one.
speckit.phase carries no evidence from the session, because none is solicited. Spec Kit’s
extension hooks are instructions an agent chooses to follow rather than callbacks, so a
report that never arrives is indistinguishable from a phase not yet reached; everything here
is read from files in the worktree instead. The argument, and the three conditions that would
make hooks worth revisiting, are in
the 007 spec.
One record per transition, not per reconciliation pass — the same shape of rule as
dispatch.at_capacity above, and a documented summarisation rather than an exception.
With a 60-second reconciliation cycle and sessions that run for hours, a record per pass
would be some thousands of lines a day saying a phase did not change, burying the handful
that say it did. Everything about the progression is still recorded: every transition, with
its time, its rungs, and the directory it came from. The pass summary in reconcile.pass
carries speckit_phase_changes, so a cycle in which nothing moved is still accounted for.
An item that reports no phase at all is a legitimate resting state, not a fault — the prompt
leaves the judgement of whether an issue warrants the lifecycle to the session, and a session
that decides a typo fix does not need four phases produces exactly this. robot-army show
explains the one case where silence is otherwise mysterious: a Spec Kit worktree whose
baseline was never recorded, which can never report a phase and says so.
Reading a GitHub project board. Five records, and none of them is an intent/outcome pair — the bracket exists for calls that change something outside the process, and every one of these is a read. The board is never written to.
| Record | When | Notable detail |
|---|---|---|
github.project.discover |
Every resolution attempt. ok when a board resolves and when the repository simply has none — an absent board is a third state, not a failure, and recording it as one would put an error a minute in the log for the ordinary condition of most repositories |
Candidate projects seen, the project and column chosen, and whether each was discovered or configured. On failure, the reason a surface shows — which is why “why is this repository not ordered by its board?” is answerable from the log as well as from status |
github.project.read |
Every successful board read | Project, column, how many items were ranked, how many were parked elsewhere, and the total the project holds |
github.project.partial |
A GraphQL response carrying both data and errors | The error type and count, written immediately before the read is failed. A partly believed board is worse than no board: the missing half is invisible, so the order would be confidently wrong rather than absent |
poll.board |
Once per repository per pass, except for a repository that has never had a board and does not have one now — that writes nothing at all | ok with the counts, ok with a skipped reason when the repository is in backoff, or error with the failure and the new backoff. A skip is recorded rather than silent — “the board was not read this pass” is a fact about the order being shown |
poll.board.fallback |
A read failed or a resolution was lost, and a previous snapshot exists | How old that snapshot is, and which of two different things follows — the two must not be confused. After a transport failure the repository stays governed by the stale snapshot, order and holds intact. After a resolution failure it becomes ungoverned: board order and off-column holds are released and it falls back to the configured order until resolution succeeds again. Both are permitted by FR-025; recording the wrong one would make the log evidence for something that did not happen |
A resolution failure is not a transport failure and does not advance the backoff counter. There is nothing to retry away: only the author can clear an ambiguity, and backing off would delay recovery once they had.
No credential reaches this log. The token is read at the moment it is needed and never
stored; the scope names appear in doctor output when a check fails, which is the whole
point of that check, and scope names are not secrets.
A board_precondition anomaly is the one kind whose text follows its condition while it is
open: when the board fails for a different reason, the open row is rewritten rather than left
naming a failure that may have been fixed. That overwrites the database’s only copy of the
previous reason, so the log keeps it.
| Record | When | Notable detail |
|---|---|---|
anomaly.restated |
New — when a failed board check differs from what the open board_precondition anomaly says. Written in the same transaction as the rewrite |
The anomaly id (as the entity), kind, anomaly_entity_id (the board), previous_failed_checks, failed_checks, previous_detected_at — when the overwritten reason had been detected — and reason. A stored detail that could not be read gives previous_failed_checks: null and previous_detail_unreadable: true rather than a guess. An unchanged failure writes none: the trello.board.check error record already says the board failed again, and nothing about the anomaly changed |
A worker never ends itself, so the successful path used to terminate in an orphan_session
and a permanently held capacity slot. Retirement is what ends it, and — because ending a
process cannot be undone from this side — the intent is on disk before the signal is sent.
| Record | When | Notable detail |
|---|---|---|
session.retire |
New — once per session, immediately before anything is signalled | item_id, session_id, pid, proc_start, idle_s — how long the worker had been idle when the decision was taken — and signal, which of the two conditions authorised it: merged_pull_request or quiet_period. Written first on purpose, so a daemon that dies between the decision and the kill still leaves the reason for a process that is about to disappear |
session.terminate |
As before | Unchanged. Retirement is a new caller of the existing confirmed-termination path, not a new mechanism, so the rungs, the refusals and the escalation record exactly as they do for cancel |
session.retired |
New — after a confirmed stop | The termination’s method, confirmed and escalated, plus how the row settled. settled: left is not a failure: it means the session recorded its own ending between the decision and the settle, which is an ordinary outcome of a successful retirement |
session.retire_refused |
New, outcome error |
The boundary declined to act on an implausible pid and sent nothing. Distinct from the next row, and the distinction matters: “we refused” and “we signalled it and it survived” are different claims about what happened to the machine |
session.retire_unconfirmed |
New, outcome error |
The worker survived the termination. The row stays open and the slot stays subscribed — “I tried and could not” is never recorded as “it is gone” |
anomaly.resolved |
When a re-checkable anomaly’s condition is confirmed false: an orphan_session’s process is gone, or a card_create_failing’s card has reached linked |
The anomaly id, its kind and entity, and the evidence — the pid and proc_start that no longer match, or the card’s state and the issue it now carries — plus the reason. This is where “on what evidence” lives: there is deliberately no resolved_reason column, because Principle III already makes this log the reconstruction path. A repeated pass writes nothing, so one resolution means one record |
reconcile.pass |
Changed — as before, once per pass | Gains retired and anomalies_resolved. retired is deliberately separate from reclaimed: both close a session row, but one means “this pass ended the worker” and the other means “the worker was already gone” |
signal was added by issue #149, and idle_s alone is why. Retirement originally had one
gate — 30 minutes of idleness — so idle_s implied the reason and nothing else needed saying.
It now has two, the merged pull request being the primary one, and an idle_s of 47 is either
a merge or a bug. Principle III’s standard is reconstruction from the log without re-running
anything, and that cannot mean “and knowing which release wrote the line”, so the condition is
recorded rather than inferred. idle_s stays and is still the truth about the worker; it is
simply no longer the reason. The settle reason on the session row names the same fact in
prose, so a row read on its own says why its worker was ended.
The follow-up to the above, and a correction: retirement ends the worker, and a separate
sweep closes the window it was running in. --hold keeps every window open after its command
exits — deliberately, so a failed launch leaves evidence — so nothing closed one until this
existed.
| Record | When | Notable detail |
|---|---|---|
kitty.close_window |
Newly reachable — once per window closed | The action has existed since milestone 001 and had no caller at all until now. Intent and outcome both come from its own audit.action context; the target is the window id |
window.list |
New, outcome error |
The terminal could not be listed. Only reachable when something actually qualified for closing — the sweep asks the database first and returns before touching the terminal when nothing does, so this record means “there was work to do and kitty could not be reached” rather than “this machine has no kitty” |
window.close |
New, outcome error |
One window could not be closed. The sweep continues to the next one; this window is simply reconsidered next pass |
reconcile.pass |
Changed | Gains windows_closed |
A window that was already gone by the time it was closed — the maintainer got there first —
is success, not a failure, and writes no error record. Kitty exits 1 for an id that no
longer matches, saying No matching windows; the boundary turns exactly that into a False
rather than raising, and the sweep treats it as settled-but-not-counted. Every other non-zero
exit, and every timeout, raises instead and is recorded — the two must not collapse together,
because a real failure reported as “nothing was there” would settle the item and leak its window
silently. So windows_closed counts only closes the system itself performed.
A worker that is not yet idle produces no record at all — not an action, not a column, not an anomaly. It is listed with the other documented gaps below.
Three actions for the pull requests a work item has. None of them is an intent/outcome pair, because every one of them is a read or a note about one: nothing here writes to GitHub.
| Action | When | Notable detail |
|---|---|---|
work_item.pull_requests |
The set a work item has changes — first discovery, a new pull request, or a state change | from and to as number:state lists, and first_check marking the one transition whose “before” is not a set at all. Written inside the same transaction as the column update |
reconcile.pull_requests_check |
A lookup fails | error, with the item it was for and the failure. The stored answer is left exactly as it was, so the log is the only place that says the attempt happened at all |
github.pull_requests.partial |
A GraphQL response carrying both data and errors — a deleted or inaccessible issue does this | The error type and count, before the read is failed. The same record github.project.partial writes for a board read, under its own name: a pull-request failure filed as a project failure is a record answering the wrong question |
first_check is the field worth knowing about. NULL → [] and [{…}] → [] both end
with no pull request, and they are not the same event: the first is “we looked for the first
time and found none”, the second is “the one it had went away”. Without the flag the log
could not tell them apart afterwards.
reconcile.pull_requests_check is what makes a failed lookup visible at all. A failure writes
neither column, so the row itself is unchanged and the interface simply keeps showing an
answer that grows older — deliberately, because “I could not ask” is not “there are none”. The
log is where the attempt and its error live.
The same rule as phase observation above, for the same reason and with the same accounting.
One record per change, not per reconciliation pass: with a 60-second cycle and sessions
that run for hours, a record per pass would be thousands of lines a day saying a pull request
did not change. reconcile.pass carries pull_request_changes, so a cycle in which nothing
moved is still accounted for, and work_items.pull_requests_at on the row carries the last
confirmation.
A keep holds a done item’s session open past its issue closing (see
keeping a session after the merge). Both
verbs are intent/outcome pairs, like hold.item; from the web, _perform’s web.keep.item
or web.unkeep.item pair comes first.
| Record | When | Notable detail |
|---|---|---|
keep.item |
robot-army keep <id>, or the web’s keep |
Intent: by (cli or web), was_kept, the item’s state, and live_session — whether a session row was open. Outcome: kept_at, kept_by and placed; a redundant keep is recorded with placed: false and the original time and surface. A refusal — the item is done or abandoned with no session open — is outcome error with a reason, and writes no row. An unknown item is an error with cause: no such work item |
unkeep.item |
robot-army unkeep <id>, or the web’s release |
Intent: by. Outcome: released, was_kept_at, and open_sessions — the ids of the item’s session rows that were open at the moment of release |
anomaly.resolved |
Changed — a second reason for orphan_session |
the work item is done and kept by the operator, so this session is accounted for, with work_item_id. Written when an orphan raised before the keep (the worker was busy when its item finished) is retracted. The pid re-check runs first, so a kept worker that has since died is resolved with the ordinary “no longer running” reason instead. Only the item’s current attempt is covered: an orphan from a superseded attempt is never retracted by a keep |
A retirement that followed a release is told apart by joining on the session id.
session.retire is unchanged; the unkeep.item before it names the sessions it released.
Every retirement that came after a release — released == true, because releasing an item
that was never kept is a no-op that still records open_sessions:
jq -sc '
(map(select(.action == "unkeep.item" and .kind == "outcome" and .detail.released == true)
| .detail.open_sessions[]?)) as $released
| .[] | select(.action == "session.retire" and (.detail.session_id | IN($released[])))
' ~/.local/state/robot-army/logs/audit-*.jsonl
Two things a keep makes the system not write, both listed in the table below: the retirement a keep withholds, and the cleanup consideration of a kept item whose session is still open.
Principle III permits gaps only when they are named and justified in the feature plan. The full argument is in plan.md; the list, so a reader of the log knows what its silence means:
| Gap | Why |
|---|---|
| Individual successful, read-only GitHub GETs — one aggregate record per repository per poll instead | They change no state outside the process, and at a 60-second poll the individual records would be pure volume. Every failure and every retry is still logged individually. The exemption is for reads a poll’s aggregate stands in for: onboarding’s repository lookup has no such aggregate and is recorded as github.get_repo (see the issue #64 section below) |
| Individual SQLite statements — the transition they effect is logged instead | The transition is the meaningful unit for reconstruction, and the database is directly inspectable |
| Heartbeat writes, every 5 seconds | ~17,000 records a day of noise. The heartbeat file is the record, and its staleness is the signal |
Individual /proc and registry reads during reconciliation — one aggregate per pass |
Same disproportion. The conclusions — sessions found, orphans detected, states changed — are each logged individually |
| Actions the session itself takes inside the worktree | They happen outside this process entirely. This log records the dispatch, the session identity, and where the transcript lives; the worker’s own transcript is the record of what it did. Claiming otherwise would be dishonest about what this log covers. Issue #32 is the sharp case: at no-remote a session pushed a branch to GitHub, and this log’s only honest account of it is daemon.start saying which delivery form the session was handed. What it did with that instruction is the transcript’s business, and no effect level makes it ours |
| Individual board reads made within a poll cycle — the freshness re-read before a move, and the comment fetch on the recovery path | Same reasoning as the GitHub one, and the same limit: they change no state outside the process, and the cycle is logged with what it evaluated and what it decided about each card. Every board write is an intent/outcome pair, without exception |
| A notification never attempted because the process died between a state transition and the send | The state change itself is fully recorded, so nothing the system did is unreconstructable. What is lost is the knowledge that I was not told. Closing it would need a durable outbound queue with its own retry and persistence, which is more machinery than an optional stretch feature is worth — the gap is named here rather than hidden |
| A passing dispatch-time clone re-verification | The worktree-creation record that follows on the same item milliseconds later already implies it passed, so a record here would be one line per dispatch answering a question the next line answers anyway. Every failure is logged, and the two that mean the machine changed under an approval also raise an anomaly. See the milestone 005 section above |
| A reconciliation pass in which an item’s pull-request set did not change | Same disproportion as the phase row below, and the same accounting: with a 60-second cycle almost every check confirms an unchanged set. Every change is recorded individually with what it changed from, reconcile.pass counts them, and the row’s own pull_requests_at carries the last confirmation — so neither the answer nor its age is unreconstructable. Every failure to look is logged individually. See the issue #143 section above |
| A reconciliation pass in which no lifecycle phase changed | Same disproportion as the heartbeat: a 60-second cycle against work that moves every few hours. Every transition is recorded individually, and the pass summary counts the changes, so nothing about the progression is unreconstructable. See the milestone 007 section above |
| The individual file reads behind Spec Kit detection and phase observation | Four stat calls per dispatch and a handful per active item per cycle. They change no state outside the process, and the decision they support is logged — logging each read would bury it |
The read of .specify/init-options.json at onboarding |
Same reasoning, one milestone later (issue #41). A stat and at most one small file read, on a command run once per repository — and the answer it produces is written onto the repo.onboard record that same run, so what the maintainer was shown when they approved is reconstructable from the log without a line saying a file was opened |
| A repository that has never had a project board and does not have one now | Not an action and not a failure — it is the ordinary condition of most repositories, and it would otherwise write a record a minute forever for every one of them. Nothing is persisted for such a repository either, so status stays quiet about it too. A board that goes away is different and is recorded: that is a change, and the author should hear about it |
| The project board’s item sequence on every read — the counts and the outcome are recorded instead | The order itself is recoverable from work_items.board_position at any instant, and it is not a question about an action the system took. Writing the same list every 60 seconds per repository would be ~1,440 copies a day drowning the records that matter. What is genuinely lost is history: from the log alone you cannot say what order the board was in three days ago. Every read, every failure, every fallback and every dispatch is recorded, so nothing the system did is unreconstructable |
A window that simply does not qualify for closing — its item is failed, or still has a live session |
The pass runs every 60 seconds and a working machine has several such windows permanently, so a record each would be thousands a day carrying no news. The condition is re-derivable from the item’s state at any instant, and everything the sweep does — every close, every failure to close, every failure to list — is recorded. Same reasoning as the row below, and as the transcript check |
| A retirement deferred because the worker was not idle yet, or idle without a merge and not for long enough | The reconciliation loop runs every 60 seconds and a finished session may sit idle for hours, so recording “still busy” would write ~1,440 records a day carrying one bit. The condition is re-derivable at any instant from the worker’s own registry entry and the item’s stored pull requests, and the decision that acts — session.retire and everything after it — is logged in full. Both of retirement’s deferrals are covered by this one line and neither writes anything, unchanged by issue #149. Precedent: the transcript check writes nothing for a session still inside its grace period, for the same reason. See the issue #138 section above |
| A retirement withheld because the item is kept | The same “not yet” as the row above, for an operator-chosen length of time — days, possibly — so a record per pass would be thousands carrying one bit. keep.item and unkeep.item already say when the keep was placed and released, and which sessions it was holding at release, so whether a session was kept at any moment is reconstructable from the log alone |
| A cleanup pass that leaves out a kept item whose session is open | It used to write cleanup.considered with skipped every pass, which was tolerable for the minute between done and retirement and is not for a keep that stands for days. The decision is re-derivable from the keep and session tables at any instant, and cleanup <id> still considers the item and records skipped on demand. A keep whose session has ended does not exclude the item, so its cleanup is recorded as before |
Read-only web requests — every GET the interface serves |
They change no state outside the process, so Principle III’s own scope does not reach them. The exception is written down because the volume makes the omission visible: a page auto-refreshing every 10 seconds issues a GET every 10 seconds, and logging those would bury the record this project exists to keep readable. Nothing a GET does is unreconstructable — the data it read is in the database and in this log. Every POST is logged, without exception |
Every swallowed error, retry, and fallback leaves a record. Specifically:
spool/exits/rejected/ and raises an anomaly.
It is never deleted./proc and raises an anomaly.creating with its reason and a failure
count, and — the part worth stating — no comment is left on the card claiming an issue
exists. A link to nothing is worse than silence.GET /log renders the same records with the same filters — ?item=, ?since=, ?outcome=
— newest first, a bounded page at a time, with GitHub repositories, issues, and pull requests
turned into followable links. Only URLs that are already https://github.com/…, or targets
shaped like owner/repo#123, become links; anything else in a record stays as text, because
a record can carry an issue body and an arbitrary URL out of one must not become a link on a
page I trust.
Paging reads daily files newest-first and stops the moment the page is full, so it stays bounded in time and memory against a log of any size rather than proportional to total history.
One new action, and two records that say more than they did. All three exist to answer the
same question: the item said dispatching for fifteen minutes and then failed — what
actually happened, and when did we know?
| Action | When | Notable detail |
|---|---|---|
dispatch.error |
An exception escaped the launch | The exception, the item’s state at that moment, and settling — whether the item was failed here before the exception was re-raised. A dispatching item is settled at that moment rather than being left for the age reaper; an item past confirmation is left alone, because the state table does not allow it to be moved and the session really is running. Carries dry_run, so a crash on a simulated item does not read as a crash on a real one |
dispatch.unconfirmed |
Changed — as before, when the confirmation window elapses | Gains session_state and outcome. outcome is lost when the session recorded nothing, and already_exited when it had already reported its own ending from the daemon’s process while dispatch was still waiting |
dispatch.confirmed |
Changed — as before, on a confirmed launch | Gains resumed_from on a resume, naming the session whose context this attempt restored. It is inside launch_argv too, but that is the whole nested wrapper argv with the prompt body in it |
outcome is the load-bearing one. “No session ever appeared” and “the session started, failed
and told us so” are different failures needing different next steps, and before this they read
identically in the log — when the second one appeared at all, which it did not: the
contradiction it caused escaped as an unhandled error and the item was never failed.
No new action, one new counter, and one anomaly that could not previously be raised. All
three answer the same question: the queue says repo_cap and nothing is running — what is
holding the slot?
| Record | When | Notable detail |
|---|---|---|
state.session |
Changed — as before, on every session transition | A session row is now closed as lost when its work item stops being one that can have a session in flight. The reason names the route: cancellation, abandonment, or the reconciliation sweep. That distinction survives nowhere else, and it is the difference between “the maintainer stopped this” and “this was found stale later” |
reconcile.pass |
Changed — as before, once per pass | Gains reclaimed: how many stale session rows that pass closed. Before this, a pass that reclaimed nothing and a pass that could not see the leaked row read identically, both as checked 0 |
orphan_session anomaly |
A live worker under a work item that is no longer running one | Existing kind, newly reachable. Carries the pid, the working directory, and the work item’s state. The session row is deliberately left open: the slot really is taken, and reporting a count lower than the number of live workers would oversubscribe the very quota the cap protects |
No new action and no new anomaly kind. Two counters, and one anomaly that could not
previously be raised — all answering the same question: status says the session is
running, but its pid belongs to nothing. Why did reconciliation not notice?
| Record | When | Notable detail |
|---|---|---|
state.session |
Changed — as before, on every session transition | A real session is now closed as lost at every effect level whose host is real, not only at live. Previously the sweep was skipped whenever the row was flagged simulated, which is true at no-remote — where the process is real — so a dead worker was never noticed there |
state.session |
A superseded attempt whose process is gone | running → lost, with a reason naming which attempt superseded which. That is the difference between “this session died” and “this session was replaced and then died”, and the log is the only place it survives |
reconcile.pass |
Changed — as before, once per pass | Gains skipped_never_real and superseded. The first is the important one: the reported defect looked like a clean pass, checked: 2, interrupted: 0, with nothing saying that one of those sessions had not been examined at all |
orphan_session anomaly |
A live worker from an attempt its item has already replaced | Existing kind, newly reachable. Carries the pid, the working directory, and both attempt and current_attempt. The row is deliberately left open, for the same reason milestone 015 leaves its own: reporting fewer running sessions than exist would oversubscribe the quota the cap protects |
dry_run on a session row means “the effect level was not live”. The question the liveness
sweep needs answered is “did this session ever have a process”, and those two disagree at
exactly one level. Reconciliation now reads the recorded pid instead, which is written from
the session-host boundary and is 0 for a simulated host — so the record answers the question
without anyone having to remember to ask it correctly, and without reconciliation learning what
an effect level is.
One gap in the record was accepted here and closed by issue #44. When the session registry
could not be observed at all, reconciliation read that as every session being dead, and nothing
distinguished “observed dead” from “could not observe”. That was pre-existing at live; issue
#33 made the two levels behave alike rather than introducing it, and enumerated the omission
rather than leaving it silent. The issue #44 record below is what closes
it.
A session row occupies a global and a per-repository capacity slot for exactly as long as it
is starting or running, and only the wrapper’s exit record used to close it. A simulated
session has no wrapper and no process, so that record can never arrive — which is why
robot-army cancel on a rehearsal used to hold its repository’s only slot forever, silently,
with repo_cap as the queue’s stated reason.
One new action, and it closes a gap that predates the issue it was added for.
robot-army worktree remove wrote no record under its own name at all. The only records
it produced came from the git boundary — git.remove_worktree and git.delete_branch —
which name a path and never the work item, so “what happened when I removed item 21’s
worktree” could not be answered without mapping a path back to an item by hand. And a guard
that refuses before reaching git would have left nothing behind at all.
| Record | When | Notable detail |
|---|---|---|
worktree.remove |
New — an intent/outcome pair around every manual removal | entity_id is the work item, target the worktree path. The intent carries force and is flushed before anything is removed, which is what the Operating Constraints require of an irreversible action. The outcome carries refused, worktree_removed, branch_deleted and forced_over_live_session |
worktree.remove |
A refusal — a live session, git’s dirty-tree refusal, or a repository that no longer resolves | refused: true plus refused_by, which is live_session, git or unresolved_repo. reason is the sentence shown at the terminal, and live_session names the session, its attempt, its state, its pid and how that pid looks from /proc |
worktree.remove |
A forced removal over a live session | forced_over_live_session: true. This is the single most destructive thing the command can do, and force: true alone does not identify it — that is equally what forcing past a merely dirty tree looks like. Those are not the same act, so they are not the same record |
git.remove_worktree, git.delete_branch |
Unchanged | Absent on a refusal, and that absence is the evidence that nothing was removed. It is what the tests assert on, in preference to a surviving directory — which is also what you get when a removal was attempted and merely failed |
A refusal is outcome: "ok", not "error". The vocabulary is fixed to ok/error/
pending, and the precedent is cleanup.considered, which records a skipped decision as
ok. Nothing failed here: the command was asked a question and answered it. error stays
for a boundary that broke, and audit.action still writes one on its own if anything raises.
A KeyboardInterrupt at the confirmation prompt is one of those — see
the issue #23 records, which is what makes it say more than the name
of an exception.
The live_session detail reports process liveness in one of four words — running, gone,
unidentified, unrecorded — and none of them decides anything. The session row is what
refuses; the liveness answer is what the operator is told. unidentified is the one worth
knowing: a recorded pid with no recorded start time cannot be told apart from an unrelated
process that inherited that number, and reporting it as running is the degradation that let
a pid of 1 through the termination guard in #69.
Nothing this feature added goes unlogged, and no Principle III exception is claimed.
No new action. worktree.remove gains two callers — the path form and purge-simulated — and
purge.simulated gains the worktrees it offered. A purge used to leave its rows’ worktrees on
disk beyond the reach of any command, and nothing in this log said which directories those
were.
| Record | When | Notable detail |
|---|---|---|
worktree.remove |
worktree remove <path> — a worktree no work item claims |
entity_type: worktree, entity_id and target the resolved path, detail.by: path. Refusals before git is reached: refused_by is outside_root, claimed (with claimed_by_item), not_a_directory, listing_failed, not_a_worktree or live_worker (with the worker’s pid, cwd, the session_id when the registry named it, and seen_by — the session registry or /proc, which is always enumerated too, so a missing or version-refused registry cannot read as “nothing running”). After git: git or directory_survived. forced_over_live_worker is this form’s forced_over_live_session |
worktree.remove |
purge-simulated removing a rehearsal row’s worktree |
As the id form — entity_type: work_item — with detail.by: purge-simulated, each pair inside the purge’s own and before its rows are deleted. A session with the simulated host’s signature does not refuse it; any other open session does, as live_session |
worktree.remove |
Any form, when a removal is reported and the directory is still there | refused_by: directory_survived, and no git.delete_branch follows. The simulated version-control boundary reports success over a real directory; the record says what happened to the disk |
purge.simulated |
As before | The intent gains worktrees (the paths offered) and remove_worktrees; the outcome gains worktrees_removed and worktrees_left. An abandoned prompt carries worktrees too, so the log says which directories were nearly removed |
anomaly.resolved |
An orphan_worktree whose directory is gone or now claimed |
reason is directory_gone, or claimed_by_item with the item id |
reconcile.list_worktrees |
As before, now also from the orphan sweep | Once per distinct failure per pass. The orphan is still reported, with listing_failed: true — a clone that cannot be listed must not hide disk |
reconcile.pass |
Changed | Gains orphan_worktrees: orphan_worktree anomalies newly raised this pass |
No new action. Below local the removal verbs reported a simulated removal in the past tense,
while the git.* records beneath them said [simulated]. The record that names the work item now
says it as well.
| Record | When | Notable detail |
|---|---|---|
worktree.remove |
Any form, once git is reached | The outcome gains simulated: true when the version-control boundary only pretends. worktree_removed: true, simulated: true reads “the boundary reported the removal, and it was simulated”. A refusal before git carries no simulated, because there was no git step to describe |
cleanup.retained |
Cleanup, when a reported removal leaves the directory on disk | state: retained, and reason is the same sentence worktree remove’s directory_survived refusal uses. No git.delete_branch follows. This used to be recorded done |
An item’s cleanup_reason reads worktree removal simulated; … when the decision came through a
simulated boundary.
No new action. worktree remove <id> now leaves a cleanup record on a finished item, and its
outcome says so — and says why a removal git answered with “is not a working tree” was recorded
as one.
| Record | When | Notable detail |
|---|---|---|
worktree.remove |
A removal of a finished item’s worktree | The outcome gains cleanup_state: done or branch_retained, the value written to the row. Absent for an unfinished item, whose path is cleared instead |
worktree.remove |
The directory was already gone before git was asked | worktree_already_gone: true. Git’s refusal once its record is pruned (“is not a working tree”) is not refused — it is about git’s record, and there were no contents to protect |
worktree.remove |
The branch was already gone | branch_already_gone: true, and no git.delete_branch follows |
worktree.remove |
The item’s record is done — worktree and branch both gone — and its directory is absent |
refused: true, refused_by: already_removed, reason naming the recorded state, time and reason. No git.* record follows, and sessions are not consulted. A branch_retained item is not refused: the re-run is how its branch half is finished |
reconcile.pass |
As before | prunable_worktrees now counts finished items too, whenever the directory is missing and the cleanup record does not account for it |
A manual removal’s cleanup_reason is the worktree’s fate, then the branch’s, then the command
that did it — for example worktree directory was already gone; branch deleted — by `robot-army
worktree remove`.
example_config.write actionrobot-army example-config renders the commented example configuration. It records only
when it writes a file, which is the one thing it can do that changes state outside the
process.
| Action | When | What it carries |
|---|---|---|
example_config.write |
Once per --output invocation, on every path |
target, the absolute destination; detail.force, whether --force was given; and on success detail.bytes, or on failure detail.error |
The target is absolute even when the author typed a relative path. A relative path is only interpretable if you also know the working directory the command ran in, which the record does not carry — and reconstruction from the log alone is the standard.
A refusal is recorded as a failure, not as nothing. Declining to overwrite an existing
file writes an outcome of failure carrying error: "file exists", because “I ran it and
nothing changed” is exactly the question this log exists to answer. An I/O failure records the operating
system’s own message the same way.
Rendering to standard output writes nothing. No file is created, no request is made, nothing outside the process is mutated, so there is no action to reconstruct. Under Principle III’s exception path this is named here and in the feature plan rather than left as an undocumented gap.
The record goes to the default layout’s log directory. This command is routed before the
configuration is loaded — it exists to be run on a machine that has none — so a non-default
[paths] state_dir cannot be honoured here and the record lands under
$XDG_STATE_HOME/robot-army/logs/ regardless.
And a log that cannot be written never fails the write. The file is the point; a read-only state directory must not cost the author their config. The failure is announced on stderr instead, so it is neither silent nor fatal.
prompt.preview actionrobot-army prompt <owner/repo> <number> prints the prompt a dispatch of that issue would
compose, and creates nothing. It still writes, because it reaches GitHub and reads a
repository’s files, and because a question asked of the system is part of what the record has
to be able to answer.
| Action | When | What it carries |
|---|---|---|
prompt.preview |
Once per invocation, on every path | The repository, the issue, the branch and whether it was recorded or derived, which directory the contextual sections were read from, whether each optional section was included, and which delivery form was composed |
Keyed entity_type: issue, entity_id: <owner>/<repo>#<number> — the same shape
poll.rejected uses for an issue with no row. Refusals carry refused: true and a cause of
malformed_arguments, not_onboarded, or issue_unavailable, which is the same distinction
the exit code makes.
speckit.detect is written too, by the same function a dispatch uses. When the issue has no
work item it is keyed on the repository rather than the row, because a sentinel id would put a
claim about work item 0 into an append-only file. component separates the two callers
otherwise: daemon for a dispatch, cli for a preview.
delivery is push or local (issue #32). The two flags beside it say whether an optional
section was there at all; this one says which of two mandatory forms was used, because the
delivery block is never absent and since #32 its content follows the effect level. It is one
word rather than the block itself, for the reason the next section gives — and it is the only
thing in the record that answers “was that session told to push?”.
The same question about a dispatch is answered a level up: daemon.start’s boundaries map
carries delivery alongside the implementation each seam got, so the startup record says what
every session that daemon launches will be told.
uv run robot-army log --limit 20 | grep daemon.start
The composed prompt itself. Not the text, not the issue body, not the contents of
.claude/robot-army.md, not the Spec Kit block. This is the same gap dispatch already has and
already justifies — the log has never reconstructed a composed prompt — and closing it here
alone would leave the record describing the rehearsal in more detail than the performance,
while writing up to 60,000 characters of issue body per invocation. A unit test asserts the
gap stays open.
A successful issue read. GitHubReader logs every retry and every failure but takes the
aggregate-read exception for successes, as it has since milestone 001. The prompt.preview
record names what was asked for and whether it was answered, which is what the reconstruction
standard needs.
One invocation shape goes entirely unrecorded: a non-numeric issue number, which argparse rejects before the log is open. Nothing was read and nothing was reached, so there is no action to reconstruct — that is a malformed invocation, not an event.
Four commands stop and ask before acting. Giving up at the question — Ctrl-C, or a stdin with nothing in it — is now a recorded result rather than a traceback, at all four.
| Record | When | Notable detail |
|---|---|---|
repo.onboard |
Onboarding abandoned at its approval prompt | refused: true and cause. Unchanged — milestone 011 wrote this one, and issue #23 is the other three catching up |
worktree.remove |
A forced removal abandoned at the typed-id prompt | The intent was already flushed before the question blocked, carrying force: true and the worktree path as target. Its outcome is now error with abandoned: true and cause — so the pair says a forced removal of that path was attempted, abandoned, and never happened. It used to say EOFError and nothing else |
session.cancel |
New — a cancel abandoned at its prompt | entity_id is the session, and the detail carries abandoned, cause and item_id |
purge.simulated |
A purge abandoned at its prompt | abandoned: true, cause, and the counts the question quoted — so the log says what was nearly deleted, not merely that something was |
cause is one of two words, and they are kept apart deliberately:
cause |
What happened | Exit |
|---|---|---|
interrupted_at_prompt |
Ctrl-C. The maintainer read the question and walked away | 1 |
no_answer_available |
stdin was at end-of-file. Nobody was there to ask — a pipeline, a cron entry, < /dev/null |
4 |
“I changed my mind” and “this ran somewhere with no maintainer attached” are different
things to find in a log, and different things for a shell script to branch on. Neither is
ever read as consent, including at the force-removal prompt, where the expected answer is
a typed item id rather than y.
# every question anyone gave up on, and why
jq -r 'select(.detail.cause == "interrupted_at_prompt" or .detail.cause == "no_answer_available")
| "\(.ts) \(.action) \(.entity_id) \(.detail.cause)"' \
~/.local/state/robot-army/logs/audit-*.jsonl
session.cancel appears only here. A cancel that goes through is already
reconstructable from session.terminate and the session and work-item transitions, so it
never needed an intent/outcome pair of its own; an abandoned one has none of those to be
read from. The asymmetry is deliberate rather than an oversight. Declining cancel or
purge-simulated — typing n — writes no record either, for the same reason: a command that
ran and changed nothing is what the absence of a record already says. The other two prompts
are not like that, and already were not before issue #23: onboard records a decline as
aborted_at_prompt, and typing the wrong item id at the force prompt sets aborted on the
worktree.remove outcome that is open by then.
The record of what a reconciliation pass declined to do. Nothing distinguished a pass across an idle machine from a pass across a registry that had moved, so the mass interruption the second caused reconstructed afterwards as a genuine finding.
| Record | When | Notable detail |
|---|---|---|
reconcile.pass |
Changed — as before, once per pass | Gains liveness_withheld, and directory_missing alongside the degraded, unknown_versions and unreadable the scan summary already carried |
registry_unobservable anomaly |
The first pass that could not read the registry | The reason — which of the three conditions it was — and liveness_withheld. Raised at the end of the pass, because that count is only final once every guarded sweep has run |
anomaly.resolved |
The first pass that could read it | The kind, and the reason: the observation failure the anomaly reported no longer holds |
Three passes used to write the same line, and the two new fields separate them:
directory_missing / degraded / unknown_versions |
liveness_withheld |
The pass was |
|---|---|---|
| all clear | 0 |
ordinary — an idle or healthy machine |
| any set | 0 |
blind, but nothing was running to withhold a conclusion about |
| any set | > 0 |
blind, with work in flight that was deliberately left alone |
No record is written per withheld decision, and that is this feature’s one documented
Principle III omission. Three open rows on a blind machine would put ~4,320 records a day into
the log, each carrying one bit, and the condition is re-derivable from the registry at any
instant. _retire_finished_sessions declines to record its own “not yet” for the same reason.
The one caller outside the pass is abandon, which writes no pass summary — so it says in its
own output that it left a session row open, and why.
No new action. github.poll still writes one aggregate record per repository per poll, but
it now says whether the stored ETag was sent. That matters because the first poll after
[github] label changes is a full 200 instead of the usual 304, and a reader should be
able to tell why from the log alone.
| Key | When | Value |
|---|---|---|
etag_sent |
always | true if If-None-Match was sent |
etag_discarded |
always | null if nothing was dropped. request_changed if a stored ETag belonged to a different request. request_unrecorded if a stored ETag had no request recorded with it (a row from before migration 015) |
request |
only when something was dropped | the request line that was sent |
etag_request |
only for request_changed |
the request line the dropped ETag belonged to |
The first poll after the label went from robot-army-verify back to robot-army:
{"action":"github.poll","outcome":"ok","entity_type":"repo","entity_id":"jantman/robot-army",
"detail":{"status":200,"etag_hit":false,"items":9,"rate_limit_remaining":4987,
"etag_sent":false,"etag_discarded":"request_changed",
"request":"/repos/jantman/robot-army/issues?direction=desc&labels=robot-army&per_page=100&sort=updated&state=open",
"etag_request":"/repos/jantman/robot-army/issues?direction=desc&labels=robot-army-verify&per_page=100&sort=updated&state=open"}}
The two request lines appear only when an ETag was dropped, so the minute-by-minute steady state stays a short line. Neither can hold a credential: they are built from the path and query only, never from headers. What the column holds and why it has no backfill is covered under state.
A queued item whose issue does not carry [github] label is held rather than dispatched
(when the label changes). Working out the hold writes
nothing, as with every hold reason. These records show where the hold came from and what
released it.
| Record | When | Notable detail |
|---|---|---|
poll.labels_refreshed |
A poll lists the issue of a ready item whose stored labels are a different set |
old and new label lists, target the owner/name#N. old is null when the stored value could not be read. The same labels in another order write nothing, so the steady-state poll stays silent |
daemon.label_warning |
Startup, before any dispatch, when one or more ready items do not carry the configured label |
outcome: error, like daemon.config_warning. label, count, item_ids, and the one-line warning. At most one per start |
dispatch.at_capacity |
Unchanged | reason may now be not_labelled, with detail naming the label. It is recorded when a pass dispatches nothing, the same as any other per-item hold |
onboard asks GitHub one question: does owner/name exist, and who owns it. That one request
is what SC-009 promises, one lookup and never a walk over every repository the author owns.
Until issue #64 it left no record. The only way to confirm the promise was to read the source,
and a later change to enumerate would not have shown in the log.
Each lookup now writes exactly one github.get_repo record, whatever the result:
outcome |
When | Detail |
|---|---|---|
ok |
GitHub answered 200 | method, path, status: 200, exists: true |
ok |
GitHub answered 404 | the same, with status: 404 and exists: false. A missing repository is an answer, not a failure. The refusal it leads to is the repo.onboard record that follows, with cause: no_such_repository |
error |
the lookup failed: any other error status, or no connection after the retries | method, path, status (null when no response arrived), error_type, error. github.retry and github.request are still written for the attempts, and repo.onboard follows with cause: source_unreachable |
The record is per lookup, not per HTTP attempt: a lookup that is retried and then succeeds writes
its github.retry records and a single github.get_repo. path is the path alone, built from
the repository key. It carries no host, no query string and no header, so it cannot hold a
token.
The check from the 005 quickstart now works as written, one line per onboarding attempt that reached GitHub:
robot-army log --since 10m | grep -c 'github.*"/repos/'
… github.get_repo [ok] repo:jantman/typoed-nmae {"method": "GET", "path": "/repos/jantman/typoed-nmae", "status": 404, "exists": false}
… repo.onboard [error] repo:jantman/typoed-nmae {"refused": true, "cause": "no_such_repository", …}
A path other than /repos/<the key you typed> in that output would mean onboarding had started
enumerating.
reset discards a checkout, re-reads the issue and requeues the item. Three of its four
records already existed — the removal’s own pair, and the state transition — so what is new
is the pair around the whole verb, plus the two the shared read path writes under reset’s
name instead of retry’s.
| Record | When | Notable detail |
|---|---|---|
reset |
An intent/outcome pair around every reset | entity_id is the work item, target is <repo>#<issue>. The intent carries force and the state the item was in, and is flushed before anything is destroyed, which is what the Operating Constraints require of an irreversible action. The outcome carries worktree_removed, branch_deleted and requeued, plus refused_by when it stopped |
reset.blocked |
A local condition refused it before any network read | blocked, the blocker’s own sentence. This record existing without a reset.evaluate beside it is how the log says GitHub was never asked |
reset.evaluate |
After the live read | eligible, reason, author, and refreshed — the columns the read rewrote. On a read that failed: cause, which is issue_unreachable or issue_absent, never one value for both, because “it does not exist” and “I could not ask” are different facts |
worktree.remove, git.remove_worktree, git.delete_branch |
Unchanged | A removal is what happened, and it is recorded under its own name. reset calls the command whole rather than reaching past it, so every refusal above is reachable from a reset and reads identically |
state.work_item |
Unchanged | from is interrupted, awaiting_review or failed; to is ready. columns shows failure_reason and blocked_reason cleared |
reset.blocked and reset.evaluate are written by the same function that writes
retry.blocked and retry.evaluate, with the verb supplied by the caller. The records are
otherwise identical because the question asked is identical — which is the point of sharing
the code rather than the wording.
A whole reset therefore reads, in order:
reset [intent] → reset.evaluate → worktree.remove [intent] → git.remove_worktree
→ git.delete_branch → worktree.remove [outcome] → state.work_item → reset [outcome]
and a refused one stops at the record naming the guard that refused, with the reset outcome
carrying refused_by.
robot-army log --item 42
Reading top to bottom gives: discovered → eligibility verdict → worktree prepared (with each preparation step’s exit code and duration) → launch argv → confirmation or its absence → exit code and signal → final state. Everything needed to answer what happened, when, to what, and with what result, without re-running anything.
The database is the other half of the picture and is directly inspectable:
sqlite3 ~/.local/state/robot-army/state.db 'SELECT id, state, failure_reason FROM work_items'