Skip to content

v0.25.0

Choose a tag to compare

@github-actions github-actions released this 19 Oct 13:13
· 16 commits to main since this release
ef9c8c3

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).
  • 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
    projects and unattributed into store/raw/spend.json (schema_version 2; 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 project field. 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= and GET /keys/{hash} answer 404, measured 2026-10-15). So openrouter.py revoke, a completed rotate and revoke --forget-pending leave a tombstone in the ledger's retired list,
      kept 62 days. It holds the name, account, hash, project, day and deleted, 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
        plus usage_daily equal the key's usage_monthly and usage_weekly within $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 rotate of a row a failure left part done finishes that rotation, then rotates afresh, so a rotate always
        replaces the value (--leaked and 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; issue over a row holding one is refused until rotate finishes 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.
      • issue over 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.
    • Every door verb that writes the OpenRouter ledger holds one lock (the one issue-for-policy held), 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. A retired that is not a list is refused before any door acts.
    • unattributed is 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 in unread. 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 partial stays about the estate total; attribution_partial covers the projects, each degraded
      entry carries its scope, and spend.estate_month names only the accounts not read.
    • No finding fires yet. A project's limit is enforced once a recorded spend policy is readable (J-11F).
  • 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 in observatory/engine/fabric/node-vectors/). A device-key store keeps the machine's #sig
    and #kem keys 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.py is a WebSocket client on the standard library and
    node/session.py holds one machine's connection to a relay: it signs auth only 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.py puts 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). --init creates this machine's device keys for the agent network
    and prints the device id and public keys an enrollment needs; --show prints 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-keys lists the machine's
    SSH keys (fingerprint from ssh-keygen -l on 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), create makes 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),
    copy puts the public line on the clipboard,
    and send registers it on GitHub (gh ssh-key add) or on a server (one line appended to
    authorized_keys under sh whatever the login shell; --verify logs 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 ~/.ssh by osprivacy, a private key saved as .pub, unused, a GitHub key not found here (information only), a
    key registered nowhere visible, no public half, a missing IdentityFile). 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 tool observatory_ssh_keys gives 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 PATH records 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-coverage records 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 --coverage and
    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-unlinked records, 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 doctor and 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_progress records 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-log plugin asks for a turn's why once 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
    Stop systemMessage after every changed turn. On one machine over fourteen days that was 3,593
    requests and 34 answers. A Stop systemMessage is 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
      to why-asks.jsonl.1 past 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 >, >> (also cat > file <<EOF), tee or sed -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, .markdown or
        .txt file under adr/, 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 ran stage_checkpoint.py), and a
        decision: or stage: 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 -i is 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 why stays empty. The link
      stores the repository's id, the register's path, the entry heading as anchor, 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 # comment line 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's git diff gets 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 -i behind a prefix (sudo, xargs, env), gsed, or a sed script containing |
        or ;;
      • a relative path written after a cd in 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 UserPromptSubmit hook, hooks/prompt-why.py, hands the
      pending request to the model as additionalContext with 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 carries fault,
        which only its receipt carried before.
    • 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 the why; 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 that continues it.

    • 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.json gains whyAsks (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.py prints 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_unanswered fires below a 10 % rate over seven days with at
        least 20 delivered requests.

    tests/test_skill.py T8 now expects the UserPromptSubmit hook it used to forbid, and runs it to
    show that it prints nothing with no request pending. New suites: tests/test_ask_why.py and
    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 .git file points at (worktree_main in store/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-index docs/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-wide git 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.remote set 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 local before-update snapshots. 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, ps renders
    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 /proc fallback 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?", names observatory_record, observatory_learn, observatory_lesson, observatory_search and
    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/.github

The organization's public key: https://github.com/passioncode-ai/.github/blob/main/release-signing/passioncode-release-signing.asc