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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!-- prpm:snippet:start @agent-workforce/trail-snippet@1.1.0 -->
<!-- prpm:snippet:start @agent-workforce/trail-snippet@1.1.2 -->
# Trail

Record your work as a trajectory for future agents and humans to follow.
Expand Down Expand Up @@ -82,6 +82,20 @@ When done, complete with a retrospective:
trail complete --summary "Added JWT auth with refresh tokens" --confidence 0.85
```

After completing work, compact the finished trajectory or merged PR into a
durable summary. When the compacted summary is sufficient, discard the raw
source trajectories so `.trajectories/index.json` and list output stay focused:

```bash
trail compact --discard-sources
# or after a PR merge:
trail compact --pr 42 --discard-sources
```

`--discard-sources` removes the source trajectory JSON/Markdown/trace files and
updates the index. Use it after confirming the compacted artifact is the record
you want to keep.

**Confidence levels:**
- 0.9+ : High confidence, well-tested
- 0.7-0.9 : Good confidence, standard implementation
Expand Down Expand Up @@ -122,23 +136,26 @@ trail export <trajectory-id> --format markdown

## Compacting Trajectories

After a PR merge, compact related trajectories into a single summary:
After a PR merge, compact related trajectories into a single summary and prune
raw source trajectories when the summary should replace them:

```bash
trail compact --pr 42
trail compact --pr 42 --discard-sources
```

Compact by branch:
```bash
trail compact --branch feature/auth
trail compact --branch feature/auth --discard-sources
```

Compact by commit range:
```bash
trail compact --commits abc123..def456
trail compact --commits abc123..def456 --discard-sources
```

Compaction consolidates decisions and creates a grouped summary, reducing noise while preserving key decisions.
Compaction consolidates decisions and creates a grouped summary. Adding
`--discard-sources` makes the compacted artifact the durable record by removing
the raw trajectories and their index entries.

## Why Trail?

Expand All @@ -149,7 +166,7 @@ Your trajectory helps others understand:
- **What challenges** you faced

Future agents can query past trajectories to learn from your decisions.
<!-- prpm:snippet:end @agent-workforce/trail-snippet@1.1.0 -->
<!-- prpm:snippet:end @agent-workforce/trail-snippet@1.1.2 -->

<!-- prpm:snippet:start @agent-relay/agent-relay-snippet@1.1.7 -->
# Agent Relay
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,25 +132,28 @@ trail compact --commits abc1234,def5678 # Trajectories matching specific commit
trail compact --pr 123 # Trajectories mentioning PR #123
trail compact --since 7d # Last 7 days
trail compact --all # Everything (including previously compacted)
trail compact --pr 123 --discard-sources # Delete source trajectories and update index after compaction
```

### Automatic Compaction (GitHub Action)

Add these steps to any workflow that runs on PR merge (e.g., your release or publish flow). Requires `ref: ${{ github.event.pull_request.base.ref }}` and `fetch-depth: 0` on checkout, plus `contents: write` permission:
Add these steps to any workflow that runs on PR merge (e.g., your release or publish flow). Requires `ref: ${{ github.event.pull_request.base.ref }}` and `fetch-depth: 0` on checkout, plus `contents: write` permission.

Use `--discard-sources` when the compacted summary should replace the raw source trajectories. This removes the source JSON/Markdown/trace files and updates `.trajectories/index.json`, reducing future list/search noise.

```yaml
- name: Compact trajectories
run: |
PR_COMMITS=$(git log ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} --format=%H | paste -sd, -)
OUTPUT=".trajectories/compacted/pr-${{ github.event.pull_request.number }}.json"
if [ -n "$PR_COMMITS" ]; then
npx agent-trajectories compact --commits "$PR_COMMITS" --output "$OUTPUT"
npx agent-trajectories compact --commits "$PR_COMMITS" --output "$OUTPUT" --discard-sources
else
npx agent-trajectories compact --pr ${{ github.event.pull_request.number }} --output "$OUTPUT"
npx agent-trajectories compact --pr ${{ github.event.pull_request.number }} --output "$OUTPUT" --discard-sources
fi
- name: Commit compacted trajectories
run: |
git add .trajectories/compacted/ || true
git add .trajectories/ || true
git diff --cached --quiet || \
(git commit -m "chore: compact trajectories for PR #${{ github.event.pull_request.number }}" && git push)
```
Expand Down
27 changes: 22 additions & 5 deletions docs/trail-snippet.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,20 @@ When done, complete with a retrospective:
trail complete --summary "Added JWT auth with refresh tokens" --confidence 0.85
```

After completing work, compact the finished trajectory or merged PR into a
durable summary. When the compacted summary is sufficient, discard the raw
source trajectories so `.trajectories/index.json` and list output stay focused:

```bash
trail compact --discard-sources
# or after a PR merge:
trail compact --pr 42 --discard-sources
```

`--discard-sources` removes the source trajectory JSON/Markdown/trace files and
updates the index. Use it after confirming the compacted artifact is the record
you want to keep.

**Confidence levels:**
- 0.9+ : High confidence, well-tested
- 0.7-0.9 : Good confidence, standard implementation
Expand Down Expand Up @@ -121,23 +135,26 @@ trail export <trajectory-id> --format markdown

## Compacting Trajectories

After a PR merge, compact related trajectories into a single summary:
After a PR merge, compact related trajectories into a single summary and prune
raw source trajectories when the summary should replace them:

```bash
trail compact --pr 42
trail compact --pr 42 --discard-sources
```

Compact by branch (finds trajectories with commits not in the specified base branch):
```bash
trail compact --branch main
trail compact --branch main --discard-sources
```

Compact by specific commits:
```bash
trail compact --commits abc123,def456
trail compact --commits abc123,def456 --discard-sources
```

Compaction consolidates decisions and creates a grouped summary, reducing noise while preserving key decisions.
Compaction consolidates decisions and creates a grouped summary. Adding
`--discard-sources` makes the compacted artifact the durable record by removing
the raw trajectories and their index entries.

## Why Trail?

Expand Down
4 changes: 1 addition & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,7 @@
"type": "git",
"url": "https://github.com/AgentWorkforce/trajectories"
},
"files": [
"dist"
],
"files": ["dist"],
"engines": {
"node": ">=20.0.0"
},
Expand Down
2 changes: 1 addition & 1 deletion prpm.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"packages": [
{
"name": "trail-snippet",
"version": "1.1.1",
"version": "1.1.2",
"description": "AGENTS.md / CLAUDE.md snippet for agents on how to use trail to record their work",
"format": "generic",
"subtype": "snippet",
Expand Down
150 changes: 139 additions & 11 deletions src/cli/commands/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@
*/

import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import {
existsSync,
mkdirSync,
readFileSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import type { Command } from "commander";
import { getCompactionConfig } from "../../compact/config.js";
Expand Down Expand Up @@ -75,6 +81,12 @@ interface IndexEntry {
compactedInto?: string;
}

interface TrajectoryIndex {
version: number;
lastUpdated: string;
trajectories: Record<string, IndexEntry>;
}

interface CompactCommandOptions {
since?: string;
until?: string;
Expand All @@ -88,10 +100,18 @@ interface CompactCommandOptions {
mechanical?: boolean;
focus?: string;
markdown?: boolean;
discardSources?: boolean;
dryRun?: boolean;
output?: string;
}

interface DiscardSourcesSummary {
removedIndexEntries: number;
deletedJsonFiles: number;
deletedMarkdownFiles: number;
deletedTraceFiles: number;
}

interface LLMCompactionPlan {
messages: Message[];
estimatedInputTokens: number;
Expand Down Expand Up @@ -137,6 +157,10 @@ export function registerCompactCommand(program: Command): void {
)
.option("--markdown", "Also write a Markdown companion file")
.option("--no-markdown", "Skip writing a Markdown companion file")
.option(
"--discard-sources",
"After saving the compaction, delete source trajectory JSON/MD/trace files and remove their index entries",
)
.option("--dry-run", "Preview what would be compacted without saving")
.option("--output <path>", "Output path for compacted trajectory")
.action(async (options: CompactCommandOptions) => {
Expand Down Expand Up @@ -193,7 +217,15 @@ export function registerCompactCommand(program: Command): void {
outputPath,
markdownEnabled,
);
await markTrajectoriesAsCompacted(trajectories, mechanicalCompacted.id);
if (options.discardSources) {
const discardSummary = discardSourceTrajectories(trajectories);
printDiscardSummary(discardSummary);
} else {
await markTrajectoriesAsCompacted(
trajectories,
mechanicalCompacted.id,
);
}

console.log(`\nCompacted trajectory saved to: ${outputPath}`);
if (markdownEnabled) {
Expand Down Expand Up @@ -252,7 +284,12 @@ export function registerCompactCommand(program: Command): void {
const outputPath =
options.output || getDefaultOutputPath(compacted, options.workflow);
saveCompactionArtifacts(compacted, outputPath, markdownEnabled);
await markTrajectoriesAsCompacted(trajectories, compacted.id);
if (options.discardSources) {
const discardSummary = discardSourceTrajectories(trajectories);
printDiscardSummary(discardSummary);
} else {
await markTrajectoriesAsCompacted(trajectories, compacted.id);
}

console.log(`\nCompacted trajectory saved to: ${outputPath}`);
if (markdownEnabled) {
Expand Down Expand Up @@ -441,9 +478,7 @@ function getCompactedTrajectoryIds(): Set<string> {

try {
const indexContent = readFileSync(indexPath, "utf-8");
const index = JSON.parse(indexContent) as {
trajectories: Record<string, IndexEntry>;
};
const index = JSON.parse(indexContent) as TrajectoryIndex;

for (const [id, entry] of Object.entries(index.trajectories || {})) {
if (entry.compactedInto) {
Expand Down Expand Up @@ -473,11 +508,7 @@ async function markTrajectoriesAsCompacted(

try {
const indexContent = readFileSync(indexPath, "utf-8");
const index = JSON.parse(indexContent) as {
version: number;
lastUpdated: string;
trajectories: Record<string, IndexEntry>;
};
const index = JSON.parse(indexContent) as TrajectoryIndex;

let updated = false;
for (const traj of trajectories) {
Expand All @@ -497,6 +528,103 @@ async function markTrajectoriesAsCompacted(
}
}

/**
* Remove raw source trajectories after a durable compacted artifact has
* been written. This keeps compaction as the long-lived record and makes
* the index reflect only material that should remain visible.
*/
function discardSourceTrajectories(
trajectories: Trajectory[],
): DiscardSourcesSummary {
const sourceIds = new Set(trajectories.map((trajectory) => trajectory.id));
const summary: DiscardSourcesSummary = {
removedIndexEntries: 0,
deletedJsonFiles: 0,
deletedMarkdownFiles: 0,
deletedTraceFiles: 0,
};

for (const searchPath of getSearchPaths()) {
const indexPath = join(searchPath, "index.json");
if (!existsSync(indexPath)) continue;

let index: TrajectoryIndex;
try {
const indexContent = readFileSync(indexPath, "utf-8");
const parsedIndex = JSON.parse(indexContent) as unknown;
if (!isTrajectoryIndex(parsedIndex)) {
continue;
}
index = parsedIndex;
} catch {
// Keep behavior consistent with markTrajectoriesAsCompacted: malformed
// indexes are ignored instead of blocking an already-saved compaction.
continue;
}

let updated = false;
for (const id of sourceIds) {
const entry = index.trajectories[id];
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Guard missing index maps during source discard

Handle structurally invalid index.json the same way as markTrajectoriesAsCompacted(): in discardSourceTrajectories(), index.trajectories is dereferenced without a guard, so a valid-but-corrupt index (for example { "version": 1 } from manual edits or partial writes in any search path) throws after the compaction artifact has already been written. That turns trail compact --discard-sources into a hard failure and can leave users with partial side effects instead of the intended best-effort cleanup.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in c6ba4cc by validating the parsed index shape before touching index.trajectories, so structurally invalid indexes are skipped during best-effort source discard.

if (!entry) continue;

if (deleteFileIfExists(entry.path)) {
summary.deletedJsonFiles += 1;
}
if (deleteFileIfExists(getMarkdownOutputPath(entry.path))) {
summary.deletedMarkdownFiles += 1;
}
if (deleteFileIfExists(getTraceOutputPath(entry.path))) {
summary.deletedTraceFiles += 1;
}

delete index.trajectories[id];
summary.removedIndexEntries += 1;
updated = true;
}

if (updated) {
index.lastUpdated = new Date().toISOString();
writeFileSync(indexPath, JSON.stringify(index, null, 2));
}
}

return summary;
}

function deleteFileIfExists(path: string): boolean {
if (!existsSync(path)) {
return false;
}

unlinkSync(path);
return true;
}

function isTrajectoryIndex(value: unknown): value is TrajectoryIndex {
if (value === null || typeof value !== "object") {
return false;
}

const candidate = value as Partial<TrajectoryIndex>;
return (
candidate.trajectories !== null &&
typeof candidate.trajectories === "object" &&
!Array.isArray(candidate.trajectories)
);
}

function getTraceOutputPath(outputPath: string): string {
return outputPath.endsWith(".json")
? outputPath.slice(0, -".json".length).concat(".trace.json")
: `${outputPath}.trace.json`;
}

function printDiscardSummary(summary: DiscardSourcesSummary): void {
console.log(
`Discarded source trajectories: ${summary.removedIndexEntries} index entries, ${summary.deletedJsonFiles} JSON files, ${summary.deletedMarkdownFiles} Markdown files, ${summary.deletedTraceFiles} trace files`,
);
}

function parseRelativeDate(input: string): Date {
// Handle relative dates like "7d", "2w", "1m"
const match = input.match(/^(\d+)([dwmh])$/);
Expand Down
Loading
Loading