Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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"
}
]
}
199 changes: 199 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
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:
msrv:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin-audit:2026-06-03 de0fac2 | v6.0.2
with:
persist-credentials: false

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

- name: Check MSRV
run: cargo check --locked

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 --locked check advisories bans sources licenses

# 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
162 changes: 162 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
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: Validate release version
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
python3 - <<'PY'
import json
import os
import re
import tomllib
from pathlib import Path

tag = os.environ["RELEASE_TAG"]
match = re.fullmatch(r"v(\d+\.\d+\.\d+)", tag)
if match is None:
raise SystemExit(f"release tag must be vMAJOR.MINOR.PATCH, got {tag!r}")
expected = match.group(1)

cargo = tomllib.loads(Path("Cargo.toml").read_text())
lock = tomllib.loads(Path("Cargo.lock").read_text())
versions = {"Cargo.toml": cargo["package"]["version"]}
root = [
package for package in lock["package"]
if package["name"] == cargo["package"]["name"] and "source" not in package
]
if len(root) != 1:
raise SystemExit(f"Cargo.lock has {len(root)} root amtr packages, expected one")
versions["Cargo.lock"] = root[0]["version"]
for manifest in (
"plugin/.claude-plugin/plugin.json",
"plugin/.codex-plugin/plugin.json",
):
versions[manifest] = json.loads(Path(manifest).read_text())["version"]

mismatches = {path: version for path, version in versions.items() if version != expected}
if mismatches:
detail = ", ".join(f"{path}={version}" for path, version in mismatches.items())
raise SystemExit(f"{tag} disagrees with product metadata: {detail}")

changelog = Path("CHANGELOG.md").read_text()
heading = rf"^## \[{re.escape(expected)}\](?: - \d{{4}}-\d{{2}}-\d{{2}})?$"
if re.search(heading, changelog, re.MULTILINE) is None:
raise SystemExit(f"CHANGELOG.md has no exact [{expected}] release heading")
PY

- 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
Loading