Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion bundled-skills/pony-trail/scripts/snapshot_change.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ 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.

Use --instruction-context to opt in to hashing known local instruction files
without recording raw prompts, transcripts, or instruction text.

pre:
snapshot_change.sh pre --files FILE... --action TEXT --purpose TEXT --reason TEXT \
--expected TEXT --verify TEXT --rollback TEXT [--snapshot-id ID]
Expand Down Expand Up @@ -114,6 +117,62 @@ sha256_file() {
fi
}

instruction_context_file_json() {
rel_path=$1
path=$root/$rel_path

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] This hashes $root/$rel_path without resolving the target and checking that it still lives inside $root. Because .cursor/rules/* can include symlinks, --instruction-context can hash a file outside the workspace while reporting only the workspace-relative symlink path. I reproduced this with .cursor/rules/external.mdc -> /private/tmp/...; the snapshot marked it captured and logged the external target hash. Please skip symlinks/out-of-root resolved paths, or mark them with a non-captured warning status.


printf '{"path":'
json_string "$rel_path"

if [ ! -e "$path" ]; then
printf ',"status":"missing"}'
return
fi

if [ ! -r "$path" ]; then
printf ',"status":"unreadable"}'
return
fi

if [ ! -f "$path" ]; then
printf ',"status":"skipped_not_file"}'
return
fi

size=$(stat_size "$path")
digest=$(sha256_file "$path")
printf ',"status":"captured","sha256":'
json_string "$digest"
printf ',"bytes":%s}' "$size"
}

append_instruction_context_file() {
rel_path=$1
if [ -n "$instruction_context_files_json" ]; then
instruction_context_files_json="$instruction_context_files_json,$(instruction_context_file_json "$rel_path")"
else
instruction_context_files_json="$(instruction_context_file_json "$rel_path")"
fi
}

instruction_context_json() {
instruction_context_files_json=""

append_instruction_context_file "AGENTS.md"
append_instruction_context_file "CLAUDE.md"
append_instruction_context_file ".github/copilot-instructions.md"

for candidate in "$root"/.cursor/rules/* "$root"/.ponytrail/skills/*; do
[ -e "$candidate" ] || continue
rel_path=${candidate#"$root"/}
append_instruction_context_file "$rel_path"
done

printf '{"mode":"opt_in","captured_at":'
json_string "$timestamp_utc"
printf ',"raw_instruction_text_included":false,"raw_prompts_included":false,"files":[%s]}' "$instruction_context_files_json"
}

dirname_of() {
dirname "$1"
}
Expand Down Expand Up @@ -236,6 +295,10 @@ entry_json() {
json_string "$root"
printf ',"git":'
git_json
if [ "$instruction_context" = "1" ]; then
printf ',"instruction_context":'
instruction_context_json
fi
printf ',"action":'
json_optional_string "$action"
printf ',"purpose":'
Expand Down Expand Up @@ -333,6 +396,7 @@ append_session_tree_entry() {
root="."
store="$DEFAULT_STORE"
copy_limit="$DEFAULT_COPY_LIMIT"
instruction_context="0"
session_id="${PONYTRAIL_SESSION_ID:-${Ponytrail_SESSION_ID:-default}}"
phase=""
snapshot_id=""
Expand Down Expand Up @@ -369,6 +433,10 @@ while [ "$#" -gt 0 ]; do
session_id=$2
shift 2
;;
--instruction-context)
instruction_context="1"
shift
;;
pre|post)
phase=$1
shift
Expand Down Expand Up @@ -455,6 +523,10 @@ while [ "$#" -gt 0 ]; do
result=$2
shift 2
;;
--instruction-context)
instruction_context="1"
shift
;;
*)
fail "Unknown option: $1"
;;
Expand Down
5 changes: 5 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,11 @@ function printSnapshotCommitDetails(commit: SnapshotCommit): void {
for (const file of commit.files) {
console.log(` ${formatSnapshotFile(file)}`);
}
if (commit.instructionContext) {
console.log(
` instruction_context: ${commit.instructionContext.files.length} files, raw_text=false`,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes the details view less useful than the issue asks for. history --json includes the file paths and hashes, but history --details only prints a count and raw_text=false, so a human cannot compare which instruction file changed between snapshots. Please render at least path, status, bytes, and hash prefix/full hash in details mode while keeping the default compact view small.

);
}
}

function parseSnapshotHistoryMode(mode: string): SnapshotHistoryMode {
Expand Down
94 changes: 94 additions & 0 deletions src/runtimes/ponytrail/snapshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@ export interface SnapshotFileState {
sha256?: string | undefined;
}

export interface SnapshotInstructionContextFile {
path: string;
status: "captured" | "missing" | "unreadable" | "skipped_not_file";
sha256?: string | undefined;
bytes?: number | undefined;
}

export interface SnapshotInstructionContext {
mode: "opt_in";
captured_at: string;
raw_instruction_text_included: false;
raw_prompts_included: false;
files: SnapshotInstructionContextFile[];
}

export interface SnapshotLogEntry {
snapshot_id: string;
session_id: string;
Expand All @@ -34,6 +49,7 @@ export interface SnapshotLogEntry {
checks?: string | null | undefined;
result?: string | null | undefined;
files: SnapshotFileState[];
instruction_context?: SnapshotInstructionContext | undefined;
}

export interface SnapshotHistory {
Expand All @@ -58,6 +74,7 @@ export interface SnapshotCommit {
result?: string | undefined;
rollback?: string | undefined;
files: SnapshotFileState[];
instructionContext?: SnapshotInstructionContext | undefined;
}

export type SnapshotRevertAction =
Expand Down Expand Up @@ -97,6 +114,7 @@ export interface RecordSnapshotPreInput {
verify: string;
rollback: string;
files?: SnapshotFileState[] | undefined;
instructionContext?: SnapshotInstructionContext | undefined;
}

export interface RecordSnapshotPostInput {
Expand All @@ -108,6 +126,7 @@ export interface RecordSnapshotPostInput {
checks: string;
result: string;
files?: SnapshotFileState[] | undefined;
instructionContext?: SnapshotInstructionContext | undefined;
}

interface SnapshotPair {
Expand Down Expand Up @@ -135,6 +154,7 @@ export async function recordSnapshotPre(
verify: input.verify,
rollback: input.rollback,
files: input.files ?? [],
instruction_context: input.instructionContext,
});
}

Expand All @@ -152,6 +172,7 @@ export async function recordSnapshotPost(
checks: input.checks,
result: input.result,
files: input.files ?? [],
instruction_context: input.instructionContext,
});
}

Expand Down Expand Up @@ -388,6 +409,8 @@ function parseSnapshotEntry(value: unknown, lineNumber: number): SnapshotLogEntr
throw new Error(`Malformed snapshot log entry on line ${lineNumber}: files must be an array`);
}

const instructionContext = value.instruction_context;

return {
snapshot_id: snapshotId,
session_id: sessionId,
Expand All @@ -403,6 +426,76 @@ 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: instructionContext
? parseInstructionContext(instructionContext, lineNumber)
: undefined,
};
}

function parseInstructionContext(value: unknown, lineNumber: number): SnapshotInstructionContext {
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}: invalid instruction_context mode`,
);
}

if (value.raw_instruction_text_included !== false || value.raw_prompts_included !== false) {
throw new Error(
`Malformed snapshot log entry on line ${lineNumber}: instruction_context must not include raw prompts or instruction text`,
);
}

if (!Array.isArray(value.files)) {
throw new Error(
`Malformed snapshot log entry on line ${lineNumber}: instruction_context files must be an array`,
);
}

return {
mode,
captured_at: readRequiredString(value, "captured_at", lineNumber),
raw_instruction_text_included: false,
raw_prompts_included: false,
files: value.files.map((file, index) => parseInstructionContextFile(file, lineNumber, index)),
};
}

function parseInstructionContextFile(
value: unknown,
lineNumber: number,
index: number,
): SnapshotInstructionContextFile {
if (!isRecord(value)) {
throw new Error(
`Malformed snapshot log entry on line ${lineNumber}: instruction_context file ${index} must be an object`,
);
}

const status = readRequiredString(value, "status", lineNumber);
if (
status !== "captured" &&
status !== "missing" &&
status !== "unreadable" &&
status !== "skipped_not_file"
) {
throw new Error(
`Malformed snapshot log entry on line ${lineNumber}: invalid instruction_context file status`,
);
}

return {
path: readRequiredString(value, "path", lineNumber),
status,
sha256: readOptionalString(value.sha256),
bytes:
typeof value.bytes === "number" && Number.isFinite(value.bytes) ? value.bytes : undefined,
};
}

Expand Down Expand Up @@ -456,6 +549,7 @@ function toSnapshotCommit(
result: post?.result ?? undefined,
rollback: pre?.rollback ?? undefined,
files: Array.from(filesByPath.values()),
instructionContext: pre?.instruction_context ?? post?.instruction_context,
};
}

Expand Down
67 changes: 67 additions & 0 deletions tests/pony-trail-script.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,73 @@ describe("pony-trail shell helper", () => {
}
});

test("optionally records hashed instruction context without raw instruction text", async () => {
const rootDir = await mkdtemp(join(tmpdir(), "pony-trail-instructions-"));
const snapshotId = "instruction-context-test-001";

try {
await writeFile(join(rootDir, "AGENTS.md"), "Do useful work.\n");
await writeFile(join(rootDir, "notes.txt"), "before\n");

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 capture",
"--reason",
"Snapshots should explain active instruction files without recording prompts",
"--expected",
"A pre snapshot includes instruction hashes",
"--verify",
"Run this test",
"--rollback",
"Restore the stored pre snapshot",
]);

const [entry] = (await readFile(join(rootDir, ".pony-trail", "snapshots.jsonl"), "utf8"))
.trim()
.split("\n")
.map((line) => JSON.parse(line));

expect(entry.instruction_context).toMatchObject({
mode: "opt_in",
raw_instruction_text_included: false,
raw_prompts_included: false,
});
expect(entry.instruction_context.files).toContainEqual(
expect.objectContaining({
path: "AGENTS.md",
status: "captured",
bytes: 16,
}),
);
expect(entry.instruction_context.files).toContainEqual(
expect.objectContaining({
path: "CLAUDE.md",
status: "missing",
}),
);
expect(JSON.stringify(entry.instruction_context)).not.toContain("Do useful work");
const agentsFile = entry.instruction_context.files.find(
(file: { path: string; sha256?: string }) => file.path === "AGENTS.md",
);
expect(agentsFile?.sha256).toBeString();
} finally {
await rm(rootDir, { recursive: true, force: true });
}
});

test("pre-file-change hook emits pony-trail context", async () => {
const hook = await execFileAsync("sh", [preFileChangeHookPath]);

Expand Down
Loading