From 00132e4a40ef1698da994b188f6832a52a852dac Mon Sep 17 00:00:00 2001 From: Agent Relay Date: Thu, 19 Feb 2026 18:50:20 +0000 Subject: [PATCH 1/8] feat: add trajectory compaction for PR merges Add `trail compact` command to consolidate multiple trajectories into a summarized form with grouped decisions. Useful for reducing context accumulation after PR merges. Features: - `--since ` - Compact trajectories since date (ISO or relative like "7d") - `--pr ` - Compact trajectories associated with a PR - `--ids ` - Compact specific trajectory IDs - `--dry-run` - Preview without saving Compaction groups similar decisions by category (architecture, api, database, testing, security, performance, tooling, naming, compliance). Also adds GitHub Action workflow that automatically runs compaction when PRs are merged to main. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/compact-on-merge.yml | 60 ++++ README.md | 5 + src/cli/commands/compact.ts | 428 +++++++++++++++++++++++++ src/cli/commands/index.ts | 5 +- 4 files changed, 497 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/compact-on-merge.yml create mode 100644 src/cli/commands/compact.ts diff --git a/.github/workflows/compact-on-merge.yml b/.github/workflows/compact-on-merge.yml new file mode 100644 index 0000000..f818bbb --- /dev/null +++ b/.github/workflows/compact-on-merge.yml @@ -0,0 +1,60 @@ +name: Compact Trajectories on PR Merge + +on: + pull_request: + types: [closed] + branches: + - main + +jobs: + compact: + # Only run when PR is actually merged, not just closed + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Need full history for trajectory lookup + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Compact trajectories for this PR + id: compact + run: | + # Run compact command for this PR + npx trail compact --pr ${{ github.event.pull_request.number }} --output .trajectories/compacted/pr-${{ github.event.pull_request.number }}.json + + # Check if any compaction was done + if [ -f ".trajectories/compacted/pr-${{ github.event.pull_request.number }}.json" ]; then + echo "compacted=true" >> $GITHUB_OUTPUT + else + echo "compacted=false" >> $GITHUB_OUTPUT + fi + + - name: Commit compacted trajectories + if: steps.compact.outputs.compacted == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add .trajectories/compacted/ + git commit -m "chore: compact trajectories for PR #${{ github.event.pull_request.number }} + + Automatically generated trajectory compaction. + + Source PR: #${{ github.event.pull_request.number }} + PR Title: ${{ github.event.pull_request.title }}" || echo "No changes to commit" + git push diff --git a/README.md b/README.md index 5ef9a9b..31c25a7 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,11 @@ trail list --search "auth" # Export for documentation (markdown, json, timeline, or html) trail export traj_abc123 --format markdown trail export --format html --open # Opens in browser + +# Compact trajectories (consolidate similar decisions) +trail compact --since 7d # Compact last 7 days +trail compact --pr 123 # Compact trajectories for a PR +trail compact --ids traj_a,traj_b # Compact specific trajectories ``` ### SDK diff --git a/src/cli/commands/compact.ts b/src/cli/commands/compact.ts new file mode 100644 index 0000000..f035d39 --- /dev/null +++ b/src/cli/commands/compact.ts @@ -0,0 +1,428 @@ +/** + * trail compact command + * + * Compresses multiple trajectories into a single compacted summary. + * Useful for reducing context after PR merges by organizing similar + * decisions into grouped, understandable summaries. + */ + +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { Command } from "commander"; +import type { + Decision, + Trajectory, + TrajectoryEvent, +} from "../../core/types.js"; +import { FileStorage, getSearchPaths } from "../../storage/file.js"; +import { generateRandomId } from "../../core/id.js"; + +/** + * A group of related decisions + */ +interface DecisionGroup { + category: string; + decisions: Array<{ + question: string; + chosen: string; + reasoning: string; + fromTrajectory: string; + }>; +} + +/** + * Compacted trajectory summary + */ +interface CompactedTrajectory { + id: string; + version: 1; + type: "compacted"; + compactedAt: string; + sourceTrajectories: string[]; + dateRange: { + start: string; + end: string; + }; + summary: { + totalDecisions: number; + totalEvents: number; + uniqueAgents: string[]; + }; + decisionGroups: DecisionGroup[]; + keyLearnings: string[]; + keyFindings: string[]; + filesAffected: string[]; + commits: string[]; +} + +export function registerCompactCommand(program: Command): void { + program + .command("compact") + .description("Compact trajectories into a summarized form") + .option( + "--since ", + "Include trajectories since this date (ISO format or relative like '7d')", + ) + .option( + "--until ", + "Include trajectories until this date (ISO format)", + ) + .option("--ids ", "Comma-separated list of trajectory IDs to compact") + .option("--pr ", "Compact trajectories associated with a PR number") + .option("--dry-run", "Preview what would be compacted without saving") + .option("--output ", "Output path for compacted trajectory") + .action(async (options) => { + const trajectories = await loadTrajectories(options); + + if (trajectories.length === 0) { + console.log("No trajectories found matching criteria"); + return; + } + + console.log(`Compacting ${trajectories.length} trajectories...\n`); + + const compacted = compactTrajectories(trajectories); + + if (options.dryRun) { + console.log("=== DRY RUN - Preview ===\n"); + printCompactedSummary(compacted); + return; + } + + // Save the compacted trajectory + const outputPath = options.output || getDefaultOutputPath(compacted); + saveCompactedTrajectory(compacted, outputPath); + + console.log(`\nCompacted trajectory saved to: ${outputPath}`); + printCompactedSummary(compacted); + }); +} + +async function loadTrajectories(options: { + since?: string; + until?: string; + ids?: string; + pr?: string; +}): Promise { + const trajectories: Trajectory[] = []; + const targetIds = options.ids ? options.ids.split(",").map((s) => s.trim()) : null; + + // Parse date filters + const sinceDate = options.since ? parseRelativeDate(options.since) : null; + const untilDate = options.until ? new Date(options.until) : null; + + const searchPaths = getSearchPaths(); + const seenIds = new Set(); + + for (const searchPath of searchPaths) { + if (!existsSync(searchPath)) continue; + + const originalDataDir = process.env.TRAJECTORIES_DATA_DIR; + process.env.TRAJECTORIES_DATA_DIR = searchPath; + + try { + const storage = new FileStorage(); + await storage.initialize(); + + const summaries = await storage.list({ status: "completed" }); + + for (const summary of summaries) { + if (seenIds.has(summary.id)) continue; + + // Filter by IDs if specified + if (targetIds && !targetIds.includes(summary.id)) continue; + + // Filter by date range + const startDate = new Date(summary.startedAt); + if (sinceDate && startDate < sinceDate) continue; + if (untilDate && startDate > untilDate) continue; + + // Load full trajectory + const trajectory = await storage.get(summary.id); + if (trajectory) { + seenIds.add(summary.id); + + // Filter by PR if specified + if (options.pr) { + const prPattern = new RegExp(`#${options.pr}\\b|PR.*${options.pr}`, "i"); + const matchesPR = + prPattern.test(trajectory.task.title) || + prPattern.test(trajectory.task.description || "") || + trajectory.commits.some((c) => prPattern.test(c)); + + if (!matchesPR) continue; + } + + trajectories.push(trajectory); + } + } + } finally { + if (originalDataDir !== undefined) { + process.env.TRAJECTORIES_DATA_DIR = originalDataDir; + } else { + process.env.TRAJECTORIES_DATA_DIR = undefined; + } + } + } + + return trajectories; +} + +function parseRelativeDate(input: string): Date { + // Handle relative dates like "7d", "2w", "1m" + const match = input.match(/^(\d+)([dwmh])$/); + if (match) { + const amount = Number.parseInt(match[1], 10); + const unit = match[2]; + const now = new Date(); + + switch (unit) { + case "h": + return new Date(now.getTime() - amount * 60 * 60 * 1000); + case "d": + return new Date(now.getTime() - amount * 24 * 60 * 60 * 1000); + case "w": + return new Date(now.getTime() - amount * 7 * 24 * 60 * 60 * 1000); + case "m": + return new Date(now.getTime() - amount * 30 * 24 * 60 * 60 * 1000); + } + } + + // Otherwise try to parse as ISO date + return new Date(input); +} + +function compactTrajectories(trajectories: Trajectory[]): CompactedTrajectory { + const allDecisions: Array<{ + decision: Decision; + fromTrajectory: string; + timestamp: number; + }> = []; + const allLearnings: string[] = []; + const allFindings: string[] = []; + const allFiles = new Set(); + const allCommits = new Set(); + const allAgents = new Set(); + let totalEvents = 0; + + // Extract data from all trajectories + for (const traj of trajectories) { + // Collect agents + for (const agent of traj.agents) { + allAgents.add(agent.name); + } + + // Collect files and commits + for (const file of traj.filesChanged) { + allFiles.add(file); + } + for (const commit of traj.commits) { + allCommits.add(commit); + } + + // Extract decisions from chapters + for (const chapter of traj.chapters) { + totalEvents += chapter.events.length; + + for (const event of chapter.events) { + if (event.type === "decision" && event.raw) { + const decision = event.raw as Decision; + allDecisions.push({ + decision, + fromTrajectory: traj.id, + timestamp: event.ts, + }); + } + + if (event.type === "finding" && event.content) { + allFindings.push(event.content); + } + } + } + + // Extract learnings from retrospective + if (traj.retrospective?.learnings) { + allLearnings.push(...traj.retrospective.learnings); + } + + // Also extract decisions from retrospective + if (traj.retrospective?.decisions) { + for (const decision of traj.retrospective.decisions) { + allDecisions.push({ + decision, + fromTrajectory: traj.id, + timestamp: new Date(traj.completedAt || traj.startedAt).getTime(), + }); + } + } + } + + // Group decisions by category/topic + const decisionGroups = groupDecisions(allDecisions); + + // Dedupe learnings + const uniqueLearnings = [...new Set(allLearnings)]; + + // Calculate date range + const dates = trajectories.map((t) => new Date(t.startedAt).getTime()); + const minDate = new Date(Math.min(...dates)); + const maxDate = new Date( + Math.max(...trajectories.map((t) => new Date(t.completedAt || t.startedAt).getTime())), + ); + + return { + id: `compact_${generateRandomId()}`, + version: 1, + type: "compacted", + compactedAt: new Date().toISOString(), + sourceTrajectories: trajectories.map((t) => t.id), + dateRange: { + start: minDate.toISOString(), + end: maxDate.toISOString(), + }, + summary: { + totalDecisions: allDecisions.length, + totalEvents, + uniqueAgents: [...allAgents], + }, + decisionGroups, + keyLearnings: uniqueLearnings, + keyFindings: [...new Set(allFindings)], + filesAffected: [...allFiles], + commits: [...allCommits], + }; +} + +function groupDecisions( + decisions: Array<{ + decision: Decision; + fromTrajectory: string; + timestamp: number; + }>, +): DecisionGroup[] { + // Simple categorization based on keywords in the question/reasoning + const categories: Record = {}; + + const categoryKeywords: Record = { + architecture: ["architecture", "structure", "pattern", "design", "module", "component"], + api: ["api", "endpoint", "rest", "graphql", "http", "request", "response"], + database: ["database", "schema", "migration", "query", "sql", "model"], + testing: ["test", "spec", "coverage", "assertion", "mock"], + security: ["security", "auth", "permission", "token", "credential", "encrypt"], + performance: ["performance", "optimize", "cache", "speed", "memory"], + tooling: ["tool", "config", "build", "lint", "format", "ci", "cd"], + naming: ["name", "rename", "convention", "format"], + compliance: ["spec", "standard", "compliance", "convention", "align"], + }; + + for (const { decision, fromTrajectory } of decisions) { + const text = `${decision.question} ${decision.reasoning}`.toLowerCase(); + + let matchedCategory = "other"; + for (const [category, keywords] of Object.entries(categoryKeywords)) { + if (keywords.some((kw) => text.includes(kw))) { + matchedCategory = category; + break; + } + } + + if (!categories[matchedCategory]) { + categories[matchedCategory] = { + category: matchedCategory, + decisions: [], + }; + } + + categories[matchedCategory].decisions.push({ + question: decision.question, + chosen: decision.chosen, + reasoning: decision.reasoning, + fromTrajectory, + }); + } + + // Sort categories by number of decisions + return Object.values(categories).sort( + (a, b) => b.decisions.length - a.decisions.length, + ); +} + +function getDefaultOutputPath(compacted: CompactedTrajectory): string { + const trajDir = process.env.TRAJECTORIES_DATA_DIR || ".trajectories"; + const compactedDir = join(trajDir, "compacted"); + + if (!existsSync(compactedDir)) { + mkdirSync(compactedDir, { recursive: true }); + } + + const dateStr = new Date().toISOString().slice(0, 10); + return join(compactedDir, `${compacted.id}_${dateStr}.json`); +} + +function saveCompactedTrajectory( + compacted: CompactedTrajectory, + outputPath: string, +): void { + const dir = join(outputPath, ".."); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + writeFileSync(outputPath, JSON.stringify(compacted, null, 2)); +} + +function printCompactedSummary(compacted: CompactedTrajectory): void { + console.log("=== Compacted Trajectory Summary ===\n"); + console.log(`ID: ${compacted.id}`); + console.log(`Source trajectories: ${compacted.sourceTrajectories.length}`); + console.log( + `Date range: ${formatDate(compacted.dateRange.start)} - ${formatDate(compacted.dateRange.end)}`, + ); + console.log(`Total decisions: ${compacted.summary.totalDecisions}`); + console.log(`Total events: ${compacted.summary.totalEvents}`); + console.log(`Agents: ${compacted.summary.uniqueAgents.join(", ")}`); + console.log(""); + + console.log("=== Decision Groups ===\n"); + for (const group of compacted.decisionGroups) { + console.log(`${capitalize(group.category)} (${group.decisions.length} decisions):`); + for (const decision of group.decisions.slice(0, 3)) { + console.log(` - ${decision.question}`); + console.log(` Chose: ${decision.chosen}`); + } + if (group.decisions.length > 3) { + console.log(` ... and ${group.decisions.length - 3} more`); + } + console.log(""); + } + + if (compacted.keyLearnings.length > 0) { + console.log("=== Key Learnings ===\n"); + for (const learning of compacted.keyLearnings.slice(0, 5)) { + console.log(` - ${learning}`); + } + if (compacted.keyLearnings.length > 5) { + console.log(` ... and ${compacted.keyLearnings.length - 5} more`); + } + console.log(""); + } + + if (compacted.filesAffected.length > 0) { + console.log(`Files affected: ${compacted.filesAffected.length}`); + } + if (compacted.commits.length > 0) { + console.log(`Commits: ${compacted.commits.length}`); + } +} + +function formatDate(isoString: string): string { + return new Date(isoString).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +function capitalize(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index 580586a..98bd755 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -3,7 +3,7 @@ * * Registers all commands with the program. * - * Core commands (11 total): + * Core commands (12 total): * - start: Begin tracking a new task * - status: Show current trajectory state * - decision: Record a decision point @@ -15,10 +15,12 @@ * - export: Output in various formats (with --open) * - enable: Install git hook for trajectory trailers * - disable: Remove the trajectory git hook + * - compact: Compress trajectories into summarized form */ import type { Command } from "commander"; import { registerAbandonCommand } from "./abandon.js"; +import { registerCompactCommand } from "./compact.js"; import { registerCompleteCommand } from "./complete.js"; import { registerDecisionCommand } from "./decision.js"; import { registerEnableCommand } from "./enable.js"; @@ -43,4 +45,5 @@ export function registerCommands(program: Command): void { registerShowCommand(program); registerExportCommand(program); registerEnableCommand(program); + registerCompactCommand(program); } From eba1a58f760e5a009cf9814bf59da2cd31873921 Mon Sep 17 00:00:00 2001 From: Agent Relay Date: Thu, 19 Feb 2026 19:29:47 +0000 Subject: [PATCH 2/8] feat: add --branch flag and uncompacted-default behavior Updates to trail compact command: - Default now only compacts trajectories that haven't been compacted yet - Add --branch flag to compact trajectories with commits not in target branch - Add --all flag to include previously compacted trajectories - Track compaction state via compactedInto field in index Example usage: trail compact # Uncompacted only (default) trail compact --branch main # Commits not in main trail compact --all # Include already compacted Co-Authored-By: Claude Opus 4.5 --- README.md | 8 +- src/cli/commands/compact.ts | 150 +++++++++++++++++++++++++++++++++++- 2 files changed, 151 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 31c25a7..211665f 100644 --- a/README.md +++ b/README.md @@ -126,9 +126,11 @@ trail export traj_abc123 --format markdown trail export --format html --open # Opens in browser # Compact trajectories (consolidate similar decisions) -trail compact --since 7d # Compact last 7 days -trail compact --pr 123 # Compact trajectories for a PR -trail compact --ids traj_a,traj_b # Compact specific trajectories +trail compact # Uncompacted trajectories (default) +trail compact --branch main # Trajectories with commits not in main +trail compact --pr 123 # Trajectories mentioning PR #123 +trail compact --since 7d # Last 7 days +trail compact --all # Everything (including previously compacted) ``` ### SDK diff --git a/src/cli/commands/compact.ts b/src/cli/commands/compact.ts index f035d39..7303736 100644 --- a/src/cli/commands/compact.ts +++ b/src/cli/commands/compact.ts @@ -4,15 +4,17 @@ * Compresses multiple trajectories into a single compacted summary. * Useful for reducing context after PR merges by organizing similar * decisions into grouped, understandable summaries. + * + * Default behavior: compact only trajectories that haven't been compacted yet. */ -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { Command } from "commander"; import type { Decision, Trajectory, - TrajectoryEvent, } from "../../core/types.js"; import { FileStorage, getSearchPaths } from "../../storage/file.js"; import { generateRandomId } from "../../core/id.js"; @@ -55,10 +57,22 @@ interface CompactedTrajectory { commits: string[]; } +/** + * Index entry with compaction tracking + */ +interface IndexEntry { + title: string; + status: string; + startedAt: string; + completedAt?: string; + path: string; + compactedInto?: string; +} + export function registerCompactCommand(program: Command): void { program .command("compact") - .description("Compact trajectories into a summarized form") + .description("Compact trajectories into a summarized form (default: uncompacted only)") .option( "--since ", "Include trajectories since this date (ISO format or relative like '7d')", @@ -69,13 +83,22 @@ export function registerCompactCommand(program: Command): void { ) .option("--ids ", "Comma-separated list of trajectory IDs to compact") .option("--pr ", "Compact trajectories associated with a PR number") + .option( + "--branch ", + "Compact trajectories with commits not in the specified branch (e.g., main)", + ) + .option("--all", "Include all trajectories, even previously compacted ones") .option("--dry-run", "Preview what would be compacted without saving") .option("--output ", "Output path for compacted trajectory") .action(async (options) => { const trajectories = await loadTrajectories(options); if (trajectories.length === 0) { - console.log("No trajectories found matching criteria"); + if (options.all || options.since || options.ids || options.pr || options.branch) { + console.log("No trajectories found matching criteria"); + } else { + console.log("No uncompacted trajectories found. Use --all to include previously compacted."); + } return; } @@ -93,6 +116,9 @@ export function registerCompactCommand(program: Command): void { const outputPath = options.output || getDefaultOutputPath(compacted); saveCompactedTrajectory(compacted, outputPath); + // Mark source trajectories as compacted + await markTrajectoriesAsCompacted(trajectories, compacted.id); + console.log(`\nCompacted trajectory saved to: ${outputPath}`); printCompactedSummary(compacted); }); @@ -103,6 +129,8 @@ async function loadTrajectories(options: { until?: string; ids?: string; pr?: string; + branch?: string; + all?: boolean; }): Promise { const trajectories: Trajectory[] = []; const targetIds = options.ids ? options.ids.split(",").map((s) => s.trim()) : null; @@ -111,6 +139,12 @@ async function loadTrajectories(options: { const sinceDate = options.since ? parseRelativeDate(options.since) : null; const untilDate = options.until ? new Date(options.until) : null; + // Get commits on current branch not in target branch + const branchCommits = options.branch ? getBranchCommits(options.branch) : null; + + // Get compaction state from index + const compactedIds = options.all ? new Set() : getCompactedTrajectoryIds(); + const searchPaths = getSearchPaths(); const seenIds = new Set(); @@ -129,6 +163,9 @@ async function loadTrajectories(options: { for (const summary of summaries) { if (seenIds.has(summary.id)) continue; + // Skip already compacted (unless --all) + if (compactedIds.has(summary.id)) continue; + // Filter by IDs if specified if (targetIds && !targetIds.includes(summary.id)) continue; @@ -153,6 +190,15 @@ async function loadTrajectories(options: { if (!matchesPR) continue; } + // Filter by branch if specified + if (branchCommits) { + const hasMatchingCommit = trajectory.commits.some((c) => + branchCommits.has(c.slice(0, 7)) || branchCommits.has(c) + ); + if (!hasMatchingCommit && trajectory.commits.length > 0) continue; + // Include trajectories with no commits (they might still be relevant) + } + trajectories.push(trajectory); } } @@ -168,6 +214,102 @@ async function loadTrajectories(options: { return trajectories; } +/** + * Get commits that are on the current branch but not in the target branch + */ +function getBranchCommits(targetBranch: string): Set { + const commits = new Set(); + + try { + // Get commits on HEAD that are not in target branch + const output = execSync(`git log ${targetBranch}..HEAD --format=%H`, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); + + for (const line of output.trim().split("\n")) { + if (line) { + commits.add(line); + commits.add(line.slice(0, 7)); // Also add short hash + } + } + } catch { + // Not in a git repo or branch doesn't exist + console.warn(`Warning: Could not get commits for branch comparison with ${targetBranch}`); + } + + return commits; +} + +/** + * Get IDs of trajectories that have already been compacted + */ +function getCompactedTrajectoryIds(): Set { + const compacted = new Set(); + const searchPaths = getSearchPaths(); + + for (const searchPath of searchPaths) { + const indexPath = join(searchPath, "index.json"); + if (!existsSync(indexPath)) continue; + + try { + const indexContent = readFileSync(indexPath, "utf-8"); + const index = JSON.parse(indexContent) as { + trajectories: Record; + }; + + for (const [id, entry] of Object.entries(index.trajectories || {})) { + if (entry.compactedInto) { + compacted.add(id); + } + } + } catch { + // Index doesn't exist or is malformed + } + } + + return compacted; +} + +/** + * Mark trajectories as having been compacted into a specific compaction + */ +async function markTrajectoriesAsCompacted( + trajectories: Trajectory[], + compactedIntoId: string, +): Promise { + const searchPaths = getSearchPaths(); + + for (const searchPath of searchPaths) { + const indexPath = join(searchPath, "index.json"); + if (!existsSync(indexPath)) continue; + + try { + const indexContent = readFileSync(indexPath, "utf-8"); + const index = JSON.parse(indexContent) as { + version: number; + lastUpdated: string; + trajectories: Record; + }; + + let updated = false; + for (const traj of trajectories) { + if (index.trajectories[traj.id]) { + index.trajectories[traj.id].compactedInto = compactedIntoId; + updated = true; + } + } + + if (updated) { + index.lastUpdated = new Date().toISOString(); + writeFileSync(indexPath, JSON.stringify(index, null, 2)); + } + } catch { + // Index doesn't exist or is malformed + } + } +} + function parseRelativeDate(input: string): Date { // Handle relative dates like "7d", "2w", "1m" const match = input.match(/^(\d+)([dwmh])$/); From 0ac43459f86910c3cc7d5ae38ba5172802d616a5 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Fri, 20 Feb 2026 10:34:58 +0100 Subject: [PATCH 3/8] devin review --- .github/workflows/compact-on-merge.yml | 4 +- package.json | 4 +- src/cli/commands/compact.ts | 90 +++++++++++++++++++------- 3 files changed, 70 insertions(+), 28 deletions(-) diff --git a/.github/workflows/compact-on-merge.yml b/.github/workflows/compact-on-merge.yml index f818bbb..d34dd91 100644 --- a/.github/workflows/compact-on-merge.yml +++ b/.github/workflows/compact-on-merge.yml @@ -47,6 +47,8 @@ jobs: - name: Commit compacted trajectories if: steps.compact.outputs.compacted == 'true' + env: + PR_TITLE: ${{ github.event.pull_request.title }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -56,5 +58,5 @@ jobs: Automatically generated trajectory compaction. Source PR: #${{ github.event.pull_request.number }} - PR Title: ${{ github.event.pull_request.title }}" || echo "No changes to commit" + PR Title: $PR_TITLE" || echo "No changes to commit" git push diff --git a/package.json b/package.json index 4057e5a..b847094 100644 --- a/package.json +++ b/package.json @@ -45,9 +45,7 @@ "type": "git", "url": "https://github.com/AgentWorkforce/trajectories" }, - "files": [ - "dist" - ], + "files": ["dist"], "engines": { "node": ">=20.0.0" }, diff --git a/src/cli/commands/compact.ts b/src/cli/commands/compact.ts index 7303736..b26432e 100644 --- a/src/cli/commands/compact.ts +++ b/src/cli/commands/compact.ts @@ -12,12 +12,9 @@ import { execSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { Command } from "commander"; -import type { - Decision, - Trajectory, -} from "../../core/types.js"; -import { FileStorage, getSearchPaths } from "../../storage/file.js"; import { generateRandomId } from "../../core/id.js"; +import type { Decision, Trajectory } from "../../core/types.js"; +import { FileStorage, getSearchPaths } from "../../storage/file.js"; /** * A group of related decisions @@ -72,7 +69,9 @@ interface IndexEntry { export function registerCompactCommand(program: Command): void { program .command("compact") - .description("Compact trajectories into a summarized form (default: uncompacted only)") + .description( + "Compact trajectories into a summarized form (default: uncompacted only)", + ) .option( "--since ", "Include trajectories since this date (ISO format or relative like '7d')", @@ -94,10 +93,18 @@ export function registerCompactCommand(program: Command): void { const trajectories = await loadTrajectories(options); if (trajectories.length === 0) { - if (options.all || options.since || options.ids || options.pr || options.branch) { + if ( + options.all || + options.since || + options.ids || + options.pr || + options.branch + ) { console.log("No trajectories found matching criteria"); } else { - console.log("No uncompacted trajectories found. Use --all to include previously compacted."); + console.log( + "No uncompacted trajectories found. Use --all to include previously compacted.", + ); } return; } @@ -133,17 +140,23 @@ async function loadTrajectories(options: { all?: boolean; }): Promise { const trajectories: Trajectory[] = []; - const targetIds = options.ids ? options.ids.split(",").map((s) => s.trim()) : null; + const targetIds = options.ids + ? options.ids.split(",").map((s) => s.trim()) + : null; // Parse date filters const sinceDate = options.since ? parseRelativeDate(options.since) : null; const untilDate = options.until ? new Date(options.until) : null; // Get commits on current branch not in target branch - const branchCommits = options.branch ? getBranchCommits(options.branch) : null; + const branchCommits = options.branch + ? getBranchCommits(options.branch) + : null; // Get compaction state from index - const compactedIds = options.all ? new Set() : getCompactedTrajectoryIds(); + const compactedIds = options.all + ? new Set() + : getCompactedTrajectoryIds(); const searchPaths = getSearchPaths(); const seenIds = new Set(); @@ -181,7 +194,10 @@ async function loadTrajectories(options: { // Filter by PR if specified if (options.pr) { - const prPattern = new RegExp(`#${options.pr}\\b|PR.*${options.pr}`, "i"); + const prPattern = new RegExp( + `#${options.pr}\\b|PR.*${options.pr}`, + "i", + ); const matchesPR = prPattern.test(trajectory.task.title) || prPattern.test(trajectory.task.description || "") || @@ -192,8 +208,8 @@ async function loadTrajectories(options: { // Filter by branch if specified if (branchCommits) { - const hasMatchingCommit = trajectory.commits.some((c) => - branchCommits.has(c.slice(0, 7)) || branchCommits.has(c) + const hasMatchingCommit = trajectory.commits.some( + (c) => branchCommits.has(c.slice(0, 7)) || branchCommits.has(c), ); if (!hasMatchingCommit && trajectory.commits.length > 0) continue; // Include trajectories with no commits (they might still be relevant) @@ -206,7 +222,8 @@ async function loadTrajectories(options: { if (originalDataDir !== undefined) { process.env.TRAJECTORIES_DATA_DIR = originalDataDir; } else { - process.env.TRAJECTORIES_DATA_DIR = undefined; + // biome-ignore lint/performance/noDelete: process.env requires delete to truly unset (assignment stores string "undefined") + delete process.env.TRAJECTORIES_DATA_DIR; } } } @@ -222,10 +239,13 @@ function getBranchCommits(targetBranch: string): Set { try { // Get commits on HEAD that are not in target branch - const output = execSync(`git log ${targetBranch}..HEAD --format=%H`, { - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }); + const output = execSync( + `git log '${targetBranch.replace(/'/g, "'\\''")}'..HEAD --format=%H`, + { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }, + ); for (const line of output.trim().split("\n")) { if (line) { @@ -235,7 +255,9 @@ function getBranchCommits(targetBranch: string): Set { } } catch { // Not in a git repo or branch doesn't exist - console.warn(`Warning: Could not get commits for branch comparison with ${targetBranch}`); + console.warn( + `Warning: Could not get commits for branch comparison with ${targetBranch}`, + ); } return commits; @@ -409,7 +431,11 @@ function compactTrajectories(trajectories: Trajectory[]): CompactedTrajectory { const dates = trajectories.map((t) => new Date(t.startedAt).getTime()); const minDate = new Date(Math.min(...dates)); const maxDate = new Date( - Math.max(...trajectories.map((t) => new Date(t.completedAt || t.startedAt).getTime())), + Math.max( + ...trajectories.map((t) => + new Date(t.completedAt || t.startedAt).getTime(), + ), + ), ); return { @@ -446,11 +472,25 @@ function groupDecisions( const categories: Record = {}; const categoryKeywords: Record = { - architecture: ["architecture", "structure", "pattern", "design", "module", "component"], + architecture: [ + "architecture", + "structure", + "pattern", + "design", + "module", + "component", + ], api: ["api", "endpoint", "rest", "graphql", "http", "request", "response"], database: ["database", "schema", "migration", "query", "sql", "model"], testing: ["test", "spec", "coverage", "assertion", "mock"], - security: ["security", "auth", "permission", "token", "credential", "encrypt"], + security: [ + "security", + "auth", + "permission", + "token", + "credential", + "encrypt", + ], performance: ["performance", "optimize", "cache", "speed", "memory"], tooling: ["tool", "config", "build", "lint", "format", "ci", "cd"], naming: ["name", "rename", "convention", "format"], @@ -527,7 +567,9 @@ function printCompactedSummary(compacted: CompactedTrajectory): void { console.log("=== Decision Groups ===\n"); for (const group of compacted.decisionGroups) { - console.log(`${capitalize(group.category)} (${group.decisions.length} decisions):`); + console.log( + `${capitalize(group.category)} (${group.decisions.length} decisions):`, + ); for (const decision of group.decisions.slice(0, 3)) { console.log(` - ${decision.question}`); console.log(` Chose: ${decision.chosen}`); From 90f0ec7371b6b20e49d3e9108de4901fa7e2ea4b Mon Sep 17 00:00:00 2001 From: Khaliq Date: Fri, 20 Feb 2026 10:44:44 +0100 Subject: [PATCH 4/8] compact aid github action snippet --- .github/workflows/compact-on-merge.yml | 27 ++++++++------------------ README.md | 22 +++++++++++++++++++++ src/cli/commands/compact.ts | 27 +++++++++++++++++++++++++- 3 files changed, 56 insertions(+), 20 deletions(-) diff --git a/.github/workflows/compact-on-merge.yml b/.github/workflows/compact-on-merge.yml index d34dd91..44ac3fb 100644 --- a/.github/workflows/compact-on-merge.yml +++ b/.github/workflows/compact-on-merge.yml @@ -33,30 +33,19 @@ jobs: run: npm run build - name: Compact trajectories for this PR - id: compact run: | - # Run compact command for this PR - npx trail compact --pr ${{ github.event.pull_request.number }} --output .trajectories/compacted/pr-${{ github.event.pull_request.number }}.json - - # Check if any compaction was done - if [ -f ".trajectories/compacted/pr-${{ github.event.pull_request.number }}.json" ]; then - echo "compacted=true" >> $GITHUB_OUTPUT + 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 trail compact --commits "$PR_COMMITS" --output "$OUTPUT" else - echo "compacted=false" >> $GITHUB_OUTPUT + npx trail compact --pr ${{ github.event.pull_request.number }} --output "$OUTPUT" fi - name: Commit compacted trajectories - if: steps.compact.outputs.compacted == 'true' - env: - PR_TITLE: ${{ github.event.pull_request.title }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add .trajectories/compacted/ - git commit -m "chore: compact trajectories for PR #${{ github.event.pull_request.number }} - - Automatically generated trajectory compaction. - - Source PR: #${{ github.event.pull_request.number }} - PR Title: $PR_TITLE" || echo "No changes to commit" - git push + git add .trajectories/compacted/ || true + git diff --cached --quiet || \ + git commit -m "chore: compact trajectories for PR #${{ github.event.pull_request.number }}" && git push diff --git a/README.md b/README.md index 211665f..64bddea 100644 --- a/README.md +++ b/README.md @@ -128,11 +128,33 @@ trail export --format html --open # Opens in browser # Compact trajectories (consolidate similar decisions) trail compact # Uncompacted trajectories (default) trail compact --branch main # Trajectories with commits not in main +trail compact --commits abc1234,def5678 # Trajectories matching specific commit SHAs trail compact --pr 123 # Trajectories mentioning PR #123 trail compact --since 7d # Last 7 days trail compact --all # Everything (including previously compacted) ``` +### Automatic Compaction (GitHub Action) + +Add these steps to any workflow that runs on PR merge (e.g., your release or publish flow). Requires `fetch-depth: 0` on checkout and `contents: write` permission: + +```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" + else + npx agent-trajectories compact --pr ${{ github.event.pull_request.number }} --output "$OUTPUT" + fi + - name: Commit compacted trajectories + run: | + git add .trajectories/compacted/ || true + git diff --cached --quiet || \ + git commit -m "chore: compact trajectories for PR #${{ github.event.pull_request.number }}" && git push +``` + ### SDK For programmatic usage, install the package and use the SDK: diff --git a/src/cli/commands/compact.ts b/src/cli/commands/compact.ts index b26432e..de05d23 100644 --- a/src/cli/commands/compact.ts +++ b/src/cli/commands/compact.ts @@ -86,6 +86,10 @@ export function registerCompactCommand(program: Command): void { "--branch ", "Compact trajectories with commits not in the specified branch (e.g., main)", ) + .option( + "--commits ", + "Comma-separated commit SHAs to match trajectories against", + ) .option("--all", "Include all trajectories, even previously compacted ones") .option("--dry-run", "Preview what would be compacted without saving") .option("--output ", "Output path for compacted trajectory") @@ -98,7 +102,8 @@ export function registerCompactCommand(program: Command): void { options.since || options.ids || options.pr || - options.branch + options.branch || + options.commits ) { console.log("No trajectories found matching criteria"); } else { @@ -137,6 +142,7 @@ async function loadTrajectories(options: { ids?: string; pr?: string; branch?: string; + commits?: string; all?: boolean; }): Promise { const trajectories: Trajectory[] = []; @@ -153,6 +159,17 @@ async function loadTrajectories(options: { ? getBranchCommits(options.branch) : null; + // Parse --commits into a set with both full and short forms + const targetCommits = options.commits + ? new Set( + options.commits.split(",").flatMap((sha) => { + const trimmed = sha.trim(); + if (!trimmed) return []; + return [trimmed, trimmed.slice(0, 7)]; + }), + ) + : null; + // Get compaction state from index const compactedIds = options.all ? new Set() @@ -215,6 +232,14 @@ async function loadTrajectories(options: { // Include trajectories with no commits (they might still be relevant) } + // Filter by commits if specified + if (targetCommits) { + const hasMatchingCommit = trajectory.commits.some( + (c) => targetCommits.has(c) || targetCommits.has(c.slice(0, 7)), + ); + if (!hasMatchingCommit) continue; + } + trajectories.push(trajectory); } } From 0546627016561c86104134fab5d7aa7ab330d74c Mon Sep 17 00:00:00 2001 From: Khaliq Date: Fri, 20 Feb 2026 10:52:07 +0100 Subject: [PATCH 5/8] pr review --- .github/workflows/compact-on-merge.yml | 2 +- README.md | 2 +- src/cli/commands/compact.ts | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/compact-on-merge.yml b/.github/workflows/compact-on-merge.yml index 44ac3fb..4653b97 100644 --- a/.github/workflows/compact-on-merge.yml +++ b/.github/workflows/compact-on-merge.yml @@ -48,4 +48,4 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" git add .trajectories/compacted/ || true git diff --cached --quiet || \ - git commit -m "chore: compact trajectories for PR #${{ github.event.pull_request.number }}" && git push + (git commit -m "chore: compact trajectories for PR #${{ github.event.pull_request.number }}" && git push) diff --git a/README.md b/README.md index 64bddea..d084ff7 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ Add these steps to any workflow that runs on PR merge (e.g., your release or pub run: | git add .trajectories/compacted/ || true git diff --cached --quiet || \ - git commit -m "chore: compact trajectories for PR #${{ github.event.pull_request.number }}" && git push + (git commit -m "chore: compact trajectories for PR #${{ github.event.pull_request.number }}" && git push) ``` ### SDK diff --git a/src/cli/commands/compact.ts b/src/cli/commands/compact.ts index de05d23..9221888 100644 --- a/src/cli/commands/compact.ts +++ b/src/cli/commands/compact.ts @@ -211,8 +211,9 @@ async function loadTrajectories(options: { // Filter by PR if specified if (options.pr) { + const escaped = options.pr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const prPattern = new RegExp( - `#${options.pr}\\b|PR.*${options.pr}`, + `#${escaped}\\b|PR.*${escaped}\\b`, "i", ); const matchesPR = From e5b43bb1b7314fbbd38f3fe4558e5354d4991b13 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Fri, 20 Feb 2026 11:04:21 +0100 Subject: [PATCH 6/8] final review --- .github/workflows/compact-on-merge.yml | 1 + README.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/compact-on-merge.yml b/.github/workflows/compact-on-merge.yml index 4653b97..baa6548 100644 --- a/.github/workflows/compact-on-merge.yml +++ b/.github/workflows/compact-on-merge.yml @@ -19,6 +19,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 with: + ref: ${{ github.event.pull_request.base.ref }} fetch-depth: 0 # Need full history for trajectory lookup - name: Setup Node.js diff --git a/README.md b/README.md index d084ff7..fc9c17a 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ trail compact --all # Everything (including previously compact ### Automatic Compaction (GitHub Action) -Add these steps to any workflow that runs on PR merge (e.g., your release or publish flow). Requires `fetch-depth: 0` on checkout and `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: ```yaml - name: Compact trajectories From 244679efe7b7ff65504319f2b67e10fa831dcf9d Mon Sep 17 00:00:00 2001 From: Khaliq Date: Fri, 20 Feb 2026 11:11:34 +0100 Subject: [PATCH 7/8] fix pagination bug --- src/cli/commands/compact.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/commands/compact.ts b/src/cli/commands/compact.ts index 9221888..62174e3 100644 --- a/src/cli/commands/compact.ts +++ b/src/cli/commands/compact.ts @@ -188,7 +188,7 @@ async function loadTrajectories(options: { const storage = new FileStorage(); await storage.initialize(); - const summaries = await storage.list({ status: "completed" }); + const summaries = await storage.list({ status: "completed", limit: Number.MAX_SAFE_INTEGER }); for (const summary of summaries) { if (seenIds.has(summary.id)) continue; From 51d351053b8d2f3e14b198805194b884416c4e09 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Fri, 20 Feb 2026 11:13:34 +0100 Subject: [PATCH 8/8] lint --- src/cli/commands/compact.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/compact.ts b/src/cli/commands/compact.ts index 62174e3..b88181c 100644 --- a/src/cli/commands/compact.ts +++ b/src/cli/commands/compact.ts @@ -188,7 +188,10 @@ async function loadTrajectories(options: { const storage = new FileStorage(); await storage.initialize(); - const summaries = await storage.list({ status: "completed", limit: Number.MAX_SAFE_INTEGER }); + const summaries = await storage.list({ + status: "completed", + limit: Number.MAX_SAFE_INTEGER, + }); for (const summary of summaries) { if (seenIds.has(summary.id)) continue;