Development record
memory-recall
Summary
This spec delivers the memory-recall bundle: memory notes
(itd-2610091918433290)
and memory pages recalled at the right moment
(itd-39).
An agent keeps short notes with /abcd:memory, and the loader that already
brings in the project's rules brings a note, and any longer memory page that
matches, back into the session when a prompt matches their words. Notes start
on the person's machine. Reviewing them and promoting one into the project's
record are a later dashboard's
(itd-2610111655362931,
the product thinker's ruling of 2026-10-11); until it ships, the review is
memory list and memory remove, and this bundle reads a note in the
project's file wherever one is.
The approach, in plain words:
- A
memory.jsonbeside eachrules.json. The machine's~/.abcd.noindex/memory.jsonholds personal notes and the project's.abcd/memory.jsonholds promoted ones (the facilitator's ruling of 2026-10-11). The loader reads both with the trust checks it already applies to the tworules.jsonlayers. Agents write the machine'smemory.json; people curaterules.json, so no agent's note ever edits the file that holds reviewed conventions (one-writer-per-file). - Write first, review later. An agent writes any note without asking. Each
note is stamped with who wrote it, when and in which session, and the
redactor and the private-names banlist refuse a note that would hold a
secret, a private name or a network address (adr-2610091918443997). Notes
ship before the dashboard's review: until then, the review is
memory listandmemory remove, which removes any personal note, run by a person or by an agent at a person's request (the product thinker's ruling of 2026-10-11). - One recall for notes and pages. The prompt hook recall-matches the prompt against rules, notes and pages in one pass. Notes and pages come back together in one block after the rules, deduplicated per session the way rules are.
- Everything shrinks as the session fills, and nothing goes silent. One table of context brackets in the loader serves rules, notes and pages. Notes and pages come back in full, then as one line each, and stay at one line to the end of the session; a rule domain comes back in full, then as its heading and first rule. A relevance ranking chooses which items survive when only some fit. The hook learns how full the session is from the status line's fill file when there is one (the one file the status line may write, by the product thinker's ruling of 2026-10-11), and otherwise from a bounded read of the transcript's tail. When neither tells it, rules come in full, as today, and notes and pages as one line each. The loader has no brackets today (see Design, "Context brackets"), so this bundle builds them once, in the loader.
- One diagnostic.
abcd rules --prompt "<text>"shows the rules, notes and pages one prompt would bring back, with the ranking's reasons. - No promotion here. This bundle has no promote operation, in the store or on the verb, so there is nothing an agent could promote with; the dashboard intent carries promotion and its person's-act rule.
How each acceptance criterion is met. N1 to N8 are the notes intent's, in the order it lists them since its review and promotion criteria moved to itd-2610111655362931; P1 to P5 are itd-39's.
| Criterion | Met by |
|---|---|
| N1, a note written unasked comes back next session on a prompt about tests | Step 1 writes it with no question; step 2 recalls it; step 8's live session shows it across two sessions. |
| N2, a matching note and a matching page come back together through one recall | Step 3 adds pages to the recall step 2 built; one call renders one block. |
| N3, a lesson the person points at in the host's memory files is filed as a note; nothing reads or changes those files on its own | Step 1, memory add --moved-from-host, which takes text only and opens no path. |
| N4, the docs set memory beside the library in plain words | Step 7. |
| N5, personal notes in the machine's file, promoted notes in the project's | Step 1's two layers, add writing only the machine's; step 2 recalls a note from the project's file. |
| N6, a note never holds a secret, a private name or a network address | Step 1's write gate, run on every write. |
| N7, every note records who wrote it, when and in which session | Step 1's stamps, with its page passing the host's session id; a host without one is recorded as unknown, said on every write. |
| N8, recall shrinks as the session fills: full, then one line, to the end of the session | Step 6, over the fill step 5 reads. |
P1, a page declaring recall: [auth, login] comes back on a prompt containing "auth", and no other page |
Step 3. |
| P2, below 40% free, three matched pages come back as their index lines only | Step 6, the depleted bracket. |
| P3, a repeated prompt injects matched memory once, until the refresh | Step 2's ledger, which step 3's pages share. |
| P4, a keyword matching a page in both scopes brings the workspace page and suppresses the user page | Step 3, narrower scope wins. |
| P5, the host's memory directory is never read or written by the retrieval path | Step 3's test over the recall's file opens, extended in step 5 to the fill reader's; N3's test covers the write side. |
Where the two intents' criteria overlap (the brackets in N8 and P2; the host's memory files in N3 and P5), both criteria stand and one mechanism serves both, as itd-39's Decisions record.
Parts of itd-39's body predate the shipped loader and the verb split
(itd-2610090831227812, shipped by pull request #936 on 2026-10-11), and this
spec reads them
as follows without editing the intent: its
hooks/prompt_router_hook.py is the binary's hook prompt-router; its
/abcd:memory ask is library ask; its abcd memory recall diagnostic is
rules --prompt "<text>", as its Decisions now record; and its reconciliation
with itd-3's rejection of a personal rules.json is moot, since the machine
layer ~/.abcd.noindex/rules.json has since shipped.
Design
The memory.json file and its two layers
One schema serves both layers. The machine layer is
~/.abcd.noindex/memory.json; the repository layer is <repo>/.abcd/memory.json.
{
"schema_version": 1,
"notes": [
{
"id": "mem-2610111200001234",
"title": "The tests need the race flag",
"body": "Run go test -race ./internal/... before saying the tests pass; the plain run misses the data race in the hook tests.",
"recall": ["test", "tests", "race"],
"root": "<the repository's root commit, full object name>",
"origin": "agent",
"written_by": "Claude:claude-opus-5-5",
"written_at": "2026-10-11T12:00:00Z",
"session": "<the host's session id, or null where the host supplies none>"
}
]
}
idis minted through the shared record-id seam (recordid.Minter, adr-45) with the familymem, so two sessions writing at once never collide and no coordination is needed.titleis one line of at most 120 characters,bodyat most 2 KiB, andrecallone to twelve words or short phrases, matched by the loader's own term rule (word boundary, stems, multi-word phrases). The figures are this spec's, set in one place, and a write past them is refused naming the limit.rootties a personal note to the repository it was written in: it is recalled only in that repository. A note written with--everywherecarries"root": nulland is recalled in every repository on the machine (the facilitator's ruling of 2026-10-11). The repository layer carries noroot: the file is already the project's.originisagentorhost-memory-move(the manual move, below).written_bytakes the shape of theAssisted-by:trailer (<Vendor>:<model-version>), so who wrote a note reads the same way as who assisted a commit.sessionis the host's own identifier for the session, which the command page passes (below). Where the host supplies none it isnull, rendered "session unknown" wherever the note is shown.- The review's markers (when a note was last reviewed) and the promotion's
stamps (who promoted it and who agreed) are not in this schema. The dashboard
intent adds them, under a
schema_versionthe reader then accepts.
Reading follows the rules.json layers exactly. The machine file is read only
through fsutil.ReadHomeDeclaration: a regular file this account owns that no
one else can write, within 256 KiB, never behind a symlinked
~/.abcd.noindex; a file failing those checks or failing to parse is a loud
error naming the file in tilde form, and the hook then recalls no notes from
it, while rules still inject. Absent, it costs nothing and nothing is created.
The repository file is read through the repository layer's guarded read, from
the root the rules loader resolved (a refused foreign-uid root reads no
.abcd/memory.json above the working directory, as with rules.json). The
kill switch in either rules.json silences note and page recall as well as
rules: it is the loader's switch, and recall is the loader's.
Writing touches the machine layer only; nothing in this bundle writes the
repository layer. Until the dashboard's promotion ships, a note reaches the
project's file only through a person's change to it, reviewed like any other
change to a committed file, and the loader recalls whatever valid notes that
file holds. A write opens ~/.abcd.noindex in one walk from the home's
descriptor (fsutil.EnsureHomeScope), takes an exclusive flock(2) on the
directory at that path (fsutil.WithDirLock, which opens it O_NOFOLLOW and
leaves no lock file), re-reads the file under the lock relative to the
descriptor the walk opened (rules.ReadUserLayerFileIn), applies the change,
and writes through that same descriptor with fsutil.WriteFileAtomicInRoot
with mode 0600, so two agents writing at once both land. A directory renamed
after the walk makes the re-read refuse, so nothing is written rather than one
directory's notes replacing another's. The machine file is never created by a
read and is created 0600 by the first write.
The write gate: redaction, banlist and stamps
Every note write passes one gate in internal/core/memnote before anything is
written:
- The shared scanner (
internal/adapter/scanner), the one canonical detector capture, history and the memory-page writer already route through, reached through the memory-page writer's own redactor, exported for it. The gate refuses on a degraded scanner, then scans the note's title, body and each recall word, and refuses on any span the memory-page writer would have to rewrite: any hard-fail secret, any identity or network span whatever its severity (scanner.BlockingResidual), and the literal home-directory backstop. Unlike the page writer, it never redacts and writes: the refusal names the kind of span and the field, never the span, and the agent rewrites the note in its own words, since a lesson with a span cut out of it may say something wrong. - The private-names banlist, through
banlist.MatchPrivate, which the implement loop already uses. A store that cannot be read is treated as a match, as that function's contract requires. The banlist's private layer is per checkout (.abcd/.work.local/private-names.txt); in a linked worktree the gate also judges the note against the primary checkout's store, found throughbanlist.PrimaryWorktreeRoot, which is the layer the committed guard enforces there. There is no machine-wide banlist layer, so a note written with--everywhere, recalled in every repository, has been judged against the writing checkout's names only.add --everywheresays so on every write, and the command page says it too. Judging it against every checkout's names instead would need a machine-wide banlist layer that does not exist, which is a banlist change outside this bundle. - The stamps.
written_atcomes from the clock, never from the caller.written_byandsessioncome from the caller (step 1's page), are shape-checked (a session that looks like a link is refused, so no session URL can enter a note), and are judged by the gate like any other field.
The gate is the only path into the machine file. How the dashboard's promotion judges a note entering the project's file is that intent's to plan.
One recall for notes and pages
A new package, internal/core/recall, holds what notes and pages share:
candidate items, matching, ranking and rendering. The prompt router calls it
once per prompt, after rules.Inject, with the items both note layers and both
page scopes yield. It returns one block, headed ## MEMORY, appended after the
rules block in the same hook output.
- Matching is the loader's.
internal/core/rulesexports its prompt index and term rule (indexPrompt,termHit, the stemmer) as one small function, so a note's recall word and a rule domain's recall word hit on exactly the same prompts. An item is a candidate when any of its recall words hits; the ranking orders candidates and never adds one. - Items. A note yields its title, body and recall words. A page yields its
filename, its
index.mdsummary line as its title, its body, and itsrecall:frontmatter list, which the memory-page schema already carries (internal/core/memory/schema.go). A page without arecall:list is never a candidate, so pages already written are silent until a recall list is added. - Scopes. Pages are read from the checkout's
.abcd/memory/and, when it exists, the machine's~/.abcd.noindex/memory/(read only, throughfsutil.OpenHomeScope; nothing in this bundle writes it). When a recall word hits a page in both scopes with the same<type>_<domain>_<slug>name, the workspace page is the candidate and the machine page is suppressed (P4). Notes follow the same rule: an id is minted once, so a note in the project's file and one in the machine's share an id only if someone copied it by hand, and then the project's is the candidate and the machine's is suppressed. - Dedup. The session ledger gains a second map,
recalled, keyed by item (mem-…orpage:<filename>) with a signature of the item's content, not of the form it was rendered in, so a bracket change never sends again an item already sent in any form (Context brackets, below). It is separate from the rules ledger becauserules.Injectprunes any ledger name that is not an active domain, which would forget every item each turn. The same resets apply:SessionStartandPreCompactclear both maps, and the fixed backstop clears both on the same prompt (P3). - Budget. Rules and memory share the loader's 64 KiB per-turn injection
budget. Rules render first and memory takes what remains; an item that does
not fit is not recorded in the ledger, so it is retried next turn, as a
truncated rule domain is today. Until step 4's ranking lands, the
candidates that fit are taken in id order (
mem-…beforepage:…), and step 4 replaces that order with the ranking. - The host's memory files are never among the paths the recall opens. The
recall reads exactly four places: the two
memory.jsonlayers and the two page scopes. A test runs the recall through a filesystem seam that records every open, with a home directory holding a host memory directory full of matching text, and asserts the recorded opens are only those four (P5). Step 5 extends the same test to the fill reader, whose opens are the status line's fill file and the one transcript the hook's payload names, and nothing under the host's memory directory.
The --json envelope of hook prompt-router gains a recalled list naming
each item injected, beside injected for domains, and (step 5) a fill
object naming the fill and where it came from, so a reader can tell what came
back and why.
Ranking
When only some matched items fit, a relevance ranking chooses which ones stay, as the facilitator ruled. The ranking is BM25 with per-field weights (BM25F), computed in about a hundred lines of standard-library Go, with no new dependency.
- Query terms are the prompt's stemmed tokens, from the same prompt index the matcher builds.
- Fields and weights. A term found in an item's recall words counts three times, in its title twice, and in its body once. The weighted frequencies are summed per term before saturation, as BM25F prescribes.
- Score. For each query term t present in the item,
idf(t) · tf_w / (tf_w + k1 · (1 − b + b · len / avg_len)), summed over the terms, withk1 = 1.2,b = 0.75, andidf(t) = ln(1 + (N − n_t + 0.5) / (n_t + 0.5))over every note and page in reach (N items, n_t of them holding t). A word every item shares counts for little; a rare word that matches counts for a lot. - Ties break, in order, on the narrower scope (repository before machine),
then the more recent
written_at(or page modification), then the id, so the order is the same on every machine and every run. - Explainable.
abcd rules --prompt "<prompt>"prints, for each candidate, its score, each matched term with its field and its contribution, the bracket in force, and whether it would come back in full or as one line, or not fit the budget. Nothing about the choice is hidden.
Prior art: BM25 is the probabilistic relevance framework's ranking function
(Robertson and Zaragoza, "The Probabilistic Relevance Framework: BM25 and
Beyond", 2009), and BM25F its multi-field extension (Robertson, Zaragoza and
Taylor, "Simple BM25 Extension to Multiple Weighted Fields", 2004). It is the
default similarity in Lucene since version 6 and the ranking SQLite's FTS5
bm25() exposes, with per-column weights. The fit challenge the prefer-sota
principle asks for set aside the two stronger-sounding options: dense
embeddings need a model or a network call inside a hook that runs on every
prompt, against the host-delegated and minimal-dependency stances and the
hook's fail-closed, never-blocking contract; a learned re-ranker needs
training data abcd does not have. A raw count of matching words, the order
library ask uses today, is what the ruling rejected: it lets a common word
outrank a rare, telling one. library ask keeps its order; changing it is not
in this bundle.
Context brackets
The facilitator ruled that memory recall shares the rules loader's context
brackets (itd-3) with no thresholds of its own, and then that the brackets gate
rules too, with the same thresholds. The shipped loader has no brackets: it has
a fixed per-turn byte budget, and nothing in it reads how full the session is.
The brackets exist only in itd-39's design (the brief's
05-internals/07-memory.md § 9). So this bundle builds one bracket table in
internal/core/rules, beside the byte budget, and the rules injection and the
recall both read it from there, so no payload can grow thresholds of its own.
The table starts from itd-39's figures, read as the fraction of the context window still free. There are three brackets of fill and no fourth in which recall sends nothing: the facilitator ruled that recall never sends nothing, because a compaction or a new session resets recall anyway, so the one-line forms cost little and a session never loses sight of what matches.
| Bracket | Free | Rules | Notes and pages |
|---|---|---|---|
| Fresh | 70% or more | Every matched domain in full, as today. | Every candidate in full, in ranked order, within the budget. |
| Moderate | 40% to 70% | Each matched domain as its heading and first rule, then a line saying how many rules it holds and that abcd rules <DOMAIN> renders the rest. |
The top-ranked candidates in full within 8 KiB, the rest as one line each. |
| Depleted | below 40%, to the end of the session | As in moderate. | One line each (a note's title and id, a page's index line), the top-ranked lines within 2 KiB, and always at least the top-ranked one. |
| Unknown | no source tells (How full the session is, below) | As in fresh. | As in depleted. |
The unknown row is not a bracket of fill but the technical facilitator's ruling of 2026-10-11 on what to do without one: rules keep the form they have today, and notes and pages come back as one line each.
- A shrunk domain is never a heading alone: the loader already skips a domain
with no rules, because a heading-only block reads as a domain that says
nothing, and the first rule plus the pointer line keep that true. Rules are
not reordered to choose the first. In a domain written in a
rules.jsonlayer, the first rule is the one its author put first. SHELL's and GRILL's rules are generated, so for them no author chooses: SHELL's first rule is the first hazard-registry entry in id order (internal/core/rules/shell.go), and GRILL's the first rulequestion.AskingRulesreturns (internal/core/rules/grill.go). A shrunk domain appears only under a known fill in the moderate or depleted bracket; an unknown fill keeps every domain in full. - An item, rule domain or note or page, is sent once per session in whatever form its bracket gave it, and is not sent again in a larger or smaller form when the bracket changes: the ledgers key on content, not on form. The next reset (a compaction or a new session) clears the ledgers, and the item comes back in the form the bracket then in force gives.
- The 8 KiB and 2 KiB figures are this spec's starting points, set beside the thresholds in one place.
How full the session is
The prompt hook's payload carries no measure of the context window. The facilitator ruled for both sources, the status line's first (2026-10-11):
- The status line's fill file. The
statuslineverb parses five values from the host's status payload today (internal/core/statusline/payload.go): the model, the context percentage (context_window.used_percentage), the two rate-limit percentages, and the working directory. It does not parse the session id. Step 5 adds the payload'ssession_idto that parse, kept out of every rendered row and JSON envelope as the working directory is, and used only to name the fill file. When the verb renders abcd's own row (never when it passes through to the person's previous status command) and the payload carries both the session id and the context percentage, it writes{"used_percentage": …, "at": …}to~/.abcd.noindex/fill/<sha256(session)>.json, mode 0600, atomically, through the home scope's guarded write (fsutil.OpenHomeScope, so never behind a symlinked~/.abcd.noindex). The file lives in the user-level abcd home, never in the host's configuration folder and never in a working tree. The writer and the file name live ininternal/core/rules, beside the fill reader, so one package owns the file.
This is an exception the product thinker granted on 2026-10-11 ("Allow one
fill file") to the ruling of 2026-10-05 that the status line writes nothing
(iss-2610050556383525). It covers exactly that one figure, in one file per
session, and nothing else: no cache, lock, log or ledger, and no directory.
The loader creates ~/.abcd.noindex/fill/ (0700) on its session-start
reset, through fsutil.EnsureHomeScope rather than os.MkdirAll, so a
symlinked ~/.abcd.noindex is refused quietly and no fill/ lands under
its target; the status line writes only into a directory that is already
there.
The two read-only tests narrow to that one file and still refuse every
other write: TestStatuslineWritesNothing (internal/surface/cli) allows
one changed entry across its snapshot, the session's fill file, holding only
those two fields, and still requires every other case, the pass-through
path among them, to leave the snapshot unchanged;
TestStatusVerbReachesNoWritePrimitive (internal/core/statusline) admits
exactly one call, the fill writer, from one function in
internal/surface/cli/statusline.go, and refuses every other write
primitive and any second call site by name, as it refuses mode.Set. The
verb's help text, which says "It writes nothing", and the doc comment above
newStatuslineCommand change to say it writes only the session's fill file
and where. An absent directory (the session-start hook not installed, or the folder
swept) is a skipped write with no notice, since a stateless verb cannot say
it once; any other failed write is named on stderr, and the row still
renders either way. The
status line runs only where the person consented to it at install, so this
source is present only there.
2. The transcript's tail. Without a fill file for the session, the hook
reads the transcript its payload names (transcript_path), at most its last
256 KiB, through the guarded read (a regular file this account owns, never
followed through a symlink). It takes the last assistant entry's usage, in
the token-count shape internal/core/history already parses (input,
cache-creation and cache-read input tokens), and divides it by the context
window a per-model table in internal/core/rules declares for that entry's
model, beside the bracket table. Where one model is offered with more than
one window size, the table holds the smaller, so a doubt errs towards
shrinking. Reading the transcript the host hands over has precedent in the
transcript capture. The comment on hookInput.TranscriptPath in
internal/surface/cli/cli.go, which says the field is supplied by the Stop
hook and read by hook session-end only, changes to name the prompt
router as its second reader.
3. Unknown fill. No fill file, no transcript, a transcript that fails the
guarded read or holds no usage, or a model the table does not name: the
fill is unknown. Rules then inject in the fresh form, exactly as they do
today, and notes and pages come back as one line each (the technical
facilitator's ruling of 2026-10-11, "Rules full, notes short"). Grounds:
rules on a host where the fill is never known do not regress from today,
and notes and pages, which are new, cannot flood a session that may be
nearly full, while every match still shows as a line carrying its id and
rules --prompt shows everything in full. The hook says the fill is
unknown: the --json envelope's fill names the source as unknown on
every prompt, and one line on stderr says it on the first prompt of each
session (the ledger records that it was said).
The reset triggers delete the session's fill file with its ledgers, so a compaction does not leave the hook reading the full session's figure until the status line next refreshes, and the loader's existing sweep of stale session ledgers sweeps the fill directory by the same age.
The recall diagnostic: rules --prompt
The facilitator placed the diagnostic on the existing rules verb, which the
verb split put with the agents (groupAgents, page commands/ahoy.md).
abcd rules --prompt "<text>" shows what that one prompt would bring back:
- the bracket in force and how it was chosen (
--fill <percent-used>sets it; without the flag the diagnostic has no session, so the fill is unknown and it says it is rendering the unknown-fill form: rules in full, notes and pages as one line each); - each rule domain the prompt matches, with the recall words that hit and the form the bracket gives it;
- each candidate note and page, with its score, each matched term with its field and contribution, and its form: full, one line, or not fitting the budget.
It reads against an empty ledger, so it shows what a fresh session would get,
and writes nothing. --prompt with a positional domain is refused, exit 2,
naming the two forms. --json carries the same facts. The verb's help text and
commands/ahoy.md document it.
The /abcd:memory agents' command
/abcd:memory is an agents' page under the verb split's rules
(itd-2610090831227812): block: agents, user-invocable: false, kept in the
flat commands/ directory, named on an agents-block line of
abcd --help --agent, and held there by TestCommandPagesMatchTheMenu. The
binary verb is abcd memory, placed in groupAgents naming
commands/memory.md. Every sub-verb writes only through the core in
internal/core/memnote; the page carries the judgement half.
| Sub-verb | What it does | Writes |
|---|---|---|
| (bare) | Counts personal notes for this repository and everywhere, and notes in the project's file; names any layer that could not be read and why. | nothing |
add |
Writes one note to the machine layer: --title, --recall (repeatable), the body on stdin, --by <Vendor>:<model>, optional --session <id>, --everywhere and --moved-from-host. Prints the minted id; without --session, also prints that the session is recorded as unknown; with --everywhere, also prints that only this checkout's private names were checked. |
the machine layer |
list |
Lists notes from both layers, each with its title, body, recall words, stamps, and whether it is tied to this repository or recalled everywhere: enough to review them by. | nothing |
remove <mem-id> |
Removes any personal note, whoever wrote it and for whatever reason, run by a person or by an agent at a person's request. A note in the project's file is removed by editing that file in a reviewed change, never by this verb. | the machine layer |
list and remove are the review until the dashboard ships (the product
thinker's ruling of 2026-10-11, "Ship, list and remove"): a person reads the
notes with list and removes any they do not want, or asks an agent to.
The page tells the agent when to write a note (a lesson it would otherwise
relearn: a flag the tests need, a preference the person stated, a trap in the
tooling), to choose recall words a later prompt would actually use, to keep one
lesson per note, to add --everywhere only for a lesson that holds in every
project, and never to put a secret, a private name or a network address in
one, since the binary refuses it. It says that an --everywhere note has been
checked against the writing checkout's private names only, so a name another
project bans is the agent's to keep out. It tells the agent to run remove
only when the person asks. It passes --by with the agent's own vendor
and model, and --session with the host's own substitution of its session id
written into the page's text, so the host fills it in and the agent never
guesses it (the facilitator's ruling of 2026-10-11). Step 1's live trial
confirms the substitution on the first host. Where a host offers no
substitution, the page leaves --session out, and the note is recorded as
session unknown, said on every write.
The manual move from the host's memory files
The person points at a lesson in the host's own memory files, or pastes it.
The agent, at that word and only then, reads the passage the person named with
the host's own tools, and files it with
abcd memory add --moved-from-host, the text on stdin. The binary stamps
origin: host-memory-move and judges it through the same gate. The binary
takes no path operand on add, so it never opens the host's files at all, on
this verb or any other; the page tells the agent never to scan those files for
candidates, and never to edit or delete the original. Removing it there is the
person's choice, in the host. abcd's ruling of 2026-10-05, that abcd never
touches the person's host configuration folder, stands unchanged.
Review and promotion: on the dashboard
The product thinker moved the review and promotion to a later dashboard where the product thinker and the technical facilitator click the notes they want to promote (2026-10-11). The two criteria and the two open questions on the maintainer count and the agreement are carried, verbatim, by the draft itd-2610111655362931, which builds on the notes intent and the dashboard intent (itd-2610032150577708).
Notes ship before that review (the product thinker's ruling of 2026-10-11,
"Ship, list and remove"). Until the dashboard ships, the review is memory list and memory remove, for any personal note, run by a person or by an
agent at a person's request; the dashboard draft is the review's later home,
as adr-2610091918443997's Consequences record.
This bundle keeps no promote operation, neither on the verb nor in the store for the dashboard to call later: a store operation with no caller until the dashboard ships is unwired code, which the wired-or-it-isn't-done boundary refuses. With no promote operation there is nothing for an agent to promote with, so this bundle adds no guard entry; the guard entry and terminal check the earlier draft of this spec designed (after the source ledger's flip, adr-41) go to the promotion intent as its starting design. What remains here is the repository layer's read, so a note a person puts in the project's file is recalled, and the schema the dashboard will extend.
Provenance
Every note answers who, when and in which session, in the file, in list and
in the rules --prompt output. written_by and session are the writer's
stamps, and written_at the clock's. The stamps are never rewritten after the
fact. The session id is the host's identifier for the session, never a link to
it, so it carries no session URL; where the host supplies none, the note says
"session unknown" rather than guessing.
A recalled note shows its source wherever it comes back. In the ## MEMORY
block each note's head names its id and its layer: (project file, committed in this repository) for a note in the project's .abcd/memory.json, and
(personal note, agent-written) for one in the machine's file. An override's
rule heading says (repo override) in the same way (GHSA-22f8-qf5r-gjgq). The
project's file holds whatever the repository's authors committed, so a cloned
or compromised repository controls it, and its notes must never read as the
person's own. Each body sits in a tilde fence under its head, through the
rules package's one fence for another party's text (rules.FenceRuleBody,
iss-2609020219265817). A heading, a list item or a setext underline in a body
is then text to a markdown reader, not structure. The hook's stderr line
names each recalled id with its layer, for example abcd memory: recalled 2 note(s) [mem-… (project), mem-… (machine)]. The layer is part of an item's
ledger signature, so a note promoted from the machine's file into the
project's comes back again under its new label. For the same reason, a
session that recalled notes before the layer joined the signature gets each
of them once more on its next matching prompt, now labelled. That one-time
re-send is expected, as it was when the rules' override label joined their
signature.
How the steps land
Each step below lands green and wired on its own. Step 1's store has its verb
from the first commit. Step 2's recall runs in the hook and on
rules --prompt, choosing survivors in id order. Steps 3 and 4 extend that
same path. Step 5's fill is written by the status line, read by the hook and
shown in the envelope before anything acts on it, and step 6 acts on it.
Steps 7 and 8 change no code.
Steps
- The notes store and the
abcd memoryverb: thememory.jsonschema, its two layers, the write gate, and bare, add, list, remove and the manual move- packages: internal/core/memnote (new: schema.go, layers.go, write.go, gate.go), internal/core/recordid (the
memfamily named where families are listed), internal/core/rules (exported layer-read helpers the store reuses, no behaviour change), internal/surface/cli (memory.go, helpgroups.go placing memory in groupAgents naming commands/memory.md), internal/core/surface (sentences.go, examples.go), commands/memory.md (new,block: agents,user-invocable: false, passing the host's session substitution, saying that--everywherenotes were checked against this checkout's private names only, and thatremoveruns at the person's request), .abcd/development/brief/04-surfaces (a memory chapter with its## Sub-verbstable, the README registry row), .abcd/rules.json (DOGFOODING recall gainsmemory), .abcd/development/release/surface.json, docs/reference/cli/commands.md - tests: TestNoteSchemaRefusesEachMalformedField; TestMachineLayerReadRefusesWhatRulesJSONRefuses (symlinked home, foreign owner, writable by others, over 256 KiB, not a regular file), mirroring the user_layer tests; TestAbsentLayersCostNothingAndCreateNothing; TestWriteStampsTimeAndMintsAMemID; TestWriteWithoutASessionRecordsItUnknown; TestASessionShapedLikeALinkIsRefused; TestEverywhereNoteCarriesNoRoot; TestWriteTouchesOnlyTheMachineLayer (N5); TestConcurrentWritesBothLand; TestGateRefusesASecretANetworkAddressAndAPrivateName with secret-shaped fixtures built at run time, each watched fail before the gate exists (N6); TestGateInALinkedWorktreeJudgesThePrimarysPrivateNames; TestGateRefusesOnADegradedScanner; TestGateNamesTheFieldNeverTheSpan; TestMemoryAddWritesTheMachineLayer; TestMemoryAddTakesNoPathOperand and TestMovedFromHostStampsTheOrigin (N3), watched fail first; TestMemoryAddWithoutSessionSaysUnknown (N7); TestMemoryAddEverywhereSaysOnlyThisCheckoutsNamesWereChecked; TestMemoryListShowsEachNotesBodyStampsAndReach; TestMemoryRemoveRemovesAnyPersonalNote, watched fail first; TestMemoryRemoveRefusesANoteInTheProjectsFile; TestBareMemoryNamesAnUnreadableLayer; TestCommandPagesMatchTheMenu, TestPluginPersonListEqualsTheCLIs and TestCommandPagesDeclareTheirBlock pass with the new page; the sentence, appendix, snapshot and dogfooding tests pass after go generate ./internal/surface/cli
- the memory store's redactor (
storeRedactorin internal/core/memory) is exported for the gate rather than a second one written (one-canonical-primitive) - the store and its verb land together, so the store has a caller from its first commit and no unwired code reaches main; a note written here is listed and removed at once, and is recalled from step 2
- a live trial in a host session checks that the host substitutes its session id into the command page, as the verb split's trial checked hiding; the capture goes under this spec's
## Live captures. If the first host offers no substitution, the page leaves--sessionout and every note on that host is recorded as session unknown, which is the ruled fallback, not a reopened question - landed: c8d1ab0df, 9ae13776b
- packages: internal/core/memnote (new: schema.go, layers.go, write.go, gate.go), internal/core/recordid (the
- One recall: notes come back through the prompt hook, and
rules --promptshows what a prompt brings back- packages: internal/core/recall (new: items, matching, rendering, the
recalledledger keyed on content; candidates that do not all fit the budget are taken in id order until step 4), internal/core/rules (exported prompt-index and term-match function; SessionState gainsrecalled; the reset clears it), internal/surface/cli (the prompt router calls recall after rules.Inject; the--jsonenvelope gainsrecalled;rules --promptlisting matched domains with their hitting words, and matched notes), commands/ahoy.md (documentsrules --prompt), .abcd/development/release/surface.json and docs/reference/cli/commands.md (the new flag) - tests: TestANoteComesBackOnAPromptAboutTests over two simulated sessions (N1), watched fail first; TestNoteRecallUsesTheRuleTermMatch (stems and phrases hit as a domain's recall words do); TestANoteInTheProjectsFileIsRecalled (N5); TestNoteTiedToAnotherRootIsNotRecalled and TestEverywhereNoteIsRecalledInAnyRepository; TestRecalledLedgerSurvivesTheRulesPrune; TestResetClearsBothLedgers; TestKillSwitchSilencesRecall; TestARefusedMachineLayerStillInjectsRules; TestMemoryTakesOnlyWhatTheRulesLeaveOfTheBudget; TestCandidatesThatFitAreTakenInIDOrder (replaced by step 4's ranking tests); TestRulesPromptShowsMatchedDomainsAndNotes and TestRulesPromptWritesNothing, watched fail first; TestRulesPromptRefusesAPositionalDomain; the existing inject and hook tests pass unchanged when no note matches (a healthy no-match still renders zero tokens)
- landed: e6d2a7df7, 59b69f398
- packages: internal/core/recall (new: items, matching, rendering, the
- Pages join the same recall, from both scopes
- packages: internal/core/memory (an exported read of each page's filename, index line, body and
recall:list, through the existing store handle; a read-only open of~/.abcd.noindex/memory/), internal/core/recall (page items, narrower scope wins; pages join the id order aspage:<filename>, after notes, until step 4), internal/surface/cli (the router andrules --prompthand pages to recall) - tests: TestAPageDeclaringAuthComesBackAndNoOther (P1); TestANoteAndAPageComeBackTogether (N2); TestRepeatedPromptInjectsMemoryOnce (P3); TestWorkspacePageSuppressesTheMachinePage (P4); TestRecallOpensOnlyTheDeclaredPlaces with a host memory directory of matching text under the test home (P5), each watched fail first; TestPageWithoutRecallIsNeverACandidate; TestRulesPromptShowsMatchedPages; the memory store's symlink and guarded-read tests pass against the new read
- landed: 57b55ed1f
- packages: internal/core/memory (an exported read of each page's filename, index line, body and
- The relevance ranking and its reasons
- packages: internal/core/recall (ranking.go: BM25F as designed, ties; the ranked order replaces the id order in deciding which items fit the budget), internal/surface/cli (
rules --promptprints each candidate's score and each matched term's field and contribution) - tests: TestRareMatchingWordOutranksACommonOne, the case a raw count gets wrong, watched fail against a count-based order; TestRecallFieldOutweighsBody; TestTiesBreakOnScopeThenRecencyThenID; TestRankingIsDeterministicAcrossRuns; TestRankingDecidesWhichItemsFitTheBudget, watched fail against step 2's id order; TestRulesPromptShowsEachTermsContribution; golden scores for a small fixed corpus so a change to the formula is a visible diff
- landed: f752318ff
- packages: internal/core/recall (ranking.go: BM25F as designed, ties; the ranked order replaces the id order in deciding which items fit the budget), internal/surface/cli (
- How full the session is: the status line's fill file, the transcript's tail, and unknown
- packages: internal/core/statusline (payload.go parses the payload's
session_id, never rendered or echoed), internal/surface/cli (statusline.go writes the fill file through the one fill writer, only on the path that renders abcd's own row; its help text and the doc comment above newStatuslineCommand say it writes only that file and where; cli.go's comment onhookInput.TranscriptPathnames the prompt router as a second reader; the prompt router reads the fill and reports it in the--jsonenvelope'sfill; the session-start reset creates~/.abcd.noindex/fill/and the resets delete the session's file), internal/core/rules (the fill writer and reader, the file name under~/.abcd.noindex/fill/, the per-model window table, the stale sweep extended to the fill directory), internal/core/history (the token-count shape exported for the fill reader, no behaviour change) - tests: TestStatuslineRecordsTheFillForItsSession, watched fail first; TestStatuslineWritesOnlyTheFillFile (the narrowed TestStatuslineWritesNothing: one changed entry, the session's fill file under
~/.abcd.noindex/fill/holding onlyused_percentageandat, and every other case, the pass-through path among them, unchanged); the narrowed TestStatusVerbReachesNoWritePrimitive (exactly one fill-writer call from one function in statusline.go; any other write primitive or second call site refused by name), with a fixture of a second write watched fail; TestStatuslineCreatesNoDirectory; TestStatuslineWritesNothingWithoutASessionID; TestStatuslineRowRendersWhenTheFillWriteFails; TestFillFileIsRefusedBehindASymlinkedHome; TestFillPrefersTheStatusLineFile; TestFillFromTheTranscriptTail, watched fail first; TestTranscriptReadIsBoundedToTheTail; TestRefusedTranscriptYieldsUnknownFill (symlink, foreign owner); TestUnnamedModelYieldsUnknownFill; TestAmbiguousWindowTakesTheSmaller; TestResetDeletesTheFillFile; TestResetCreatesTheFillDirectoryAndRefusesASymlinkedHome; TestAbsentFillDirectoryIsASilentSkip; TestStaleSweepReachesTheFillDirectory; TestHookEnvelopeNamesTheFillAndItsSource; TestRecallOpensOnlyTheDeclaredPlaces extended to the fill reader's two (P5); the statusline help snapshot updated - the hook reports the fill and does not yet act on it, so this step changes no injection and lands green on its own, wired through the envelope and through the status line's write, which the hook reads
- landed: a5b89898725f
- packages: internal/core/statusline (payload.go parses the payload's
- Context brackets: rules, notes and pages shrink as the session fills
- packages: internal/core/rules (the one bracket table beside the byte budget, its unknown row included; rules shrink to heading, first rule and the pointer line under a known moderate or depleted fill; an unknown fill keeps rules in full and is said once per session), internal/core/recall (full or one line per bracket, one line under an unknown fill, at least the top-ranked line; the ranking chooses survivors), internal/surface/cli (the router passes the fill;
rules --promptnames the bracket and gains--fill), commands/ahoy.md (--fill), .abcd/development/release/surface.json and docs/reference/cli/commands.md (the new flag) - tests: TestRecallShrinksFullThenOneLineToTheEndOfTheSession over the three brackets (N8), watched fail first; TestNoBracketSendsNothing; TestDepletedBracketInjectsIndexLinesOnly with three matched pages (P2); TestTopRankedLineAlwaysComesBack; TestRankingChoosesWhichLinesFit; TestRulesShrinkToHeadingAndFirstRule and TestShrunkDomainNamesTheRulesCommand, watched fail first; TestGeneratedDomainsShrinkToTheirFirstGeneratedRule (SHELL's first registry entry in id order, GRILL's first asking rule); TestABracketChangeNeverResendsAnItem; TestUnknownFillKeepsRulesFull and TestUnknownFillShortensNotesAndPages, watched fail first; TestUnknownFillIsSaidOncePerSession; TestBracketTableHasOneSource (no threshold literal outside rules); TestRulesPromptFillSetsTheBracket and TestRulesPromptWithoutFillRendersTheUnknownForm; the rules golden tests and the existing inject and hook tests pass unchanged, since a test that supplies no fill gets the unknown form and that keeps rules in full
- landed: acb18d1836fc
- packages: internal/core/rules (the one bracket table beside the byte budget, its unknown row included; rules shrink to heading, first rule and the pointer line under a known moderate or depleted fill; an unknown fill keeps rules in full and is said once per session), internal/core/recall (full or one line per bracket, one line under an unknown fill, at least the top-ranked line; the ranking chooses survivors), internal/surface/cli (the router passes the fill;
- The docs and the brief
- packages: docs/explanation (memory beside the library in plain words: what each holds, who adds to it, how it comes back, where it can go; N4), docs/reference/terminology.md, docs/how-to (keeping memory notes, reviewing them with
memory listandmemory removeuntil the dashboard, and reading what a prompt brings back withrules --prompt), mkdocs.yml, README.md if it lists the commands, .abcd/development/brief/04-surfaces/README.md (the library-and-memory section's last sentence at about line 430, which still says the notes intent's open questions name where notes are stored and what an agent may write without the person's yes, though both are decided; the table's "Where it can go" row, so promotion reads as the dashboard's; the rules row gains--prompt), .abcd/development/brief/05-internals/07-memory.md (§ 0 and § 9 rewritten to the shipped recall, the brackets, the fill reader and the ranking; in § 9, about lines 185 to 197, the memory-only bracket table, "Per-prompt, not sticky" where recall now deduplicates per session, therecallsub-verb ofabcd librarythatrules --promptreplaces, and the link text that still names itd-39 under drafts/), .abcd/development/brief/05-internals/03-configuration.md (the twomemory.jsonlayers beside the tworules.jsonlayers; the fill file under~/.abcd.noindex/fill/and the status line's one write), .abcd/development/brief/glossary/core/memory-note.md (status: drafttostable; the commands it names; and its example "is promoted into the project's rules", since a promoted note lives in the project's.abcd/memory.json, not inrules.json), the status line's pages in docs and the brief wherever they say it writes nothing, AGENTS.md's rule-loader paragraph and the marker blockahoyinstalls, since the loader now injects notes and pages and shrinks rules by the brackets - tests: docs-lint and site-render clean; record-lint clean; a docs-currency review of the changed pages; the docs page read against the brief's library-and-memory table so the two say the same thing
- the verb split's open question 12 (spc-2610100613109045) was answered "Wait for memory notes", and its close in pull request #936 struck A8 there: the notes intent's criterion N4 carries the docs comparison alone, and this step meets it
- packages: docs/explanation (memory beside the library in plain words: what each holds, who adds to it, how it comes back, where it can go; N4), docs/reference/terminology.md, docs/how-to (keeping memory notes, reviewing them with
- The live session, the doc-fidelity review and the close
- packages: this spec (the live captures under a
## Live capturesheading after Steps), .abcd/development/intents (both intents move to shipped by the close) - tests: a live host session in which an agent writes a note that the tests need the race flag, and a second session in which a prompt about tests brings it back (N1) together with a matching page (N2), with the session id the host substituted shown on the note; the hook's
--jsonenvelope for that prompt showing the fill and its source; therules --promptoutput for that prompt attached; the note removed withmemory removeat the person's word and gone frommemory list; a docs review recorded for HEAD with go run ./cmd/abcd docs fidelity record; then go run ./cmd/abcd spec close spc-2610111627213138 withDelivers: itd-2610091918433290andDelivers: itd-39trailers (both intents already declareimpact: additive, so no --impact is passed) - the live session is the only evidence that recall reaches a real host's context; script output does not stand in for it
- packages: this spec (the live captures under a
Footprint
- packages: internal/core/memnote, internal/core/recall, internal/core/rules, internal/core/memory, internal/core/recordid, internal/core/statusline, internal/core/history, internal/core/surface, internal/surface/cli, commands/memory.md, commands/ahoy.md, .abcd/rules.json, .abcd/development/release/surface.json, docs/reference, docs/explanation, docs/how-to, mkdocs.yml, README.md, AGENTS.md, .abcd/development/brief/04-surfaces, .abcd/development/brief/05-internals, .abcd/development/brief/glossary
- files outside the working tree:
~/.abcd.noindex/memory.json(written bymemory addandmemory remove) and~/.abcd.noindex/fill/<sha256(session)>.json(written by the status line, created as a directory by the loader); nothing under the host's configuration folder - tests: the store's schema, layer, write, session and gate tests, the linked-worktree banlist test among them; the memory verb's add, list, remove, manual-move, everywhere and bare tests; the recall's note, page, ledger, budget, id-order, scope, root and file-open tests; the
rules --prompttests; the ranking's order, tie, determinism, budget, reasons and golden tests; the status line's fill-file tests and its narrowed write-nothing tests, structural and end to end; the fill reader's file, transcript, bound, refusal, model, reset and sweep tests; the bracket tests for rules, generated domains, notes and pages, and the unknown-fill tests; the menu, parity, sentence, appendix, snapshot and dogfooding tests after go generate ./internal/surface/cli; record-lint, docs-lint and site-render clean; the live session captures
Decisions
The rulings this spec carries, all of 2026-10-11 and recorded in the intents' Decisions and adr-2610091918443997:
- Agents capture notes automatically and every note is stamped (product thinker).
- Only a person promotes; above two maintainers another must agree (product thinker). Carried by itd-2610111655362931 since the move below.
- Notes and pages are built together and share one recall (product thinker).
- The host's memory files move by hand only, and abcd never reads or writes them on its own (product thinker).
- A
memory.jsonbeside eachrules.jsonlayer (technical facilitator). - Recall shares the loader's brackets, with no thresholds of its own (technical facilitator).
- A relevance ranking chooses the survivors, with no new dependency unless the person signs off (technical facilitator).
- Release placement no longer waits on itd-36 (settled on the record).
- A note never holds a secret, a private name or a live network identifier (settled on the record).
The nine open questions first left here, all answered on 2026-10-11:
- The brackets gate rules too, with the same thresholds (technical facilitator, option b). A matched domain comes back as its heading and first rule in the moderate bracket, as notes and pages shrink to one line; one bracket table in the loader serves rules, notes and pages. Step 6 and the Context brackets section.
- Context fill: both, the status line first (technical facilitator, option c). The hook uses the status line's used percentage, from its fill file, when present, and otherwise a bounded read of the transcript's tail against a per-model window. Step 5 and "How full the session is". What an unknown fill does is ruling 12 below.
- Never send nothing: one line to the end (technical facilitator, after the explanation that a compaction and a new session reset recall). Recall goes full, then one-line titles, and stays at them to the end of the session; there is no bracket in which matches come back as nothing. This changed the notes intent's last criterion and itd-39's press release, each recorded in its Decisions.
- to 6. Review and promotion move to a later dashboard (product thinker: "this will be part of a later PT / TF dashboard where the PT can click on those they want to promote", then "Move them to the dashboard"). The notes intent's review and promotion criteria, and the questions on the maintainer count (4) and the agreement (5), moved verbatim to the draft itd-2610111655362931; question 6, where the person runs them, is answered by the move. The earlier draft's promotion step is dropped; no promote operation remains in this bundle, so no guard entry is added (Review and promotion: on the dashboard).
- The host fills in the session id (technical facilitator, option a). The command page passes the host's own substitution of its session id; where a host offers none, the note records the session as unknown and says so on every write. Step 1's live trial confirms the substitution.
- Personal notes are tied to their repository unless flagged (technical
facilitator, option a): recalled only in the repository they were written
in, and everywhere with
--everywhere. - The recall diagnostic is
rules --prompt "<text>"(technical facilitator, option c), showing the rules, notes and pages one prompt would bring back, with the ranking's reasons, on the existingrulesverb. Steps 2 to 4 and 6 build it up.
Three rulings of 2026-10-11 that answered the spec review:
- The status line may save one fill file (product thinker, asked "May
the status line save that one figure?", answer "Allow one fill file"). An
exception to the product thinker's ruling of 2026-10-05 that the status
line writes nothing (iss-2610050556383525), covering exactly the session's
context fill in
~/.abcd.noindex/fill/<sha256(session)>.jsonand nothing else: no cache, lock, log or ledger. The two read-only tests narrow to that one file and still refuse every other write, and the help text says so. Step 5 and "How full the session is". Recorded in the notes intent's Decisions. - Ship notes before the dashboard's review, with list and remove
(product thinker, answer "Ship, list and remove"). Until the dashboard
draft itd-2610111655362931 ships, the review is
memory listandmemory remove, which removes any personal note, run by a person or by an agent at a person's request. Step 1 and "Review and promotion: on the dashboard". Recorded in the notes intent's Decisions and in adr-2610091918443997's Consequences. - An unknown fill leaves rules full, and notes and pages short
(technical facilitator, answer "Rules full, notes short"). With no source
for the fill, rules inject in the fresh form, with no regression from
today, and notes and pages come back as one-line titles. The unknown fill
is still reported, in the
--jsonenvelope on every prompt and on stderr once a session. This replaces the facilitator-proposed default this spec first carried, that an unknown fill is treated as depleted. Steps 5 and 6.
Design choices this spec makes inside those rulings, each with its grounds and each open to the facilitator's veto at review:
- A shrunk rule domain ends with a pointer line naming how many rules it
holds and
abcd rules <DOMAIN>. Grounds: a guardrail domain shrunk to its first rule must still say where the rest is, and the loader already skips a domain that would render as a heading alone. - The ledgers key on content, not on rendered form. Grounds: a bracket change would otherwise send again, in a smaller form, an item already in the context in full.
- The fill file lives under
~/.abcd.noindex/fill/, created by the loader. Grounds: the question behind ruling 10 offered the local tier or the user-level abcd home, and this location is the facilitator's choice, recorded in the notes intent's Decisions as the ruling's place; a session is not bound to one checkout, so the user-level home keeps one file per session wherever its working directory is, and no status refresh writes into a working tree. The loader creates the directory so the status line's one write stays one file. --everywherenotes are judged against the writing checkout's private names only, and say so. Grounds: the reviewer offered judging them against a machine-wide banlist layer as well, if cheap; no such layer exists (the private layer is per checkout, a linked worktree inheriting its primary's), so building one is a banlist change outside this bundle. The gate reads the primary checkout's store in a linked worktree, which is cheap, andadd --everywhereand the command page say which names were checked.- Survivors are taken in id order until the ranking lands. Grounds: step 2 must choose when only some candidates fit before step 4 builds the ranking; id order is deterministic and is replaced, not kept beside it.
- Where a model has more than one window size, the smaller. Grounds: a doubt about the window should shrink recall, not flood the context.
- No promote operation, and so no guard entry, in this bundle. Grounds: a store operation with no caller until the dashboard ships is unwired code.
- BM25F with recall words weighted three, title two, body one. Grounds: the prior art above; standard library only; deterministic and explainable.
- The gate refuses rather than redacts a note. Grounds: a lesson with a span cut out of it may say something wrong, and the agent can always rewrite it.
- The bracket table and the window table live in
internal/core/rules. Grounds: the ruling's "share the brackets" read literally, so no payload can grow a second set. - Rules and memory share the 64 KiB per-turn budget, rules first. Grounds: the budget is the loader's flood guard, and a second budget would double it.
- A separate
recalledledger. Grounds: the rules ledger is pruned to active domains every turn. - The figures (120-character titles, 2 KiB bodies, twelve recall words, the 8 KiB and 2 KiB bracket budgets, the 256 KiB transcript tail) are starting points, each set in one place.
- Step 1's own limits, chosen while building it (2026-10-11). A recall
word is at most 64 characters, holds a letter or digit, and is unique
ignoring case; the body is trimmed and must not be empty; a session id is
letters, digits,
-and_([A-Za-z0-9_-], starting with a letter or digit), at most 128 characters; the no-root-commit refusal points at--everywhere;memory listshows a note tied to another repository as "another repository"; andmemjoinsrecordid.handleInTextRein step 2, where notes first come back in running text, not in step 1. Grounds: each bounds what a note can carry or how it is named without a ruling of its own, and is open to the facilitator's veto at review like the choices above.
Settled here
Choices an agent made while building steps 3 and 4 where this spec is silent, each the narrowest reading the agent found that meets the criteria, and each an agent's choice for review, open to the facilitator's veto like the design choices above.
- Which pages the recall reads (2026-10-12, step 3, agent's choice for
review). In each scope, the pages
index.mdlists, the page files at the store's top level, whose names are<type>_<domain>_<slug>.md. A page's title is its index line's summary, derived from the page the way the index derives it, so the recall reads noindex.mdand opens only the four places it declares; its body is the rest of the page after the line the title comes from, so the title is counted and sent once. Grounds: P4 compares pages by that name, and the name's characters keep a page's key (page:<filename>) safe to print in the hook's stderr line and envelope. - A workspace page shadows the machine page of the same name, whether or
not it carries a recall list, and when the read refuses it (2026-10-12,
step 3, agent's choice for review). Grounds: the narrower scope's page is the page of that name, as a
repository layer's field replaces the machine's and a note in the project's
file suppresses the machine's copy of its id; it also keeps one
page:<filename>key per name in the ledger and the envelope. - A machine page is read with the guards the machine
memory.jsongets (2026-10-12, step 3, agent's choice for review): the directory throughfsutil.OpenHomeDeclarationScope, the home scope's walk with the directory judgementfsutil.ReadHomeDeclarationmakes (a symlinked directory, or one another account can change, refused), and each page a regular file this account owns that no one else can write (fsutil.ReadHomeDeclarationDenyingIn). A page failing them is named on stderr and brings nothing back, while the scope's other pages still come back. Grounds: a machine page injects text into every session as a machine note does; the spec names only the directory's open, and reading it alone would trust a page another account could write while the notes file beside it is refused for the same thing. - A page store or a page that cannot be read is named (2026-10-12,
step 3, agent's choice for review): on the hook's stderr with "recalling
nothing from it", and in
rules --prompt'sunreadablelist, which gains akind(noteorpage); a refusedmemory.jsonkeeps step 2's words. - What a page's recency is (2026-10-12, step 4, agent's choice for
review). The tie-break's "page modification" is the latest
ingested_atthe page's source block records, a date, and a page that records none sorts as the oldest. Grounds: a checkout stamps each file with the time it wrote it out, so a file's modification time differs between clones and the order would not be the same on every machine, as Ranking requires;ingested_atis in the page and travels with it. - How the ranking counts terms and lengths (2026-10-12, step 4, agent's
choice for review). Each distinct word of the prompt is one query term,
however often or in whatever inflection the prompt repeats it (tokens whose
candidate roots overlap, "merge" and "merging", are one term), and a word
in an item counts as that term when its stem is among the roots the
loader's term rule matches the prompt's tokens by, so the ranking finds a
term exactly where the matcher hits, and under one term at most. That takes two small exports from
internal/core/rulesbesideTermMatcher(PromptTerms, read from the same prompt index, andTextStems), though step 4's packages line names only the recall and the CLI. An item's length is its weighted length, recall words three times, title twice, body once, BM25F's one pseudo-document of repeated fields;avg_lenand N run over every item read after the narrower-scope suppression, so a page without a recall list, never an item, counts in neither. A personal note tied to another repository counts in both (amended 2026-10-17 on merging main, agent's choice for review): step 2's review made the tie a lazy judgement of the matched candidates alone (the recall package'smatching, which asks git only when a tied note matched), so the corpus is fixed before git is asked and never depends on whether it was. - The ranked order keeps step 2's take (2026-10-12, step 4, agent's
choice for review): candidates are taken in ranked order while they fit,
and the first that does not fit stops the take, so nothing comes back
ahead of an item ranked above it. The one exception is an item whose block
alone is larger than the whole per-turn budget, which no turn can carry: it
is set aside as too large, never recorded, named on the hook's stderr and
given the form
too-largeinrules --prompt, and it does not stop the take. Grounds: a page can be as large as 4 MiB, and one that ranks first would otherwise hold back every item below it on every turn. - Scores tie at a billionth (2026-10-12, step 4, agent's choice for review): two scores within a billionth of each other are a tie, broken by scope, recency and key. Grounds: arithmetic equal on paper can differ in its last bits between machines, and the order must be the same on every machine.
- The diagnostic ranks notes and pages together (2026-10-12, step 4,
agent's choice for review):
rules --promptkeeps itsnotesandpageslists, and each entry carries itsrankin the one order they share, itsscore, and itsterms, each with its counts inrecall,titleandbodyand itscontribution.
Open Questions
None. The nine questions first left here, and the three the spec review raised, were answered on 2026-10-11 and are recorded under Decisions.
Frontmatter
| id | spc-2610111627213138 |
|---|---|
| type | spec |
| lifecycle | open |
.abcd/development/specs/open/spc-2610111627213138-memory-recall.md
open on GitHub ↗ · commit history ↗
References2
- itd-39abcd Surfaces the Right Memory at the Right Momentimplements
- itd-2610091918433290Agents keep memory notes with /abcd:memory, recalled when they matterimplements
mentions17
- adr-41Documents and ledgers never leave the user tier; a public citation requires both gates
- adr-45Record ids are timestamp-numeric, capture-stable, and never allocated by looking at the maximum
- adr-2610091918443997What may enter abcd's memory notes without the person's yes, and how a note reaches the record
- dcn-26102306305067262026-10-23 — The memory-recall spec stays open when steps 3 and 4 land, with no close and no remainder (facilitator, on abcd-ae's question of 2026-10-23; spc-2610111627213138, itd-2610091918433290): PR #982 delivers steps 3 and 4, so steps 1 to 4 have landed, but steps 5 to 8 (how full the session is, context brackets, the docs and brief, and the live session with the doc-fidelity review and the close) are not built, so the spec is not closed and the intent stays planned; a --remainder close would only move those four steps into a new spec under the same intent, which adds a record and changes nothing, so the open spec keeps them and the change carries no Delivers trailer (grounds: AGENTS.md definition of done, a change that delivers part of an intent carries no trailer; commands on spec close --remainder)
- iss-2609020219265817sanitizeRuleBody indents every continuation line of a rule body by two spaces, which defuses the line-start contract the host-side parser splits on but not CommonMark: a two-space-indented heading line inside a list item still renders as a heading INSIDE that item. A repo-overridden domain can therefore put an unmarked heading in front of any reader that renders the injected block as markdown, wearing no repo-override label of its own. The line-start contract holds and this is already stronger than the pre-fix behaviour, which indented nothing at all; closing the CommonMark half needs a decision on whether rule bodies get escaped, fenced, or left as the parser contract defines them. Evidence: sanitizeRuleBody in internal/core/rules/rules.go.
- iss-2610050556383525Product thinker ruling, 2026-10-05: abcd does not touch a person's ~/.claude/ folder, and nothing in it calls abcd, with one exception the person consents to: the status line, which is essential for product thinkers and which a plugin cannot set (a plugin's settings may set only agent and subagentStatusLine; the hooks abcd needs can all live in the plugin's hooks/hooks.json). Today ahoy install offers and writes the statusLine entry, and the person's settings had also gained a SubagentStop entry that called a stale abcd build (the defect that recreated ~/.abcd the same day). Because the status line runs on every refresh in every session, the risks the ruling accepts are: a stale or missing binary at the wired path; a status-line verb that writes state; a line left behind after an uninstall; the person's previous status command recorded once and gone stale; a concurrent write corrupting settings.json so the harness ignores the whole file; a wired path a project or another user can replace; and settings synced to a machine without that path.
- iss-2610110918418459The implement loop's landing never writes a spec step's '- landed:' line, so a spec the loop landed part of still reads those steps as unlanded: a later 'spec close --remainder' carries steps that already merged into the remainder, and a fresh 'abcd build' of the same spec opens a lane for them again. Found by the fidelity audit of itd-2609212103565953 (receipt rcp-2257cc3a38d9, criterion ac-3 MET_WITH_CONCERNS); spc-2609202134338445's Progress already says the landing does not mark a non-closing lane's step.
- iss-2610151422092354The brief at .abcd/development/brief/04-surfaces/37-memory.md:6-7 says: Recall of a note through the prompt hook is the same spec's later step; this chapter covers the store and the verb. The shipped surface differs: Recall through the prompt hook is already shipped: internal/surface/cli/rules_prompt.go (recallMemory, header says spc-2610111627213138 step 2) calls internal/core/recall on every prompt, the 04-surfaces/README.md library/memory table says notes are brought into a session automatically when a prompt matches their recall words, and commands/memory.md line 76 describes the prompt-match recall. It is not a later step. (v0.14.2 release crosscheck, false-claim, raised by sonnet in a29.)
- iss-2610241549334990A spec's Footprint packages line is reserved exactly as written. An entry carrying a note or prose therefore becomes a footprint path with that text in it. A build of spc-2610111627213138 would reserve internal/core/memnote (new). A build of spc-2610120941441282 would reserve internal/surface/cli (the pre-push hook's fixture tests), and its parenthetical containing a comma would split into the two entries scripts/cloud/connectors/claude (new and the host connector). Overlap compares paths segment by segment, and claim.ValidFootprintPath accepts spaces. So such an entry shares no path with the package it means, and the build does not hold that package against another build. Several open specs carry such entries, among them spc-2609221011151661, spc-2610111627213138 and spc-2610120941441282.
- iss-2610262034370169itd-39's hook queries a user-scope memory store at ~/.abcd.noindex/memory/; the library chapter lists user-scope memory outside the repo as not built.
- iss-2610311224596015itd-39's ## Open Questions section is one bullet, 'None for the product thinker. The three questions first left here are settled under Decisions (2026-10-11); …', and the fail-closed reader in internal/core/intent/questions.go counts every list item without a resolved or deferred marker as a question, so abcd build itd-39 refuses at open_questions (itd-2610091918433290, which shares spec spc-2610111627213138, reads only its own section and was refused at the reservation instead). The record says it is settled in a form the reader does not recognise.
- itd-3abcd Loads Its Own Rules On Demand
- itd-36Knowledge That Compounds, Not Knowledge That Re-Derives
- itd-2610032150577708The product thinker's dashboard, in a browser through Tailscale
- itd-2610090831227812A person who types `/abcd:` sees the commands people use
- itd-2610111655362931On the dashboard, the product thinker and facilitator review memory notes and promote the ones worth keeping
- spc-2610100613109045a-person-who-types-abcd-sees-only-the-commands-people-use