It is continuous because it is a contract on every command rather than a
feature of any of them. The entries below are almost all the same defect
arriving from a different direction — a note on stdout that breaks a jq
pipeline, a padded column that counted SGR bytes, an empty listing that reads
as there are none when the real answer was that the flag was misspelled, a
refusal that exits the unclassified 1 with its real answer sitting in a
field.
Record text printed at a person has the characters a terminal acts on
taken out of it first. Titles, bodies, comments, excerpts and the
handles an index hands back all reached a terminal with C0, C1 and
whole escape sequences intact, and the only code that had noticed
pointed the other way: column::width recognises CSI and OSC so a cell
carrying one is padded to the width it draws in, which meant a hostile
title lined up perfectly. Opening a pull against a repo needs no
permission on it, so the set of accounts that can put a string in front
of atgc pr list is everyone. term::text::clean drops C0 except \n
and \t, U+007F and C1; one_line drops the other two as well for a
value going into a column, because a title with a newline in it prints a
row the reader did not fetch. hyperlink::handle now refuses to link a
string that is not shaped like a domain name, rather than pasting it
into the middle of an OSC 8 escape it can close. --json and pr diff
are exempt and say so where a reader would look: one is a value and the
other is bytes for git am (3murv2zwtfm22)
Three functions called author_handles, with three different
contracts, are now named for what they return. One built display
labels (@handle, or the DID), one returned bare handles, and one
returned bare handles under a cap — same name, three answers, in three
files. A reader moving between them had no way to tell, which is how a
call site came to prefix an @ onto a value that already had one.
author_labels, capped_author_handles, and — for the third — nothing
at all: it was a one-line wrapper over
handles::handles whose only real effect was to be the third function
with that name. The label rule itself moved to
hyperlink::account, which owns it for the whole tree; labels.rs
was still hand-rolling the @-or-DID fallback, a copy the earlier
sweep missed because it builds a map rather than printing a line
A raw DID printed at a person is refused by prek rather than found by
somebody reading the output. Three separate sweeps have fixed these and
each one missed sites the next found, always the same way: the listings
were fixed through the label code they share, and the write verbs were
missed because they never built a label. scripts/check-identifiers.py
is that sweep made mechanical.
It turns on one distinction, which is what got the false positives from
five-per-true-hit down to none: a struct field holds what the record
holds and a local holds what the code computed. issue.author is a
DID off a record and always was; a local called author is nearly
always a label somebody already resolved. So field accesses are
suspect and bare locals only when their name says _did outright.
Name-based and therefore defeated by an alias or by a function that
returns a DID — the type-level version is a newtype with no Display,
which is a much larger change and is written up below. A line that
means to show a DID says raw-did-ok: above itself and why; two do.
pr create's target: line was the last real one, and it had a
RepoRef in scope the whole time
The three write verbs that print author: name a handle rather than a
raw DID. pr close/pr reopen and both halves of issue formatted
issue.author straight into the line, so the sweep that linked every
listing's author column went straight past them: it looked for the
label-building code those columns share, and these three never built a
label at all. The reader saw author: did:plc:kd3… on the one screen
that is otherwise all human names
Every account printed at a person is an @handle linked to its
profile, and the DID only where no handle resolves. One rule, written
out six times — handle.map(|h| format!("@{h}")).unwrap_or_else(|| did .clone()) — in the comment thread, key list, both issue readers, the
pull labels and repo configure, and six copies had already drifted:
none of them linked, and key list printed {who} ({did}) over a
who that had already fallen back to the DID, so an account with no
handle was named by its DID twice on one line. hyperlink::account
is now the single answer, and hyperlink::cut_account is its column
half: ellipsize counts characters and would slice an escape in two,
so the cut happens first and a truncated @verylonghandl… is left
unlinked, because it names nobody and a link under it would promise a
profile that does not exist.
Three sites keep the bare spelling on purpose and are not oversights:
stack merge's authorName goes into an RPC payload, and the two
repo configure names are written into .git/config — none of the
three is a terminal, so none may carry an escape. The git clone: and
remote: URLs stay bare for the same kind of reason: they are strings
to paste into git, not pages to open. browse's URL, the view:
lines and repo clone's owner/name are now links, and issue view's
repo: line links its DID without a lookup — tangled.org/<repoDid>
302s to owner/name, so the link lands even where the name is unknown
A repo is named owner/name and linked, not printed as a DID. A DID
is an identifier and not a name: two cannot be told apart at a glance,
neither can be typed back into any command, and a reader who has never
been told that a repo has a DID met the concept for the first time in
an error message. RepoRef::label derives the path from web_url,
which the remote or a redirect already carried, so it costs nothing —
and RepoRef::linked wraps it in the OSC 8 hyperlink the terminal
takes. The empty pr list and issue list messages and the three
stack target: lines use it.
The logged-out pr list message was the worst of them and is fixed
with it. It is the first command a stranger runs, it reported an
absence in atgc's own vocabulary, and it named nothing to type — while
the logged-in arm eleven lines above it named both --source values.
A stranger in a repo with six open pulls was told there were none,
correctly and uselessly. It now names --source web, --source bobbin
and atgc auth login
--json on the three logs readers printed one record per line while
docs/output.md's rule said one value on stdout and enumerated four
exceptions without listing this one — so the page was false about the
command with the most machine callers. Kept as JSON Lines rather than
converted, because --follow is an unbounded stream and the closing
] of an array is a byte that never arrives; making the flag print an
array without --follow and lines with it would have been the same
flag returning two shapes. Written down as the fifth exception, with
--follow and the empty case both stated
The bytes those lines carry are the log record verbatim, which had
silently made crate::logging::file's envelope and the three Event
enums a public schema. Judged fit to publish rather than curated into
a second view: the fields are already typed against leaking a
credential, the --help examples already quote them at jq, and a
re-serialized view would drop any field a newer atgc added — the loss
landing exactly when an old binary reads a new machine's log. The
promise is now stated in docs/output.md and on the types themselves
A panic is a status somebody can branch on, and a bug report rather
than a compiler diagnostic. atgc had no panic policy at all: the default
hook wrote thread 'main' panicked at src/… to stderr and the process
exited 101 — Rust's number, which appears nowhere in this table. So
the one failure a caller most wants to tell apart from the ordinary
ones answered with a status the documented interface does not define.
A hook installed as the second statement of main prints the panic's
own message and location, says it is a bug and where to report it, keeps
the backtrace behind RUST_BACKTRACE, and exits Exit::Bug — a ninth
code, added deliberately while the table is still free to grow. Folding
it into 1 would have thrown information away instead: 1 means "read
the message, it is one of the ordinary failures", and a caller that
retries on it would be retrying a crash.
One answer, wherever a bug surfaces. exit::how_to_report writes the
sentence and exit::bug builds an error that carries it, so an
invariant that did not hold says what a panic says — and both name
atgc report bug --title … rather than a URL, because the command
attaches atgc's version, the platform, git's version and the PDS host
without anybody being asked for them, while a URL asks the reporter to
assemble by hand the facts that make a report actionable. The retry
loop in clients/http.rs was the first to move: it ended in an
expect on a whole-loop invariant, which aborted the process, and now
refuses with a report instruction instead.
Worth more than the sites it would have caught. Two were found by audit
and fixed beside it — stack up/down cast a usize step count to
isize, so a large one sent up to the bottom of the stack in every
profile and overflowed the multiply in a checked build; and two of the
four &sha[..7] slices in stack/write.rs lacked the .min(len) the
other two carry. The value is the ones nobody has found
Widening 1 to a specific code is free before 1.0 and a breaking
change after it, so every uncoded refusal with an obvious home got one.
The worst of them was a single sentence: "not logged in" reached main
down four paths in config/account/selection.rs and exactly one of them
carried a status, so atgc key list exited 3 and
atgc auth switch <handle> exited 1 printing the identical words —
a caller testing $? -eq 3 got a different answer depending on which
command it ran. stack was the other half: one coded refusal across
some forty bail!s, and its first one contradicted pr, which has
answered 5 for a branch with no pull since the pr family was
classified.
What the statuses came out as, and the rule that decided them — a
status is the next move , not a restatement of the message. 3 only
where a login is the fix; 2 where the fix is a word on the command
line, which is most of them: several accounts and none active (every
login it could want is already here), a handle that has changed hands,
a key selector matching several, a kind the board does not declare, a
member position this stack does not have, a pull that is not stacked,
and auth login refusing in a session that cannot open a browser —
3 there would be advice to repeat the failure, since 3 means run
atgc auth login. 5 for an identifier that resolved to nothing: a
branch with no pull of yours, a key selector matching none. 4 for a
chain member somebody else wrote, which is what pr already answers
for an edit against another account's record. 7 for the two
stack write refusals that say "retry when the index catches up",
which is precisely what 7 means.
One was genuinely ambiguous and is commented as such at the call site:
resolve_spec's "could not resolve @handle, and no logged-in account is
cached under it". resolve_handle fails identically for a handle nobody
publishes and for a resolver that is down, so it is 5 or 6 and
nothing there can tell which. Settled to 5, because 6 is the one
code meaning retry this unchanged and guessing it would loop a script
over a handle that will never resolve; guessing 5 only stops a caller
who could have waited. atgc key add with nothing in ~/.ssh went the
same way, to 2 rather than 5: 5 is about an identifier the caller
supplied, and here the problem is that there was none — the argument
nobody types, which is the largest family of 2s already
One XRPC error-name table, not two, which is the entry below finished
rather than a new one: from_status was unified while the name layer
above it stayed split, knot.rs's Refused::exit over the knot's
vocabulary and api.rs's exit_for over the PDS's. They agreed on
AccessControl and RepoNotFound and nothing kept them agreeing.
exit::from_error_name is both, and merging is safe because every name
in it is a next move rather than a fact about one service — nothing
about "this record already exists" changes depending on who said it —
so a service growing a name its neighbour already uses gets its
neighbour's answer instead of falling through to a number. Refused
keeps the paragraph about why the name outranks the status, since
that reasoning is the knot's, and keeps one job of its own: a knot
answers some refusals with no tag at all, and an empty string is not a
name to look up
The knot funnel in clients/tangled/knot.rs was the one left, and it
is the one that turned out not to be a status question. Refused is a
typed error carrying the knot's HTTP status and its error tag, and
exit::classify only downcast Coded, so every refusal through it —
pr merge, stack merge, repo delete-branch, api — exited 1
with the answer sitting unread in a field.
Neither shape this entry proposed survived contact. Converting at the
call sites would have been the same mapping written five times, and
either of them classifying by status would have been wrong, because a
knot does not spell one refusal with one status: read off
knotserver/xrpc/ on 2026-08-26, AccessControl comes back 401
from pushableRepoDID — the shared push-access guard every merge and
branch deletion passes through — 403 from the membership and
collaborator handlers, and 400 from delete_repo; RepoNotFound
comes back 404 , 400 and 204 . Classifying by number answers
"log in", "not as this account" and "fix your command line" for one
problem, depending on which handler the call reached.
So Refused::exit reads the tag for the handful whose meaning is
unambiguous and defers to exit::from_status for everything else, and
classify asks it — which it can, since it has always reached into the
chain for reqwest::Error. The one call site that already coded its
own answer, stack merge's AccessControl sentence about push access,
still outranks it
Output channels — one rule, on every flag: stdout is the answer,
everything else is on stderr. Warnings, notes and progress lines all
go through crate::term::say at a level (warn/note/step/debug) and
under a topic (account, config, ssh, git, index, knot,
pds, …), so -q/-qq and ATGC_LOG=note,pds=debug can turn them
down without touching the answer or the exit code. Replaces the old
jsonout::say!, which moved notes to stderr only under --json and
so left the same prose in the middle of every other pipeline; --debug
is now the loudest rung of the same ladder. The bug that prompted it:
logs pds --json printed two sentences of prose on stdout when no log
file existed yet, which is a jq parse error rather than an empty
stream. See docs/output.md
"N scope(s) granted since this login" said the opposite of what it
meant, in all three places it was printed: doctor local's scopes row,
auth status's per-account lines, and scope_gap's own doc comment.
Nothing was granted — these are the scopes this build asks for that
the session does not carry, which is why the row is a failure rather
than a note. The agent notes made it worse by telling an agent to
ignore the message "which is just advisory", when a missing scope fails
doctor local and refuses the command it names; they now separate it
from the expired access token, which really is advisory
--limit 0 parsed on pr list, issue list and repo list and then
printed "no open pull requests" / "no issues for " — an empty
listing that reads as there are none , which is the stale-index
failure this tree opted out of, arriving from the command line instead.
Exactly the --state opne bug wearing a different hat, and closed the
same way: a value_parser floor of 1, so it is clap's own refusal
naming the range, exit 2. search has carried that floor since it
shipped, which is how the other three were found
atgc logs oauth --limit 20 was "unexpected argument '--limit' found".
-n/--lines is tail(1)'s spelling and is the right one for a log,
but --limit is what every listing in the tool takes and what a caller
arrives here having learned, so it is a visible_alias now. No floor
on that one: 0 is meaningful for a log, being the head lines and the
summary with no events between them
Exit statuses that mean something — src/exit.rs, eight of them, with
main returning ExitCode instead of anyhow::Result. Every failure
atgc has ever had exited 1, so a caller could tell that something went
wrong and nothing else; the five that want different next moves (no
session, denied, not found, unreachable, conflict) now say which. The
code rides inside one error type rather than as a marker beside it,
because both other shapes break the message — above it to_string()
goes empty, below it {e:#} gains a trailing ": ", and pr read,
stack write and backfill all print errors with {e:#} today.
Unclassified stays 1, so this is additive. Unreachable is mostly
inferred rather than tagged: every request goes through one reqwest
client, and a connect failure or timeout says so about itself
Errors print as error: <one line> on stderr with stdout left empty,
matching say's warning:/note: labels; --debug prints anyhow's
full multi-line form, which makes that flag's "full error details"
true of errors and not only of HTTP bodies
Running atgc outside a checkout is a real error rather than git's
plumbing. remote_url, current_branch and repo_root forwarded
git's own fatal: not a git repository (or any parent up to mount point /) with an unclassified 1 behind it, naming neither the
directory nor a way out. Four cases now, all Usage: no repository, no
remote of that name (listing the ones the checkout does have), a
detached HEAD, and a repo with no commits — the last of which used to
leak ambiguous argument 'HEAD', because rev-parse --abbrev-ref fails
on an unborn ref before the detached check can run. Each names the
directory atgc looked in, which is the whole diagnosis when a shell or
an agent harness is standing somewhere other than its operator believes
More sites worth classifying, done for pr, repo and the identifier
parser. pr view 99999 and pr diff 99999 exited the unclassified 1
for a pull that does not exist, and the arm they actually take is not
the status check — tangled.org answers /pulls/99999 with a 200 and a
page carrying no record URI, so "there is no such pull" is decided by
finding nothing in the HTML. That is 5 now, as is a branch with no
pull. 2 covers the command lines that name nothing: a pull or repo
reference that does not parse, a --source or --round value that
means nothing, --from without --interdiff, a --website with no
scheme, a --spindle that is a URL. 4 covers the account being
wrong: a round, an edit or a close against somebody else's record, and
an --ssh clone with no registered key, which is the status doctor
already gave that case. The knot funnel in repo/mod.rs reads the
status rather than assuming Denied, since these calls also answer
"not here" and "already there"
One HTTP-status-to-exit table, not four. exit::from_status is it, and
exit_for kept only the XRPC-error-name layer on top, since a name like
RecordNotFound is more specific than the 400 it arrives with.
knot_exit is gone; web/pulls.rs and repo/read.rs call the table
instead of matching inline. Two arms moved as a result, both toward the
table and both worth naming: a knot answering 400, 405 or 422 is
now 2 rather than 1 — XRPC puts most refusals there and a request a
knot will not accept is a command line to fix — and a 502 from the
appview's pull page is now 6 rather than 1
The two readers that authenticate nothing take from_public_status, the
same table with the four session-shaped answers removed, rather than a
subset written by hand. A DID document from plc.directory and a pull's
page from the appview are public GETs, and 4 on one of them would tell
the reader to try a different account when the request never carried
one. Derived from from_status and not a second table, so a row added
there is considered here too; the sweep test pins that every status
answers either the table's answer or 1
ellipsize and day are term::column, not cmd::pr::read. Neither is
about pull requests: issue list and search were reaching in as
crate::cmd::pr::read::ellipsize, repo list had the cut open-coded
with the off-by-one done by hand, and day — ten characters off an
RFC 3339 stamp — existed five times over as the same one-line body.
cmd/pr/mod.rs argued there was nothing to put in a shared module
because neither had a caller outside the read half; that is why the
paragraph making the argument moved too
report's error: label takes style::BAD, through the same
say::label the warning: and note: prefixes go through rather than a
second copy of the gate. That gate is stderr's — is_terminal,
NO_COLOR, TERM=dumb, and no --json veto — so atgc … --json still
colours the label above the document it did not touch
One style vocabulary, on anstyle — term/style.rs holds the six
styles anything painted is painted in, and the one paint. Three
modules had each grown the same format!("\x1b[{code}m{text}\x1b[0m")
over a stringly-typed SGR parameter: say's prefixes, logs' Palette
and repo view's bold heading. Nothing was wrong with any of them,
which is the problem with three copies — 93 was the warning colour in
two of them by coincidence, and a fourth caller had nowhere to look it
up. anstyle was already in the tree via clap, so it costs no dependency,
and the bytes are unchanged: logs oauth and logs pds render
byte-identically against a read-only fixture, across all six styles
SIGPIPE restored to its default disposition in main, so a reader that
stops reading ends atgc quietly instead of panicking. Rust sets it to
SIG_IGN before main and println! panics on the resulting EPIPE,
which made atgc completion zsh | head exit 101 with a Rust panic from
inside clap_complete — a failure no Result returned to main could
have caught, and one that applied to all ~240 println! sites. Through
the sigpipe crate because the call is signal(2) and this crate
forbids unsafe; it adds eight lines and no transitive dependency,
libc already being in the tree. tests/broken_pipe.rs drives the real
binary and fails without the fix. One consequence, stated rather than
discovered later: quitting the pager early during pr diff now ends
atgc with SIGPIPE where the write error used to be swallowed, which is
where git ends too, and needs a patch past the 64 KiB pipe buffer to
reach at all
hyperlink.rs's OSC-8 gate said in its own comment that it matched
the colour gate about.rs, repo.rs, review.rs and authlog.rs
each carried a copy of, TERM=dumb included — but none of the four
actually checked TERM, so a dumb terminal suppressed links while
still receiving raw ANSI colour underneath them. Factored the
three-fact decision (a real terminal, NO_COLOR unset, TERM not
dumb) into one pure function, hyperlink::escapes_wanted, and
pointed every call site at it, so the comment is true instead of
aspirational and there is one place left to get the policy right
--help's command list is split into five headed groups — Meta,
Account management, Repository operations, Checking and
troubleshooting, Other — instead of one flat column.
clap cannot do this itself (help_heading is for arguments, and
subcommand_help_heading renames the one section), so help.rs
renders the section from the built command tree and hands it to the
root as before_help, with a template that drops clap's own list.
Only the grouping is written down; names and abouts still come out of
the tree, and a test holds that the groups cover it exactly
shell completions (clap_complete) — atgc completion <shell> writes a
static script to stdout for bash/zsh/fish/powershell/elvish; generated
from the binary, never checked in. Dynamic completion was rejected:
it routes every TAB through a process spawn, and the values worth
completing are behind Bobbin
Two exit statuses docs/output.md promises that no code produces — and
the entry was wrong in both directions, which is why it contradicted
itself: it claimed nothing produced 4 or 7 while also saying the
sites "already hold the status when they refuse". Driven against the
real binary, both were already reachable before this branch. 4:
atgc doctor local exits it on a scope gap or an unregistered push key,
repo default-branch on a repo you do not own exits it through
repo/mod.rs's own_repo, and tests/issue_flows.rs had been pinning
it since the issue family was classified. 7: hold
~/.config/atgc/.lock with flock and any command that writes account
state exits it from config/lock.rs after its 30-second wait, and
atgc api maps an InvalidSwap to it. What was actually missing was
narrower than "the status": record.rs's swap_advice and
batch_advice built bare anyhow!s, so the one refusal that means
re-read and try again was indistinguishable from the ones that mean
this will fail identically forever . Now 7, with both unit tests
pinning the classification in both directions. The knot half is still
open and has its own entry above: knot.rs's Refused holds an HTTP
status and an AccessControl tag that exit::classify never looks at
day() parses. term::column::day took the first ten characters of a
raw stamp, so a record written at +03:00 printed the writer's local
date and two rows of one listing could disagree by a day about the same
instant. It is DateTime::parse_from_rfc3339 now, rendered in UTC —
the only choice that makes two rows comparable, which is what a column
is for, and the rule is written down in docs/output.md. Visible
consequence, intended: a record written at +03:00 just before
midnight prints the previous day. Anything that will not parse still
falls back to the first ten characters, so an empty createdAt still
comes back empty and a malformed record still lists.
chrono is a direct dependency for it, which reopens the line in the
surveyed-and-left-alone entry below on purpose; the reasoning is
recorded there and in Cargo.toml. One correction to what this entry
used to say: Datetime does hand back its inner value, through
impl AsRef<chrono::DateTime<FixedOffset>> — the inherent methods are
only as_str/timestamp*, which is what the survey saw. It does not
help here either way, because day takes a raw &str from
serde_json at every call site and jacquard's own ISO8601_REGEX is
stricter than RFC 3339 (it rejects -10:00, among others), so routing
through it would narrow what a listing can render.
Still worth a jacquard report: Datetime could offer a date or
format of its own, and the AsRef impl deserves a mention on the
type rather than only in three method docs
Fixed columns pad by cells now, not by char. term::column::width
answers "how many terminal cells will this print in", with pad_to and
pad_start beside it, and every {:<N}/{:>N} over a string in the
tree goes through them. It fixes two defects at once: an emoji or CJK
title was half the cells it should be and shoved every column to its
right, and padding applied after painting counted the SGR bytes and
so applied none — which is what logs pds was losing a column to. So
width discounts escapes rather than only measuring characters: CSI
for colour, and OSC 8 for the hyperlinks where a whole URL sits in the
string and draws as nothing. unicode_width alone gets that wrong in
the same direction the char count did.
The truncation unit is unchanged and stays chosen: ellipsize counts
chars, a test pins the emoji case, and a second test now pins that the
two units are allowed to disagree.
unicode-width is taken at 0.1, the copy miette already pulls in, so
it costs no crate; 0.2 would have put a second one in the graph
pr list --json / pr view --json — machine-readable output, the
first any read command has grown besides auth log --json. Emits the
derived view (state, resolved handle, round count, appview number),
not the raw record: a pull's state and number are not fields on the
record at all, so printing the record back would omit the entire
reason to run the command. Unknown reads as null, never the ? the
terminal columns print. Colour, hyperlinks and ellipsizing are off
unconditionally, not just off a tty. Field set is versioned like every
other flag (CONTRIBUTING.md ); see docs/output.md
pr status --json — the pr list row plus the repo it targets,
flattened in rather than nested, so a caller widening from one repo
to every repo reads .state and not .pull.state. A repo neither
Bobbin nor the appview can name reads null and keeps its DID: the
truncated DID the column falls back to is a display string
stack view --json, repo view --json, repo list --json — done.
stack view's members carry rkey, number and round count, which
pr view --json's stack.members deliberately does not (there the
chain is a cross-reference; numbering it would be one appview sweep
per member). repo view counts come in pairs — branches/
branches_exact — because a full knot page means "or more", and
stars is null rather than 0 since Bobbin answers 0 for a repo it
never indexed
--json on every other command: auth status, key list, and the
whole writing surface (pr create/resubmit/edit/close/reopen/merge/ comment, stack create/resubmit/merge, repo create/clone/ configure/edit/default-branch/delete-branch, key add/delete,
report). Writers all carry dry_run, and leave the identifiers a
dry run did not mint null rather than guessing a record key the PDS
chooses; the ones that can succeed writing nothing say so in
changed/added/merged instead of by omission, since both cases
exit 0. The shared rules — one value on stdout, notes on stderr,
decoration off at hyperlink::stdout_escapes_wanted, errors left as
plain text — live in crate::term::jsonout and in
docs/output.md