Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "naoto256-amtr",
"description": "Amnestic Trace — ephemeral replacement memory across a context boundary.",
"owner": {
"name": "Naoto Morishima"
},
"plugins": [
{
"name": "amtr",
"description": "Replaces a session's short-term working memory across compaction, and hands it to another session on request. A PreCompact hook starts a detached extraction over the journal since the last compaction, so it runs beside the compaction rather than after it. Three hooks then race to inject the result — as the compaction ends, at the next tool call, or at the next prompt — whichever comes first once it is ready. Adds the /amtr skill for cross-session handoff.",
"author": {
"name": "Naoto Morishima"
},
"source": "./plugin"
}
]
}
184 changes: 184 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
name: CI

on:
push:
branches: [main, 'release/*']
pull_request:
branches: [main, 'release/*']
workflow_dispatch:

# Minimum required for `actions/checkout` (read the repo). Cache via
# `Swatinem/rust-cache` works in read-only fallback without an explicit
# `actions: write` grant. No GitHub API writes from this job.
#
# Cache saves are gated on non-PR events (see `save-if:` below). PR runs can
# restore an existing cache but cannot write one back, so a hostile PR cannot
# poison the cache a subsequent trusted `push` run would restore.
permissions:
contents: read

env:
CARGO_TERM_COLOR: always

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin-audit:2026-06-03 de0fac2 | v6.0.2
with:
persist-credentials: false

- name: Install Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # pin-audit:2026-05-21 29eef33 | stable
with:
components: clippy, rustfmt

- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # pin-audit:2026-05-21 e18b497 | v2
with:
save-if: ${{ github.event_name != 'pull_request' }}

- name: Rustfmt
run: cargo fmt --all -- --check

- name: Build
run: cargo build --locked

- name: Test
run: cargo test --locked

- name: Clippy
run: cargo clippy --locked --all-targets -- -D warnings

# The hook script is the other half of this tool and is not covered by
# `cargo test`: it is what the host actually executes, and a failure in it
# fails open and silently, which is precisely the failure this project cannot
# detect at runtime by design.
#
# Syntax checking alone is not enough. A redirection error on a POSIX special
# built-in terminates the shell outright — dash does this, bash and zsh do
# not — so a hook can parse cleanly everywhere and still die on the first
# line of real work on Debian and Ubuntu. Only running it catches that, which
# is what the regression script does under all four supported shells.
hook-script:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin-audit:2026-06-03 de0fac2 | v6.0.2
with:
persist-credentials: false

- name: Install ksh
run: |
sudo apt-get update
sudo apt-get install -y ksh

- name: Shell syntax
run: |
sh -n plugin/tools/amtr-hook.sh
dash -n plugin/tools/amtr-hook.sh

- name: Hook behaviour
run: tests/hook-regressions.sh


# `cargo deny` gates every dimension deny.toml covers — RustSec advisories,
# duplicate and wildcard dependencies, source restrictions, license policy —
# against Cargo.lock. Pinned and `--locked` so the gate is itself
# reproducible: a floating tool version could silently change what "pass"
# means.
supply-chain:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin-audit:2026-06-03 de0fac2 | v6.0.2
with:
persist-credentials: false

- name: Install Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # pin-audit:2026-05-21 29eef33 | stable

- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # pin-audit:2026-05-21 e18b497 | v2
with:
key: supply-chain
save-if: ${{ github.event_name != 'pull_request' }}

- name: Install cargo-deny
run: cargo install --locked --version 0.18.6 cargo-deny

- name: cargo deny check
run: cargo deny check advisories bans sources licenses
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

# The manifests decide whether either host loads anything at all. They are
# data, so nothing else in this pipeline would notice a trailing comma or a
# path that no longer resolves.
manifests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin-audit:2026-06-03 de0fac2 | v6.0.2
with:
persist-credentials: false

- name: JSON is well-formed
run: |
for f in .claude-plugin/marketplace.json \
plugin/.claude-plugin/plugin.json \
plugin/.codex-plugin/plugin.json \
plugin/hooks/claude.json \
plugin/hooks/codex.json; do
python3 -m json.tool "$f" > /dev/null
done

# Each manifest names its hook file explicitly; neither host falls back
# to a convention, so a rename that missed one would silently disable
# that host.
- name: Declared hook files exist
run: |
for manifest in plugin/.claude-plugin/plugin.json plugin/.codex-plugin/plugin.json; do
rel=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['hooks'])" "$manifest")
test -f "plugin/${rel#./}" || { echo "missing: $manifest -> $rel"; exit 1; }
done

# Hook declarations are the only source of these canonical arguments.
# The shell regressions can prove the adapter forwards each form, but
# cannot prove either host manifest actually names it.
- name: Canonical hook invocations are declared
run: |
for f in plugin/hooks/claude.json plugin/hooks/codex.json; do
python3 - "$f" <<'PY'
import json, sys

path = sys.argv[1]
declared = json.load(open(path))["hooks"]
expected = {
"PreCompact": ("", " synthesize"),
"SessionStart": ("compact", " recall SessionStart"),
"PreToolUse": (None, " recall PreToolUse"),
"UserPromptSubmit": ("", " recall UserPromptSubmit"),
}
unexpected = set(declared) - set(expected)
if unexpected:
sys.exit(f"{path}: unexpected hook events declared: {sorted(unexpected)}")
for event, (matcher, suffix) in expected.items():
entries = declared.get(event, [])
if len(entries) != 1 or len(entries[0].get("hooks", [])) != 1:
sys.exit(f"{path}: expected exactly one {event} command")
if matcher is not None and entries[0].get("matcher") != matcher:
sys.exit(f"{path}: {event} matcher is {entries[0].get('matcher')!r}, not {matcher!r}")
command = entries[0]["hooks"][0]["command"]
if "tools/amtr-hook.sh" not in command:
sys.exit(f"{path}: {event} command does not invoke the adapter: {command!r}")
if not command.endswith(suffix):
sys.exit(f"{path}: {event} command does not end in {suffix!r}: {command!r}")
PY
done

# The version appears in three places and they are read by three
# different consumers; a release tagged against a stale one installs the
# wrong thing.
- name: Versions agree
run: |
crate=$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2)
for manifest in plugin/.claude-plugin/plugin.json plugin/.codex-plugin/plugin.json; do
got=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['version'])" "$manifest")
test "$got" = "$crate" || { echo "$manifest is $got, Cargo.toml is $crate"; exit 1; }
done
118 changes: 118 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
name: Release

on:
push:
tags:
- 'v*'

# No workflow-level grant: only the job that creates the release needs write,
# and the build jobs run first with a token that cannot publish anything.
permissions:
contents: read

env:
CARGO_TERM_COLOR: always

jobs:
# Both platforms, because nothing about the tool is platform-specific and a
# host runs wherever the user is.
build:
strategy:
matrix:
include:
- runner: macos-latest
target: aarch64-apple-darwin
- runner: ubuntu-latest
target: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.runner }}
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin-audit:2026-06-03 de0fac2 | v6.0.2
with:
persist-credentials: false

- name: Install Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # pin-audit:2026-05-21 29eef33 | stable
with:
targets: ${{ matrix.target }}

- name: Build release
run: cargo build --release --locked --target ${{ matrix.target }}

- name: Package
run: |
tar -czf "amtr-${GITHUB_REF_NAME}-${{ matrix.target }}.tar.gz" \
-C "target/${{ matrix.target }}/release" amtr

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pin-audit:2026-07-11 043fb46 | v7.0.1
with:
name: amtr-${{ matrix.target }}
path: amtr-*.tar.gz
if-no-files-found: error

release:
needs: build
runs-on: ubuntu-latest
# The only job that publishes anything.
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin-audit:2026-06-03 de0fac2 | v6.0.2
with:
persist-credentials: false

- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # pin-audit:2026-07-11 3e5f45b | v8.0.1
with:
path: dist
merge-multiple: true

- name: Extract release notes from CHANGELOG
id: notes
run: |
VERSION="${GITHUB_REF_NAME#v}"
# Body: everything between this version's heading and the next.
awk -v ver="$VERSION" '
$0 ~ "^## \\["ver"\\]" { found=1; next }
found && /^## \[/ { exit }
found { print }
' CHANGELOG.md > RELEASE_NOTES.md
if [ ! -s RELEASE_NOTES.md ]; then
echo "::warning::No CHANGELOG entry found for ${VERSION}; release body will be empty."
fi
# Tagline (optional): a `> ...` blockquote line immediately following
# the version heading, used as the second clause of the release name.
TAGLINE=$(awk -v ver="$VERSION" '
$0 ~ "^## \\["ver"\\]" { in_section=1; next }
in_section && /^>/ { sub(/^> ?/, ""); print; exit }
in_section && /^##/ { exit }
' CHANGELOG.md)
if [ -n "$TAGLINE" ]; then
NAME="amtr ${VERSION} — ${TAGLINE}"
else
NAME="amtr ${VERSION}"
fi
echo "name=${NAME}" >> "$GITHUB_OUTPUT"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- name: Generate checksums
# Lets consumers verify offline (`sha256sum -c SHA256SUMS`). Basenames
# rather than paths, so the check works from the download directory.
run: |
cd dist
sha256sum *.tar.gz > SHA256SUMS

- name: Upload to GitHub Release
env:
# Pre-installed gh CLI authenticates with the job's GITHUB_TOKEN; no
# third-party action in the trust path.
GH_TOKEN: ${{ github.token }}
# Passed as an env var rather than interpolated into `run:`. The value
# comes from CHANGELOG via awk, so it is repo-controlled, but
# expression substitution into a shell command is the wrong shape to
# rely on if that ever stops being true.
RELEASE_TITLE: ${{ steps.notes.outputs.name }}
run: |
gh release create "${GITHUB_REF_NAME}" \
dist/*.tar.gz dist/SHA256SUMS \
--title "$RELEASE_TITLE" \
--notes-file RELEASE_NOTES.md
57 changes: 57 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

Pre-1.0 releases may introduce breaking changes freely as the storage layout and hook contract converge. After 1.0, changes will follow semver strictly.

## [0.1.0] - 2026-08-09

> hooks name the event; Rust owns the memory protocol

### Added — replacement working memory across compaction

`amtr synthesize` accepts a host's PreCompact payload, detaches an extraction
worker, and reduces the journal since the previous boundary into one bounded
handoff. Each session owns one row that is overwritten rather than accumulated,
so the store remains ephemeral and the journal remains the source of truth.

`amtr recall SessionStart`, `PreToolUse`, and `UserPromptSubmit` are equal
injection opportunities over the same debt. Any event may open or join the one
25-second patience window, deliver the ready snapshot through an exclusive
atomic claim, or fold an unfinished debt after the deadline. A publication
racing with expiry is preserved for the next event.

The shell adapter is limited to canonical argument forwarding, minimal PATH
repair, stdin/stdout transport, and fail-open behavior. JSON parsing, store-path
resolution, deadline arithmetic, polling, claims, and cleanup live in Rust so
the protocol does not vary across sh, dash, bash, and ksh.

### Added — explicit handoff and read-only inspection

`amtr recall Handoff --amtr-key <key> [--clone]` moves or copies a named
snapshot into the current host session. `amtr key <session_id>` reveals the
current snapshot's capability only when a handoff is requested; injected memory
never carries the key.

`amtr peek` displays every matching snapshot together with its marker, deadline,
remaining wait, and orphan atomic-claim files. `--session-id` and `--amtr-key`
narrow the projection, while `--json` selects compact machine output.
Inspection does not create, repair, claim, or discharge store state. The
command exposes matching handoffs and AMTR keys and is therefore a local
same-user diagnostic, not a redacted sharing format.

### Security — private state and defensive boundaries

Store directories are created owner-only and machine-managed files are written
with mode 0600 on Unix. Host session identifiers are validated at capture,
delivery, and explicit-handoff boundaries; stored handoff text is escaped before
it enters the host's context frame. Journal fallback traversal skips unreadable
subtrees and symlinked directories.

The plugin declares exactly one PreCompact capture hook and three recall hooks,
with the event name passed explicitly to the Rust runtime. Hook failures emit no
diagnostic context and cannot fail the surrounding host event.

[0.1.0]: https://github.com/naoto256/amnestic-trace/releases/tag/v0.1.0
Loading