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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ work/
.DS_Store
docs/superpowers/
.codegraph
__pycache__/
*.pyc
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ The shell environment for this workspace expects commands to be prefixed with
- Keep interfaces small and deep. Callers should use runtime exports instead of
reaching into internal files unnecessarily.
- Use Zod for manifest and user-provided JSON validation.
- Use Zod schemas to replace `any` types with validated `unknown` inputs and
inferred TypeScript types.
- Keep onboarding deterministic and safe to run repeatedly in a scratch
directory.
- Do not add live Codex, Claude, network, or filesystem side effects to core
Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Changelog

## Unreleased - 2026-06-23

### Added

- Added opt-in `instruction_context` metadata for Pony Trail snapshots. Snapshot entries can now record hashed instruction files, skill metadata, git branch/commit/dirty state, and warnings without storing prompt text, transcript text, or instruction file contents.
- Added `ponytrail skills update <skill>` to refresh installed skills through the same local-history flow as skill installation.
- Added `--instruction-context` and `PONYTRAIL_INSTRUCTION_CONTEXT=1` support to the bundled `pony-trail` shell and Python snapshot helpers.
- Added CLI history detail output for captured `instruction_context` blocks.

### Changed

- `ponytrail onboard` now refreshes stale existing bundled `pony-trail` skill installs instead of leaving old skill files in place.
- Skill updates now compare installed targets against the bundled source and report `already_present` when the target already matches.
- Skill install/update history now uses operation-specific local snapshot ids such as `skill-install` and `skill-update`.
- Agent guidance now calls for Zod schemas to replace `any` types with validated `unknown` inputs and inferred TypeScript types.

### Fixed

- Fixed stale installed skills staying unchanged after `onboard` when the bundled `pony-trail` skill has newer instructions or helper scripts.
- Removed the generated Python bytecode artifact from the bundled skill source.

### Tested

- Added coverage for instruction-context capture, warning handling, git metadata, shell helper opt-in behavior, Python fallback behavior, CLI history rendering, `skills update`, and onboard skill refresh.
- Verified with `rtk bun run check`.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ bunx ponytrail skills install pony-trail
The installer records a local skill-install snapshot before writing agent skill
files, so the install can be found later in `ponytrail history --details`.

Refresh an installed bundled skill later:

```bash
npx ponytrail skills update pony-trail
```

## View History

Show the snapshot tree:
Expand Down
1 change: 0 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.0/schema.json",
"formatter": {
"enabled": true,
"indentStyle": "space",
Expand Down
8 changes: 6 additions & 2 deletions bundled-skills/pony-trail/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@ Core principle: no file edit without a pre-change decision snapshot and a post-c

Use `scripts/snapshot_change.sh` from this skill directory. It writes JSONL under `.pony-trail/`, stores file hashes plus small before/after copies, and appends a per-session git-like tree under `.pony-trail/sessions/<session-id>/tree.md` without requiring Python. `scripts/snapshot_change.py` remains available for older environments that already call it.

Add `--instruction-context` or set `PONYTRAIL_INSTRUCTION_CONTEXT=1` to store a compact local-only `instruction_context` block on the snapshot entry. The block hashes allowlisted instruction files and skill metadata, records branch/commit/dirty state, and stores warnings for missing or unreadable context. It never stores prompt text, transcript text, or instruction file contents.

Pre-change:

```bash
sh /path/to/pony-trail/scripts/snapshot_change.sh pre \
sh /path/to/pony-trail/scripts/snapshot_change.sh \
--session-id "${PONYTRAIL_SESSION_ID:-${Ponytrail_SESSION_ID:-default}}" \
pre \
--files src/example.ts \
--action "edit validation" \
--purpose "Reject empty names before saving" \
Expand All @@ -41,8 +44,9 @@ sh /path/to/pony-trail/scripts/snapshot_change.sh pre \
Post-change:

```bash
sh /path/to/pony-trail/scripts/snapshot_change.sh post \
sh /path/to/pony-trail/scripts/snapshot_change.sh \
--session-id "${PONYTRAIL_SESSION_ID:-${Ponytrail_SESSION_ID:-default}}" \
post \
--snapshot-id 20260621T120000Z-abc12345 \
--files src/example.ts \
--summary "Added empty-name guard and test coverage" \
Expand Down
86 changes: 85 additions & 1 deletion bundled-skills/pony-trail/scripts/snapshot_change.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import argparse
import hashlib
import json
import os
import secrets
import shutil
import subprocess
Expand Down Expand Up @@ -55,6 +56,78 @@ def sha256(path: Path) -> str:
return digest.hexdigest()


def hash_string(value: str) -> str:
return "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest()


def instruction_git_info(root: Path) -> dict[str, str | bool | None]:
status = run_git(root, "status", "--porcelain")
return {
"branch": run_git(root, "branch", "--show-current"),
"commit": run_git(root, "rev-parse", "--short", "HEAD"),
"dirty": None if status is None else bool(status),
}


def instruction_file(root: Path, rel: str) -> dict[str, object]:
path = root / rel
if not path.exists():
return {"path": rel, "status": "missing"}
if not path.is_file():
return {"path": rel, "status": "unreadable"}

content = path.read_bytes()
return {
"path": rel,
"status": "captured",
"sha256": "sha256:" + hashlib.sha256(content).hexdigest(),
"bytes": len(content),
}


def discover_cursor_rules(root: Path) -> list[str]:
rules = root / ".cursor" / "rules"
if not rules.exists():
return []
return sorted(path.relative_to(root).as_posix() for path in rules.rglob("*") if path.is_file())


def installed_skill_metadata() -> dict[str, object]:
skill_dir = Path(__file__).resolve().parent.parent
skill_file = skill_dir / "SKILL.md"
if not skill_file.exists():
return {"name": skill_dir.name, "status": "missing"}
if not skill_file.is_file():
return {"name": skill_dir.name, "status": "unreadable"}

return {
"name": skill_dir.name,
"status": "captured",
"version_or_sha256": "sha256:" + sha256(skill_file),
}


def instruction_context(root: Path, session_id: str, timestamp_utc: str) -> dict[str, object]:
files = [instruction_file(root, rel) for rel in discover_cursor_rules(root)]
files.extend(
[
instruction_file(root, ".github/copilot-instructions.md"),
instruction_file(root, "AGENTS.md"),
instruction_file(root, "CLAUDE.md"),
]
)

return {
"mode": "opt_in",
"captured_at": timestamp_utc,
"session_id_hash": hash_string(session_id),
"git": instruction_git_info(root),
"files": files,
"skills": [installed_skill_metadata()],
"warnings": [],
}


def resolve_file(root: Path, raw_path: str) -> tuple[Path, str]:
path = Path(raw_path).expanduser()
if not path.is_absolute():
Expand Down Expand Up @@ -104,15 +177,17 @@ def write_entry(args: argparse.Namespace) -> dict[str, object]:
store = (root / args.store).resolve()
store.mkdir(parents=True, exist_ok=True)
snapshot_id = args.snapshot_id or f"{utc_now()}-{secrets.token_hex(4)}"
timestamp_utc = datetime.now(timezone.utc).isoformat()
files = [
file_state(root, raw_path, store, snapshot_id, args.phase, args.copy_limit)
for raw_path in args.files
]

entry = {
"snapshot_id": snapshot_id,
"session_id": args.session_id,
"phase": args.phase,
"timestamp_utc": datetime.now(timezone.utc).isoformat(),
"timestamp_utc": timestamp_utc,
"cwd": str(root),
"git": git_info(root),
"action": getattr(args, "action", None),
Expand All @@ -127,6 +202,9 @@ def write_entry(args: argparse.Namespace) -> dict[str, object]:
"files": files,
}

if args.instruction_context:
entry["instruction_context"] = instruction_context(root, args.session_id, timestamp_utc)

log_path = store / "snapshots.jsonl"
with log_path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(entry, sort_keys=True) + "\n")
Expand All @@ -138,6 +216,12 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--root", default=".", help="Workspace root to snapshot. Defaults to current directory.")
parser.add_argument("--store", default=DEFAULT_STORE, help="Snapshot directory relative to root.")
parser.add_argument("--copy-limit", type=int, default=DEFAULT_COPY_LIMIT, help="Maximum bytes to copy per file.")
parser.add_argument("--session-id", default=os.environ.get("PONYTRAIL_SESSION_ID", "default"))
parser.add_argument(
"--instruction-context",
action="store_true",
default=(os.environ.get("PONYTRAIL_INSTRUCTION_CONTEXT") or "").lower() in {"1", "true", "yes"},
)

subparsers = parser.add_subparsers(dest="phase", required=True)
pre = subparsers.add_parser("pre", help="Record intent and original file state before a mutation.")
Expand Down
Loading
Loading