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
35 changes: 35 additions & 0 deletions docs/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,41 @@ cron entries. The nunchi code and local database remain in place, while the next
Codex materialization falls back to canonical `load-memory.sh`. This wiring does
not claim completion of pilot or gate-3 observation.

### Piri global snapshot materializer (ccc-piri)

`scripts/ccc-piri` is the node-global Piri counterpart of `scripts/ccc-codex`.
Piri auto-loads `<piri-agent-dir>/AGENTS.md` (`${PIRI_CODING_AGENT_DIR:-~/.piri/agent}`)
as its global context file, and the shared materializer resolves its output as
`<CODEX_HOME>/AGENTS.md`, so the launcher runs the materializer with
`CODEX_HOME` pointed at the Piri agent dir and
`CCC_MEMORY_MATERIALIZER_PROVIDER=piri` before exec'ing the real CLI. Every
node-global Piri launch — interactive, print-mode, or the bridge RPC runtime —
then starts from the same bounded snapshot policy as Claude SessionStart and
the Codex global block, including the managed nunchi merge when nunchi mode is
`on`.

Point `CCC_PIRI_CLI_PATH` at the installed `~/.claude/hooks/ccc-piri` and
`CCC_PIRI_REAL_CLI_PATH` at the real Piri CLI (e.g. a model-selecting shim).
`CCC_PIRI_MEMORY_MATERIALIZER_PATH` and `CCC_PIRI_MEMORY_HOME` override the
materializer and target agent dir. The launcher preserves argv, cwd, stdio,
exit status, and signals via a final `exec`, and shares the ccc-codex
fail-closed contract: a refresh failure may proceed on a structurally valid
private last snapshot; otherwise launch exits 78.

Three bypass guards keep non-user runs memory-free:

- `PIRI_CODING_AGENT_SESSION_DIR` under `.piri-feed-extractor-sessions`
(the nunchi piri-feed extractor) routes straight to the real CLI, so the
tool-free extractor never receives user memory.
- `CCC_MEMORY_AUDIENCE_SCOPED` truthy skips the node-global bootstrap;
audience-scoped sessions are bootstrapped by the bridge runtime itself via
`--no-context-files --append-system-prompt` and must not touch global memory.
- `CCC_PIRI_MEMORY_SKIP=1` is the explicit operator kill-switch.

`setup.sh` installs the launcher beside `ccc-codex` under
`${CCC_CLAUDE_DIR:-$HOME/.claude}/hooks`, and `scripts/ccc-piri.test.sh`
covers the launch surface and all three guards hermetically.

### Managed MemPalace refresh

The nunchi installer schedules `hooks/nunchi/mempalace-refresh.sh` once per
Expand Down
60 changes: 60 additions & 0 deletions scripts/ccc-piri
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# ccc-piri — materialize the ccc-node snapshot into the Piri global context
# file before every Piri run. Node-global counterpart of ccc-codex: the
# materializer writes <CODEX_HOME>/AGENTS.md, so CODEX_HOME is pointed at the
# Piri agent dir, whose AGENTS.md Piri auto-loads as its global context file.
set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
materializer="${CCC_PIRI_MEMORY_MATERIALIZER_PATH:-$SCRIPT_DIR/ccc_codex_memory.py}"
real_candidate="${CCC_PIRI_REAL_CLI_PATH:-piri}"
memory_home="${CCC_PIRI_MEMORY_HOME:-$HOME/.piri/agent}"

skip=0
# Isolated tool-free extractor runs (nunchi piri-feed) must not receive user
# memory; route them straight to the real CLI.
case "${PIRI_CODING_AGENT_SESSION_DIR:-}" in
*/.piri-feed-extractor-sessions|*/.piri-feed-extractor-sessions/*) skip=1 ;;
esac
# Audience-scoped sessions are bootstrapped by the bridge runtime itself
# (--no-context-files --append-system-prompt); never touch global memory here.
case "${CCC_MEMORY_AUDIENCE_SCOPED:-0}" in
1|true|TRUE|on|ON|yes|YES) skip=1 ;;
esac
[ "${CCC_PIRI_MEMORY_SKIP:-0}" = "1" ] && skip=1

if [ "$skip" != "1" ]; then
if [ ! -f "$materializer" ] || [ ! -x "$materializer" ] || [ -L "$materializer" ]; then
printf "%s\n" "ccc-piri: memory bootstrap unavailable" >&2
exit 78
fi
if ! env CODEX_HOME="$memory_home" CCC_MEMORY_MATERIALIZER_PROVIDER="${CCC_MEMORY_MATERIALIZER_PROVIDER:-piri}" \
"$materializer" materialize --json >/dev/null 2>&1; then
# A refresh failure may proceed only when a previously materialized block
# is still structurally valid and private. status is read-only/body-free.
if ! env CODEX_HOME="$memory_home" "$materializer" status --json >/dev/null 2>&1; then
printf "%s\n" "ccc-piri: memory bootstrap unavailable" >&2
exit 78
fi
fi
fi

case "$real_candidate" in
""|-*)
printf "%s\n" "ccc-piri: real CLI unavailable" >&2
exit 127
;;
*/*) real_cli="$real_candidate" ;;
*) real_cli="$(command -v "$real_candidate" 2>/dev/null || true)" ;;
esac

if [ -z "${real_cli:-}" ] || [ ! -x "$real_cli" ] || [ ! -f "$real_cli" ]; then
printf "%s\n" "ccc-piri: real CLI unavailable" >&2
exit 127
fi
if [ "$real_cli" -ef "$0" ]; then
printf "%s\n" "ccc-piri: recursive real CLI configuration" >&2
exit 127
fi

exec "$real_cli" "$@"
113 changes: 113 additions & 0 deletions scripts/ccc-piri.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# Hermetic launch-surface tests for scripts/ccc-piri (node-global Piri
# counterpart of the ccc-codex launcher tests in ccc-codex.test.sh).
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
LAUNCHER="$ROOT/scripts/ccc-piri"
pass=0; fail=0
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
ok() { if eval "$2"; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: $1"; fi; }

mat="$TMP/materializer"
real="$TMP/real-piri"
cat > "$mat" <<'SH'
#!/usr/bin/env bash
case "${1:-}" in
materialize)
printf 'materialize\n' >> "${ORDER_FILE:?}"
printf 'CODEX_HOME=%s\nPROVIDER=%s\n' "${CODEX_HOME:-}" "${CCC_MEMORY_MATERIALIZER_PROVIDER:-}" >> "${ENV_FILE:?}"
printf 'MATERIALIZER_BODY_SENTINEL\n'
printf 'MATERIALIZER_ERROR_SENTINEL\n' >&2
exit "${MAT_RC:-0}"
;;
status)
printf 'status\n' >> "${ORDER_FILE:?}"
exit "${STATUS_RC:-1}"
;;
*) exit 64 ;;
esac
SH
cat > "$real" <<'SH'
#!/usr/bin/env bash
printf 'real\n' >> "${ORDER_FILE:?}"
pwd > "${CWD_FILE:?}"
python3 - "$ARGV_FILE" "$@" <<'PY'
import json,sys
with open(sys.argv[1], "w", encoding="utf-8") as fh:
json.dump(sys.argv[2:], fh)
PY
input="$(cat)"
printf 'REAL:%s' "$input"
exit "${REAL_RC:-0}"
SH
chmod 0700 "$mat" "$real"

piri_home="$TMP/piri-agent"; mkdir -p "$piri_home"
work="$TMP/work dir"; mkdir -p "$work"
order="$TMP/order"; argv="$TMP/argv.json"; cwd_file="$TMP/cwd"; err="$TMP/err"; env_file="$TMP/env"
set +e
out="$(cd "$work" && printf 'stdin data' | ORDER_FILE="$order" ARGV_FILE="$argv" CWD_FILE="$cwd_file" ENV_FILE="$env_file" MAT_RC=0 STATUS_RC=1 REAL_RC=23 CCC_PIRI_MEMORY_HOME="$piri_home" CCC_PIRI_MEMORY_MATERIALIZER_PATH="$mat" CCC_PIRI_REAL_CLI_PATH="$real" "$LAUNCHER" --mode rpc --approve 2>"$err")"
rc=$?
set -e
ok "launcher preserves real Piri exit code and stdio" '[ "$rc" = 23 ] && [ "$out" = "REAL:stdin data" ]'
ok "launcher suppresses materializer body and error output" '! grep -q "MATERIALIZER_" "$err" && [[ "$out" != *MATERIALIZER_* ]]'
ok "launcher materializes before exec and preserves cwd" '[ "$(cat "$order")" = $'"'"'materialize\nreal'"'"' ] && [ "$(cat "$cwd_file")" = "$work" ]'
ok "launcher preserves argv boundaries" 'python3 - "$argv" <<'"'"'PY'"'"'
import json,sys
raise SystemExit(0 if json.load(open(sys.argv[1])) == ["--mode", "rpc", "--approve"] else 1)
PY'
ok "launcher points the materializer at the Piri agent dir with the piri provider" \
'grep -Fx "CODEX_HOME='"$piri_home"'" "$env_file" >/dev/null && grep -Fx "PROVIDER=piri" "$env_file" >/dev/null'

: > "$order"; rm -f "$argv"
set +e
out="$(ORDER_FILE="$order" ARGV_FILE="$argv" CWD_FILE="$cwd_file" ENV_FILE="$env_file" MAT_RC=9 STATUS_RC=0 REAL_RC=0 CCC_PIRI_MEMORY_HOME="$piri_home" CCC_PIRI_MEMORY_MATERIALIZER_PATH="$mat" CCC_PIRI_REAL_CLI_PATH="$real" "$LAUNCHER" ready 2>"$err")"
rc=$?
set -e
ok "launcher uses last valid snapshot when refresh fails" '[ "$rc" = 0 ] && [ "$out" = "REAL:" ] && [ "$(cat "$order")" = $'"'"'materialize\nstatus\nreal'"'"' ]'

: > "$order"; rm -f "$argv"
set +e
ORDER_FILE="$order" ARGV_FILE="$argv" CWD_FILE="$cwd_file" ENV_FILE="$env_file" MAT_RC=9 STATUS_RC=7 REAL_RC=0 CCC_PIRI_MEMORY_HOME="$piri_home" CCC_PIRI_MEMORY_MATERIALIZER_PATH="$mat" CCC_PIRI_REAL_CLI_PATH="$real" "$LAUNCHER" blocked >"$TMP/out" 2>"$err"
rc=$?
set -e
ok "launcher fails closed when no current or last-valid snapshot exists" '[ "$rc" = 78 ] && [ ! -e "$argv" ] && [ "$(cat "$order")" = $'"'"'materialize\nstatus'"'"' ]'
ok "fail-closed diagnostic is bounded and body-free" '[ "$(wc -c < "$err")" -lt 256 ] && ! grep -q "MATERIALIZER_\|SECRET" "$err"'

: > "$order"
set +e
ORDER_FILE="$order" ARGV_FILE="$argv" CWD_FILE="$cwd_file" ENV_FILE="$env_file" MAT_RC=0 STATUS_RC=0 CCC_PIRI_MEMORY_HOME="$piri_home" CCC_PIRI_MEMORY_MATERIALIZER_PATH="$mat" CCC_PIRI_REAL_CLI_PATH="$LAUNCHER" "$LAUNCHER" >"$TMP/out" 2>"$err"
rc=$?
set -e
ok "launcher rejects recursive real-cli configuration" '[ "$rc" = 127 ]'
ok "launcher uses final exec rather than a child Piri process" 'grep -Fq '"'"'exec "$real_cli" "$@"'"'"' "$LAUNCHER"'

# Guard: the nunchi piri-feed extractor runs tool-free and must never receive
# user memory — it is routed straight to the real CLI with no materialize.
: > "$order"; rm -f "$argv"
set +e
out="$(ORDER_FILE="$order" ARGV_FILE="$argv" CWD_FILE="$cwd_file" ENV_FILE="$env_file" MAT_RC=0 STATUS_RC=0 REAL_RC=0 PIRI_CODING_AGENT_SESSION_DIR="$TMP/nunchi/.piri-feed-extractor-sessions" CCC_PIRI_MEMORY_HOME="$piri_home" CCC_PIRI_MEMORY_MATERIALIZER_PATH="$mat" CCC_PIRI_REAL_CLI_PATH="$real" "$LAUNCHER" --mode text --print x 2>"$err")"
rc=$?
set -e
ok "extractor-session guard bypasses the memory bootstrap entirely" '[ "$rc" = 0 ] && [ "$out" = "REAL:" ] && [ "$(cat "$order")" = "real" ]'

# Guard: audience-scoped sessions are bootstrapped by the bridge runtime
# itself; the node-global launcher must not touch global memory for them.
: > "$order"
set +e
out="$(ORDER_FILE="$order" ARGV_FILE="$argv" CWD_FILE="$cwd_file" ENV_FILE="$env_file" MAT_RC=0 STATUS_RC=0 REAL_RC=0 CCC_MEMORY_AUDIENCE_SCOPED=1 CCC_PIRI_MEMORY_HOME="$piri_home" CCC_PIRI_MEMORY_MATERIALIZER_PATH="$mat" CCC_PIRI_REAL_CLI_PATH="$real" "$LAUNCHER" 2>"$err")"
rc=$?
set -e
ok "audience-scoped guard bypasses the node-global memory bootstrap" '[ "$rc" = 0 ] && [ "$out" = "REAL:" ] && [ "$(cat "$order")" = "real" ]'

# Guard: explicit operator kill-switch.
: > "$order"
set +e
out="$(ORDER_FILE="$order" ARGV_FILE="$argv" CWD_FILE="$cwd_file" ENV_FILE="$env_file" MAT_RC=0 STATUS_RC=0 REAL_RC=0 CCC_PIRI_MEMORY_SKIP=1 CCC_PIRI_MEMORY_HOME="$piri_home" CCC_PIRI_MEMORY_MATERIALIZER_PATH="$mat" CCC_PIRI_REAL_CLI_PATH="$real" "$LAUNCHER" 2>"$err")"
rc=$?
set -e
ok "CCC_PIRI_MEMORY_SKIP=1 bypasses the memory bootstrap" '[ "$rc" = 0 ] && [ "$out" = "REAL:" ] && [ "$(cat "$order")" = "real" ]'

echo "----"; echo "PASS=$pass FAIL=$fail"
[ "$fail" = 0 ]
4 changes: 3 additions & 1 deletion scripts/setup.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ ok "setup deploys the mtime-prune library the pruning hooks source" \
'[ -x "$rewrite_claude/hooks/lib/mtime-prune.sh" ] && cmp -s "$ROOT/claude/hooks/lib/mtime-prune.sh" "$rewrite_claude/hooks/lib/mtime-prune.sh"'
ok "setup installs the Codex launcher and materializer as executable managed hooks" \
'[ -x "$rewrite_claude/hooks/ccc-codex" ] && [ -x "$rewrite_claude/hooks/ccc_codex_memory.py" ] && cmp -s "$ROOT/scripts/ccc-codex" "$rewrite_claude/hooks/ccc-codex" && cmp -s "$ROOT/scripts/ccc_codex_memory.py" "$rewrite_claude/hooks/ccc_codex_memory.py"'
ok "setup installs the Piri launcher as an executable managed hook" \
'[ -x "$rewrite_claude/hooks/ccc-piri" ] && cmp -s "$ROOT/scripts/ccc-piri" "$rewrite_claude/hooks/ccc-piri"'
ok "setup installs the managed nunchi Codex loader" \
'[ -x "$rewrite_claude/hooks/nunchi/codex-loader.py" ] && cmp -s "$ROOT/claude/hooks/nunchi/codex-loader.py" "$rewrite_claude/hooks/nunchi/codex-loader.py"'
ok "setup installs the body-free memory readiness probe beside memory-check" \
Expand All @@ -285,7 +287,7 @@ ok "source-checkout local-memory transaction imports canonical secure-fs directl
'PYTHONDONTWRITEBYTECODE=1 PYTHONPATH= python3 -S "$ROOT/bridge/memory/local_memory_transaction.py" --help >/dev/null 2>&1'
codex_dry_out="$(HOME="$nonroot_home" CCC_CLAUDE_DIR="$nonroot_claude" CCC_HERMES_DIR="$nonroot_hermes" CCC_WIKI_AGENT_BIN="$nonroot_wiki" CCC_BRIDGE_DEFAULT_PATH="$nonroot_bridge" bash "$SETUP" --dry-run 2>&1)"; codex_dry_rc=$?
ok "setup non-root dry-run includes all Codex managed launch artifacts" \
'[ "$codex_dry_rc" = 0 ] && grep -Fq "$nonroot_claude/hooks/ccc-codex" <<<"$codex_dry_out" && grep -Fq "$nonroot_claude/hooks/ccc_codex_memory.py" <<<"$codex_dry_out" && grep -Fq "$nonroot_claude/hooks/ccc_secure_fs.py" <<<"$codex_dry_out" && grep -Fq "$nonroot_claude/hooks/ccc_local_memory_transaction.py" <<<"$codex_dry_out"'
'[ "$codex_dry_rc" = 0 ] && grep -Fq "$nonroot_claude/hooks/ccc-codex" <<<"$codex_dry_out" && grep -Fq "$nonroot_claude/hooks/ccc-piri" <<<"$codex_dry_out" && grep -Fq "$nonroot_claude/hooks/ccc_codex_memory.py" <<<"$codex_dry_out" && grep -Fq "$nonroot_claude/hooks/ccc_secure_fs.py" <<<"$codex_dry_out" && grep -Fq "$nonroot_claude/hooks/ccc_local_memory_transaction.py" <<<"$codex_dry_out"'

# --- #569: hook-tree walk — deploys recursively, excludes tests/bytecode/wiring,
# and dry-run only RENDERS the walk (no copies). rewrite_claude is a real install.
Expand Down
2 changes: 1 addition & 1 deletion scripts/validate-harness.sh
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ for t in claude/hooks/observability.test.sh claude/hooks/security-scan.test.sh \
claude/hooks/distill/local-facts.test.sh claude/hooks/memory-hooks.test.sh \
claude/hooks/refresh-memory-freshness.test.sh \
claude/hooks/nunchi/nunchi.test.sh claude/hooks/nunchi/bench.test.sh \
scripts/ccc-doctor.test.sh scripts/ccc-memory.test.sh scripts/ccc-codex-memory.test.sh scripts/ccc-codex.test.sh scripts/ccc-codex-github-policy.test.sh scripts/ccc-distill-check.test.sh scripts/ccc-security-audit.test.sh \
scripts/ccc-doctor.test.sh scripts/ccc-memory.test.sh scripts/ccc-codex-memory.test.sh scripts/ccc-codex.test.sh scripts/ccc-piri.test.sh scripts/ccc-codex-github-policy.test.sh scripts/ccc-distill-check.test.sh scripts/ccc-security-audit.test.sh \
scripts/ccc-fleet-matrix.test.sh scripts/ccc-wiki-triage.test.sh scripts/setup.test.sh \
scripts/harness-paths.test.sh scripts/canonical-paths.test.sh \
scripts/agent-cron.test.sh scripts/agent-cron-lib.test.sh scripts/a2a-termux-native-worker.test.sh \
Expand Down
9 changes: 9 additions & 0 deletions setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,9 @@ run cp "$SRC/scripts/lib/harness_paths.py" "$CLAUDE_DIR/hooks/lib/harness_paths.
# load-memory.sh so every direct/app-server run reuses the same snapshot policy.
run cp "$SRC/scripts/ccc-codex" "$CLAUDE_DIR/hooks/ccc-codex"
run cp "$SRC/scripts/ccc_codex_memory.py" "$CLAUDE_DIR/hooks/ccc_codex_memory.py"
# Piri launch boundary: node-global counterpart of ccc-codex — materializes the
# same snapshot into the Piri global context file (<piri-agent-dir>/AGENTS.md).
run cp "$SRC/scripts/ccc-piri" "$CLAUDE_DIR/hooks/ccc-piri"
# Claude's distill committer imports the same crash-recoverable transaction
# implementation as the Codex bridge. Install the canonical module beside the
# existing standalone secure-fs copy instead of forking provider logic.
Expand Down Expand Up @@ -402,6 +405,7 @@ installed_hook_scripts=(
"$CLAUDE_DIR/hooks/lib/harness_paths.py"
"$CLAUDE_DIR/hooks/ccc-codex"
"$CLAUDE_DIR/hooks/ccc_codex_memory.py"
"$CLAUDE_DIR/hooks/ccc-piri"
"$CLAUDE_DIR/hooks/ccc-memory-index.sh"
"$CLAUDE_DIR/hooks/ccc-memory-search.sh"
"$CLAUDE_DIR/hooks/ccc-memory-consolidate.sh"
Expand Down Expand Up @@ -693,6 +697,10 @@ cat <<'EOF'
11. (Optional Codex) Keep CCC_CODEX_CLI_PATH on ~/.claude/hooks/ccc-codex,
set CCC_CODEX_REAL_CLI_PATH only for a non-PATH binary, and require
`ccc-memory-check.sh --json` to report `.codex.status == "ready"`.
12. (Optional Piri) Point CCC_PIRI_CLI_PATH at ~/.claude/hooks/ccc-piri and
CCC_PIRI_REAL_CLI_PATH at the real Piri CLI so every node-global Piri
launch materializes the same memory snapshot into the Piri global
context file first.

Secrets that are intentionally NOT installed by this script:
- ~/.claude/.credentials.json (Claude OAuth — created on `claude` login)
Expand All @@ -709,6 +717,7 @@ printf ' - CCC_WIKI_AGENT_BIN=%s\n' "$WIKI_AGENT_BIN"
printf ' - CCC_BRIDGE_DEFAULT_PATH=%s\n' "$BRIDGE_DEFAULT_PATH"
printf ' - CCC_CODEX_CLI_PATH=%s/hooks/ccc-codex\n' "$CLAUDE_DIR"
printf ' - CCC_CODEX_MEMORY_MATERIALIZER_PATH=%s/hooks/ccc_codex_memory.py\n' "$CLAUDE_DIR"
printf ' - CCC_PIRI_CLI_PATH=%s/hooks/ccc-piri\n' "$CLAUDE_DIR"
printf ' - CODEX_HOME=%s (GitHub plugin disabled; gh CLI-first)\n' "$CODEX_DIR"
printf ' - Codex managed skills=%s/skills (catalog: codex/compatibility.json)\n' "$CODEX_DIR"
printf ' - bridge command=./start.sh --path %s -d\n' "$BRIDGE_DEFAULT_PATH"
Expand Down