Development record
after-update
Summary
This spec delivers the bundle after-update, two intents met in one moment of
the agent's first reply after an abcd update:
- itd-2610161617096084: abcd says what changed, one line per release, with the full summary one command away;
- itd-2610161616086564: abcd offers, once per update per repository, to run setup again when the update brings something the repository needs.
The two intents' ## Decisions are binding and are not repeated here, except
where a step names one. In short: the session-start hook adds one fixed line
and writes nothing; after the person's first message the agent runs one abcd
verb and relays what it prints, first the summary line per release, then the
offer; the person's answer is recorded by a second run of that verb, in abcd's
own data directory, keyed on the repository's root commit and the release.
Approach. Five parts, each on code that already exists.
- The digest.
abcd launch shipalready composes, for each release, one changelog line per shipped record and proves the lines cite exactly the cut (internal/core/release,Ingest). The cut gains a fourth write beside the archive, the page and the CHANGELOG heading: a digest, a JSON file holding, per release, its tag, the tag it was cut from (Cut.BaseTag) and each line as composed, with the record ids it cites and their impact. The file lives in a Go package,internal/core/releasedigest, which embeds it with//go:embed, so the binary built from the release's tag carries every release up to and including its own. The.abcd/namespace never reaches a released binary (AGENTS.md, Boundaries), so the digest cannot live there. - Release versions, strictly. One parser,
launch.ParseReleaseTag, accepts exactlyvMAJOR.MINOR.PATCH(digits only, no leading zero, no pre-release or build part) and orders through the existinglaunch.Semver/CoreGreater. Every version this bundle reads (the running binary'score.Version, the repository'smeta.setup_version, the binary record'sprevious_tag, a digest tag) passes it or takes no part.embargo's private copy of the same pattern calls it (one-canonical-primitive). - What waits on this repository. A new
ahoy.Waiting(cwd, from, to)returns the questions setup would ask here that were never answered, leaving out those whose latest setup answers record sayslater(the "decide later" valueinterview.Writealready records in.abcd/.work.local/interviews/), plus the settings whose default changed in a release afterfromup totoand which this repository takes from the default. The default changes come from a table embedded inahoy, each entry naming a setup key and the record that changed its default; the digest says which release shipped that record. - The record of what was shown and answered. A new core package,
internal/core/afterupdate, composes the moment (the summary lines and the offer) from the digest,ahoy.Waitingand its own store under~/.abcd.noindex/after-update/, and owns that store: one file per repository and release holding the answer, and one per computer holding the newest release whose general news was shown. Only the verb writes it. - The front doors.
abcd update newsprints the moment bare, the full summary with--full, and records the answer with--answer yes|not-now(or that the summary was read, with--seen, when no offer was made).abcd ahoy install --only-waitingis the setup the yes runs: it asks only the waiting set. The session-start hook, which already runsahoy.Detect, asksafterupdate.Pending(read-only) and, when anything is pending, prints one constant sentence to stdout. Both verbs are wired on the CLI and on their command pages,commands/update.mdandcommands/ahoy.md.
The moment, as the agent relays it. For Alice, set up with v0.15.0 and now running v0.16.0, with two questions waiting:
abcd v0.16.0: 9 changes, 1 needs you to act (itd-N). Ask for details: abcd update news --full
abcd was updated from v0.15.0 to v0.16.0 since this repository was set up; 2 setup questions wait here. Run setup now? (yes / not now)
The first line is the general news, once per computer; the second is what
waits here, once per repository. Neither carries a word from the repository's
tracked files: versions pass ParseReleaseTag, the counts are numbers, and the
line text comes only from the digest inside the binary. With --full, each
release in the range is named, its changes that need action first, each as its
digest line with the record id to read more, then the waiting questions' and
changed settings' titles.
Settled here
The intents leave these technical choices open. Each is decided on the grounds given; none changes a criterion.
- The digest's path is declared, not fixed.
launch shipwrites it to the repository-relative path the repository's.abcd/config.jsondeclares underrelease.digest(thereleasenamespace claimed throughinternal/core/layered, read from the repository layer only), and writes no digest where none is declared. abcd's own config declaresinternal/core/releasedigest/digest.json. Grounds: managed repositories replicate abcd's path, so the cut carries no abcd-only case;go:embedreaches only files under the embedding package's directory, and.abcd/is denied to released binaries. - The digest is JSON with a schema version, read strictly. Read through
jsonstrict(unknown fields, duplicate keys and trailing content refused), capped in size, each line passed throughtermsafe.Sanitizeagain on read. Grounds: the same posture as every other embedded default (rules.json,guard.json), and the intent's "sanitised as every other text abcd reads at run time". - Each digest entry is one changelog line, with every record it cites. A line that cites two records is stored once with both ids, not copied per record. Grounds: the line is the one source (scope condition cond-2610170635096007), and two copies of one line would show twice.
- A gap in the chain is found, never guessed. Each release stores the tag
it was cut from, so walking down from the running release finds every
release in the range; a release the chain does not reach, or a range that
starts below the first described release, is named as not described, with
the release notes' published address (
<releaseOrigin>/releases/tag/<tag>, the origininternal/core/updatealready pins), and the summary says it is partial. Grounds: the intent's "never fills the gap with a guess", and a derived link needs no fetch. - The digest is seeded from the derived CHANGELOG sections. The first
digest carries every release from v0.7.0 on, whose sections
launch shipderived from records and whose lines cite record ids, each line copied fromCHANGELOG.mdand its impact read from the cited record. Releases rolled by hand (v0.6.9 and earlier) are named as not described. A test holds every digest line equal to its CHANGELOG line. Grounds: scope condition cond-2610170635090370 offers either treatment of hand-rolled sections; naming them is the one that cannot misstate, and the seed lets an update from any recent release summarise in full on the first shipped digest. - "Needs you to act" means a breaking record. An entry needs action when
any record it cites has
impact: breaking, the one field the cut already carries per record (release.Entry.Impact). Grounds: the changelog's own rule states a break as an Added line, so the impact, not the section, is the signal; nothing new is asked of the composer. - The default-change table names a record, not a release. Each entry of
internal/core/ahoy/defaults/default-changes.jsonis a setup key and theitd-/iss-record that changed its default; the release comes from the digest. A test refuses an entry whose key is not a setup question key (SetupFixedValuesorhelpForanswers it). Grounds: versions are derived at the cut (the derived changelog), so a release written by hand when a default changes would be a guess; a record not yet in a digest (a dev build) counts in no release. - A changed default counts only where the repository takes the default. A key the repository's layer sets is unaffected by its default, so it waits on nothing. Grounds: the product thinker's "offered only when something is required".
- "Decide later" is read from the setup answers records of this checkout.
A new
interview.Latest(place, name)returns, per question id, the newest recorded value across the records of one interview. A checkout with no records (a fresh clone or worktree) counts every unset question as never answered, and so errs towards offering. Grounds: one source, the record setup already writes; the error costs one "not now" and never a missed question. - The store is abcd's user-level home, keyed as its other stores are.
~/.abcd.noindex/after-update/<root-sha>/<tag>.jsonholds a repository's answer for one release, created exclusively (O_CREATE|O_EXCLthroughfsutil's guarded home-scope maker), so of two sessions answering together the first wins;~/.abcd.noindex/after-update/news-seenholds the newest release whose general news this computer was shown, replaced atomically and only ever raised. Grounds: the worktree and transcript stores key on the root commit already (adr-2610031751065746); the user-level home is host-agnostic where the plugin's data directory is not; per-user state stays out of the repository. - Where the general news starts. The range starts at
news-seen, else the binary record'sprevious_tag(read asahoy.TakeUnseenUpdatereads it, without its claim), else the repository'ssetup_version, else the running release alone; each candidate passesParseReleaseTagor is skipped. Grounds: the newest fact on disk about what this computer last knew wins, and each fallback is the intent's named source (scope condition cond-2610170635095099). version.upgradegains a direction. It is reported only when the running release is newer thansetup_version, and its fix hint namesabcd ahoy install --only-waiting;stepVersionStampnever writes a version older than the one recorded. Grounds: Carol's criterion, and a comparison the gap already makes by string equality only.- The hook's line is one constant. It carries no version and no count, and reads: "abcd: an abcd update has news for this session; after the person's first message, run abcd update news and relay what it prints at the start of your first reply." Grounds: the fixed-text rule of the session-start stdout (iss-2608241115201044); the verb's output reaches context by the agent's own tool call, the channel every abcd verb already uses, and that output carries only numbers, checked tags and digest text.
- A session with no person records nothing. The hook writes nothing, and
bare
update newsand--fullwrite nothing; only--answerand--seenwrite, andcommands/update.mdtells the agent to run them only with a person's reply. Grounds: the binary cannot tell a headless agent from one with a person, so the record is tied to the act a headless run never performs, the person's answer. - Amended 2026-10-17 by the agent, building step 1: a release tag's
components are bounded at nine digits, and the refusal never echoes the
value.
launch.ParseReleaseTagaccepts each component as0or one to nine digits without a leading zero, so every component fits an int32 on any platform and a whole tag is at most 30 bytes; its error names the form, never the value. Grounds: the spec asks for bounded components without a bound; nine digits is far above any release and keeps the value from a tracked file short, and a parser whose input is often a tracked file must not hand that text to a caller that relays its error. - Amended 2026-10-17 by the agent, building step 1: the stamp keeps a
recorded release against any running version that is not a release at
least as new.
stepVersionStampwrites the running version unless the recorded value is a release tag and the running one is older or not a release tag at all (a dev build, a pre-release); a recorded value that is not a release tag is replaced as before. Grounds: "never records a version older than the one recorded" cannot be judged against a dev build, and writingdevover a teammate's committed release is the downgrade the criterion forbids. - Amended 2026-10-17 by the agent, building step 2: the digest's shape.
{"schema_version": 1, "releases": [...]}, releases newest first, each{"tag", "base_tag", "entries"}, each entry{"line", "records": [{"id", "impact"}]}, wherelineis the composed wording without the citation suffix the cut appends and the entries keep the CHANGELOG section's order.base_tagis""only for a repository's first cut. Caps: 8 MiB per document and 4096 releases, and per release the cut's own caps (500 entries, 32 records an entry, 4096-byte lines, 32-byte ids). The file is written in one canonical form (two-space indent, HTML characters unescaped), so an append changes only the release it adds, and a line the reader's sanitising would change is refused at the append rather than stored changed. Grounds: the spec names the fields, not their form; the canonical form keeps a cut's diff to its own release, and storing lines byte-equal to the CHANGELOG is what the seed test holds. - Amended 2026-10-17 by the agent, building step 2: a gap names a span,
not a guessed list. The walk stops at the first release the chain
reaches that the digest does not carry, and the gap reads "the releases
after
<from>through<that tag>", with that tag's notes address and the release list's (<releaseOrigin>/releases). Afromof""is the running release alone, and afromat or above the running release is an empty range. Grounds: below a missing release the chain cannot say which releases exist, so naming any one of them would be the guess the intent forbids. - Amended 2026-10-17 by the agent, building step 2: the release origin
moved to
core.ReleaseOrigin.internal/core/updatenow reads itsreleaseOriginfrom the leaf packageinternal/core, and the digest names notes addresses from the same constant. This touchesinternal/coreandinternal/core/update, outside the footprint. Grounds: update depends on ahoy, and ahoy will read the digest (steps 4 and 5), so the digest cannot import update; a second copy of the origin would breakone-canonical-primitive. - Amended 2026-10-17 by the agent, building step 2: the seed is generated once and held by tests, not by a kept generator. A one-off script copied the seventeen derived sections (v0.7.0 to v0.15.0) and read each cited record's impact from its terminal folder; tests hold every line equal to its CHANGELOG line, every base tag to the release below it, every impact to its record's, and the file to its canonical form. Grounds: from the next cut on the cut itself appends, so a generator would be dead code.
- Amended 2026-10-17 by the agent, building step 3: what the declared path
may be, and where the write goes. Besides a path outside the tree, under
.abcd/or not ending in.json, the cut refuses a path under.git/, one with a backslash, a dot or empty segment, or longer than 512 bytes, and compares the first segment without case (.ABCD/is.abcd/on a case-insensitive filesystem). The path's directory must already be a directory inside the tree; the cut creates none. The write goes through anos.Rooton the repository root (atomic, keeping the file's mode), so a symlinked directory cannot carry it outside, and a rollback restores the file's bytes or removes the file the cut created. The repository layer alone is read by loading the configuration with no machine layer (the landing's precedent for a branch's own checkout), so a machine'srelease.digestis never read rather than refused. Grounds:.git/is not the tree; a created directory would be one more thing to undo; the write path is a value a repository commits, so it is contained the way the archive's create already is. - Amended 2026-10-17 by the agent, building step 3: checked early,
composed late. The declared path and the file it appends to (a present
digest must parse) are checked with the cut's other repository
preconditions, before the payload's verdict; the appended bytes are
composed only once the payload is accepted, so a payload fault is reported
as itself and never masked by a digest refusal. Each cited record's impact
is the one the cut carries for it (
Cut.Added/Cut.Removed). Grounds: the ingest's existing rule that a repository fault is a stop found before the payload's verdict, and the composer's retry loop reading only payload reasons. - Amended 2026-10-17 by the agent, building step 4: a default change
names only a key whose setting
.abcd/config.jsonholds. Besides a key that is not a setup question key and a malformed record id, the table refuses a setup key whose answer the repository's layer does not keep in.abcd/config.json(todayvisibility,docs_target,oracle_backendandscan_deep, the four the install collects), an entry listed twice, and another schema; it is read throughjsonstrictand capped at 64 KiB. Grounds: "a changed default counts only where the repository takes the default" needs a place to see whether the repository set the key, and a key with no such place (a status line element, an approval) could only be guessed at; a key gains a slot in the same change that first lists it. - Amended 2026-10-17 by the agent, building step 5: a recorded answer of
any value is an answer, and only questions wait. A question waits when
the setup records hold no answer to it at all; "answered with a value or
deferred with
later" are both a record, sointerview.Latest's value is read only for its presence. A machine-wide question (ahoy.SetupMachineWide) is answered by the home's records, the rest by the checkout's. The questions are the ones the detection's gaps arm, in setup's order: the four config values (the oracle question only where setup asks it), the artefact kind, the house-style question, the git identity, the lineage link, the optional offers, and each tool conventions file's retirement; a follow-up (a README part, a status line element) rides on its lead question. A category approval and the adoption never wait: they are consent to a change, asked whenever there is one, not a setting answered once. Each question is titled by the gap that arms it (core's words), a changed default by its config slot, release and record. A changed default already covered by a waiting question is listed once, as the question, and a key is listed for its newest release only. A setup record that cannot be read is an error, not "nothing answered". Grounds: the product thinker's rule names questions and settings, and an approval re-asked on every run would make an offer of every lingering gap; refusing a record abcd did not write keeps a planted file from silencing a question. - Amended 2026-10-17 by the agent, building step 5:
Waitinglands unexported and its name is shared. Step 5 addswaiting(cwd, from, to)andwaitingIn(cwd, det, from, to)for a caller already holding a detection; step 6 exports both asafterupdatecalls them, because the ahoy package's front-door audit refuses an exported function no production code outside it calls. The setup interview's name moves toahoy.SetupInterview, which the CLI's record writer now names (internal/surface/cli/setup_interview.go, one line). The install test approves every category up front, as the yes to the offer does, so none is put. Grounds: the loud-staging rule the audit enforces, andone-canonical-primitivefor the name a reader and a writer share. - Amended 2026-10-17 by the agent, building step 6: one exported
ahoy.Waiting(cwd, det, from, to), and two read-only accessors. The step 5 pair became one exported function that takes the caller's detection, since the only caller holds one and ahoy's front-door audit refuses an exported wrapper nothing calls. Step 6 also adds, ininternal/core/ahoy(outside its package list),SetupVersion(cwd), the recordedsetup_versiononly when it is exactly a release tag, andPreviousReleaseTag(pluginRoot, cwd), the binary record'sprevious_tagread where and through the same data-directory checkTakeUnseenUpdateuses, whether or not the swap was seen, writing nothing. Grounds: ahoy already owns both files' readers, and the strict parse at the reader keeps every other word of the tracked file inside ahoy. - Amended 2026-10-17 by the agent, building step 6: the moment's words.
The functions keep the spec's names (
Pending,Moment,Full,Record,MarkSeen); their results arePendingState,MomentLinesandFullSummary. News lines are newest first, each "abcd<tag>:<n>changes[,<k>need(s) you to act (<ids>)]. Ask for details: abcd ahoy after-update --full", naming at most five breaking record ids and counting the rest; a gap is one line naming the span, its notes address and the release list's, and saying the summary is partial. The offer line counts questions and changed defaults separately ("2 setup questions and 1 changed default wait here"). The full summary heads each release with the release it was cut after, marks each entry needing action, cites every record of every entry, then lists what waits. Grounds: the spec's own example lines, extended only where a case it does not show (several breaking records, defaults) needs words. - Amended 2026-10-17 by the agent, building step 6: the store's details.
An answer is
{"schema_version": 1, "release", "answer"}at mode 0600; anything at its name counts as answered.news-seenholds one tag and a newline; a value that is not a release tag reads as absent and the next raise replaces it; the raise compares and writes under~/.abcd.noindex/after-update/news-seen.lock, so two raises together never lower it.Recordrefuses an answer that is notyesornot-now, a running binary that is not a release, a setup version that is not an older release, and a repository with no root commit (or one that is not a full object name), before writing anything; it does not require something still to wait, so an answer given to a moment is never lost to a race. A news range starting at or above the running release is no news; with nonews-seen,previous_tagorsetup_versionit is the running release alone.Full(cwd, "")starts atprevious_tag, elsesetup_version, else the running release alone, never atnews-seen, so the summary shows again after it was seen; what waits is listed where the setup version is an older release.Pendingruns a detection only where the setup version is an older release. Grounds: an exclusive create is the first-wins rule the spec asks for, and a lock is the only way a read-compare-replace stays raise-only under concurrency. - Amended 2026-10-17 by the agent, building step 6: the reach audit stays
red until the front doors land.
afterupdate's exported functions are called only from steps 7 and 8, sointernal/reachaudit's ratchet names them (and step 2's unreachedreleasedigestexports) until then. Grounds: the spec orders the core before its front doors; staging them unexported would make steps 7 and 8 rename the package's whole surface.
- Amended 2026-10-17 by the agent, building step 7: what the only-waiting
run approves, and what it leaves. The run wraps the install's prompter:
the adoption and every category approval reach the person's door; a
question Waiting returned reaches it too (a README part riding on the
README question, a status line element on the status line offer); any
other value question takes no answer, as a door's "decide later" does, and
any other offer is declined. Every category the detection carries is
approved up front, unless
--yesor an explicit approval set decides them. The answers file's pre-check walks the same filter, so a question deferred withlaterneeds no answer there. With--yesnothing is answered, and the two value questions a plain--yessettles by default, the artefact kind and the house-style question, are left unwritten with a note naming the run that asks them, so each still waits; the version stamp follows the existing rule, so such a run records the running release and the offer for it ends whileabcd ahoystill lists the questions. Waiting counts no changed default where the running build is not a release. Grounds: the step 5 amendment's "the yes approves every category up front", and the criterion that--yesnever answers a value question. - Amended 2026-10-17 by the agent, building step 7: the verb's edges.
--fullwith--seenis a usage error too, since--fullwrites nothing; every usage error and every refusal of core's (ErrNotRelease,ErrNoOffer,ErrAlreadyAnswered) exits 2 saying nothing was written. Bare prints nothing when the moment holds nothing, so the agent relays nothing. An answer recorded whosenews-seenraise then fails is printed and the error returned, so the answer is never reported lost. The verb lives ininternal/surface/cli/update_news.go, andcli.gogains one line each for its registration, the install's flag and the flag's value. Step 2'sEmbedded,Marshal,NotesURLandReleasesURLare unexported (internal/core/releasedigest, outside the step's packages), because no front door calls them and the reach audit refuses an exported function nothing outside its package reaches. Grounds: the reach audit's ratchet and the coordination asked of this branch. - Amended 2026-10-17 by the agent, building step 8:
Pendingtakes the caller's detection.Pending(cwd, det)reads the detection the session start already ran and runs none of its own;MomentandRecordstill detect for themselves where the setup version is an older release. The hook asks only when its detection succeeded, prints the constant from the CLI (afterUpdateSessionLine, which a test holdscommands/ahoy.mdto quote), and reports a failure to work it out as a notice on stderr, never on stdout. The update chapter's "the session start check does not compare versions" is rewritten, since the check now readssetup_version, though it prints no version. Grounds: the step's "with the detection it already has", and the fixed-text rule of session start's stdout. - Ruled 2026-10-17 by the product thinker: the verb is
abcd update news. The verb step 7 added asabcd ahoy after-updateis renamedabcd update news, a sub-verb ofabcd update, with the same flags. The first name never shipped, so no alias keeps it. Each news line now ends "Ask for details: abcd update news --full", and session start's fixed line namesabcd update news.ahoy install --only-waitingkeeps its name. This supersedes the verb's name, its file, its session-line constant and its page in the step 6, 7 and 8 amendments above: the verb lives ininternal/surface/cli/update_news.gowith the constantupdateNewsSessionLine, which a test holdscommands/update.mdto quote; the install's flag lives ininternal/surface/cli/ahoy_only_waiting.go;update.gogains the registration line, and the host steps moved fromcommands/ahoy.mdtocommands/update.md. Having a sub-verb makesabcd updatea parent, so a lowercase word that is not a release tag (every release tag starts withvand a digit) is refused as an unknown command, exit 2, before anything is resolved or fetched, where it was taken as the name of a release. Grounds: the product thinker's ruling, and the sweep that holds every parent to refuse an unknown sub-verb at exit 2. - Amended 2026-10-17 by the agent, after step 9's live run: the fixed line
names the plugin's command page and asks for the lines word for word. The
live run found two gaps: the agent restated the two lines in its own words,
and it ran a bare
abcd, which on the person's machine is whatever is first onPATH, an older release with noupdate news. The constant now reads: "abcd: an abcd update has news for this session. After the person's first message, run /abcd:update news, whose page runs the abcd this plugin ships (never a bare abcd: the one on PATH can be an older release), then begin your first reply with the lines it prints, word for word, before anything else." This supersedes the line quoted under "The hook's line is one constant" above; it still carries no version and no count, andcommands/update.mdquotes it and tells the agent to run"${CLAUDE_PLUGIN_ROOT}/abcd" update news --jsonand to begin the reply with each line word for word. Grounds: every byte of the line stays a constant in the binary (iss-2608241115201044), and only the command page reaches this plugin's binary from such a line. The binary's own path fromos.Executableis not a constant: the plugin root under the host's plugin cache is named for the marketplace, and a repository's tracked host settings can declare a marketplace under a name it chooses, while a plugin loaded from a directory can sit at any path, so the path is text a repository can choose. A literal${CLAUDE_PLUGIN_ROOT}on the line would reach the agent unexpanded, since the host sets that variable for hooks and writes it into command pages at load, not into the agent's shell. The command page is where the host writes the plugin root it loaded this session, the same root whose binary printed the line, so the stale-root window iss-2609020113012227 describes does not open here, and the page's resolution ladder (iss-2608230943088357) puts that binary first. A binary naming a command page in its output already has precedent (staleusage.go: "run /abcd:library").
Known limits, stated, not claimed
- An agent that runs
--seenor--answerwithout a person (against the plugin page) marks the news seen or the offer answered. Nothing in the binary can tell; the plugin page is the control, andabcd update news --fullstill shows the summary, andabcd ahoystill lists the waiting setup. - A host without hooks gets no line;
abcd ahoylists the waiting setup andabcd update newsshows the summary (scope condition cond-2610170635092426). - A plugin root whose binary has not been swapped shows nothing new: the running binary's version is the "updated to" side (scope condition cond-2610170635092640).
- Setup asks each of its four config values in turn and saves every one
answered. A value deferred with "decide later" stays unset in
.abcd/config.jsonand is listed as remaining, so the run is partial andabcd ahoystill lists it; the values answered beside it, before or after, are saved. A full install and the only-waiting run behave the same way. - An agent that ignores the session line and runs
abcd update newsthrough an older release onPATH(v0.6.2 to v0.15.0, whoseupdate [tag]takes one positional tag) does not get the news.newspasses those releases' tag shape (^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$), and no confirmation is asked for a named tag. A plugin-root binary, the dev shim and a package-manager install are refused before anything is fetched; a PATH-installed copy goes on to fetchreleases/download/news/checksums.txtfrom the release origin, gets a 404, and fails with "release news has no checksums.txt", having downloaded no binary and swapped nothing. It would download and swap only if the origin ever published a release taggednewswhose binary matched that release'schecksums.txt(and, from v0.14.1, passed the provenance check bound torelease.ymlonmain); checked 2026-10-17,intentdriven/abcdhas no such tag or release, so a release must never be taggednews. Releases before v0.6.2 have noupdateverb and refuse it as an unknown command.
How each criterion is met
itd-2610161616086564 (the offer):
- The one-line offer, nothing written before the answer. Step 6 composes
the offer line from
setup_version, the running release and the waiting count;--fulladds the titles (step 6); step 8's hook line leads the agent to it; bareupdate newswrites nothing (step 7's test). - Nothing needed, no offer.
afterupdate.Pendingreports no offer whenahoy.Waitingis empty (step 6);abcd ahoykeeps listingversion.upgrade(step 1). - Yes asks only the waiting set and records the version.
ahoy install --only-waitingasks exactlyahoy.Waiting's questions and settings, never one answered with a value or deferredlater, andstepVersionStamprecords the running release (step 7). - Not now is not repeated.
--answer not-nowwrites the release's record (step 6);Pendingreads it (step 6);abcd ahoystill lists the gap with--only-waitingin its fix hint (step 1). - The verb records it, keyed on repository and release, in abcd's data directory; the hook writes nothing. Steps 6 and 8, with a test that the hook leaves the store unchanged.
- A new release offers again. The record is keyed on the release (step 6).
- No person: nothing run, nothing written;
--yesnever answers a value question. The hook writes nothing and only the answer writes (steps 6 and 8); a test holds thatahoy install --yes --only-waitingleaves every waiting value question unset (step 7). - Matching versions or a dev build: no offer.
ParseReleaseTagrefusesdev, and equal versions wait on nothing (steps 1 and 6). - A newer committed setup version: no offer, never a downgrade. Step 1.
- No network; nothing unchecked from tracked files. Every input is a
file on disk or the embedded digest;
setup_versionpassesParseReleaseTagbefore it reaches any output (steps 1 and 6); the zero-network test covers the hook and the verb (step 8).
itd-2610161617096084 (the summary):
- One line per release, the full summary one command away, action first.
Steps 2 and 6 (
--fullorders entries needing action first and cites ids). - Skipped releases are all covered and named. The chain walk (step 2).
- What waits here apart from abcd's news; news once per computer, waiting once per repository. Two lines from two sources, two records (step 6).
- A release the digest lacks is named with where its notes are; never a
guess, never presented as complete. Step 2's gap report, rendered by
step 6 with
partial: truein--json. - No network; only the binary's digest. Steps 2 and 8.
- Shown once, again on request.
news-seen(step 6);--fullshows it again at any time and writes nothing (step 7). - No person: not counted as seen. Only
--answeror--seenraisesnews-seen(step 6). - Text only from the binary's digest, sanitised.
releasedigestreads only its embedded file and sanitises every line (step 2); a test runs the verb in a repository with its ownCHANGELOG.mdand asserts none of it appears (step 7).
Proof
Every step is test-first, each test watched fail before its change. The
end-to-end proof is real, per the product thinker's rule that real runs are the
proof: before the spec closes, a binary built at a test tag from this branch
(with a digest carrying that tag) runs in a scratch repository set up by the
previous release's binary, and a live host session's first reply is captured
before and after the answer, with the store and .abcd/config.json shown
before and after.
Coordination
- Step 3 changes
internal/core/release, which every cut runs through, so it lands while no cut is in flight, and the next cut is the first to write a digest. - Steps 7 and 8 change
internal/surface/cli, whose tests can raise a keychain prompt on a person's machine; they run in CI or a fenced lane, not in a live session. - Every step reserves its footprint with
abcd reservebefore it starts.
Out of scope
- Answering the offer from a plain Terminal outside a host session:
abcd ahoyandahoy install --only-waitingalready serve it. - A digest for any binary but abcd's own: the cut writes one wherever a repository declares it, and only abcd's binary reads one.
- Rewording a digest line for the summary: the line is the record's changelog line as the cut composed it.
Footprint
- packages: internal/core/launch, internal/core/embargo, internal/core/releasedigest, internal/core/release, internal/core/ahoy, internal/core/interview, internal/core/afterupdate, internal/surface/cli, .abcd/config.json, commands/ahoy.md, commands/launch.md, .abcd/development/brief/04-surfaces/01-ahoy.md, .abcd/development/brief/04-surfaces/04-launch.md, .abcd/development/brief/04-surfaces/21-update.md
- tests: internal/core/launch (strict tag parse and order), internal/core/embargo (its tags through the shared parser), internal/core/releasedigest (schema, strict read, sanitising, chain walk and gap report, seed equals CHANGELOG), internal/core/release (the digest write, its order and undo, the declared path, no path no digest), internal/core/ahoy (directional version gap and stamp, default-change table, waiting set, only-waiting install,
--yesleaves value questions unset), internal/core/interview (latest value per question), internal/core/afterupdate (pending, moment, store, once per repository and release, once per computer), internal/surface/cli (the verb's plain and JSON output, the hook's constant line and no write, the zero-network harness, surface parity), and the live end-to-end run
Steps
- Strict release versions and a directional upgrade gap
- packages: internal/core/launch, internal/core/embargo, internal/core/ahoy
- tests: internal/core/launch, internal/core/embargo, internal/core/ahoy
- Add
launch.ParseReleaseTag: exactlyvMAJOR.MINOR.PATCH, digits only, no leading zero, components bounded, ordered byCoreGreater; embargo'sreleaseTagRecalls it. detectVersionreportsversion.upgradeonly when both sides parse and the running release is newer; its fix hint namesabcd ahoy install --only-waiting.stepVersionStampnever records a version older than the one recorded.- landed: 006217b6a
- The digest package
- packages: internal/core/releasedigest
- tests: internal/core/releasedigest
- Define the schema (schema version; per release its tag, the tag it was
cut from, and its entries: the line, the record ids, their impact) and
embed
digest.jsonwith//go:embed. - Read it strictly and capped, sanitising every line.
Range(from, to)walks the chain down fromto, returns each release in the range with its entries (needing action first), and names every release it cannot describe with its published address and a partial flag.- Seed
digest.jsonfrom the derived CHANGELOG sections (v0.7.0 on); a test holds each line equal to its CHANGELOG line. - landed: 6ba0199d9
- The release cut writes the digest
- packages: internal/core/release, .abcd/config.json, commands/launch.md, .abcd/development/brief/04-surfaces/04-launch.md
- tests: internal/core/release
- Claim the
releasenamespace with the one keydigest, read from the repository layer; a path outside the tree, under.abcd/, or not ending in.jsonis refused before any write. - Add the digest as a write in the cut's plan, before the CHANGELOG heading
(which stays last), with its undo; the release appended is the cut's
NextTag,BaseTagand the validated lines. - No declared path writes no digest; abcd's config declares
internal/core/releasedigest/digest.json. - landed: db2308f1f
- The default-change table
- packages: internal/core/ahoy
- tests: internal/core/ahoy
- Embed
defaults/default-changes.json: entries of a setup key and the record that changed its default, empty at first. - A test refuses an entry whose key is not a setup question key or whose record id is malformed.
- landed: 4fa84825a
- What waits on this repository
- packages: internal/core/ahoy, internal/core/interview
- tests: internal/core/ahoy, internal/core/interview
- Add
interview.Latest(place, name): per question id, the newest value across one interview's records. - Add
ahoy.Waiting(cwd, from, to): the never-answered setup questions the detection's gaps arm, less those whose latest setup value islater, plus the settings whose default changed in a release afterfromup totoand which the repository layer leaves unset, each with its title. - A test drives a dry install over a fixture and holds every key the
install prompts for to be a key
Waitingcan return. - landed: 66fc2dd65, 668057055
- The moment and its record
- packages: internal/core/afterupdate
- tests: internal/core/afterupdate
Pending(cwd), read-only: whether news is unseen on this computer and whether an offer is due here (both versions parse,setup_versionolder than the running release,Waitingnon-empty, no answer recorded for this repository and release).Moment(cwd): the summary line per unseen release and the offer line;Full(cwd, from): every release in the range, action first, with record ids, the undescribed releases named, and the waiting titles.Record(cwd, answer)andMarkSeen(): the exclusive per-release answer file under~/.abcd.noindex/after-update/<root-sha>/and the raise-onlynews-seen; an answer also raisesnews-seen.- landed: 89b995d93, 20d51beed
- The verb and the setup the yes runs
- packages: internal/surface/cli, internal/core/ahoy, commands/ahoy.md, commands/update.md, .abcd/development/brief/04-surfaces/01-ahoy.md, .abcd/development/brief/04-surfaces/21-update.md
- tests: internal/surface/cli, internal/core/ahoy
abcd update news [--full] [--answer yes|not-now] [--seen] [--json], a sub-verb ofabcd update(renamed fromabcd ahoy after-updateby the product thinker's ruling of 2026-10-17, no alias), wired on the CLI andcommands/update.md; bare and--fullwrite nothing;--answerwith--fullor--seenis a usage error.ahoy install --only-waitingasks onlyahoy.Waiting's set and stamps the running release on completion; with--yesit leaves every waiting value question unset.commands/update.mdgives the host steps: relay the moment in the first reply, put the offer through the host's question tool, run--answeronly with the person's reply, and on yes run the only-waiting setup.- A test runs the verb in a repository whose own
CHANGELOG.mdis not abcd's and asserts none of its text is printed. - landed: d90f0b6db, b1549fd73, ee7cc5884, 336b9a137
- Session start's fixed line
- packages: internal/surface/cli
- tests: internal/surface/cli
- The session-start hook asks
afterupdate.Pendingwith the detection it already has and, when anything is pending, prints the one constant line to stdout, apart from the notices count. - The tests hold that the line is byte-equal to the constant whatever the repository's
config holds (a directive payload in
setup_versionincluded); the store and the repository are unchanged after the hook; the zero-network harness (countingTransport, the release-fetcher seam) covers the hook and every form of the verb. - landed: e86583387, 0dd101292
- The live run
- packages: internal/surface/cli
- tests: internal/surface/cli
- Build at a test tag with a digest carrying it, run in a scratch repository set up by the previous release, and capture a live session's first reply, the answer, and the store and config before and after.
- Evidence: a live headless host session on a build of 1beb791b9 stamped v0.15.0, in a scratch repository whose config recorded
setup_versionv0.14.2, printed the session-start line, ranabcd update newsand opened its first reply with the news and the offer (restated, not verbatim); afterabcd update news --answer not-nowa second session printed neither, and.abcd/config.jsonwas unchanged throughout; transcripts and the store and config before and after are in the session scratchpad'slive-run/transcripts/(session1.jsonl,session2.jsonl,00-before.txtto03-after-session2.txt). - Evidence, re-run after the amended line: a live headless host session on a build of 0ab48ce64 stamped v0.15.0, loaded from a plugin copy of that commit, in a fresh scratch repository whose config recorded
setup_versionv0.14.2, with a decoyabcdfirst onPATHthat logs any call and exits 1, printed the amended session-start line, opened/abcd:update newsthrough the host's skill tool and ran the page's"<plugin root>/abcd" update news --jsonwith the plugin root the host wrote in; the decoy's log stayed empty, and the first reply began with the two printed lines byte for byte (checked against the command'slines). Afterupdate news --answer not-nowthrough the plugin's binary, a second session printed neither the line nor the offer, and.abcd/config.jsonwas unchanged throughout; transcripts and the store and config before and after are in the session scratchpad'slive-run/transcripts2/(session1.jsonl,session2.jsonl,00-before.txtto03-after-session2.txt). - landed: 1beb791b9
Frontmatter
| id | spc-2610170635097509 |
|---|---|
| type | spec |
| lifecycle | closed |
.abcd/development/specs/closed/spc-2610170635097509-after-update.md
open on GitHub ↗ · commit history ↗
References2
- itd-2610161616086564After a plugin update, abcd offers to bring the repository's setup up to dateimplements
- itd-2610161617096084After a plugin update, abcd says what changed for this repositoryimplements
mentions4
- adr-2610031751065746The worktree store lives under the renamed home, `~/.abcd.noindex/worktrees/<root-sha>/<name>/`, and the rest of its rule is unchanged
- iss-2608230943088357The plugin-root binary predates the verbs its own command surface documents, and the failure is an unknown-command error rather than a named refusal. Verified 2026-08-23: the commands/update.md page instructs the reader to run the plugin-root binary first, and that binary on this machine is v0.6.1, whose help lists no update verb at all. Running it as documented gives 'abcd: unknown flag: --yes', and running the bare verb gives 'abcd: unknown command "update" for "abcd"'. The page's own resolution ladder recovers the situation, since a source checkout falls through to go run ./cmd/abcd, but only for a reader who knows to keep going: the first rung produces an error that looks like a malformed invocation rather than a stale install. This refines iss-228, which records the same root cause with a different consequence -- a plugin-root binary sitting a month stale so ahoy's first resolution rung reported pre-iss-171 gaps with no staleness signal. That entry is about wrong gap reporting; this is about a documented command surface pointing at a binary that cannot serve it. The general shape is that the plugin surface and the plugin binary ship from one release but a repository checkout can be arbitrarily far ahead of the installed plugin, so every commands/*.md page documenting a verb newer than the reader's plugin has this failure mode, and update is merely the first to be noticed. Two directions, neither adopted: have the command pages degrade legibly when the resolved binary lacks the verb, by checking for it before invoking; or have the binary answer an unknown verb by naming its own version and vintage, so the error says stale rather than wrong. Worth deciding whether this is inherent to plugin-versus-checkout skew and therefore only a documentation problem, or whether the unknown-command path should carry install context.
- iss-2608241115201044SessionStart exit-2-as-advisory no longer reaches the user. abcd hook session-start returns exitError{Code: 2} whenever it has notices to print (internal/surface/cli/cli.go:1289), on the documented assumption that a non-zero SessionStart surfaces stderr without blocking. Claude Code v2.1.241 instead renders it as 'SessionStart:startup hook error' followed by a truncated echo of the hooks.json command string; the stderr notice itself never reaches the user. Every session-start notice is therefore an opaque error banner: transcript-capture gaps, staged-drain failures, the remaining-backlog count, binary skew, dogfood staleness, and the version transition. The data-loss-adjacent ones matter most - a user told that a session transcript was not captured now sees only a hook error, with no way to learn what it was.
- iss-2609020113012227Refines iss-2608230943088357, which main resolved for the two LOUD shapes (an unknown flag, an unknown command) by naming the stale binary in the refusal. This record carries the third shape, which is SILENT and which that fix cannot reach: the verb exists, is served by the old plugin root, and answers confidently. Observed 2026-09-01 immediately after the v0.7.0 release; the mechanism is that plugin roots are keyed by the commit they were installed from, so a hash-pinned path baked into a skill page is designed to expire, and the old root stays on disk ready to answer. The section that follows is the observation as recorded on the day.