feat: scenarios for curator/memo-inbox/pi-grok, deploy and backup tooling

Scenarios
- memo-inbox: mirrored by copying; the live directory was not moved or modified
  and the service was not restarted. All four tracked files match byte for byte
  (pi-diff.sh reports SAME). Marked deploy = "mirror" so deploy-scenario.sh
  refuses --apply: applying a mirror would invert the direction of truth and
  could change a service in daily use.
- curator: target configuration, not yet deployed. .pi/SYSTEM.md replaces pi's
  coding-assistant prompt; durable role text is in .pi/APPEND_SYSTEM.md;
  profile.toml is the single source of truth for the launch contract.
- pi-grok: registered only. It is genuinely a coding agent, so the isolation
  baseline does not apply in full.

Corrections to the documentation, found by testing rather than by reading
- AGENTS.override.md does NOT block parent-directory context files; it only
  shadows its own directory. Verified: with an override file in the workspace, a
  marker in /tmp/AGENTS.md still reached the system prompt. The only effective
  switch is --no-context-files, so durable role text must live in
  .pi/APPEND_SYSTEM.md, which is a system-prompt file and unaffected by -nc.
  Verified end state: no coding-assistant framing, no pi-docs block, own
  identity and role text present, no parent pollution, only own skills/tools.
- PI_CODING_AGENT_DIR isolates settings/models/auth/trust/extensions/skills/
  prompts/themes under the agent directory -- stronger than the --no-* flags
  because it also repoints credentials -- but does NOT cover ~/.agents/skills.
  Measured: find-skills, modsearch and summarize still leak. So it complements
  --no-skills rather than replacing it.
- --append-system-prompt accepts a file path, which pi-grok relies on.
- cwd is what anchors .pi discovery: a probe that forgot cwd silently lost
  .pi/SYSTEM.md and kept the coding-assistant persona.

Tooling (all dry-run by default; none of them restarts a service)
- pi-diff.sh: compares tracked config against the live install in both
  directions, with a key-redacted comparison for models.json
- deploy-scenario.sh: installs a workspace and renders profile.toml into
  .pi/launch.json, then checks that every referenced path exists
- deploy-runtime.sh: renders models.json from its template, refusing placeholder
  or missing keys. Verified byte-identical to the live file
- pi-backup.sh / pi-restore.sh: archives outside the repo, sha256 manifest
  verified before any restore, live paths preserved rather than overwritten

Fixed while testing: pi-backup.sh compared the destination against the repo root
literally, so a relative --dest ./backups wrote credential archives into the work
tree. Now canonicalised with realpath; ./backups, an absolute in-repo path and
./docs/../backups are all refused.
This commit is contained in:
Kai
2026-08-26 23:17:12 -07:00
parent 7b5e0b093d
commit 07dd648b5f
20 changed files with 2611 additions and 17 deletions
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# Install a scenario's tracked workspace into its live location, and render the
# launch contract from profile.toml.
#
# Dry run by default. Never restarts a service: restarting is a decision about
# when to interrupt a live conversation, and belongs to the operator.
#
# Usage:
# scripts/deploy-scenario.sh <scenario> # show what would change
# scripts/deploy-scenario.sh <scenario> --apply
# scripts/deploy-scenario.sh --list
# ---------------------------------------------------------------------------
# shellcheck source=lib/common.sh
. "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh"
if [ "${1:-}" = "--list" ]; then
list_scenarios
exit 0
fi
NAME="${1:-}"
APPLY=0
[ "${2:-}" = "--apply" ] && APPLY=1
DIR="$(require_scenario "$NAME")"
PROFILE="$DIR/profile.toml"
[ -f "$PROFILE" ] || die "scenario '$NAME' has no profile.toml"
TRACKED="$(toml_get "$PROFILE" scenario tracked)"
if [ "$TRACKED" = "false" ]; then
die "scenario '$NAME' is registered but not tracked; nothing to deploy"
fi
# A scenario is either an authoritative source ("managed") or a record of what
# the live host already does ("mirror"). Applying a mirror would invert the
# direction of truth and, for a service in daily use, risks a behaviour change
# that nobody asked for. Mirrors are diffable but not deployable.
MODE="$(toml_get "$PROFILE" scenario deploy)"
MODE="${MODE:-managed}"
if [ "$MODE" = "mirror" ] && [ "$APPLY" -eq 1 ]; then
die "scenario '$NAME' is a mirror of the live configuration, not an authoritative source.
Refusing --apply. Use scripts/pi-diff.sh $NAME to compare, and promote the
scenario to deploy = \"managed\" in profile.toml once it is the source of truth."
fi
WORKSPACE="$(toml_get "$PROFILE" scenario workspace)"
[ -n "$WORKSPACE" ] || die "scenario '$NAME': [scenario].workspace is not set"
[ -d "$DIR/workspace" ] || die "scenario '$NAME' has no tracked workspace/"
head1 "deploy $NAME -> $WORKSPACE"
[ "$APPLY" -eq 1 ] || info "${C_DIM}(dry run; pass --apply to write)${C_OFF}"
# ---------------------------------------------------------------------------
# 1. Workspace files
# ---------------------------------------------------------------------------
CHANGES=0
while IFS= read -r rel; do
src="$DIR/workspace/$rel"
dst="$WORKSPACE/$rel"
if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
continue
fi
CHANGES=$((CHANGES + 1))
if [ -f "$dst" ]; then
info " update $rel"
else
info " create $rel"
fi
if [ "$APPLY" -eq 1 ]; then
install -d -m 700 "$(dirname "$dst")"
install -m 600 "$src" "$dst"
fi
done < <(cd "$DIR/workspace" && find . -type f -printf '%P\n' | sort)
# Executables under bin/ need the execute bit back.
if [ "$APPLY" -eq 1 ] && [ -d "$DIR/workspace/bin" ]; then
while IFS= read -r rel; do
[ -x "$DIR/workspace/$rel" ] && chmod 700 "$WORKSPACE/$rel"
done < <(cd "$DIR/workspace" && find bin -type f -printf '%P\n' 2>/dev/null | sed 's|^|bin/|')
fi
[ "$CHANGES" -eq 0 ] && ok "workspace already matches"
# ---------------------------------------------------------------------------
# 2. Launch contract
#
# profile.toml is the single source of truth for launch flags. Rendering it to
# JSON lets the gateway read the contract instead of hard-coding flags, and lets
# the gateway fail closed when the file is missing -- silently running without
# --no-extensions would widen the agent's reach.
# ---------------------------------------------------------------------------
if [ "$MODE" = "mirror" ]; then
head1 "launch contract"
info " ${C_DIM}skipped: '$NAME' is a mirror; its launch flags live in its own"
info " gateway, not in profile.toml${C_OFF}"
printf '\n'
ok "dry run complete (mirror scenario; nothing to apply)"
exit 0
fi
LAUNCH="$WORKSPACE/.pi/launch.json"
head1 "launch contract -> $LAUNCH"
RENDERED="$(python3 - "$PROFILE" "$WORKSPACE" <<'PY'
import json, sys, tomllib, pathlib
profile = tomllib.loads(pathlib.Path(sys.argv[1]).read_text())
workspace = pathlib.Path(sys.argv[2])
def rel(paths):
return [str(workspace / p) for p in paths or []]
iso = profile.get("isolation", {})
res = profile.get("resources", {})
pers = profile.get("personality", {})
out = {
"_generated_from": "profile.toml",
"_note": "Do not edit. Regenerate with scripts/deploy-scenario.sh.",
"scenario": profile.get("scenario", {}).get("name"),
"workspace": str(workspace),
"session_dir": profile.get("scenario", {}).get("session_dir"),
"model": profile.get("model", {}),
"session": profile.get("session", {}),
"isolation": iso,
"personality": {
"system_prompt": str(workspace / pers["system_prompt"]) if pers.get("system_prompt") else "",
"append_system_prompt": str(workspace / pers["append_system_prompt"]) if pers.get("append_system_prompt") else "",
"context_files": pers.get("context_files", []),
},
"resources": {
"extensions": rel(res.get("extensions")),
"skills": rel(res.get("skills")),
},
"tools": profile.get("tools", {}),
"budget": profile.get("budget", {}),
"env": profile.get("env", {}),
"bridge": profile.get("bridge", {}),
}
print(json.dumps(out, ensure_ascii=False, indent=2))
PY
)" || die "cannot render profile.toml"
if [ -f "$LAUNCH" ] && [ "$RENDERED" = "$(cat "$LAUNCH")" ]; then
ok "launch.json already current"
else
info " write .pi/launch.json"
if [ "$APPLY" -eq 1 ]; then
install -d -m 700 "$WORKSPACE/.pi"
printf '%s\n' "$RENDERED" > "$LAUNCH"
chmod 600 "$LAUNCH"
fi
fi
# ---------------------------------------------------------------------------
# 3. Referenced paths must exist
# ---------------------------------------------------------------------------
head1 "referenced resources"
MISSING=0
while IFS= read -r p; do
[ -n "$p" ] || continue
if [ -e "$p" ]; then ok "$p"; else warn "missing: $p"; MISSING=$((MISSING + 1)); fi
done < <(printf '%s\n' "$RENDERED" | python3 -c '
import json, sys
d = json.load(sys.stdin)
for p in d["resources"]["extensions"] + d["resources"]["skills"]:
print(p)
for key in ("system_prompt", "append_system_prompt"):
if d["personality"].get(key):
print(d["personality"][key])
')
printf '\n'
if [ "$APPLY" -eq 1 ]; then
ok "applied"
info ""
SERVICE="$(toml_get "$PROFILE" scenario service)"
if [ -n "$SERVICE" ]; then
info "The service was NOT restarted. When you are ready:"
info " systemctl --user restart $SERVICE"
info " systemctl --user status $SERVICE"
fi
else
info "${C_DIM}dry run complete; re-run with --apply${C_OFF}"
fi
[ "$MISSING" -eq 0 ] || warn "$MISSING referenced path(s) missing; the agent will not start correctly"
exit 0