diff --git a/AGENTS.md b/AGENTS.md index f505f88..5d8ed90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Before ending a session that changed team status, decisions, or work another per 1. Update `HANDOFF.md` (overwrite; keep it current, not a diary) 2. Update `STATUS.md` if in-flight work changed 3. Append to `DECISIONS.md` only for a real decision -4. Commit and push this repo +4. Follow `skills/zero-state-context/SKILL.md` Safe shared-context workflow: reconcile freshness, review named staged files for secrets, commit locally, and push only with explicit current authorization. Verify the exact remote commit afterward. ## Do not diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 5dcd31b..528a9d0 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -13,7 +13,7 @@ If history matters, append a dated note to `DECISIONS.md` or the product repo in ## Pull before you write -Someone else may have pushed. `git pull --ff-only` in `~/agent-context` before editing. +Someone else may have pushed. Follow `skills/zero-state-context/SKILL.md` Safe shared-context workflow: clean-tree checks, confirmed upstream, freshness before and after drafting, reviewed named staging, authorized publication and exact remote read-back. Stop on divergence. ## Size diff --git a/README.md b/README.md index 62beec2..b5950dc 100644 --- a/README.md +++ b/README.md @@ -27,14 +27,27 @@ gh repo clone Zero-State-LLC/agent-context ~/agent-context ~/agent-context/install.sh ``` -Then `git pull` in `~/agent-context` at the start of a working day. After a session that changed team status, update the files here and `git push`. +Follow the [safe shared-context workflow](skills/zero-state-context/SKILL.md) before reading or drafting. Publication is separately authorized, reviewed, and verified; installation does not grant push authority. -`install.sh` only **symlinks** the shared rule and skill into your local Grok / Claude / Cursor dirs (`alwaysApply` on the Cursor `.mdc`). It does not copy personal memory. +`install.sh` only **symlinks** the shared rule and skill into your local Hermes / Grok / Claude / Cursor dirs (`alwaysApply` on the Cursor `.mdc`). It does not copy personal memory. Cursor Cloud VMs do not inherit `~/.cursor`. Clone this repo (or `gh repo clone Zero-State-LLC/agent-context ~/agent-context`) and read `HANDOFF.md` at session start. +## Hermes only + +With Bash available, run `bash install.sh --hermes-only --dry-run` from the +reviewed checkout, inspect the destination, then `bash install.sh --hermes-only`. +The skill is linked at `${HERMES_HOME:-$HOME/.hermes}/skills/zero-state-context`. +Keep the clone at its installed path; restart Hermes and load +`skill_view(name="zero-state-context")`, checking its returned skill_dir. +Existing unrelated destinations are refused, not overwritten. To migrate, +review the old link/package and move it outside discovery before reinstalling. +`--dry-run` works for the multi-host default too and writes nothing. + ## What stays private - `~/.grok/memory/`, Claude/Cursor/OpenClaw session logs - Preferences, unfinished rabbit holes - Tokens, keys, `.env`, anything that could authenticate + +Skill source selection, migration and safe checks: [SKILL_PROVENANCE.md](SKILL_PROVENANCE.md). diff --git a/SKILL_PROVENANCE.md b/SKILL_PROVENANCE.md new file mode 100644 index 0000000..623d96a --- /dev/null +++ b/SKILL_PROVENANCE.md @@ -0,0 +1,30 @@ +# Skill source provenance and verification + +Before installation record `git remote get-url origin`, `git rev-parse HEAD`, +skill path/name/version, and resolved host/profile target. Repository names or +identical triggers do not prove two packs are interchangeable. The operator must +choose the source/version; this document makes no new canonical ownership choice. +Preserve existing domain authority distinctions (especially SUAS specification +versus runtime conformance). Do not install both same-named variants into one +profile without a deliberate selection. Avoid globally installing whole project +packs when repository-local discovery is enough. + +Before migration compare source contents and dependencies, preserve the old +package/link and mutable state outside discovery, and record old/new commit and +target. Verify the selected new package before retiring the old discovery entry. +Deprecation requires owner approval and an explicit replacement source/commit; +do not silently delete another profile's copy or infer retirement from age. + +## Safe local checks + +Run from this checkout; Python 3.11+, pytest for pytest commands, and +PyYAML for SUAS metadata tests (`python3 -m pip install pytest PyYAML` in a +disposable venv). No live enrollment, deploy, or publication is needed. + +```bash +python3 -m pytest tests/test_skill_install.py -q +``` + +Shared synchronization remains a human/agent procedure, not an executable guard. +Installer tests do not certify concurrent editing or remote publication. Review +the baseline SHA, outgoing content and current authorization on each handoff. diff --git a/install.sh b/install.sh index 26bb351..39413a7 100755 --- a/install.sh +++ b/install.sh @@ -6,30 +6,70 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")" && pwd)" RULE_SRC="$ROOT/rules/zero-state-context.md" SKILL_SRC="$ROOT/skills/zero-state-context" +DRY_RUN=0 +HERMES_ONLY=0 +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=1 ;; + --hermes-only) HERMES_ONLY=1 ;; + --help) printf '%s\n' 'Usage: install.sh [--hermes-only] [--dry-run]'; exit 0 ;; + *) printf 'Unknown argument: %s\n' "$arg" >&2; exit 2 ;; + esac +done + +reject_symlink_ancestors() { + local parent + parent="$(dirname "$1")" + # Do not canonicalize first: that would hide profile/skills redirects. + while :; do + if [[ -L "$parent" ]]; then + printf 'Refusing symlink ancestor: %s\n' "$parent" >&2 + exit 1 + fi + [[ "$parent" != / && "$parent" != . ]] || break + parent="$(dirname "$parent")" + done +} link_file() { local src="$1" dest="$2" + reject_symlink_ancestors "$dest" + if [[ -e "$dest" || -L "$dest" ]]; then + if [[ -L "$dest" && "$(readlink "$dest")" == "$src" ]]; then + printf 'already linked %s\n' "$dest" + return + fi + printf 'Refusing to replace existing destination: %s\n' "$dest" >&2 + exit 1 + fi + if [[ "$DRY_RUN" == 1 ]]; then + printf '[dry-run] link %s -> %s\n' "$dest" "$src" + return + fi mkdir -p "$(dirname "$dest")" - ln -sfn "$src" "$dest" + reject_symlink_ancestors "$dest" + ln -s "$src" "$dest" echo "linked $dest" } +# Hermes is profile-scoped; keep the source checkout at this path. +link_file "$SKILL_SRC" "${HERMES_HOME:-$HOME/.hermes}/skills/zero-state-context" +[[ "$HERMES_ONLY" == 0 ]] || exit 0 + # Grok link_file "$RULE_SRC" "${GROK_HOME:-$HOME/.grok}/rules/zero-state-context.md" link_file "$SKILL_SRC" "${GROK_HOME:-$HOME/.grok}/skills/zero-state-context" # Claude Code (create dirs even if unused yet) -mkdir -p "$HOME/.claude/rules" "$HOME/.claude/skills" link_file "$RULE_SRC" "$HOME/.claude/rules/zero-state-context.md" link_file "$SKILL_SRC" "$HOME/.claude/skills/zero-state-context" # Cursor local IDE — user-level alwaysApply rule (.mdc) + skill -mkdir -p "$HOME/.cursor/rules" "$HOME/.cursor/skills" link_file "$RULE_SRC" "$HOME/.cursor/rules/zero-state-context.mdc" link_file "$SKILL_SRC" "$HOME/.cursor/skills/zero-state-context" echo echo "Clone path: $ROOT" -echo "Pull this repo at the start of a working day. Push after a real handoff." +echo "Follow the skill safe-sync workflow; publishing requires current user authorization." echo "Personal memory stays local — do not copy ~/.grok/memory into git." echo "Cursor Cloud does not see ~/.cursor; it must clone this repo or read HANDOFF via gh." diff --git a/rules/zero-state-context.md b/rules/zero-state-context.md index 39179f9..edb8e8d 100644 --- a/rules/zero-state-context.md +++ b/rules/zero-state-context.md @@ -9,15 +9,9 @@ Team knowledge for Zero State LLC lives in the git repo cloned at `~/agent-conte This rule applies in Cursor, Claude Code, Grok, Codex, and OpenClaw. Personal memory and chat logs are not the sync channel. -At session start, when the task involves org status, a partner handoff, PLAY/Noema, or work another Zero State agent may have touched: - -1. `git -C ~/agent-context pull --ff-only` if the clone exists -2. Read `~/agent-context/HANDOFF.md` -3. Read `~/agent-context/STATUS.md` -4. Read `DECISIONS.md` only if a prior choice affects the task - -If `~/agent-context` is missing: `gh repo clone Zero-State-LLC/agent-context ~/agent-context && ~/agent-context/install.sh` - -Before ending a session that changed team status, overwrite `HANDOFF.md`, update `STATUS.md`, append `DECISIONS.md` only for a real decision, then commit and push `~/agent-context`. - -Personal memory, session logs, and secrets do not go in that repo. Product code maps stay in each product repo's `graft/` and `AGENTS.md`. +Use the [shared-context skill](../skills/zero-state-context/SKILL.md)'s +Safe shared-context workflow: confirm repo/remote/branch, require a clean tree, +fetch and fast-forward before reading/drafting, reconcile concurrent handoffs, +review precisely staged files for secrets, and publish only with current user +authorization. Verify the exact remote commit after an authorized push. +Do not copy personal memory, raw transcripts, or secrets into team context. diff --git a/skills/zero-state-context/SKILL.md b/skills/zero-state-context/SKILL.md index 4eb11dc..20e53ac 100644 --- a/skills/zero-state-context/SKILL.md +++ b/skills/zero-state-context/SKILL.md @@ -11,19 +11,46 @@ Team durable knowledge is the git repo at `~/agent-context` (or `$ZERO_STATE_CON Any session that continues org work, partner work, or a prior agent session. -Read, in order: - -1. `HANDOFF.md` -2. `STATUS.md` -3. `DECISIONS.md` only if a prior choice affects this task - -## When to write - -If this session changed what the other person or another model must know: - -1. Overwrite `HANDOFF.md` with the current baton -2. Update `STATUS.md` if in-flight work changed -3. Append a dated entry to `DECISIONS.md` only for a real decision -4. `git pull --ff-only && git add … && git commit && git push` in that repo - -Do not write secrets, personal preferences, or raw transcripts there. +## Safe shared-context workflow + +Resolve `${ZERO_STATE_CONTEXT:-$HOME/agent-context}` to the intended repository. +Confirm `git remote -v`, the named branch (`git branch --show-current`), its +upstream (`git rev-parse --abbrev-ref '@{upstream}'`), and the user's allowed +publication scope. Stop if detached, no upstream, wrong remote, or any dirty or +staged changes (`git status --porcelain`); never stash or discard teammates' work. + +Before reading or drafting, fetch the confirmed remote and fast-forward only: +`git fetch ` then `git merge --ff-only '@{upstream}'`. +Stop on divergence or fetch failure; offline context must be labeled stale. +Require `git rev-parse HEAD` to equal `git rev-parse '@{upstream}'` after +fast-forwarding; stop on unreviewed local-ahead commits so they cannot hitchhike. +Coordinate one local editor per clone; if another editor changes the draft, +repeat content review and obtain fresh authorization. +Record `git rev-parse HEAD` as the baseline. Read HANDOFF.md, STATUS.md, and +DECISIONS.md only as relevant. Reconcile the partner's baton; do not blindly +replace content just because a textual merge would succeed. + +Draft only changes needed for the present handoff. HANDOFF.md stays concise, +STATUS.md tracks in-flight work, and DECISIONS.md is append-only for real decisions. +Before committing, fetch again and compare `git rev-parse '@{upstream}'` with the +recorded baseline. If it moved, stop: preserve the draft separately, reconcile +against the new handoff with the operator, and repeat review. No force push. + +Stage only the actual named context files, for example +`git add -- HANDOFF.md STATUS.md` (include DECISIONS.md only when edited). +Review `git diff --cached --check`, `git diff --cached --name-only`, and the full +`git diff --cached` for accidental deletion, secrets, tokens, personal data and +raw transcripts. Do not commit until the reviewed file list and content match +scope. If secrets are found, stop and arrange rotation; never publish them. +Commit locally. Publication requires explicit current user authorization for the +confirmed remote and branch; without it report the local commit and do not push. + +When authorized, push the explicit destination: +`git push HEAD:refs/heads/`. +On rejection, stop and reconcile; do not force. Read back that exact remote ref +with `git ls-remote refs/heads/` and compare +the returned SHA to `git rev-parse HEAD`. Only an exact match verifies publication; +a newer/different SHA needs reconciliation, not a success claim. + +These are operator-resolved placeholders, not literal commands to paste before +confirming repository/remote/branch. No network publication is part of installation. diff --git a/tests/test_skill_install.py b/tests/test_skill_install.py new file mode 100644 index 0000000..841b4f0 --- /dev/null +++ b/tests/test_skill_install.py @@ -0,0 +1,59 @@ +import os +from pathlib import Path +import subprocess +import sys +ROOT = Path(__file__).resolve().parents[1] + +import pytest + +@pytest.mark.parametrize("component", ["skills", "profile", "leaf"]) +@pytest.mark.parametrize("dry_run", [False, True]) +def test_symlink_redirect_is_refused(tmp_path, component, dry_run): + profile = tmp_path / "home/profile" + other = tmp_path / "home/other" + other.mkdir(parents=True) + (other / "sentinel").write_text("keep") + if component == "profile": + profile.symlink_to(other, target_is_directory=True) + else: + profile.mkdir() + if component == "skills": + (profile / "skills").symlink_to(other, target_is_directory=True) + else: + (profile / "skills").mkdir() + (profile / "skills" / 'zero-state-context').symlink_to(other, target_is_directory=True) + env = dict(os.environ, HOME=str(tmp_path / "home"), HERMES_HOME=str(profile)) + command = ["bash", str(ROOT / "install.sh"), "--hermes-only"] + result = subprocess.run(command + (["--dry-run"] if dry_run else []), + cwd=tmp_path, env=env, capture_output=True, text=True) + assert result.returncode != 0, result.stdout + assert "symlink" in result.stderr.lower(), result.stderr + assert (other / "sentinel").read_text() == "keep" + assert sorted(p.name for p in other.iterdir()) == ["sentinel"] + + +def test_profile_install(tmp_path): + env = dict(os.environ, HOME=str(tmp_path / "home"), HERMES_HOME=str(tmp_path / "profile")) + command = ['bash', str(ROOT / 'install.sh'), '--hermes-only'] + result = subprocess.run(command + ["--dry-run"], cwd=tmp_path, env=env, capture_output=True) + assert result.returncode == 0, result.stderr + assert not (tmp_path / "profile").exists() + assert not (tmp_path / "home").exists() + result = subprocess.run(command, cwd=tmp_path, env=env, capture_output=True) + assert result.returncode == 0, result.stderr + dest = tmp_path / "profile/skills/zero-state-context" + assert (dest / "SKILL.md").read_text().startswith("---\n") + for source in (ROOT / "skills/zero-state-context").rglob("*"): + if source.is_file(): + assert (dest / source.relative_to(ROOT / "skills/zero-state-context")).read_bytes() == source.read_bytes() + assert not (tmp_path / "home").exists() + +def test_existing_directory_is_preserved(tmp_path): + dest = tmp_path / "profile/skills/zero-state-context" + dest.mkdir(parents=True) + (dest / "sentinel").write_text("keep") + result = subprocess.run(['bash', str(ROOT / 'install.sh'), '--hermes-only'], + env=dict(os.environ, HOME=str(tmp_path / "home"), HERMES_HOME=str(tmp_path / "profile")), + capture_output=True) + assert result.returncode != 0 + assert (dest / "sentinel").read_text() == "keep"