From 4c467609ed41350841eb81b50ea04566c3174a2b Mon Sep 17 00:00:00 2001 From: 0xRoy <1997roylee@gmail.com> Date: Tue, 23 Jun 2026 01:39:56 +0800 Subject: [PATCH 1/7] Add opt-in instruction context snapshots --- bundled-skills/pony-trail/SKILL.md | 8 +- .../snapshot_change.cpython-310.pyc | Bin 0 -> 8592 bytes .../pony-trail/scripts/snapshot_change.py | 86 +++++- .../pony-trail/scripts/snapshot_change.sh | 144 +++++++++- docs/architecture.md | 2 + src/cli.ts | 39 +++ src/runtimes/ponytrail/index.ts | 1 + src/runtimes/ponytrail/instruction-context.ts | 270 ++++++++++++++++++ src/runtimes/ponytrail/snapshots.ts | 210 +++++++++++++- tests/cli.test.ts | 32 +++ tests/instruction-context.test.ts | 179 ++++++++++++ tests/pony-trail-script.test.ts | 223 +++++++++++++++ tests/snapshots.test.ts | 59 ++++ 13 files changed, 1243 insertions(+), 10 deletions(-) create mode 100644 bundled-skills/pony-trail/scripts/__pycache__/snapshot_change.cpython-310.pyc create mode 100644 src/runtimes/ponytrail/instruction-context.ts create mode 100644 tests/instruction-context.test.ts diff --git a/bundled-skills/pony-trail/SKILL.md b/bundled-skills/pony-trail/SKILL.md index 26a43984..6aaed492 100644 --- a/bundled-skills/pony-trail/SKILL.md +++ b/bundled-skills/pony-trail/SKILL.md @@ -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//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" \ @@ -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" \ diff --git a/bundled-skills/pony-trail/scripts/__pycache__/snapshot_change.cpython-310.pyc b/bundled-skills/pony-trail/scripts/__pycache__/snapshot_change.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0d73b875f9b0edbfea924a3e114b795397573cbf GIT binary patch literal 8592 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 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(): @@ -104,6 +177,7 @@ 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 @@ -111,8 +185,9 @@ def write_entry(args: argparse.Namespace) -> dict[str, object]: 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), @@ -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") @@ -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.") diff --git a/bundled-skills/pony-trail/scripts/snapshot_change.sh b/bundled-skills/pony-trail/scripts/snapshot_change.sh index e3b0a76d..3f809404 100755 --- a/bundled-skills/pony-trail/scripts/snapshot_change.sh +++ b/bundled-skills/pony-trail/scripts/snapshot_change.sh @@ -6,7 +6,7 @@ DEFAULT_COPY_LIMIT=1048576 usage() { cat <<'EOF' -usage: snapshot_change.sh [--root DIR] [--store DIR] [--copy-limit BYTES] [--session-id ID] {pre,post} ... +usage: snapshot_change.sh [--root DIR] [--store DIR] [--copy-limit BYTES] [--session-id ID] [--instruction-context] {pre,post} ... Record file-change rationale snapshots. @@ -114,6 +114,16 @@ sha256_file() { fi } +hash_string() { + if command -v shasum >/dev/null 2>&1; then + printf '%s' "$1" | shasum -a 256 | awk '{print "sha256:" $1}' + elif command -v sha256sum >/dev/null 2>&1; then + printf '%s' "$1" | sha256sum | awk '{print "sha256:" $1}' + else + fail "Neither shasum nor sha256sum is available." + fi +} + dirname_of() { dirname "$1" } @@ -223,6 +233,118 @@ git_json() { printf '}' } +instruction_git_json() { + if ! command -v git >/dev/null 2>&1; then + printf '{"branch":null,"commit":null,"dirty":null}' + return + fi + + git_root=$(git -C "$root" rev-parse --show-toplevel 2>/dev/null || true) + if [ -z "$git_root" ]; then + printf '{"branch":null,"commit":null,"dirty":null}' + return + fi + + git_branch=$(git -C "$root" branch --show-current 2>/dev/null || true) + git_head=$(git -C "$root" rev-parse --short HEAD 2>/dev/null || true) + git_dirty=$(git -C "$root" status --porcelain 2>/dev/null || true) + printf '{"branch":' + json_optional_string "$git_branch" + printf ',"commit":' + json_optional_string "$git_head" + printf ',"dirty":' + if [ -n "$git_dirty" ]; then + printf 'true' + else + printf 'false' + fi + printf '}' +} + +instruction_file_json() { + rel=$1 + path=$root/$rel + + printf '{"path":' + json_string "$rel" + + if [ ! -e "$path" ]; then + printf ',"status":"missing"}' + return + fi + + if [ ! -f "$path" ] || [ ! -r "$path" ]; then + printf ',"status":"unreadable"}' + return + fi + + size=$(stat_size "$path") + digest=$(sha256_file "$path") + printf ',"status":"captured","sha256":' + json_string "sha256:$digest" + printf ',"bytes":%s}' "$size" +} + +instruction_skill_json() { + skill_file=$skill_dir/SKILL.md + + printf '{"name":' + json_string "$skill_name" + + if [ ! -e "$skill_file" ]; then + printf ',"status":"missing"}' + return + fi + + if [ ! -f "$skill_file" ] || [ ! -r "$skill_file" ]; then + printf ',"status":"unreadable"}' + return + fi + + digest=$(sha256_file "$skill_file") + printf ',"status":"captured","version_or_sha256":' + json_string "sha256:$digest" + printf '}' +} + +append_instruction_file_json() { + file_json=$(instruction_file_json "$1") + if [ -n "$instruction_files_json" ]; then + instruction_files_json="$instruction_files_json,$file_json" + else + instruction_files_json="$file_json" + fi +} + +collect_instruction_context() { + instruction_files_json="" + + if [ -d "$root/.cursor/rules" ]; then + cursor_paths_file=$store_path/.instruction-context-files.$$ + find "$root/.cursor/rules" -type f | LC_ALL=C sort >"$cursor_paths_file" + while IFS= read -r path; do + [ -n "$path" ] || continue + rel=${path#"$root"/} + append_instruction_file_json "$rel" + done <"$cursor_paths_file" + rm -f "$cursor_paths_file" + fi + + append_instruction_file_json ".github/copilot-instructions.md" + append_instruction_file_json "AGENTS.md" + append_instruction_file_json "CLAUDE.md" + + session_hash=$(hash_string "$session_id") + skill_json=$(instruction_skill_json) + printf '{"mode":"opt_in","captured_at":' + json_string "$timestamp_utc" + printf ',"session_id_hash":' + json_string "$session_hash" + printf ',"git":' + instruction_git_json + printf ',"files":[%s],"skills":[%s],"warnings":[]}' "$instruction_files_json" "$skill_json" +} + entry_json() { printf '{"snapshot_id":' json_string "$snapshot_id" @@ -254,7 +376,11 @@ entry_json() { json_optional_string "$checks" printf ',"result":' json_optional_string "$result" - printf ',"files":[%s]}' "$files_json" + printf ',"files":[%s]' "$files_json" + if [ -n "$instruction_context_json" ]; then + printf ',"instruction_context":%s' "$instruction_context_json" + fi + printf '}' } ensure_session_tree() { @@ -346,6 +472,11 @@ rollback="" summary="" checks="" result="" +instruction_context="${PONYTRAIL_INSTRUCTION_CONTEXT:-}" +instruction_context_json="" +script_dir=$(cd "$(dirname_of "$0")" 2>/dev/null && pwd -P || true) +skill_dir=$(dirname_of "$script_dir") +skill_name=$(basename_of "$skill_dir") while [ "$#" -gt 0 ]; do case "$1" in @@ -369,6 +500,10 @@ while [ "$#" -gt 0 ]; do session_id=$2 shift 2 ;; + --instruction-context) + instruction_context=1 + shift + ;; pre|post) phase=$1 shift @@ -497,6 +632,11 @@ IFS=$old_ifs log_path=$store_path/snapshots.jsonl timestamp_utc=$(utc_iso) +if [ "$instruction_context" = "1" ] || + [ "$instruction_context" = "true" ] || + [ "$instruction_context" = "yes" ]; then + instruction_context_json=$(collect_instruction_context) +fi entry=$(entry_json) printf '%s\n' "$entry" >>"$log_path" ensure_session_tree diff --git a/docs/architecture.md b/docs/architecture.md index 4da9e940..81911e7d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -36,6 +36,8 @@ src/ - read Pony Trail snapshot history and plan file-level reverts - print visible role-bot discussion before worker execution is allowed +Snapshot entries may also include opt-in `instruction_context` metadata. This metadata belongs to the snapshot system, not a separate audit log: it stores hashes and statuses for allowlisted instruction files plus compact git/session context so history can explain rule changes without recording prompts or file contents. + The CLI should call this runtime through its exported interface instead of knowing the internals of goal drafting or vote tallying. ## Models diff --git a/src/cli.ts b/src/cli.ts index 07a2d38f..54524be3 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,6 +14,7 @@ import { import { applySnapshotRevert, createOnboardingFiles, + type InstructionContext, loadManifest, planSnapshotRevert, prepareGoalDiscussion, @@ -433,6 +434,8 @@ function printSnapshotCommitDetails(commit: SnapshotCommit): void { for (const file of commit.files) { console.log(` ${formatSnapshotFile(file)}`); } + printInstructionContextDetails("pre", commit.instructionContexts.pre); + printInstructionContextDetails("post", commit.instructionContexts.post); } function parseSnapshotHistoryMode(mode: string): SnapshotHistoryMode { @@ -460,6 +463,42 @@ function formatSnapshotFile(file: SnapshotFileState): string { return file.exists ? `file: ${file.path}` : `missing before: ${file.path}`; } +function printInstructionContextDetails( + phase: "pre" | "post", + context: InstructionContext | undefined, +): void { + if (!context) { + return; + } + + console.log(` instruction_context: ${phase}`); + const git = [ + context.git.branch, + context.git.commit, + context.git.dirty === undefined ? undefined : context.git.dirty ? "dirty" : "clean", + ] + .filter(Boolean) + .join(" "); + if (git) { + console.log(` git: ${git}`); + } + + for (const file of context.files) { + const hash = file.sha256 ? ` ${file.sha256.slice(0, 15)}` : ""; + const bytes = file.bytes === undefined ? "" : ` ${file.bytes} bytes`; + console.log(` ${file.path} ${file.status}${hash}${bytes}`); + } + + for (const skill of context.skills) { + const version = skill.version_or_sha256 ? ` ${skill.version_or_sha256.slice(0, 15)}` : ""; + console.log(` skill ${skill.name} ${skill.status}${version}`); + } + + for (const warning of context.warnings) { + console.log(` warning: ${warning}`); + } +} + function printSnapshotRevertPlan(actions: SnapshotRevertAction[], dryRun: boolean): void { console.log(pc.cyan("Snapshot revert plan")); for (const action of actions) { diff --git a/src/runtimes/ponytrail/index.ts b/src/runtimes/ponytrail/index.ts index aa17df4f..b85fe7d9 100644 --- a/src/runtimes/ponytrail/index.ts +++ b/src/runtimes/ponytrail/index.ts @@ -1,5 +1,6 @@ export * from "./brainstorm"; export * from "./goal"; +export * from "./instruction-context"; export * from "./manifest"; export * from "./onboarding"; export * from "./requirement-court"; diff --git a/src/runtimes/ponytrail/instruction-context.ts b/src/runtimes/ponytrail/instruction-context.ts new file mode 100644 index 00000000..9994862b --- /dev/null +++ b/src/runtimes/ponytrail/instruction-context.ts @@ -0,0 +1,270 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import { access, readdir, readFile, stat } from "node:fs/promises"; +import { join, relative, resolve, sep } from "node:path"; + +export type InstructionContextMode = "opt_in"; +export type InstructionContextFileStatus = "captured" | "missing" | "unreadable"; + +export interface InstructionContextFile { + path: string; + status: InstructionContextFileStatus; + sha256?: string | undefined; + bytes?: number | undefined; +} + +export interface InstructionContextSkill { + name: string; + status: InstructionContextFileStatus; + path?: string | undefined; + version_or_sha256?: string | undefined; +} + +export interface InstructionContextGit { + branch?: string | undefined; + commit?: string | undefined; + dirty?: boolean | undefined; +} + +export interface InstructionContext { + mode: InstructionContextMode; + captured_at: string; + session_id_hash: string; + git: InstructionContextGit; + files: InstructionContextFile[]; + skills: InstructionContextSkill[]; + warnings: string[]; +} + +export interface CaptureInstructionContextInput { + rootDir: string; + sessionId: string; + timestampUtc?: string | undefined; + git?: InstructionContextGit | undefined; +} + +export interface ShouldCaptureInstructionContextInput { + instructionContext?: boolean | undefined; + env?: Record | undefined; +} + +const rootInstructionFiles = ["AGENTS.md", "CLAUDE.md"]; +const singleInstructionFiles = [".github/copilot-instructions.md"]; +const globInstructionDirs = [".cursor/rules"]; +const skillMetadataDirs = ["bundled-skills", ".codex/skills", ".agents/skills", ".claude/skills"]; + +export function shouldCaptureInstructionContext( + input: ShouldCaptureInstructionContextInput, +): boolean { + if (input.instructionContext !== undefined) { + return input.instructionContext; + } + + const value = input.env?.PONYTRAIL_INSTRUCTION_CONTEXT; + return value === "1" || value === "true" || value === "yes"; +} + +export async function captureInstructionContext( + input: CaptureInstructionContextInput, +): Promise { + const rootDir = resolve(input.rootDir); + const warnings: string[] = []; + const files = await captureInstructionFiles(rootDir, warnings); + const skills = await captureSkillMetadata(rootDir, warnings); + + return { + mode: "opt_in", + captured_at: input.timestampUtc ?? new Date().toISOString(), + session_id_hash: hashString(input.sessionId), + git: input.git ?? readGitContext(rootDir), + files, + skills, + warnings, + }; +} + +async function captureInstructionFiles( + rootDir: string, + warnings: string[], +): Promise { + const candidates = [ + ...rootInstructionFiles, + ...singleInstructionFiles, + ...(await discoverFilesUnderDirs(rootDir, globInstructionDirs, warnings)), + ]; + const unique = Array.from(new Set(candidates)).sort(); + return Promise.all(unique.map((path) => captureFile(rootDir, path, warnings))); +} + +async function captureSkillMetadata( + rootDir: string, + warnings: string[], +): Promise { + const paths = await discoverSkillMetadataPaths(rootDir, warnings); + const entries = await Promise.all( + paths.map(async (path): Promise => { + const file = await captureFile(rootDir, path, warnings); + return { + name: skillNameFromPath(path), + status: file.status, + path, + version_or_sha256: file.sha256, + }; + }), + ); + + return entries.sort((left, right) => (left.path ?? "").localeCompare(right.path ?? "")); +} + +async function discoverFilesUnderDirs( + rootDir: string, + dirs: string[], + warnings: string[], +): Promise { + const paths: string[] = []; + for (const dir of dirs) { + await collectFiles(rootDir, dir, paths, warnings); + } + return paths; +} + +async function discoverSkillMetadataPaths(rootDir: string, warnings: string[]): Promise { + const paths: string[] = []; + for (const dir of skillMetadataDirs) { + await collectFiles(rootDir, dir, paths, warnings, isSkillMetadataPath); + } + return Array.from(new Set(paths)).sort(); +} + +async function collectFiles( + rootDir: string, + relativeDir: string, + paths: string[], + warnings: string[], + predicate: (path: string) => boolean = () => true, +): Promise { + const absoluteDir = resolve(rootDir, relativeDir); + assertInside(absoluteDir, rootDir, relativeDir); + + let entries: Array<{ + name: string; + isDirectory(): boolean; + isFile(): boolean; + }>; + try { + entries = await readdir(absoluteDir, { withFileTypes: true }); + } catch (error) { + if (isMissingFileError(error)) { + return; + } + warnings.push(`${relativeDir} unreadable`); + return; + } + + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const absolutePath = join(absoluteDir, entry.name); + const relativePath = toRelativePath(rootDir, absolutePath); + if (entry.isDirectory()) { + await collectFiles(rootDir, relativePath, paths, warnings, predicate); + continue; + } + if (entry.isFile() && predicate(relativePath)) { + paths.push(relativePath); + } + } +} + +async function captureFile( + rootDir: string, + path: string, + warnings: string[], +): Promise { + const absolutePath = resolve(rootDir, path); + assertInside(absolutePath, rootDir, path); + + try { + await access(absolutePath, constants.R_OK); + const fileStat = await stat(absolutePath); + if (!fileStat.isFile()) { + warnings.push(`${path} unreadable`); + return { path, status: "unreadable" }; + } + + const content = await readFile(absolutePath); + return { + path, + status: "captured", + sha256: hashBuffer(content), + bytes: content.byteLength, + }; + } catch (error) { + if (isMissingFileError(error)) { + return { path, status: "missing" }; + } + warnings.push(`${path} unreadable`); + return { path, status: "unreadable" }; + } +} + +function readGitContext(rootDir: string): InstructionContextGit { + const branch = runGit(rootDir, ["branch", "--show-current"]); + const commit = runGit(rootDir, ["rev-parse", "--short", "HEAD"]); + const status = runGit(rootDir, ["status", "--porcelain"]); + + return { + branch: branch || undefined, + commit: commit || undefined, + dirty: status === undefined ? undefined : status.length > 0, + }; +} + +function runGit(rootDir: string, args: string[]): string | undefined { + const result = spawnSync("git", args, { cwd: rootDir }); + if (result.status !== 0) { + return undefined; + } + return result.stdout.toString("utf8").trim(); +} + +function isSkillMetadataPath(path: string): boolean { + return path.endsWith("/SKILL.md") || path.endsWith(".yaml") || path.endsWith(".yml"); +} + +function skillNameFromPath(path: string): string { + const parts = path.split("/"); + const skillIndex = parts.findIndex((part) => part === "skills" || part === "bundled-skills"); + const skillName = parts[skillIndex + 1]; + if (skillIndex >= 0 && skillName) { + return skillName; + } + return path; +} + +function hashString(value: string): string { + return hashBuffer(Buffer.from(value, "utf8")); +} + +function hashBuffer(value: Buffer): string { + return `sha256:${createHash("sha256").update(value).digest("hex")}`; +} + +function toRelativePath(rootDir: string, absolutePath: string): string { + return relative(rootDir, absolutePath).split(sep).join("/"); +} + +function assertInside(path: string, rootDir: string, label: string): void { + const normalizedRoot = resolve(rootDir); + if (path !== normalizedRoot && !path.startsWith(`${normalizedRoot}${sep}`)) { + throw new Error(`Instruction context path escapes workspace: ${label}`); + } +} + +function isMissingFileError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === "ENOENT" + ); +} diff --git a/src/runtimes/ponytrail/snapshots.ts b/src/runtimes/ponytrail/snapshots.ts index 70b860e6..74b2d97b 100644 --- a/src/runtimes/ponytrail/snapshots.ts +++ b/src/runtimes/ponytrail/snapshots.ts @@ -2,6 +2,14 @@ import { randomBytes } from "node:crypto"; import { constants } from "node:fs"; import { access, appendFile, cp, mkdir, readFile, rm } from "node:fs/promises"; import { dirname, join, resolve, sep } from "node:path"; +import { + captureInstructionContext, + type InstructionContext, + type InstructionContextFile, + type InstructionContextGit, + type InstructionContextSkill, + shouldCaptureInstructionContext, +} from "./instruction-context"; const snapshotStoreDir = ".pony-trail"; const snapshotLogFile = "snapshots.jsonl"; @@ -34,6 +42,7 @@ export interface SnapshotLogEntry { checks?: string | null | undefined; result?: string | null | undefined; files: SnapshotFileState[]; + instruction_context?: InstructionContext | undefined; } export interface SnapshotHistory { @@ -58,6 +67,10 @@ export interface SnapshotCommit { result?: string | undefined; rollback?: string | undefined; files: SnapshotFileState[]; + instructionContexts: { + pre?: InstructionContext | undefined; + post?: InstructionContext | undefined; + }; } export type SnapshotRevertAction = @@ -97,6 +110,7 @@ export interface RecordSnapshotPreInput { verify: string; rollback: string; files?: SnapshotFileState[] | undefined; + instructionContext?: boolean | undefined; } export interface RecordSnapshotPostInput { @@ -108,6 +122,7 @@ export interface RecordSnapshotPostInput { checks: string; result: string; files?: SnapshotFileState[] | undefined; + instructionContext?: boolean | undefined; } interface SnapshotPair { @@ -123,7 +138,7 @@ export async function recordSnapshotPre( const snapshotId = input.snapshotId ?? createSnapshotId(input.idPrefix ?? "snapshot", timestampUtc); - return appendSnapshotEntry(rootDir, { + const entry: SnapshotLogEntry = { snapshot_id: snapshotId, session_id: input.sessionId, phase: "pre", @@ -135,24 +150,55 @@ export async function recordSnapshotPre( verify: input.verify, rollback: input.rollback, files: input.files ?? [], - }); + }; + + if ( + shouldCaptureInstructionContext({ + instructionContext: input.instructionContext, + env: process.env, + }) + ) { + entry.instruction_context = await captureInstructionContext({ + rootDir, + sessionId: input.sessionId, + timestampUtc, + }); + } + + return appendSnapshotEntry(rootDir, entry); } export async function recordSnapshotPost( input: RecordSnapshotPostInput, ): Promise { const rootDir = resolve(input.rootDir); + const timestampUtc = input.timestampUtc ?? new Date().toISOString(); - return appendSnapshotEntry(rootDir, { + const entry: SnapshotLogEntry = { snapshot_id: input.snapshotId, session_id: input.sessionId, phase: "post", - timestamp_utc: input.timestampUtc ?? new Date().toISOString(), + timestamp_utc: timestampUtc, summary: input.summary, checks: input.checks, result: input.result, files: input.files ?? [], - }); + }; + + if ( + shouldCaptureInstructionContext({ + instructionContext: input.instructionContext, + env: process.env, + }) + ) { + entry.instruction_context = await captureInstructionContext({ + rootDir, + sessionId: input.sessionId, + timestampUtc, + }); + } + + return appendSnapshotEntry(rootDir, entry); } export async function readSnapshotHistory(input: SnapshotHistoryInput): Promise { @@ -311,6 +357,11 @@ function formatSessionTreeEntry(entry: SnapshotLogEntry): string { } } + if (entry.instruction_context) { + lines.push("- instruction_context:"); + lines.push(formatInstructionContextTreeLine(entry.instruction_context)); + } + return `${lines.join("\n")}\n`; } @@ -335,6 +386,24 @@ function formatSessionTreeFile(file: SnapshotFileState): string { return parts.join(" "); } +function formatInstructionContextTreeLine(context: InstructionContext): string { + const capturedFiles = context.files.filter((file) => file.status === "captured").length; + const capturedSkills = context.skills.filter((skill) => skill.status === "captured").length; + const warningText = + context.warnings.length === 0 ? "0 warnings" : `${context.warnings.length} warning(s)`; + const gitText = [ + context.git.branch ? `branch=${context.git.branch}` : "", + context.git.commit ? `commit=${context.git.commit}` : "", + context.git.dirty === undefined ? "" : `dirty=${String(context.git.dirty)}`, + ] + .filter(Boolean) + .join(" "); + + return ` - mode=${context.mode} files=${capturedFiles} skills=${capturedSkills} ${warningText}${ + gitText ? ` ${gitText}` : "" + }`; +} + function createSnapshotId(prefix: string, timestampUtc: string): string { const compactTimestamp = timestampUtc.replace(/\D/g, "").slice(0, 14); return `${prefix}-${compactTimestamp}Z-${randomBytes(4).toString("hex")}`; @@ -403,6 +472,7 @@ function parseSnapshotEntry(value: unknown, lineNumber: number): SnapshotLogEntr checks: readOptionalString(value.checks), result: readOptionalString(value.result), files: files.map((file, index) => parseSnapshotFile(file, lineNumber, index)), + instruction_context: parseInstructionContext(value.instruction_context, lineNumber), }; } @@ -429,6 +499,118 @@ function parseSnapshotFile(value: unknown, lineNumber: number, index: number): S }; } +function parseInstructionContext( + value: unknown, + lineNumber: number, +): InstructionContext | undefined { + if (value === undefined) { + return undefined; + } + if (!isRecord(value)) { + throw new Error( + `Malformed snapshot log entry on line ${lineNumber}: instruction_context must be an object`, + ); + } + + const mode = readRequiredString(value, "mode", lineNumber); + if (mode !== "opt_in") { + throw new Error( + `Malformed snapshot log entry on line ${lineNumber}: instruction_context mode is invalid`, + ); + } + + const files = value.files; + const skills = value.skills; + const warnings = value.warnings; + if (!Array.isArray(files) || !Array.isArray(skills) || !Array.isArray(warnings)) { + throw new Error( + `Malformed snapshot log entry on line ${lineNumber}: instruction_context arrays are required`, + ); + } + + return { + mode, + captured_at: readRequiredString(value, "captured_at", lineNumber), + session_id_hash: readRequiredString(value, "session_id_hash", lineNumber), + git: parseInstructionContextGit(value.git, lineNumber), + files: files.map((file, index) => parseInstructionContextFile(file, lineNumber, index)), + skills: skills.map((skill, index) => parseInstructionContextSkill(skill, lineNumber, index)), + warnings: warnings.map((warning, index) => + readRequiredArrayString(warning, "instruction_context.warnings", lineNumber, index), + ), + }; +} + +function parseInstructionContextGit(value: unknown, lineNumber: number): InstructionContextGit { + if (!isRecord(value)) { + throw new Error( + `Malformed snapshot log entry on line ${lineNumber}: instruction_context.git must be an object`, + ); + } + + return { + branch: readOptionalString(value.branch), + commit: readOptionalString(value.commit), + dirty: typeof value.dirty === "boolean" ? value.dirty : undefined, + }; +} + +function parseInstructionContextFile( + value: unknown, + lineNumber: number, + index: number, +): InstructionContextFile { + if (!isRecord(value)) { + throw new Error( + `Malformed snapshot log entry on line ${lineNumber}: instruction_context.files ${index} must be an object`, + ); + } + + const status = readRequiredString(value, "status", lineNumber); + if (!isInstructionContextStatus(status)) { + throw new Error( + `Malformed snapshot log entry on line ${lineNumber}: instruction_context.files ${index} has invalid status`, + ); + } + + return { + path: readRequiredString(value, "path", lineNumber), + status, + sha256: readOptionalString(value.sha256), + bytes: typeof value.bytes === "number" ? value.bytes : undefined, + }; +} + +function parseInstructionContextSkill( + value: unknown, + lineNumber: number, + index: number, +): InstructionContextSkill { + if (!isRecord(value)) { + throw new Error( + `Malformed snapshot log entry on line ${lineNumber}: instruction_context.skills ${index} must be an object`, + ); + } + + const status = readRequiredString(value, "status", lineNumber); + if (!isInstructionContextStatus(status)) { + throw new Error( + `Malformed snapshot log entry on line ${lineNumber}: instruction_context.skills ${index} has invalid status`, + ); + } + + return { + name: readRequiredString(value, "name", lineNumber), + status, + path: readOptionalString(value.path), + version_or_sha256: readOptionalString(value.version_or_sha256), + }; +} + +function isInstructionContextStatus(status: string): status is InstructionContextFile["status"] { + return status === "captured" || status === "missing" || status === "unreadable"; +} + function toSnapshotCommit( sessionId: string, snapshotId: string, @@ -456,6 +638,10 @@ function toSnapshotCommit( result: post?.result ?? undefined, rollback: pre?.rollback ?? undefined, files: Array.from(filesByPath.values()), + instructionContexts: { + pre: pre?.instruction_context, + post: post?.instruction_context, + }, }; } @@ -475,6 +661,20 @@ function readOptionalString(value: unknown): string | undefined { return typeof value === "string" && value ? value : undefined; } +function readRequiredArrayString( + value: unknown, + field: string, + lineNumber: number, + index: number, +): string { + if (typeof value !== "string" || !value) { + throw new Error( + `Malformed snapshot log entry on line ${lineNumber}: ${field} ${index} is required`, + ); + } + return value; +} + function assertSafeRelativePath(rootDir: string, path: string): void { const target = resolve(rootDir, path); assertInside(target, rootDir, `Target path escapes workspace: ${path}`); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 04045088..aba0d86f 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -475,11 +475,18 @@ describe("cli", () => { " * snapshot-001 (pre/post)", ]); expect(treeLogs.some((line) => line.includes("Updated note"))).toBe(false); + expect(treeLogs.some((line) => line.includes("instruction_context"))).toBe(false); expect(detailsLogs).toContain("Snapshot history"); expect(detailsLogs.some((line) => line.includes("session-alpha"))).toBe(true); expect(detailsLogs.some((line) => line.includes("snapshot-001"))).toBe(true); expect(detailsLogs.some((line) => line.includes("Updated note"))).toBe(true); + expect(detailsLogs.some((line) => line.includes("instruction_context: pre"))).toBe(true); + expect(detailsLogs.some((line) => line.includes("AGENTS.md captured sha256:aaaaaaaa"))).toBe( + true, + ); + expect(detailsLogs.some((line) => line.includes("git: main abc123 dirty"))).toBe(true); expect(jsonLogs.some((line) => line.includes('"sessionId": "session-alpha"'))).toBe(true); + expect(jsonLogs.some((line) => line.includes('"instructionContexts"'))).toBe(true); } finally { console.log = originalLog; await rm(rootDir, { recursive: true, force: true }); @@ -633,6 +640,31 @@ async function writeSampleSnapshotLog(rootDir: string): Promise { expected: "A note changes", verify: "Run tests", rollback: "Restore pre snapshot", + instruction_context: { + mode: "opt_in", + captured_at: "2026-06-22T17:04:23Z", + session_id_hash: "sha256:session", + git: { branch: "main", commit: "abc123", dirty: true }, + files: [ + { + path: "AGENTS.md", + status: "captured", + sha256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + bytes: 12, + }, + { path: "CLAUDE.md", status: "missing" }, + ], + skills: [ + { + name: "pony-trail", + path: "bundled-skills/pony-trail/SKILL.md", + status: "captured", + version_or_sha256: + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }, + ], + warnings: [], + }, files: [ { path: "notes.txt", diff --git a/tests/instruction-context.test.ts b/tests/instruction-context.test.ts new file mode 100644 index 00000000..8dbfadde --- /dev/null +++ b/tests/instruction-context.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from "bun:test"; +import { execFile } from "node:child_process"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { + captureInstructionContext, + shouldCaptureInstructionContext, +} from "../src/runtimes/ponytrail/instruction-context"; + +const execFileAsync = promisify(execFile); + +describe("instruction context", () => { + test("hashes allowlisted instruction files with stable ordering", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "ponytrail-instructions-")); + + try { + await writeFile(join(rootDir, "AGENTS.md"), "agent rules\n"); + await mkdir(join(rootDir, ".cursor", "rules"), { recursive: true }); + await writeFile(join(rootDir, ".cursor", "rules", "review.mdc"), "review rules\n"); + await mkdir(join(rootDir, ".cursor", "rules", "team"), { recursive: true }); + await writeFile(join(rootDir, ".cursor", "rules", "team", "security.mdc"), "security\n"); + await mkdir(join(rootDir, ".github"), { recursive: true }); + await writeFile(join(rootDir, ".github", "copilot-instructions.md"), "copilot rules\n"); + await mkdir(join(rootDir, "bundled-skills", "pony-trail"), { recursive: true }); + await writeFile(join(rootDir, "bundled-skills", "pony-trail", "SKILL.md"), "skill\n"); + + const context = await captureInstructionContext({ + rootDir, + sessionId: "session-alpha", + timestampUtc: "2026-06-22T17:04:23Z", + git: { branch: "main", commit: "abc123", dirty: true }, + }); + + expect(context).toMatchObject({ + mode: "opt_in", + captured_at: "2026-06-22T17:04:23Z", + session_id_hash: "sha256:99b1d23983d285eb64aa2e321f429dd6678a40ec15149dc258098ed6a5bd536d", + git: { branch: "main", commit: "abc123", dirty: true }, + warnings: [], + }); + expect(context.files.map((file) => file.path)).toEqual([ + ".cursor/rules/review.mdc", + ".cursor/rules/team/security.mdc", + ".github/copilot-instructions.md", + "AGENTS.md", + "CLAUDE.md", + ]); + expect(context.files[0]).toMatchObject({ + path: ".cursor/rules/review.mdc", + status: "captured", + bytes: 13, + }); + expect(context.files[0]?.sha256).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(context.files[4]).toEqual({ path: "CLAUDE.md", status: "missing" }); + expect(context.skills).toEqual([ + { + name: "pony-trail", + path: "bundled-skills/pony-trail/SKILL.md", + status: "captured", + version_or_sha256: expect.stringMatching(/^sha256:[a-f0-9]{64}$/), + }, + ]); + expect(JSON.stringify(context)).not.toContain("agent rules"); + expect(JSON.stringify(context)).not.toContain("review rules"); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); + + test("treats non-file instruction paths as warnings without failing capture", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "ponytrail-instructions-")); + + try { + await writeFile(join(rootDir, "AGENTS.md"), "agent rules\n"); + await mkdir(join(rootDir, "CLAUDE.md")); + + const context = await captureInstructionContext({ + rootDir, + sessionId: "session-alpha", + timestampUtc: "2026-06-22T17:04:23Z", + }); + + expect(context.git.branch).toBeUndefined(); + expect(context.git.commit).toBeUndefined(); + expect(context.git.dirty).toBeUndefined(); + expect(context.files.find((file) => file.path === "CLAUDE.md")).toEqual({ + path: "CLAUDE.md", + status: "unreadable", + }); + expect(context.warnings).toContain("CLAUDE.md unreadable"); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); + + test("records warnings for unreadable context paths without failing capture", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "ponytrail-instructions-")); + const rulesDir = join(rootDir, ".cursor", "rules"); + const claudePath = join(rootDir, "CLAUDE.md"); + + try { + await writeFile(join(rootDir, "AGENTS.md"), "agent rules\n"); + await mkdir(rulesDir, { recursive: true }); + await writeFile(claudePath, "claude rules\n"); + await chmod(rulesDir, 0o000); + await chmod(claudePath, 0o000); + + const context = await captureInstructionContext({ + rootDir, + sessionId: "session-alpha", + timestampUtc: "2026-06-22T17:04:23Z", + }); + + expect(context.files.find((file) => file.path === "CLAUDE.md")).toEqual({ + path: "CLAUDE.md", + status: "unreadable", + }); + expect(context.warnings).toEqual( + expect.arrayContaining([".cursor/rules unreadable", "CLAUDE.md unreadable"]), + ); + } finally { + await chmod(rulesDir, 0o700).catch(() => {}); + await chmod(claudePath, 0o600).catch(() => {}); + await rm(rootDir, { recursive: true, force: true }); + } + }); + + test("captures allowlisted git metadata when the root is a git repository", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "ponytrail-instructions-")); + + try { + await execFileAsync("git", ["init"], { cwd: rootDir }); + await writeFile(join(rootDir, "AGENTS.md"), "agent rules\n"); + + const context = await captureInstructionContext({ + rootDir, + sessionId: "session-alpha", + timestampUtc: "2026-06-22T17:04:23Z", + }); + + expect(context.git.branch).toBeString(); + expect(context.git.commit).toBeUndefined(); + expect(context.git.dirty).toBe(true); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); + + test("normalizes opt-in from explicit options and environment", () => { + expect(shouldCaptureInstructionContext({ instructionContext: true, env: {} })).toBe(true); + expect(shouldCaptureInstructionContext({ instructionContext: false, env: {} })).toBe(false); + expect( + shouldCaptureInstructionContext({ + instructionContext: undefined, + env: { PONYTRAIL_INSTRUCTION_CONTEXT: "1" }, + }), + ).toBe(true); + expect( + shouldCaptureInstructionContext({ + instructionContext: undefined, + env: { PONYTRAIL_INSTRUCTION_CONTEXT: "true" }, + }), + ).toBe(true); + expect( + shouldCaptureInstructionContext({ + instructionContext: undefined, + env: { PONYTRAIL_INSTRUCTION_CONTEXT: "yes" }, + }), + ).toBe(true); + expect( + shouldCaptureInstructionContext({ + instructionContext: undefined, + env: { PONYTRAIL_INSTRUCTION_CONTEXT: "0" }, + }), + ).toBe(false); + }); +}); diff --git a/tests/pony-trail-script.test.ts b/tests/pony-trail-script.test.ts index 18324fe8..5df38bd0 100644 --- a/tests/pony-trail-script.test.ts +++ b/tests/pony-trail-script.test.ts @@ -15,6 +15,13 @@ const shellHelperPath = join( "scripts", "snapshot_change.sh", ); +const pythonHelperPath = join( + repoRoot, + "bundled-skills", + "pony-trail", + "scripts", + "snapshot_change.py", +); const preFileChangeHookPath = join( repoRoot, "bundled-skills", @@ -23,6 +30,33 @@ const preFileChangeHookPath = join( "pre-file-change.sh", ); +interface ScriptInstructionContextFile { + path: string; + status: string; + sha256?: string | undefined; + bytes?: number | undefined; +} + +interface ScriptInstructionContextSkill { + name: string; + status: string; + version_or_sha256?: string | undefined; +} + +interface ScriptInstructionContext { + mode: string; + session_id_hash: string; + files: ScriptInstructionContextFile[]; + skills: ScriptInstructionContextSkill[]; + warnings: string[]; +} + +interface ScriptSnapshotEntry { + snapshot_id: string; + session_id?: string | undefined; + instruction_context?: ScriptInstructionContext | undefined; +} + describe("pony-trail shell helper", () => { test("records pre and post snapshots without requiring Python", async () => { const rootDir = await mkdtemp(join(tmpdir(), "pony-trail-sh-")); @@ -159,6 +193,186 @@ describe("pony-trail shell helper", () => { } }); + test("captures instruction context only when opted in", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "pony-trail-sh-")); + const snapshotId = "shell-helper-context-001"; + const targetFile = join(rootDir, "notes.txt"); + + try { + await writeFile(targetFile, "before\n"); + await writeFile(join(rootDir, "AGENTS.md"), "agent rules\n"); + + await execFileAsync("sh", [ + shellHelperPath, + "--root", + rootDir, + "--session-id", + "session-alpha", + "pre", + "--snapshot-id", + `${snapshotId}-default`, + "--files", + "notes.txt", + "--action", + "edit note", + "--purpose", + "Exercise default privacy boundary", + "--reason", + "The helper should not capture instructions by default", + "--expected", + "No instruction context is written", + "--verify", + "Run this test", + "--rollback", + "Restore the stored pre snapshot", + ]); + + await execFileAsync("sh", [ + shellHelperPath, + "--root", + rootDir, + "--session-id", + "session-alpha", + "--instruction-context", + "pre", + "--snapshot-id", + snapshotId, + "--files", + "notes.txt", + "--action", + "edit note", + "--purpose", + "Exercise instruction context", + "--reason", + "The helper should hash instructions when opted in", + "--expected", + "Instruction context is written", + "--verify", + "Run this test", + "--rollback", + "Restore the stored pre snapshot", + ]); + + const entries = (await readFile(join(rootDir, ".pony-trail", "snapshots.jsonl"), "utf8")) + .trim() + .split("\n") + .map((line): ScriptSnapshotEntry => JSON.parse(line)); + const defaultEntry = entries.find((entry) => entry.snapshot_id === `${snapshotId}-default`); + const contextEntry = entries.find((entry) => entry.snapshot_id === snapshotId); + if (!defaultEntry || !contextEntry?.instruction_context) { + throw new Error("Expected shell helper instruction context entries"); + } + const agentsFile = contextEntry.instruction_context.files.find( + (file) => file.path === "AGENTS.md", + ); + const claudeFile = contextEntry.instruction_context.files.find( + (file) => file.path === "CLAUDE.md", + ); + const ponyTrailSkill = contextEntry.instruction_context.skills.find( + (skill) => skill.name === "pony-trail", + ); + if (!agentsFile) { + throw new Error("Expected AGENTS.md in shell helper instruction context"); + } + if (!ponyTrailSkill) { + throw new Error("Expected pony-trail skill metadata in shell helper instruction context"); + } + + expect(defaultEntry.instruction_context).toBeUndefined(); + expect(contextEntry.instruction_context).toMatchObject({ + mode: "opt_in", + warnings: [], + }); + expect(contextEntry.instruction_context.session_id_hash).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(agentsFile).toMatchObject({ + path: "AGENTS.md", + status: "captured", + bytes: 12, + }); + expect(agentsFile.sha256).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(claudeFile).toEqual({ path: "CLAUDE.md", status: "missing" }); + expect(ponyTrailSkill).toMatchObject({ + name: "pony-trail", + status: "captured", + }); + expect(ponyTrailSkill.version_or_sha256).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(JSON.stringify(contextEntry.instruction_context)).not.toContain("agent rules"); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); + + test("python fallback captures schema-compatible instruction context when available", async () => { + if (!(await hasPython3())) { + return; + } + + const rootDir = await mkdtemp(join(tmpdir(), "pony-trail-py-")); + + try { + await writeFile(join(rootDir, "notes.txt"), "before\n"); + await writeFile(join(rootDir, "AGENTS.md"), "agent rules\n"); + + await execFileAsync("python3", [ + pythonHelperPath, + "--root", + rootDir, + "--session-id", + "session-alpha", + "--instruction-context", + "pre", + "--snapshot-id", + "python-context-001", + "--files", + "notes.txt", + "--action", + "edit note", + "--purpose", + "Exercise Python helper", + "--reason", + "Python fallback should match the shell schema", + "--expected", + "Instruction context is written", + "--verify", + "Run this test", + "--rollback", + "Restore the stored pre snapshot", + ]); + + const entry = JSON.parse( + (await readFile(join(rootDir, ".pony-trail", "snapshots.jsonl"), "utf8")).trim(), + ) as ScriptSnapshotEntry; + if (!entry.instruction_context) { + throw new Error("Expected Python helper instruction context"); + } + const agentsFile = entry.instruction_context.files.find((file) => file.path === "AGENTS.md"); + const ponyTrailSkill = entry.instruction_context.skills.find( + (skill) => skill.name === "pony-trail", + ); + if (!agentsFile) { + throw new Error("Expected AGENTS.md in Python helper instruction context"); + } + if (!ponyTrailSkill) { + throw new Error("Expected pony-trail skill metadata in Python helper instruction context"); + } + + expect(entry.session_id).toBe("session-alpha"); + expect(entry.instruction_context.mode).toBe("opt_in"); + expect(entry.instruction_context.session_id_hash).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(agentsFile).toMatchObject({ + path: "AGENTS.md", + status: "captured", + bytes: 12, + }); + expect(agentsFile.sha256).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(ponyTrailSkill.status).toBe("captured"); + expect(ponyTrailSkill.version_or_sha256).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(JSON.stringify(entry.instruction_context)).not.toContain("agent rules"); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); + test("pre-file-change hook emits pony-trail context", async () => { const hook = await execFileAsync("sh", [preFileChangeHookPath]); @@ -168,3 +382,12 @@ describe("pony-trail shell helper", () => { expect(output.systemMessage).toContain("Ponytrail"); }); }); + +async function hasPython3(): Promise { + try { + await execFileAsync("python3", ["--version"]); + return true; + } catch { + return false; + } +} diff --git a/tests/snapshots.test.ts b/tests/snapshots.test.ts index d8e81a13..a9565f8a 100644 --- a/tests/snapshots.test.ts +++ b/tests/snapshots.test.ts @@ -6,6 +6,8 @@ import { applySnapshotRevert, planSnapshotRevert, readSnapshotHistory, + recordSnapshotPost, + recordSnapshotPre, } from "../src/runtimes/ponytrail/snapshots"; describe("snapshot history", () => { @@ -50,6 +52,63 @@ describe("snapshot history", () => { } }); + test("records and reads instruction context for pre and post phases", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "ponytrail-snapshots-")); + + try { + await writeFile(join(rootDir, "AGENTS.md"), "agent rules\n"); + const pre = await recordSnapshotPre({ + rootDir, + sessionId: "session-alpha", + snapshotId: "snapshot-context-001", + timestampUtc: "2026-06-22T17:04:23Z", + action: "edit note", + purpose: "Exercise instruction context", + reason: "Snapshots should explain active rules without storing content", + expected: "Instruction context hashes are present", + verify: "bun test tests/snapshots.test.ts", + rollback: "Restore pre snapshot", + instructionContext: true, + }); + + await writeFile(join(rootDir, "AGENTS.md"), "agent rules changed\n"); + await recordSnapshotPost({ + rootDir, + sessionId: pre.sessionId, + snapshotId: pre.snapshotId, + timestampUtc: "2026-06-22T17:05:23Z", + summary: "Updated note", + checks: "bun test", + result: "pass", + instructionContext: true, + }); + + const history = await readSnapshotHistory({ rootDir }); + const commit = history.sessions[0]?.commits[0]; + + expect( + commit?.instructionContexts.pre?.files.find((file) => file.path === "AGENTS.md"), + ).toMatchObject({ status: "captured", bytes: 12 }); + expect( + commit?.instructionContexts.post?.files.find((file) => file.path === "AGENTS.md"), + ).toMatchObject({ status: "captured", bytes: 20 }); + expect( + commit?.instructionContexts.pre?.files.find((file) => file.path === "AGENTS.md")?.sha256, + ).not.toBe( + commit?.instructionContexts.post?.files.find((file) => file.path === "AGENTS.md")?.sha256, + ); + + const rawEntries = (await readFile(join(rootDir, ".pony-trail", "snapshots.jsonl"), "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(rawEntries[0].instruction_context.mode).toBe("opt_in"); + expect(JSON.stringify(rawEntries[0].instruction_context)).not.toContain("agent rules"); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); + test("plans and applies a revert from a pre snapshot", async () => { const rootDir = await mkdtemp(join(tmpdir(), "ponytrail-snapshots-")); From 33f2546cdbfa4f1497c57aa1836997a56539ddc5 Mon Sep 17 00:00:00 2001 From: 0xRoy <1997roylee@gmail.com> Date: Tue, 23 Jun 2026 11:34:19 +0800 Subject: [PATCH 2/7] Remove generated Python bytecode --- .gitignore | 2 ++ .../__pycache__/snapshot_change.cpython-310.pyc | Bin 8592 -> 0 bytes 2 files changed, 2 insertions(+) delete mode 100644 bundled-skills/pony-trail/scripts/__pycache__/snapshot_change.cpython-310.pyc diff --git a/.gitignore b/.gitignore index f96317cf..b4e2ebd6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ work/ .DS_Store docs/superpowers/ .codegraph +__pycache__/ +*.pyc diff --git a/bundled-skills/pony-trail/scripts/__pycache__/snapshot_change.cpython-310.pyc b/bundled-skills/pony-trail/scripts/__pycache__/snapshot_change.cpython-310.pyc deleted file mode 100644 index 0d73b875f9b0edbfea924a3e114b795397573cbf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8592 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 Date: Tue, 23 Jun 2026 01:51:57 +0800 Subject: [PATCH 3/7] Add skill update support --- README.md | 6 +++ src/cli.ts | 71 ++++++++++++++++++++++++++-------- src/plugins/skill-installer.ts | 18 +++++++++ tests/cli.test.ts | 54 +++++++++++++++++++++++++- tests/skill-installer.test.ts | 43 ++++++++++++++++++++ 5 files changed, 174 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 94658513..949c4150 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/cli.ts b/src/cli.ts index 54524be3..312dd9f4 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -61,6 +61,7 @@ export interface RevertApprovalPromptInput { export type RevertApprovalPrompter = (input: RevertApprovalPromptInput) => Promise; type SnapshotHistoryMode = "tree" | "details"; +type SkillChangeOperation = "install" | "update"; const defaultManifestPath = ".ponytrail/manifest.json"; const skillInstallHistorySessionId = "ponytrail-skills"; @@ -111,6 +112,7 @@ export function buildProgram(options: BuildProgramOptions = {}): Command { const skillResult = await installSkillWithLocalHistory({ rootDir: targetDir, + operation: "install", source: "pony-trail", homeDir: resolveHomePath(commandOptions.home), agents: parseSkillInstallAgents(commandOptions.agents), @@ -119,7 +121,7 @@ export function buildProgram(options: BuildProgramOptions = {}): Command { installPrehook: false, }); - printSkillInstallResult(skillResult.skillInstall, skillResult.history); + printSkillInstallResult(skillResult.skillInstall, skillResult.history, "install"); }, ); @@ -275,6 +277,13 @@ export function buildProgram(options: BuildProgramOptions = {}): Command { .description("Install a bundled or local skill for Claude, GitHub Copilot, and Codex."), rootDir, ); + configureSkillChangeCommand( + skillsCommand + .command("update") + .description("Update a bundled or local skill for configured agent targets."), + rootDir, + "update", + ); return program; } @@ -601,6 +610,18 @@ function resolvePath(rootDir: string, path: string): string { } function configureSkillInstallCommand(command: Command, rootDir: string): Command { + return configureSkillChangeCommand(command, rootDir, "install").option( + "-f, --force", + "overwrite existing installed skill folders", + false, + ); +} + +function configureSkillChangeCommand( + command: Command, + rootDir: string, + operation: SkillChangeOperation, +): Command { return command .argument("[source-or-name]", "bundled skill name or local skill directory", "pony-trail") .option( @@ -609,9 +630,8 @@ function configureSkillInstallCommand(command: Command, rootDir: string): Comman "claude,copilot,codex,cursor", ) .option("--home ", "home directory that contains agent config folders", homedir()) - .option("--dry-run", "show install destinations without writing files", false) + .option("--dry-run", `show ${operation} destinations without writing files`, false) .option("--prehook", "also install a Ponytrail prehook reminder for file mutations", false) - .option("-f, --force", "overwrite existing installed skill folders", false) .action( async ( sourceOrName: string, @@ -620,26 +640,28 @@ function configureSkillInstallCommand(command: Command, rootDir: string): Comman home: string; dryRun: boolean; prehook: boolean; - force: boolean; + force?: boolean; }, ) => { const result = await installSkillWithLocalHistory({ rootDir, + operation, source: sourceOrName, homeDir: resolveHomePath(commandOptions.home), agents: parseSkillInstallAgents(commandOptions.agents), dryRun: commandOptions.dryRun, - force: commandOptions.force, + force: operation === "install" ? commandOptions.force === true : false, installPrehook: commandOptions.prehook, }); - printSkillInstallResult(result.skillInstall, result.history); + printSkillInstallResult(result.skillInstall, result.history, operation); }, ); } interface InstallSkillWithLocalHistoryInput { rootDir: string; + operation: SkillChangeOperation; source: string; homeDir: string; agents: ReturnType; @@ -663,11 +685,13 @@ async function installSkillWithLocalHistory( history = await recordSnapshotPre({ rootDir: input.rootDir, sessionId: skillInstallHistorySessionId, - idPrefix: "skill-install", - action: "install skill", - purpose: `Install ${input.source} skill for ${formatAgentList(input.agents)}`, + idPrefix: `skill-${input.operation}`, + action: `${input.operation} skill`, + purpose: `${formatSkillChangeVerb(input.operation)} ${input.source} skill for ${formatAgentList( + input.agents, + )}`, reason: "Keep project-local history before changing agent skill files.", - expected: "The skill install result is captured in local Ponytrail history.", + expected: `The skill ${input.operation} result is captured in local Ponytrail history.`, verify: "ponytrail history --details", rollback: "Remove or reinstall the affected agent skill folders, then record another snapshot.", @@ -682,6 +706,7 @@ async function installSkillWithLocalHistory( agents: input.agents, dryRun: input.dryRun, force: input.force, + operation: input.operation, installPrehook: input.installPrehook, }); @@ -690,7 +715,7 @@ async function installSkillWithLocalHistory( rootDir: input.rootDir, sessionId: history.sessionId, snapshotId: history.snapshotId, - summary: formatSkillInstallSummary(skillInstall), + summary: formatSkillInstallSummary(skillInstall, input.operation), checks: commandText, result: formatSkillInstallHistoryResult(skillInstall), }); @@ -703,7 +728,9 @@ async function installSkillWithLocalHistory( rootDir: input.rootDir, sessionId: history.sessionId, snapshotId: history.snapshotId, - summary: `Failed to install ${input.source} skill for ${formatAgentList(input.agents)}`, + summary: `Failed to ${input.operation} ${input.source} skill for ${formatAgentList( + input.agents, + )}`, checks: commandText, result: `fail: ${formatErrorMessage(error)}`, }); @@ -725,8 +752,9 @@ function resolveHomePath(path: string): string { function printSkillInstallResult( result: SkillInstallResult, history?: RecordedSnapshotCommit | undefined, + operation: SkillChangeOperation = "install", ): void { - console.log(pc.cyan(result.dryRun ? "Skill install plan" : "Skill install result")); + console.log(pc.cyan(result.dryRun ? `Skill ${operation} plan` : `Skill ${operation} result`)); console.log(`Skill: ${result.skillName}`); console.log(`${pc.dim("Source:")} ${result.source.path}`); @@ -738,7 +766,9 @@ function printSkillInstallResult( if (result.prehooks.length > 0) { console.log(""); - console.log(pc.cyan(result.dryRun ? "Prehook install plan" : "Prehook install result")); + console.log( + pc.cyan(result.dryRun ? `Prehook ${operation} plan` : `Prehook ${operation} result`), + ); for (const prehook of result.prehooks) { console.log( `${prehook.agent}: ${formatSkillInstallStatus(prehook.status)} ${pc.dim( @@ -783,15 +813,22 @@ function formatSkillInstallCommand(input: InstallSkillWithLocalHistoryInput): st input.force ? "--force" : "", input.installPrehook ? "--prehook" : "", ].filter(Boolean); - return ["ponytrail skills install", input.source, ...flags].join(" "); + return ["ponytrail skills", input.operation, input.source, ...flags].join(" "); } -function formatSkillInstallSummary(result: SkillInstallResult): string { - return `Installed ${result.skillName} skill for ${formatAgentList( +function formatSkillInstallSummary( + result: SkillInstallResult, + operation: SkillChangeOperation, +): string { + return `${formatSkillChangeVerb(operation)} ${result.skillName} skill for ${formatAgentList( result.targets.map((target) => target.agent), )}`; } +function formatSkillChangeVerb(operation: SkillChangeOperation): "Installed" | "Updated" { + return operation === "install" ? "Installed" : "Updated"; +} + function formatSkillInstallHistoryResult(result: SkillInstallResult): string { const targetStatuses = result.targets .map((target) => `${target.agent}:${target.status}`) diff --git a/src/plugins/skill-installer.ts b/src/plugins/skill-installer.ts index 7a29b7cf..da0de9a8 100644 --- a/src/plugins/skill-installer.ts +++ b/src/plugins/skill-installer.ts @@ -15,6 +15,7 @@ export type SkillInstallStatus = | "updated" | "would_update" | "already_present"; +export type SkillInstallOperation = "install" | "update"; export interface ResolvedInstallSkillSource { kind: "bundled" | "path"; @@ -50,6 +51,7 @@ export interface InstallAgentSkillInput { agents: SkillInstallAgent[]; dryRun?: boolean; force?: boolean; + operation?: SkillInstallOperation; installPrehook?: boolean; } @@ -95,6 +97,7 @@ export async function installAgentSkill( ); const targets: SkillInstallTargetResult[] = []; const prehooks: SkillInstallPrehookResult[] = []; + const operation = input.operation ?? "install"; for (const agent of input.agents) { const destination = getSkillDestination(input.homeDir, agent, source.name); @@ -103,6 +106,7 @@ export async function installAgentSkill( exists, dryRun: input.dryRun ?? false, force: input.force ?? false, + operation, }); if (!input.dryRun && status !== "skipped_exists") { @@ -368,7 +372,21 @@ function getInstallStatus(input: { exists: boolean; dryRun: boolean; force: boolean; + operation: SkillInstallOperation; }): SkillInstallStatus { + if (input.operation === "update") { + if (input.dryRun && input.exists) { + return "would_update"; + } + if (input.dryRun) { + return "would_install"; + } + if (input.exists) { + return "updated"; + } + return "installed"; + } + if (input.dryRun && input.exists) { return "would_overwrite"; } diff --git a/tests/cli.test.ts b/tests/cli.test.ts index aba0d86f..a1f66813 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildProgram, type GoalClarificationPrompter } from "../src/cli"; @@ -23,6 +23,7 @@ describe("cli", () => { const onboardCommand = program.commands.find((command) => command.name() === "onboard"); const revertCommand = program.commands.find((command) => command.name() === "revert"); + const skillsCommand = program.commands.find((command) => command.name() === "skills"); expect(onboardCommand?.options.map((option) => option.long)).toEqual([ "--dir", @@ -31,6 +32,7 @@ describe("cli", () => { "--home", ]); expect(revertCommand?.options.map((option) => option.long)).toEqual(["--dry-run"]); + expect(skillsCommand?.commands.map((command) => command.name())).toEqual(["install", "update"]); }); test("runs onboarding and manifest-backed commands", async () => { @@ -450,6 +452,56 @@ describe("cli", () => { } }); + test("skills update refreshes installed skill files and records a local project history commit", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "ponytrail-cli-")); + const homeDir = await mkdtemp(join(tmpdir(), "ponytrail-skill-home-")); + const installedSkillPath = join(homeDir, ".codex", "skills", "pony-trail", "SKILL.md"); + const logs: string[] = []; + const originalLog = console.log; + + console.log = (...values: unknown[]) => { + logs.push(values.join(" ")); + }; + + try { + await buildProgram({ cwd: rootDir }).parseAsync( + ["skills", "install", "pony-trail", "--home", homeDir, "--agents", "codex"], + { from: "user" }, + ); + await writeFile(installedSkillPath, "stale skill"); + logs.splice(0); + + await buildProgram({ cwd: rootDir }).parseAsync( + ["skills", "update", "pony-trail", "--home", homeDir, "--agents", "codex"], + { from: "user" }, + ); + + expect(stripAnsiLines(logs)).toContain("Skill update result"); + expect(logs.some((line) => line.includes("codex: updated"))).toBe(true); + expect(logs.some((line) => line.includes("Local history:"))).toBe(true); + expect(await readFile(installedSkillPath, "utf8")).toContain("name: pony-trail"); + + await buildProgram({ cwd: rootDir }).parseAsync(["history", "--details"], { from: "user" }); + const historyLogs = logs.splice(0).map(stripAnsi); + + expect(historyLogs.some((line) => line.includes("action: update skill"))).toBe(true); + expect( + historyLogs.some((line) => line.includes("summary: Updated pony-trail skill for codex")), + ).toBe(true); + + const entries = (await readFile(join(rootDir, ".pony-trail", "snapshots.jsonl"), "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(entries.at(-2)?.snapshot_id).toStartWith("skill-update-"); + expect(entries.at(-1)?.snapshot_id).toStartWith("skill-update-"); + } finally { + console.log = originalLog; + await rm(rootDir, { recursive: true, force: true }); + await rm(homeDir, { recursive: true, force: true }); + } + }); + test("history supports tree, details, and JSON output", async () => { const rootDir = await mkdtemp(join(tmpdir(), "ponytrail-cli-")); const logs: string[] = []; diff --git a/tests/skill-installer.test.ts b/tests/skill-installer.test.ts index 0018c88c..331e26de 100644 --- a/tests/skill-installer.test.ts +++ b/tests/skill-installer.test.ts @@ -68,6 +68,49 @@ describe("skill installer", () => { } }); + test("updates existing bundled skill targets when requested", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "skill-installer-home-")); + const installedSkillPath = join(homeDir, ".codex", "skills", "pony-trail", "SKILL.md"); + + try { + await installAgentSkill({ + source: "pony-trail", + homeDir, + agents: ["codex"], + }); + await writeFile(installedSkillPath, "stale skill"); + + const dryRun = await installAgentSkill({ + source: "pony-trail", + homeDir, + agents: ["codex"], + dryRun: true, + operation: "update", + }); + + expect(dryRun.targets[0]).toMatchObject({ + agent: "codex", + status: "would_update", + }); + expect(await readFile(installedSkillPath, "utf8")).toBe("stale skill"); + + const result = await installAgentSkill({ + source: "pony-trail", + homeDir, + agents: ["codex"], + operation: "update", + }); + + expect(result.targets[0]).toMatchObject({ + agent: "codex", + status: "updated", + }); + expect(await readFile(installedSkillPath, "utf8")).toContain("name: pony-trail"); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }); + test("dry-runs Cursor rule installation", async () => { const homeDir = await mkdtemp(join(tmpdir(), "skill-installer-home-")); From 5fbde79b994298203c380160553aac93273d1cb2 Mon Sep 17 00:00:00 2001 From: 0xRoy <1997roylee@gmail.com> Date: Tue, 23 Jun 2026 15:55:30 +0800 Subject: [PATCH 4/7] Clarify Zod typing guidance in AGENTS --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 3859c6db..b7260433 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 From bdddb13fc17cdb8cda7d5b83863d4e33e4e439f7 Mon Sep 17 00:00:00 2001 From: 0xRoy <1997roylee@gmail.com> Date: Tue, 23 Jun 2026 16:21:55 +0800 Subject: [PATCH 5/7] Refresh installed skills during onboard --- CHANGELOG.md | 27 ++++++ src/cli.ts | 4 + src/plugins/skill-installer.ts | 88 +++++++++++++++++-- src/runtimes/ponytrail/instruction-context.ts | 12 ++- tests/cli.test.ts | 31 ++++++- tests/instruction-context.test.ts | 53 ++++++++++- tests/skill-installer.test.ts | 26 ++++++ 7 files changed, 231 insertions(+), 10 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..5ddea14c --- /dev/null +++ b/CHANGELOG.md @@ -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 ` 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`. diff --git a/src/cli.ts b/src/cli.ts index 312dd9f4..8ec9ac5d 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -118,6 +118,7 @@ export function buildProgram(options: BuildProgramOptions = {}): Command { agents: parseSkillInstallAgents(commandOptions.agents), dryRun: false, force: false, + refreshExisting: true, installPrehook: false, }); @@ -651,6 +652,7 @@ function configureSkillChangeCommand( agents: parseSkillInstallAgents(commandOptions.agents), dryRun: commandOptions.dryRun, force: operation === "install" ? commandOptions.force === true : false, + refreshExisting: false, installPrehook: commandOptions.prehook, }); @@ -667,6 +669,7 @@ interface InstallSkillWithLocalHistoryInput { agents: ReturnType; dryRun: boolean; force: boolean; + refreshExisting: boolean; installPrehook: boolean; } @@ -707,6 +710,7 @@ async function installSkillWithLocalHistory( dryRun: input.dryRun, force: input.force, operation: input.operation, + refreshExisting: input.refreshExisting, installPrehook: input.installPrehook, }); diff --git a/src/plugins/skill-installer.ts b/src/plugins/skill-installer.ts index da0de9a8..4c0bddf6 100644 --- a/src/plugins/skill-installer.ts +++ b/src/plugins/skill-installer.ts @@ -1,5 +1,5 @@ import { constants } from "node:fs"; -import { access, chmod, cp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { access, chmod, cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; @@ -52,6 +52,7 @@ export interface InstallAgentSkillInput { dryRun?: boolean; force?: boolean; operation?: SkillInstallOperation; + refreshExisting?: boolean; installPrehook?: boolean; } @@ -98,18 +99,25 @@ export async function installAgentSkill( const targets: SkillInstallTargetResult[] = []; const prehooks: SkillInstallPrehookResult[] = []; const operation = input.operation ?? "install"; + const refreshExisting = operation === "update" || input.refreshExisting === true; for (const agent of input.agents) { const destination = getSkillDestination(input.homeDir, agent, source.name); const exists = await pathExists(destination); + const matchesSource = + exists && refreshExisting + ? await skillTargetMatchesSource({ agent, source, destination }) + : false; const status = getInstallStatus({ exists, dryRun: input.dryRun ?? false, force: input.force ?? false, operation, + refreshExisting, + matchesSource, }); - if (!input.dryRun && status !== "skipped_exists") { + if (!input.dryRun && status !== "skipped_exists" && status !== "already_present") { if (exists) { await rm(destination, { recursive: true, force: true }); } @@ -229,6 +237,74 @@ async function installSkillTarget(input: { await cp(input.source.path, input.destination, { recursive: true }); } +async function skillTargetMatchesSource(input: { + agent: SkillInstallAgent; + source: ResolvedInstallSkillSource; + destination: string; +}): Promise { + try { + const target = agentSkillTargets[input.agent]; + if (target.kind === "cursor_rule") { + return (await readFile(input.destination, "utf8")) === (await renderCursorRule(input.source)); + } + + return directoryContentsMatch(input.source.path, input.destination); + } catch { + return false; + } +} + +async function directoryContentsMatch(sourceDir: string, targetDir: string): Promise { + const [sourceEntries, targetEntries] = await Promise.all([ + readSortedDirectoryEntries(sourceDir), + readSortedDirectoryEntries(targetDir), + ]); + + if (sourceEntries.length !== targetEntries.length) { + return false; + } + + for (const [index, sourceEntry] of sourceEntries.entries()) { + const targetEntry = targetEntries[index]; + if (!targetEntry || sourceEntry.name !== targetEntry.name) { + return false; + } + + const sourcePath = join(sourceDir, sourceEntry.name); + const targetPath = join(targetDir, targetEntry.name); + if (sourceEntry.isDirectory() || targetEntry.isDirectory()) { + if (!sourceEntry.isDirectory() || !targetEntry.isDirectory()) { + return false; + } + if (!(await directoryContentsMatch(sourcePath, targetPath))) { + return false; + } + continue; + } + + if (!sourceEntry.isFile() || !targetEntry.isFile()) { + return false; + } + + if (!(await fileContentsMatch(sourcePath, targetPath))) { + return false; + } + } + + return true; +} + +async function readSortedDirectoryEntries(path: string) { + return (await readdir(path, { withFileTypes: true })).sort((left, right) => + left.name.localeCompare(right.name), + ); +} + +async function fileContentsMatch(leftPath: string, rightPath: string): Promise { + const [left, right] = await Promise.all([readFile(leftPath), readFile(rightPath)]); + return left.equals(right); +} + async function renderCursorRule(source: ResolvedInstallSkillSource): Promise { const content = await readFile(join(source.path, "SKILL.md"), "utf8"); @@ -373,16 +449,18 @@ function getInstallStatus(input: { dryRun: boolean; force: boolean; operation: SkillInstallOperation; + refreshExisting: boolean; + matchesSource: boolean; }): SkillInstallStatus { - if (input.operation === "update") { + if (input.operation === "update" || input.refreshExisting) { if (input.dryRun && input.exists) { - return "would_update"; + return input.matchesSource ? "already_present" : "would_update"; } if (input.dryRun) { return "would_install"; } if (input.exists) { - return "updated"; + return input.matchesSource ? "already_present" : "updated"; } return "installed"; } diff --git a/src/runtimes/ponytrail/instruction-context.ts b/src/runtimes/ponytrail/instruction-context.ts index 9994862b..a1c7231a 100644 --- a/src/runtimes/ponytrail/instruction-context.ts +++ b/src/runtimes/ponytrail/instruction-context.ts @@ -220,13 +220,23 @@ function readGitContext(rootDir: string): InstructionContextGit { } function runGit(rootDir: string, args: string[]): string | undefined { - const result = spawnSync("git", args, { cwd: rootDir }); + const result = spawnSync("git", args, { cwd: rootDir, env: withoutInheritedGitEnv() }); if (result.status !== 0) { return undefined; } return result.stdout.toString("utf8").trim(); } +function withoutInheritedGitEnv(): Record { + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if (key.startsWith("GIT_")) { + delete env[key]; + } + } + return env; +} + function isSkillMetadataPath(path: string): boolean { return path.endsWith("/SKILL.md") || path.endsWith(".yaml") || path.endsWith(".yml"); } diff --git a/tests/cli.test.ts b/tests/cli.test.ts index a1f66813..1d6d61d0 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildProgram, type GoalClarificationPrompter } from "../src/cli"; @@ -154,6 +154,35 @@ describe("cli", () => { } }); + test("onboard refreshes an older installed bundled skill", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "ponytrail-cli-")); + const homeDir = await mkdtemp(join(tmpdir(), "ponytrail-skill-home-")); + const installedSkillPath = join(homeDir, ".codex", "skills", "pony-trail", "SKILL.md"); + const logs: string[] = []; + const originalLog = console.log; + + console.log = (...values: unknown[]) => { + logs.push(values.join(" ")); + }; + + try { + await mkdir(join(homeDir, ".codex", "skills", "pony-trail"), { recursive: true }); + await writeFile(installedSkillPath, "stale pony trail skill"); + + await buildProgram({ cwd: rootDir }).parseAsync( + ["onboard", "--name", "Refresh Workspace", "--home", homeDir, "--agents", "codex"], + { from: "user" }, + ); + + expect(await readFile(installedSkillPath, "utf8")).toContain("name: pony-trail"); + expect(logs.some((line) => line.includes("codex: updated"))).toBe(true); + } finally { + console.log = originalLog; + await rm(rootDir, { recursive: true, force: true }); + await rm(homeDir, { recursive: true, force: true }); + } + }); + test("prints clarification questions for unclear goal requests in non-interactive mode", async () => { const rootDir = await mkdtemp(join(tmpdir(), "ponytrail-cli-")); const logs: string[] = []; diff --git a/tests/instruction-context.test.ts b/tests/instruction-context.test.ts index 8dbfadde..d59e1dce 100644 --- a/tests/instruction-context.test.ts +++ b/tests/instruction-context.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test"; import { execFile } from "node:child_process"; -import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { promisify } from "node:util"; import { captureInstructionContext, @@ -127,11 +127,38 @@ describe("instruction context", () => { } }); + test("ignores inherited Git hook environment when reading git context", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "ponytrail-instructions-")); + const inheritedGitDir = await currentWorktreeGitDir(); + const originalGitDir = process.env.GIT_DIR; + + try { + process.env.GIT_DIR = inheritedGitDir; + + const context = await captureInstructionContext({ + rootDir, + sessionId: "session-alpha", + timestampUtc: "2026-06-22T17:04:23Z", + }); + + expect(context.git.branch).toBeUndefined(); + expect(context.git.commit).toBeUndefined(); + expect(context.git.dirty).toBeUndefined(); + } finally { + if (originalGitDir === undefined) { + delete process.env.GIT_DIR; + } else { + process.env.GIT_DIR = originalGitDir; + } + await rm(rootDir, { recursive: true, force: true }); + } + }); + test("captures allowlisted git metadata when the root is a git repository", async () => { const rootDir = await mkdtemp(join(tmpdir(), "ponytrail-instructions-")); try { - await execFileAsync("git", ["init"], { cwd: rootDir }); + await execFileAsync("git", ["init"], { cwd: rootDir, env: withoutGitEnv() }); await writeFile(join(rootDir, "AGENTS.md"), "agent rules\n"); const context = await captureInstructionContext({ @@ -177,3 +204,23 @@ describe("instruction context", () => { ).toBe(false); }); }); + +async function currentWorktreeGitDir(): Promise { + const gitPath = join(process.cwd(), ".git"); + const content = await readFile(gitPath, "utf8"); + const gitDirPrefix = "gitdir:"; + if (content.startsWith(gitDirPrefix)) { + return resolve(process.cwd(), content.slice(gitDirPrefix.length).trim()); + } + return gitPath; +} + +function withoutGitEnv(): Record { + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if (key.startsWith("GIT_")) { + delete env[key]; + } + } + return env; +} diff --git a/tests/skill-installer.test.ts b/tests/skill-installer.test.ts index 331e26de..07cbadd7 100644 --- a/tests/skill-installer.test.ts +++ b/tests/skill-installer.test.ts @@ -111,6 +111,32 @@ describe("skill installer", () => { } }); + test("keeps matching bundled skill targets when update is requested", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "skill-installer-home-")); + + try { + await installAgentSkill({ + source: "pony-trail", + homeDir, + agents: ["codex"], + }); + + const result = await installAgentSkill({ + source: "pony-trail", + homeDir, + agents: ["codex"], + operation: "update", + }); + + expect(result.targets[0]).toMatchObject({ + agent: "codex", + status: "already_present", + }); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }); + test("dry-runs Cursor rule installation", async () => { const homeDir = await mkdtemp(join(tmpdir(), "skill-installer-home-")); From d6eb846d05e26ba51e0c4ed234a0b9d9f4013636 Mon Sep 17 00:00:00 2001 From: 0xRoy <1997roylee@gmail.com> Date: Tue, 23 Jun 2026 17:22:25 +0800 Subject: [PATCH 6/7] Add skill update welcome guidance --- src/cli.ts | 12 +++++ src/plugins/skill-installer.ts | 84 +++++++++++++++++++++++++++++----- tests/cli.test.ts | 13 ++++-- tests/skill-installer.test.ts | 7 ++- 4 files changed, 99 insertions(+), 17 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 8ec9ac5d..4d924a47 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -787,6 +787,8 @@ function printSkillInstallResult( console.log(`${pc.dim("Local history:")} ${history.snapshotId}`); console.log(`${pc.dim("History log:")} ${history.logPath}`); } + + printPostSkillChangeWelcome(result); } function formatSkillInstallStatus(status: SkillInstallResult["targets"][number]["status"]): string { @@ -843,6 +845,16 @@ function formatSkillInstallHistoryResult(result: SkillInstallResult): string { return [targetStatuses, prehookStatuses].filter(Boolean).join("; "); } +function printPostSkillChangeWelcome(result: SkillInstallResult): void { + if (result.dryRun) { + return; + } + + console.log(""); + console.log(pc.green("Welcome to Ponytrail.")); + console.log("Restart your agent IDE so it loads the latest Pony Trail skill."); +} + function formatAgentList(agents: string[]): string { return agents.join(", "); } diff --git a/src/plugins/skill-installer.ts b/src/plugins/skill-installer.ts index 4c0bddf6..731257e3 100644 --- a/src/plugins/skill-installer.ts +++ b/src/plugins/skill-installer.ts @@ -74,7 +74,7 @@ const agentSkillTargets: Record< > = { claude: { kind: "directory", path: [".claude", "skills"] }, copilot: { kind: "directory", path: [".agents", "skills"] }, - codex: { kind: "directory", path: [".codex", "skills"] }, + codex: { kind: "directory", path: [".agents", "skills"] }, cursor: { kind: "cursor_rule", path: [".cursor", "rules"] }, }; @@ -100,13 +100,47 @@ export async function installAgentSkill( const prehooks: SkillInstallPrehookResult[] = []; const operation = input.operation ?? "install"; const refreshExisting = operation === "update" || input.refreshExisting === true; + const handledDestinations = new Map(); for (const agent of input.agents) { const destination = getSkillDestination(input.homeDir, agent, source.name); + const mirrorDestinations = getSkillMirrorDestinations(input.homeDir, agent, source.name); + const handledStatus = handledDestinations.get(destination); + if (handledStatus) { + if ( + !input.dryRun && + handledStatus !== "skipped_exists" && + handledStatus !== "already_present" + ) { + for (const mirrorDestination of mirrorDestinations) { + if (await pathExists(mirrorDestination)) { + await rm(mirrorDestination, { recursive: true, force: true }); + } + await installSkillTarget({ agent, source, destination: mirrorDestination }); + } + } + targets.push({ agent, destination, status: handledStatus }); + if (input.installPrehook && hasPrehookSupport(agent)) { + prehooks.push( + await installAgentSkillPrehook({ + agent, + source, + homeDir: input.homeDir, + dryRun: input.dryRun ?? false, + }), + ); + } + continue; + } + const exists = await pathExists(destination); const matchesSource = exists && refreshExisting - ? await skillTargetMatchesSource({ agent, source, destination }) + ? await skillTargetsMatchSource({ + agent, + source, + destinations: [destination, ...mirrorDestinations], + }) : false; const status = getInstallStatus({ exists, @@ -122,8 +156,15 @@ export async function installAgentSkill( await rm(destination, { recursive: true, force: true }); } await installSkillTarget({ agent, source, destination }); + for (const mirrorDestination of mirrorDestinations) { + if (await pathExists(mirrorDestination)) { + await rm(mirrorDestination, { recursive: true, force: true }); + } + await installSkillTarget({ agent, source, destination: mirrorDestination }); + } } + handledDestinations.set(destination, status); targets.push({ agent, destination, status }); if (input.installPrehook && hasPrehookSupport(agent)) { @@ -221,6 +262,17 @@ function getSkillDestination(homeDir: string, agent: SkillInstallAgent, skillNam return join(homeDir, ...target.path, skillName); } +function getSkillMirrorDestinations( + homeDir: string, + agent: SkillInstallAgent, + skillName: string, +): string[] { + if (agent !== "codex") { + return []; + } + return [join(homeDir, ".codex", "skills", skillName)]; +} + async function installSkillTarget(input: { agent: SkillInstallAgent; source: ResolvedInstallSkillSource; @@ -237,21 +289,29 @@ async function installSkillTarget(input: { await cp(input.source.path, input.destination, { recursive: true }); } -async function skillTargetMatchesSource(input: { +async function skillTargetsMatchSource(input: { agent: SkillInstallAgent; source: ResolvedInstallSkillSource; - destination: string; + destinations: string[]; }): Promise { - try { - const target = agentSkillTargets[input.agent]; - if (target.kind === "cursor_rule") { - return (await readFile(input.destination, "utf8")) === (await renderCursorRule(input.source)); - } + for (const destination of input.destinations) { + try { + const target = agentSkillTargets[input.agent]; + if (target.kind === "cursor_rule") { + if ((await readFile(destination, "utf8")) !== (await renderCursorRule(input.source))) { + return false; + } + continue; + } - return directoryContentsMatch(input.source.path, input.destination); - } catch { - return false; + if (!(await directoryContentsMatch(input.source.path, destination))) { + return false; + } + } catch { + return false; + } } + return true; } async function directoryContentsMatch(sourceDir: string, targetDir: string): Promise { diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 1d6d61d0..f7b91646 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -147,6 +147,8 @@ describe("cli", () => { expect(promptedDefaults).toEqual([rootDir.slice(rootDir.lastIndexOf("/") + 1)]); expect(logs.some((line) => line.includes("Skill install result"))).toBe(true); expect(logs.some((line) => line.includes("Ponytrail onboarding complete"))).toBe(true); + expect(stripAnsiLines(logs)).toContain("Welcome to Ponytrail."); + expect(logs.some((line) => line.includes("Restart your agent IDE"))).toBe(true); } finally { console.log = originalLog; await rm(rootDir, { recursive: true, force: true }); @@ -157,7 +159,7 @@ describe("cli", () => { test("onboard refreshes an older installed bundled skill", async () => { const rootDir = await mkdtemp(join(tmpdir(), "ponytrail-cli-")); const homeDir = await mkdtemp(join(tmpdir(), "ponytrail-skill-home-")); - const installedSkillPath = join(homeDir, ".codex", "skills", "pony-trail", "SKILL.md"); + const installedSkillPath = join(homeDir, ".agents", "skills", "pony-trail", "SKILL.md"); const logs: string[] = []; const originalLog = console.log; @@ -166,7 +168,7 @@ describe("cli", () => { }; try { - await mkdir(join(homeDir, ".codex", "skills", "pony-trail"), { recursive: true }); + await mkdir(join(homeDir, ".agents", "skills", "pony-trail"), { recursive: true }); await writeFile(installedSkillPath, "stale pony trail skill"); await buildProgram({ cwd: rootDir }).parseAsync( @@ -364,6 +366,7 @@ describe("cli", () => { expect(logs.some((line) => line.includes("codex: would install"))).toBe(true); expect(logs.some((line) => line.includes("cursor: would install"))).toBe(true); expect(logs.some((line) => line.includes(".cursor/rules/pony-trail.mdc"))).toBe(true); + expect(stripAnsiLines(logs)).not.toContain("Welcome to Ponytrail."); } finally { console.log = originalLog; await rm(homeDir, { recursive: true, force: true }); @@ -446,6 +449,8 @@ describe("cli", () => { await expect( stat(join(homeDir, ".codex", "skills", "pony-trail", "SKILL.md")), ).resolves.toBeTruthy(); + expect(stripAnsiLines(logs)).toContain("Welcome to Ponytrail."); + expect(logs.some((line) => line.includes("Restart your agent IDE"))).toBe(true); expect(logs.some((line) => line.includes("Local history:"))).toBe(true); await buildProgram({ cwd: rootDir }).parseAsync(["history", "--details"], { from: "user" }); @@ -484,7 +489,7 @@ describe("cli", () => { test("skills update refreshes installed skill files and records a local project history commit", async () => { const rootDir = await mkdtemp(join(tmpdir(), "ponytrail-cli-")); const homeDir = await mkdtemp(join(tmpdir(), "ponytrail-skill-home-")); - const installedSkillPath = join(homeDir, ".codex", "skills", "pony-trail", "SKILL.md"); + const installedSkillPath = join(homeDir, ".agents", "skills", "pony-trail", "SKILL.md"); const logs: string[] = []; const originalLog = console.log; @@ -507,6 +512,8 @@ describe("cli", () => { expect(stripAnsiLines(logs)).toContain("Skill update result"); expect(logs.some((line) => line.includes("codex: updated"))).toBe(true); + expect(stripAnsiLines(logs)).toContain("Welcome to Ponytrail."); + expect(logs.some((line) => line.includes("Restart your agent IDE"))).toBe(true); expect(logs.some((line) => line.includes("Local history:"))).toBe(true); expect(await readFile(installedSkillPath, "utf8")).toContain("name: pony-trail"); diff --git a/tests/skill-installer.test.ts b/tests/skill-installer.test.ts index 07cbadd7..1927c7c3 100644 --- a/tests/skill-installer.test.ts +++ b/tests/skill-installer.test.ts @@ -70,7 +70,8 @@ describe("skill installer", () => { test("updates existing bundled skill targets when requested", async () => { const homeDir = await mkdtemp(join(tmpdir(), "skill-installer-home-")); - const installedSkillPath = join(homeDir, ".codex", "skills", "pony-trail", "SKILL.md"); + const installedSkillPath = join(homeDir, ".agents", "skills", "pony-trail", "SKILL.md"); + const legacyInstalledSkillPath = join(homeDir, ".codex", "skills", "pony-trail", "SKILL.md"); try { await installAgentSkill({ @@ -79,6 +80,7 @@ describe("skill installer", () => { agents: ["codex"], }); await writeFile(installedSkillPath, "stale skill"); + await writeFile(legacyInstalledSkillPath, "stale legacy skill"); const dryRun = await installAgentSkill({ source: "pony-trail", @@ -106,6 +108,7 @@ describe("skill installer", () => { status: "updated", }); expect(await readFile(installedSkillPath, "utf8")).toContain("name: pony-trail"); + expect(await readFile(legacyInstalledSkillPath, "utf8")).toContain("name: pony-trail"); } finally { await rm(homeDir, { recursive: true, force: true }); } @@ -276,7 +279,7 @@ describe("skill installer", () => { const rootDir = await mkdtemp(join(tmpdir(), "skill-installer-source-")); const homeDir = await mkdtemp(join(tmpdir(), "skill-installer-home-")); const sourceDir = join(rootDir, "custom-skill"); - const existingSkillDir = join(homeDir, ".codex", "skills", "custom-skill"); + const existingSkillDir = join(homeDir, ".agents", "skills", "custom-skill"); try { await mkdir(sourceDir, { recursive: true }); From b0ca18ecf95d0d2d688adb5f1d64c500476bcb57 Mon Sep 17 00:00:00 2001 From: 0xRoy <1997roylee@gmail.com> Date: Tue, 23 Jun 2026 17:39:45 +0800 Subject: [PATCH 7/7] Fix instruction context CI check --- biome.json | 1 - tests/instruction-context.test.ts | 6 +++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/biome.json b/biome.json index 026c84ce..5f01a5a2 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,4 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.0/schema.json", "formatter": { "enabled": true, "indentStyle": "space", diff --git a/tests/instruction-context.test.ts b/tests/instruction-context.test.ts index d59e1dce..84814e56 100644 --- a/tests/instruction-context.test.ts +++ b/tests/instruction-context.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { execFile } from "node:child_process"; -import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { promisify } from "node:util"; @@ -207,6 +207,10 @@ describe("instruction context", () => { async function currentWorktreeGitDir(): Promise { const gitPath = join(process.cwd(), ".git"); + if ((await stat(gitPath)).isDirectory()) { + return gitPath; + } + const content = await readFile(gitPath, "utf8"); const gitDirPrefix = "gitdir:"; if (content.startsWith(gitDirPrefix)) {