Skip to content
Draft
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
142 changes: 142 additions & 0 deletions .claude/scripts/memory-backup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
#
# Takes a timestamped copy of a durable-memory file (or the whole store) BEFORE
# a destructive consolidate/rewrite, so a trim cannot erase unrecoverable
# history. The memory store is multi-writer, un-versioned, and outside git —
# without a backup, a size-threshold consolidation is a one-way delete
# (monorepo#2304: ~48KB of learnings lost on 2026-07-20 with no restore path).
#
# This script ONLY copies. It never edits, truncates, or consolidates the
# source — the agent still decides what to keep after the backup lands.
#
# Usage:
# memory-backup.sh [--backup-dir <dir>] <file>
# memory-backup.sh --all [--backup-dir <dir>] <memory-dir>
#
# Default backup root is <parent>/.memory-backups/ (sibling of the file, or
# inside the memory dir for --all). Nested under the store so it stays with
# the runtime that owns the memory; hygiene ignores nested dirs (maxdepth 1).
#
# Single-file layout: .memory-backups/<basename>.<UTC-timestamp>
# Whole-store layout: .memory-backups/store.<UTC-timestamp>/<basename>
#
# Exit codes:
# 0 backup written; path + restore command printed on stdout
# 2 usage error, missing source, or copy failure
set -Eeuo pipefail

mode="file"
backup_dir=""
target=""

usage() {
sed -n '/^# Usage:/,/^# Exit codes:/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'
}

while [[ $# -gt 0 ]]; do
case "$1" in
--all) mode="all"; shift ;;
--backup-dir) backup_dir="${2-}"; shift 2 || exit 2 ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
-*)
echo "memory-backup: unknown argument '$1'" >&2
usage >&2
exit 2
;;
*)
if [[ -n "$target" ]]; then
echo "memory-backup: unexpected extra argument '$1'" >&2
usage >&2
exit 2
fi
target="$1"
shift
;;
esac
done

if [[ -z "$target" ]]; then
echo "memory-backup: a file (or memory-dir with --all) is required" >&2
usage >&2
exit 2
fi

# Tests pin the timestamp so golden paths stay deterministic; production uses UTC now.
ts="${MEMORY_BACKUP_TS:-$(date -u +%Y%m%dT%H%M%SZ)}"
if ! [[ "$ts" =~ ^[0-9]{8}T[0-9]{6}Z$ ]]; then
echo "memory-backup: MEMORY_BACKUP_TS must look like YYYYMMDDTHHMMSSZ (got '$ts')" >&2
exit 2
fi

# Copy via a temp name in the destination dir, then rename — so a crash mid-copy
# never leaves a partial file that looks like a successful backup.
atomic_cp() {
local src="$1" dest="$2"
local dest_dir tmp
dest_dir="$(dirname "$dest")"
mkdir -p "$dest_dir"
tmp="$(mktemp "$dest_dir/.memory-backup.XXXXXX")"
# mktemp creates an empty file; replace it with the source contents.
cp -p "$src" "$tmp"
mv -f "$tmp" "$dest"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the final backup rename non-clobbering

When two instances select the same destination—identical basename, backup directory, and timestamp—both can pass the earlier existence check, and mv -f lets the later process silently replace the first backup. Running two such backups concurrently made both exit successfully and print restore commands, while only the second source's bytes remained, so the first command would restore incorrect data. Publish with an atomic no-replace operation or lock and report a collision instead.

AGENTS.md reference: AGENTS.md:L2706-L2709

Useful? React with 👍 / 👎.

}

if [[ "$mode" == "file" ]]; then
if [[ ! -f "$target" ]]; then
echo "memory-backup: not a readable file: $target" >&2
exit 2
fi
parent="$(cd "$(dirname "$target")" && pwd)"
base="$(basename "$target")"
if [[ -z "$backup_dir" ]]; then
backup_dir="$parent/.memory-backups"
fi
dest="$backup_dir/${base}.${ts}"
if [[ -e "$dest" ]]; then
echo "memory-backup: refusing to overwrite existing backup: $dest" >&2
exit 2
fi
atomic_cp "$target" "$dest"
printf 'Backed up %s -> %s\n' "$target" "$dest"
printf 'Restore: cp %q %q\n' "$dest" "$target"
exit 0
fi

# --all: snapshot every top-level *.md in the memory dir (including archives —
# a recovery snapshot should be complete, not budget-filtered).
if [[ ! -d "$target" ]]; then
echo "memory-backup: not a directory: $target" >&2
exit 2
fi
memory_dir="$(cd "$target" && pwd)"
if [[ -z "$backup_dir" ]]; then
backup_dir="$memory_dir/.memory-backups"
fi
store_dest="$backup_dir/store.${ts}"
if [[ -e "$store_dest" ]]; then
echo "memory-backup: refusing to overwrite existing snapshot: $store_dest" >&2
exit 2
fi

if ! file_list="$(find "$memory_dir" -maxdepth 1 -type f -name '*.md' 2>/dev/null | sort)"; then
echo "memory-backup: failed to enumerate memory files in $memory_dir" >&2
exit 2
fi
if [[ -z "$file_list" ]]; then
echo "memory-backup: no top-level *.md files in $memory_dir" >&2
exit 2
fi

mkdir -p "$store_dest"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Publish snapshots only after every file is copied

If a later source disappears after enumeration or any copy fails, this final-named snapshot directory and its already-copied files remain behind even though the command fails. Deleting a later file during a large --all run reproduced an exit 1 with store.<timestamp>/a.md plus a temporary file still present; that directory looks like a completed recovery snapshot and can later be used despite being incomplete. Build in a temporary sibling directory, clean it on failure, and rename it to store.<timestamp> only after every copy succeeds.

AGENTS.md reference: AGENTS.md:L2693-L2697

Useful? React with 👍 / 👎.

count=0
while IFS= read -r file; do
[[ -n "$file" ]] || continue
base="$(basename "$file")"
atomic_cp "$file" "$store_dest/$base"
count=$(( count + 1 ))
done <<< "$file_list"

printf 'Backed up %s file(s) from %s -> %s\n' "$count" "$memory_dir" "$store_dest"
printf 'Restore one file: cp %q/<basename> %q/<basename>\n' "$store_dest" "$memory_dir"
exit 0
122 changes: 122 additions & 0 deletions .claude/scripts/memory-backup.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
#
# Self-test for memory-backup.sh — proves a destructive-edit precursor actually
# lands a recoverable copy (monorepo#2304), that --all snapshots the whole
# top-level store, that an existing backup is never overwritten, and that the
# source file is left byte-identical.
#
# Fixtures are throwaway files in a temp dir — no real memory touched.
set -Eeuo pipefail

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
tool="$script_dir/memory-backup.sh"

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT

failures=0
pass() { printf 'ok — %s\n' "$1"; }
fail() { printf 'FAIL — %s\n' "$1"; failures=$(( failures + 1 )); }

check() {
local desc="$1" expected="$2" actual="$3"
if [[ "$expected" == "$actual" ]]; then pass "$desc"; else
fail "$desc (expected '$expected', got '$actual')"
fi
}

# Capture exit code without tripping set -e on an expected failure.
run() { local rc=0; "$tool" "$@" >/dev/null 2>&1 || rc=$?; echo "$rc"; }

export MEMORY_BACKUP_TS=20260724T003000Z

# ---------------------------------------------------------------------------
# Single-file backup lands under .memory-backups/ with the pinned timestamp,
# prints a restore command, and leaves the source untouched.
# ---------------------------------------------------------------------------
store="$tmp/single"
mkdir -p "$store"
printf 'keep-me learnings theme\n' > "$store/learnings.md"
before="$(shasum "$store/learnings.md" | awk '{print $1}')"
rc=0
out="$("$tool" "$store/learnings.md" 2>&1)" || rc=$?
check "single-file backup exits 0" "0" "$rc"

dest="$store/.memory-backups/learnings.md.${MEMORY_BACKUP_TS}"
if [[ -f "$dest" ]]; then
pass "timestamped backup file exists"
else
fail "timestamped backup file exists (missing $dest)"
fi
check "backup content matches source" "$(cat "$store/learnings.md")" "$(cat "$dest")"
check "source unchanged after backup" "$before" "$(shasum "$store/learnings.md" | awk '{print $1}')"
if grep -q "Restore: cp" <<<"$out"; then
pass "stdout carries a restore command"
else
fail "stdout carries a restore command (got: $out)"
fi

# ---------------------------------------------------------------------------
# A second backup at the same timestamp must REFUSE to overwrite (fail closed).
# ---------------------------------------------------------------------------
check "refuses to overwrite existing backup" "2" "$(run "$store/learnings.md")"

# ---------------------------------------------------------------------------
# --all snapshots every top-level *.md, including archives.
# ---------------------------------------------------------------------------
store="$tmp/all"
mkdir -p "$store"
printf 'a\n' > "$store/MEMORY.md"
printf 'b\n' > "$store/learnings.md"
printf 'c\n' > "$store/learnings-archive-2026-07-12.md"
export MEMORY_BACKUP_TS=20260724T003001Z
rc=0
out="$("$tool" --all "$store" 2>&1)" || rc=$?
check "--all exits 0" "0" "$rc"
snap="$store/.memory-backups/store.${MEMORY_BACKUP_TS}"
if [[ -d "$snap" ]]; then
pass "whole-store snapshot directory exists"
else
fail "whole-store snapshot directory exists"
fi
check "snapshot includes MEMORY.md" "a" "$(cat "$snap/MEMORY.md")"
check "snapshot includes learnings.md" "b" "$(cat "$snap/learnings.md")"
check "snapshot includes archive" "c" "$(cat "$snap/learnings-archive-2026-07-12.md")"
if grep -q "3 file" <<<"$out"; then
pass "--all reports file count"
else
fail "--all reports file count (got: $out)"
fi

# ---------------------------------------------------------------------------
# Usage / missing source → exit 2 (distinct from success).
# ---------------------------------------------------------------------------
check "no args exits 2" "2" "$(run)"
check "missing file exits 2" "2" "$(run "$tmp/does-not-exist.md")"
check "missing dir with --all exits 2" "2" "$(run --all "$tmp/no-such-dir")"

empty="$tmp/empty"
mkdir -p "$empty"
check "empty store with --all exits 2" "2" "$(run --all "$empty")"

# ---------------------------------------------------------------------------
# Custom --backup-dir is honoured.
# ---------------------------------------------------------------------------
store="$tmp/custom"
alt="$tmp/alt-backups"
mkdir -p "$store"
printf 'x\n' > "$store/portfolio-status.md"
export MEMORY_BACKUP_TS=20260724T003002Z
check "custom --backup-dir exits 0" "0" "$(run --backup-dir "$alt" "$store/portfolio-status.md")"
if [[ -f "$alt/portfolio-status.md.${MEMORY_BACKUP_TS}" ]]; then
pass "backup lands in --backup-dir"
else
fail "backup lands in --backup-dir"
fi

if [[ "$failures" -eq 0 ]]; then
printf '\nAll memory-backup self-tests passed.\n'
exit 0
fi
printf '\n%d memory-backup self-test(s) FAILED.\n' "$failures"
exit 1
4 changes: 4 additions & 0 deletions .claude/scripts/memory-hygiene-go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@ func run(args []string, stdout, stderr io.Writer) int {
checked,
)
output.line(" These will TRUNCATE at run start and silently hide carry-forwards.")
output.line(" BEFORE any destructive rewrite: .claude/scripts/memory-backup.sh <file>")
output.line(
" (or --all <memory-dir> for a whole-store snapshot). Restore: cp '<backup>' '<file>'.",
)
output.line(" Memory is a multi-writer surface: re-read immediately before writing and")
output.line(" prefer a non-clobbering append over a whole-file rewrite.")
}
Expand Down
2 changes: 1 addition & 1 deletion .claude/scripts/memory-hygiene-go/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ func TestLegacyLayout(t *testing.T) {
if code != 1 {
t.Fatalf("exit code = %d, want 1", code)
}
for _, expected := range []string{"OVER", "portfolio-status.md", "append"} {
for _, expected := range []string{"OVER", "portfolio-status.md", "append", "memory-backup.sh"} {
if !strings.Contains(stdout, expected) {
t.Fatalf("stdout %q does not contain %q", stdout, expected)
}
Expand Down
4 changes: 4 additions & 0 deletions .claude/skills/portfolio-maintenance/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ card.
through the runtime's supported path when needed and **restart the run** because this session did
not start with the projection the guard checked;
other exit-2 causes may rerun the guard in this session after resolution.
**Before any destructive rewrite of an author-managed (legacy) file**, take a timestamped copy:
`.claude/scripts/memory-backup.sh <file>` (or `--all <memory-dir>` for a whole-store snapshot).
Restore with `cp '<backup>' '<file>'`. The store is un-versioned; a trim without a backup is
unrecoverable (monorepo#2304). Prefer append; rewrite only after that backup.
**Memory is a MULTI-WRITER surface** — several instances append per hour. Re-read immediately
before writing, prefer a **non-clobbering append** (`>>`) over a whole-file rewrite, and if a
rewrite is rejected because the file moved under you, **stand down rather than clobber** a sibling's
Expand Down
24 changes: 24 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ jobs:
board-add: ${{ steps.filter.outputs.board-add }}
flow-scorecard: ${{ steps.filter.outputs.flow-scorecard }}
memory-hygiene: ${{ steps.filter.outputs.memory-hygiene }}
memory-backup: ${{ steps.filter.outputs.memory-backup }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down Expand Up @@ -100,6 +101,12 @@ jobs:
# Self-gate on the workflow too, so a change that breaks this
# job's own wiring still runs the test it gates.
- '.github/workflows/ci.yaml'
memory-backup:
- '.claude/scripts/memory-backup.sh'
- '.claude/scripts/memory-backup.test.sh'
# Self-gate on the workflow too, so a change that breaks this
# job's own wiring still runs the test it gates.
- '.github/workflows/ci.yaml'
maintainer-preflight:
- '.claude/skills/portfolio-maintenance/SKILL.md'
- '.claude/scripts/maintainer-preflight.test.sh'
Expand Down Expand Up @@ -415,6 +422,21 @@ jobs:
- name: Verify the memory hygiene guard
run: bash .claude/scripts/memory-hygiene.test.sh

test-memory-backup:
name: Test memory backup helper
needs: changes
if: needs.changes.outputs.memory-backup == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Verify the memory backup helper
run: bash .claude/scripts/memory-backup.test.sh

test-product-value-contract:
name: Test product value contract
needs: changes
Expand Down Expand Up @@ -539,6 +561,7 @@ jobs:
- test-submodule-init
- test-maintainer-preflight
- test-memory-hygiene
- test-memory-backup
- test-self-review-contract
- test-review-provider-loop-contract
- test-agent-role-delivery-contract
Expand All @@ -563,6 +586,7 @@ jobs:
${{ needs.test-submodule-init.result }}
${{ needs.test-maintainer-preflight.result }}
${{ needs.test-memory-hygiene.result }}
${{ needs.test-memory-backup.result }}
${{ needs.test-self-review-contract.result }}
${{ needs.test-review-provider-loop-contract.result }}
${{ needs.test-agent-role-delivery-contract.result }}
Expand Down
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2690,7 +2690,12 @@ step:
diagnostic-only (`--all` shows the exemption). Legacy/Claude stores retain the original root-file
checks. An exit 1 makes repairing the over-threshold boot-loaded file that tick's mandated hygiene
item: consolidate an author-managed file safely, or refresh an oversized Codex projection through
the runtime. An exit 2 indicates a usage, malformed-layout, missing, or unreadable-store error;
the runtime. **Before any destructive consolidate/rewrite of an author-managed file**, run
[`.claude/scripts/memory-backup.sh`](.claude/scripts/memory-backup.sh)
`<file>` (or `--all <memory-dir>` for a whole-store snapshot under `.memory-backups/`); restore with
`cp '<backup>' '<file>'` — the store is un-versioned and outside git, so a trim without a backup is a
one-way delete (monorepo#2304). Prefer append; rewrite only when consolidating **after** that backup.
An exit 2 indicates a usage, malformed-layout, missing, or unreadable-store error;
resolve it before proceeding. If a Codex exit 2 names a missing, unreadable, malformed, or
post-injection-changed `memory_summary.md`, repair it through the runtime's supported path when
needed and **restart the run**: this session did not start with the projection the guard checked.
Expand Down
Loading