Repository navigation
v0.25.0
The recovery work of 2026-10-13 lands on this line. Cleanup keeps local work it cannot verify, rechecks every target
just before removal and refuses when it cannot tell whether a process holds a path. The machine survey records what
it could not measure: checkout state and upstream relation under explicit roots, cache coverage, open files whose
path is gone and memory pressure apart from swap, each read-only and none of it cleanup authority. Each tick stage
leaves a receipt, and an offline check passes only on tests that actually ran. 0.25.0 reclaims less disk than
0.24.5: with holder coverage unknown, nothing that needs a holder check is removed. On the main line since 0.24.5:
each project's OpenRouter spend, the agent-network node, SSH keys and external checkouts declared by SessionStart.
The companion plugin observatory-log moves to 0.25.0: it asks for a turn's why once per record, with the next
prompt, and measures the answers.
Added
-
registry/agents.json: the agents installed on this machine (L-11, OBS-83, #288), generated from the Fabric
service descriptors, each one's manifest, launchd's job list and the MCP inventory by origin, and projected into
the wiki unchanged. No token path, no credential-shaped URL and no absolute home path reaches either copy.- A home written in an equivalent lexical form (doubled separators,
.segments,x/..detours, backslashes,
JSON and percent escapes, fullwidth characters,~user) is caught by the independent check: the scan is
refused and the last document is kept stale (round 8 of #288).
- A home written in an equivalent lexical form (doubled separators,
-
Each project's spend, this month and over the last 30 days (J-11, OBS-93). The spend scan now reads every
key in the OpenRouter door's ledger by its hash, one request per key. Beside the estate's month it writes
projectsandunattributedintostore/raw/spend.json(schema_version2; the existing fields are
unchanged).tools/spend.py project [PROJECT] [--json]answers from that file without asking the provider
(monthUsd,last30Usd,keys,retiredKeys,folders,through,partial,unread,fresh). It is the
figure Fabric's launcher reads as a project's recent spend (contract DEC-0037).- A key belongs to the project named in its ledger row's
projectfield. Every name of one registered project
(its folders, its id, its name) is one bucket keyed by its registry id, so any of them answers the whole project. - A retired key keeps its spend with its project (OBS-103). The provider keeps no history of a deleted key
(GET /activity?api_key_hash=andGET /keys/{hash}answer 404, measured 2026-10-15). Soopenrouter.py revoke, a completedrotateandrevoke --forget-pendingleave a tombstone in the ledger'sretiredlist,
kept 62 days. It holds the name, account, hash, project, day anddeleted, and the key's spend by day: the
activity report, then the key's own row as the last read before the DELETE.- The snapshot is trusted whole only when every clause holds: the month's and the week's (from Monday) days
plususage_dailyequal the key'susage_monthlyandusage_weeklywithin $0.0001, and each of the last
three completed days falls in the month or week checked, or was reached by the report. A two-day lag on the
Tuesday a month begins, and Monday the 1st before the report reaches yesterday, do not reconcile. - Otherwise every window holding a day after its last activity day, through the retirement day, reads null.
The month window stays null until the month ends. The counter difference is not booked for the gap. - It is written to the ledger before the DELETE, so a failure after the DELETE keeps it for the retry. A
rotation's tombstone is written before the successor's rename. - A
rotateof a row a failure left part done finishes that rotation, then rotates afresh, so a rotate always
replaces the value (--leakedand the key page say "rotated" only then). A minted successor is written to the
row before its delivery, read by the scan, and deleted once a newer successor holds the slot.
A stale successor still alive is snapshotted again before its DELETE, never with a snapshot kept from an
earlier run;issueover a row holding one is refused untilrotatefinishes it. A predecessor
still live is snapshotted, deleted and retired; one already gone is retired with the snapshot kept before its
DELETE. - A tombstone carries its row's untracked rotation and pending predecessor, from the one rule the scan uses
for live rows (openrouter.untracked_through), so a revoke never turns an honest null into a whole, lower
figure. issueover a row whose key is gone at the provider keeps its hash as a tombstone, with the snapshot a failed
revoke kept. Over a key still live under another name it is refused.- A snapshot that cannot be read still lets the delete proceed; that key's windows are then null.
- The snapshot is trusted whole only when every clause holds: the month's and the week's (from Monday) days
- Every door verb that writes the OpenRouter ledger holds one lock (the one
issue-for-policyheld), across
processes and threads. Two rotations at once used to drop one rotation's successor from the ledger. A second
operation waits up to 150 s (a rotation's delivery can take a minute), and its refusal names the wait and every
locked verb. The key server answers each request on its own thread, so a request waiting for the lock does not
hold the page. Aretiredthat is not a list is refused before any door acts. unattributedis its own total, never guessed into a project. It holds keys whose ledger row names no project,
and an account's spend on hashes no ledger row or tombstone holds.- A window that was not read whole is
null, never a lower figure, and the key is named inunread. This covers
a key the provider would not answer for or answered with malformed rows, a pending row, a tombstone without a
usage snapshot, a hash two ledger entries hold, a rotation made before tombstones existed, and the keys not reached when the step's deadline
(OBSERVATORY_STEP_DEADLINE) arrives or the provider becomes unreachable. An unreadable registry, or a ledger
entry that is not an object, nulls every project. Both windows hold completed UTC days only. - The top-level
partialstays about the estate total;attribution_partialcovers the projects, eachdegraded
entry carries itsscope, andspend.estate_monthnames only the accounts not read. - No finding fires yet. A project's limit is enforced once a recorded spend policy is readable (J-11F).
- A key belongs to the project named in its ledger row's
-
The agent-network node's foundation (OBS-107, RM-21).
observatory/engine/node/holds what one machine
signs to join a relay and to send a message or a blob, and how it checks what the relay delivers: RFC 8785,
P-256 key validation, low-S ES256 and fabric-relay/0.1, ported from the contract kit and run over the contract's
own vectors (vendored inobservatory/engine/fabric/node-vectors/). A device-key store keeps the machine's#sig
and#kemkeys owner-only and never replaces them. Nothing runs yet: the relay session client, the estate record
and the node service are the next slices (NODE.md). -
The node's relay session (OBS-108).
node/ws.pyis a WebSocket client on the standard library and
node/session.pyholds one machine's connection to a relay: it signsauthonly for the origin it dialled, sends
signed messages, and acts on a delivered one only after checking its signature, its recipient (this machine) and
the replay window, acknowledging it after the handler took it.node/blobs.pyputs and fetches blobs by digest
and refuses fetched bytes that are not the named blob.
A relay's hello is checked before anything is signed (its origin, a 32-byte nonce), and nothing a relay sends
ends the reconnect loop. NODE.md states the stage-1 threat model. -
The node's estate record (OBS-109). A node verifies a revision of the person's estate record offline — its
digest, its chain to the parent, the passkey assertion over its challenge and the record's own rules — before it
believes any machine the record lists, and follows the signed revocation delta and heartbeat: after 72 hours
without a fresh heartbeat it refuses what other machines send. -
The node's secret envelope (OBS-113). A machine seals a secret to another machine's key-agreement key (HPKE,
RFC 9180) and signs it; the target opens it only when the estate record lets the sender hold that slot and the
envelope is not older than the last one it opened. -
project-observatory full node(OBS-114).--initcreates this machine's device keys for the agent network
and prints the device id and public keys an enrollment needs;--showprints them again. Private keys never leave
their owner-only files, and a key is never replaced. -
The estate ceremonies on the node (OBS-133, DEC-0084). A new machine pairs with a trusted one through a one-time
relay rendezvous: each side commits to its keys before it sees the other's, both screens show one six-digit code, and
the new machine checks that the signed revision lists it with exactly its keys before it trusts the estate. A one-time
enroll token is read only from stdin or an owner-only file. A revision that drops a machine or a key without revoking
it is refused, a replaced key is revoked by its fingerprint reference, and a machine whose keys were replaced stops. -
SSH keys on this machine (OBS-110, PB-163).
project-observatory full ssh-keyslists the machine's
SSH keys (fingerprint fromssh-keygen -lon the.pub, whether it has a passphrase from the private
file's header alone, the Host blocks and agent that use it, where GitHub accepts it for sign-in and
signing),createmakes an ed25519 key that never overwrites a file (modes 0600/0644, the passphrase
typed into ssh-keygen and kept in the macOS Keychain, which also loads the key into the agent),
copyputs the public line on the clipboard,
andsendregisters it on GitHub (gh ssh-key add) or on a server (one line appended to
authorized_keysundershwhatever the login shell;--verifylogs in with no config and the
new key alone and passes only when the server names its fingerprint), each with a receipt in the
key movements journal. Ten findings reach the board (no passphrase, weak, readable by others and an
open~/.sshbyosprivacy, a private key saved as.pub, unused, a GitHub key not found here (information only), a
key registered nowhere visible, no public half, a missingIdentityFile). Observatory never advises
deleting a GitHub key: a key GitHub accepts that it did not find is reported as information, naming
where it looked and what it could not read, with no command. Only a private key's
clear header is read (the cipher name and the public key), never its private section. The read-only MCP toolobservatory_ssh_keysgives agents the same inventory and
one key's public line. The private key is never shown or moved; tests plant one and search every
output for it. Clouds are refused with the gap named (OBS-112); the dashboard section is OBS-111.
Design, scenarios OSS-62–67. -
Survey gaps stay visible (OBS-87): bounded checkout registration and fairer scans
preserve older measurements with their dates. Unknown swap, failed file walks and
incomplete registration are not reported as measured zero or fresh coverage.
full machine --checkouts --root PATHrecords read-only checkout candidates under
explicit roots, with bounded traversal and honest unknown/stale rows. A candidate is
not an abandoned checkout or permission to delete it; no cleanup planner consumes it. -
Cache coverage separates declarations from measurements (OBS-87):
full machine --cache-coveragerecords configured roots, available executables and
previously measured sizes with their original dates. Unreadable sources, protected
system areas and missing ownership or regeneration evidence stay explicitly unknown.
The command does not scan contents, run cache tools or authorize deletion; reclaimable
bytes and current use remain unmeasured. -
Saved coverage is readable from the CLI and MCP:
full machine --coverageand
observatory_machine(section="coverage")return bounded checkout/cache summaries
without starting scans. Missing, partial, stale and invalid receipts remain explicit;
sample rows never claim whole-machine totals or permission to clean up. -
Open files whose path is gone are observed, not cleaned (OBS-87).
full machine --open-unlinkedrecords, on
macOS and Linux, files a process still holds open after their directory entry was removed: bounded metadata only,
no contents and no command lines. The receipt,full doctorand the saved-coverage reader say what was not read;
no row authorizes an action, and whole-machine coverage stays unknown. -
A checkout's local state and its relation to its upstream (OBS-87). The explicit-root checkout inventory now
records a clean Git status apart from changes, hidden index flags and incomplete reads, and the local HEAD's
ahead/behind counts against its locally resolved upstream. Every Git read uses an offline profile
(GIT_NO_LAZY_FETCH=1), so observing a partial clone never downloads objects. A remote-tracking ref is not a new
remote observation, and no count is cleanup authority. -
Memory pressure, dated apart from swap (OBS-87). On macOS the memory collector reads the platform's pressure
level (normal,warning,critical) with its own timestamp and source, beside swap use and process memory. An
absent interface, an unsupported code or a failed read stays unknown. -
Log retention says what it did. The maintenance sweep reports, per scope, whether its coverage was
complete, partial or unknown, how many entries it saw, rotations completed, refusals and failures, as counts with
fixed reasons and never paths or file contents. A receipt lost mid-sweep keeps its action counts null, because the
effects may already have happened. -
Each tick stage leaves a receipt (OBS-89).
tick_progressrecords which stage is running and how each ended
(not started, stopped, failed, completed) before and after every streamed or captured step, keeping the previous
completed report. A silent tail stage that consumed the tick is now named, and a failed receipt write never
changes the supervised command's outcome.
Changed
-
The
observatory-logplugin asks for a turn'swhyonce per record, with the next prompt, and
measures the answers on what was delivered (M-8, INB-004). The Stop hook printed the request as a
StopsystemMessageafter every changed turn. On one machine over fourteen days that was 3,593
requests and 34 answers. A StopsystemMessageis a notice for the person and is not rendered into
the model's input. Now:-
Once per record. A session is one record revised per turn, so a session is asked once.
store/raw/why-asks.jsonl(new, append-only) journals each request and each delivery. It rotates
towhy-asks.jsonl.1past 1 MB, under a lock, and both are read, so a record is not asked again
when it scrolls. -
The record first. The Stop hook writes the record before anything else.
-
Not when this session wrote the rationale. Within 2 seconds after the record is written, one
pass over the last 4 MB of the session's transcript (named by the Stop payload) collects this
session's writing calls:- Edit, Write, MultiEdit and NotebookEdit, with the file each names;
- Bash commands that write through
>,>>(alsocat > file <<EOF),teeorsed -i, with
the files they write.
No request is made in two cases:
- one of those calls targets a decision register (a
decisions.md, or a.md,.markdownor
.txtfile underadr/, outside vendored trees and submodules, not deleted, with at least one
non-blank added line, whitespace ignored), and its text carries the entry's own heading (the
shallowest heading the work added, never a repeated### Consequences), or, with no heading,
more than half of its added lines; that heading must appear in the register exactly once, as a
heading line outside fenced code; - one of those calls wrote
.task-pipeline/run.md(or ranstage_checkpoint.py), and a
decision:orstage:line among the ledger's newest 64 KB and 200 such lines, read even when
git ignores it, has its text in what those calls wrote.
The pass skips calls whose result was an error and subagent (
isSidechain) lines, and it does
not scan a Bash command longer than 65,536 characters.sed -iis read by splitting a command on;,|,
&and newlines, so the scan is linear in the command's length.A second revision of the same record then links the file, and its
whystays empty. The link
stores the repository's id, the register's path, the entry heading asanchor, the exact raw
heading line at full length, and the length and SHA-256 of the whole register file; no entry
boundary is guessed. A heading over 120 characters is never linked. A later revision carries the
link only while it holds by bytes: this hook wrote it, it names the current repository, the raw
line occurs exactly once as a whole line, and the file is byte-identical or its linked bytes are
unchanged and what follows starts, at its first non-blank line, with a column-0 heading no
deeper than the entry's own (accepted only when the linked file ended in a newline outside any
code fence and HTML comment). Appending a later entry keeps the link. So the record stays explained, with no request and no
extra revision, after the register is committed and pushed and after the write scrolls out of the
transcript. Any other change to the file drops the link: an edit to any entry, a paragraph or a
deeper heading appended, a change of line endings, or an edit below a# commentline inside a
code block or HTML comment. So does a move to another repository. A dropped link is attributed
afresh only from the session's writes after it was made (the mark is carried in every later
revision), and asked about when there are none. A turn
that touches a register is attributed again, and a newer decision replaces the anchor.Every read of a register or the run ledger, fresh or carried, passes one gate: inside the
checkout, not a symbolic link and not under one, opened once without following a link or
blocking, a regular file with one hard link, read up to its limit plus one byte. A fresh anchor
is chosen by a linear Markdown heading scan (newline split only, fences and multi-line comments
skipped,## Use C#keeps its#), and an added block whose heading syntax the scan rejects is
asked about, never matched line by line. The attribution'sgit diffgets only the time left in
the 2 seconds.The turn is asked about in every other case:
- another session's edit in the same checkout;
- a file the session only read or grepped;
- a pull-request body that shares a heading;
- a write by a tool the pass does not know (
cp, a script,perl -i); sed -ibehind a prefix (sudo,xargs,env),gsed, or a sed script containing|
or;;- a relative path written after a
cdin the same command; - a path whose letter case differs from the file's on a case-insensitive volume;
- a subagent's edit, a failed call, or a command longer than 65,536 characters;
- no transcript;
- a pass that runs out of its 2 seconds.
-
Where the model reads it. A new
UserPromptSubmithook,hooks/prompt-why.py, hands the
pending request to the model asadditionalContextwith the next prompt, once, and records the
delivery. The context is capped at 4,000 characters, and a request that does not fit waits for the
following prompt without being marked delivered. It prints nothing when nothing is pending, never
writes through a symbolic link, and reaches no file for a session id that is not a plain name.- The request waits in
$XDG_STATE_HOME/project-observatory/ask-why/<session>.json(0600). An
undelivered one older than seven days is pruned by either hook. - If that file cannot be written, the request falls back to the Stop notice. An older engine whose
recorder does not decide whether to ask also gets the Stop notice, never an injected prompt. A
fault to record is still a Stop notice: the recorder's output to the hook now carriesfault,
which only its receipt carried before.
- The request waits in
-
Answering keeps the session recorded. The answer the request asks for,
observatory_record(owner, memory_id, expected_revision, statement, why), is written as a note with
no session id. The next turn revises the record from its latest revision, whoever wrote it, and
keeps thewhy; before, it revised from its own last revision and every later turn of the session
failed with a revision conflict. A latest revision that names another project is not revised: the
turn starts a new record thatcontinuesit. -
One new record, not one per turn. The Stop hook picks the session's record by the newest row
the session wrote, not by the highest revision number. A record it starts after a promotion, a
rejection or a takeover by another project begins at revision 1, so it lost to the old record, and
every later changed turn started another record and asked again. -
Measured.
store/raw/record-turn.jsongainswhyAsks(asked,delivered,pending,
answered,rate,window), with rate = answered ÷ delivered. A request that never reached the
model, such as a headless single-prompt session, is pending and never unanswered.why_asks.pyprints the same.why_asks.py reached <transcript> <memory id>says whether a request reached the model in a
session transcript.- The new finding
observatory.why_unansweredfires below a 10 % rate over seven days with at
least 20 delivered requests.
tests/test_skill.pyT8 now expects theUserPromptSubmithook it used to forbid, and runs it to
show that it prints nothing with no request pending. New suites:tests/test_ask_why.pyand
tests/test_ask_why_reach.py(a synthetic transcript fixture). -
Fixed
-
A worktree of a repository with no remote is no longer a second project. The filesystem scan records the
checkout a worktree's.gitfile points at (worktree_maininstore/raw/local.json), and the merge folds a
remoteless worktree into its main checkout's project, named in that project's membership rules. With a remote the
repository pass already did this; a worktree whose main checkout was not scanned stays visible as its own project.
Found by the automatic project registration run's isolated cross-product scenario
(org-indexdocs/runs/2026-10-17-automatic-project-registration/). -
Cleanup preserves unverified local state (OBS-84, OBS-86): ignored files, nested
repositories, hidden index flags and unresolved worktree state prevent automatic
removal. A manual dirty-worktree removal verifies its archive against current tracked,
staged and untracked content. Branch cleanup binds archive/proof and deletion to the
same commit and refuses symbolic refs. Disk-guard actions recheck target identity and
safety evidence before removal; the legacy unverified build-artifact action is refused.
Log rotation also refuses symbolic links, linked directories and hardlinks before
rotating or changing permissions. External writers can still race the final filesystem
check: use an idle workspace. Copy/truncate can still lose a concurrent append.
Incomplete or unreadable process listings cannot declare an omitted target free;
clean and archived dirty worktrees repeat a bounded holder check immediately
before removal. A missing worktree's registration goes only when its directory is
proven absent (an unplugged drive, a renamed parent folder or a permission error is
unknown and prunes nothing) and after its HEAD is kept by its branch or an
archive/wt-<sha8>branch; only that registration's.git/worktrees/<name>entry is
moved aside to.git/worktrees-retired/, never a working directory and never a
repository-widegit worktree prune, which could make a detached worktree's commits
unreachable. Failed removal reports unknown effects instead of claiming success. A
branch whose upstream is local (branch.X.remoteset to.) is no longer deleted as
pushed. Log rotation follows links above the workspace (macOS/tmp,/var, a linked
home) and still refuses them inside it, a dangling link or a..included, and a log
folder that does not exist yet is reported absent, not unknown. -
0.25.0 reclaims less disk than 0.24.5. With holder coverage unknown (no complete
process inventory on any platform), the disk guard and worktree cleanup now remove
nothing that needs a holder check: build output, caches tied to a running tool and
worktrees are kept and reported, where 0.24.5 removed them. This is deliberate: a
path a process may still hold is not deleted on a guess. -
A failed check retains its peers' receipts (OBS-85): setup, execution and report-write
failures produce error evidence without losing completed suite results. Unknown
completion fails the run instead of becoming a passing or empty summary. -
Findings follow the measured cause: collector deadlines distinguish work not started
from interrupted work; partial leak scans retain sightings; memory and swap findings use
their own measurement freshness and preserve unreadable-source degradation. -
Engine-update snapshots stay visible: backup status and verified migration now
include localbefore-updatesnapshots. Recognized snapshots share one kind list;
damaged copies remain reported and retained, while foreign kinds remain untouched. -
Cleanup child receipts are validated before use: failed commands and malformed
summaries remain failures. Valid summaries retain only known structured fields;
exception details, child output and extra fields do not enter persisted diagnostics. -
Scoped Heroku refreshes preserve the rest of the survey (OBS-80):
scan_heroku --only
updates only successfully measured apps, retains unrequested rows and the global survey
timestamp, and records per-scope results. Missing apps, malformed responses and invalid
costs preserve old measurements with degradation. Provider-only refreshes retain local
checkout information and its unknown state until a full scan measures it. Full and scoped
collector invocations share a writer lock; no provider state is changed by the collector. -
An offline check passes only on executed tests (OBS-85). A unittest suite that defined cases but never ran
them exited 0 and passed with zero tests. Suites now declare their reporting protocol: unittest suites need a
complete summary with at least one executed case, the legacy PASS/FAIL scripts keep theirs, and an earlier failure
is never hidden by a later clean summary. -
Cleanup refuses when a process might still hold the path (OBS-86). On Windows there is no holder inventory and
a successful rename no longer counts as proof that nothing holds a folder (a handle opened with shared delete
allows the rename), so path removal there is refused; older rename-mode state refuses too. On POSIX,psrenders
arguments with spaces in place of their boundaries, so a missing match is no proof of absence: positive matches
still hold a path, but an absent one no longer admits deletion or a cache tool's busy check. Preliminary admission
no longer captures the process table it cannot use; explicit holder captures are bounded (2 MiB per child, a
shared deadline, only the capture's own process group stopped), and the old synchronous/procfallback is retired.
A process starting after the final check remains a race.
Documentation
- The README's capability table names records and lessons (N-4, USR-009). A new row, "Can agents keep what
they learned?", namesobservatory_record,observatory_learn,observatory_lesson,observatory_searchand
observatory_recall, says every write is a proposal, and links Install and the
MCP section.
Verify
gpg --verify SHA256SUMS.asc SHA256SUMS # key 63B30DC324BD697487AA31944FAFB8AEC803B6A7
shasum -a 256 -c SHA256SUMS --ignore-missing
gh attestation verify <file> --owner passioncode-ai --signer-repo passioncode-ai/.githubThe organization's public key: https://github.com/passioncode-ai/.github/blob/main/release-signing/passioncode-release-signing.asc