📦 deps(thirdparty): update snapshots

This commit is contained in:
ci[bot]
2026-05-29 08:33:53 +00:00
parent fdb52f1e96
commit 06e0d13d57
1615 changed files with 232858 additions and 0 deletions
@@ -0,0 +1,171 @@
---
name: trails
description: This skill should be used when creating session handoffs, logging research findings, or reading previous trail notes. Triggers include "handoff", "session continuity", "log note", "trail notes", or when ending a session.
metadata:
version: "1.0.0"
---
# Trail
Session continuity through structured handoffs and freeform logs.
<when_to_use>
- End of session — create handoff for continuity
- During research — capture findings in logs
- Subagent work — preserve context with parent session linking
- Any time you need to leave a trail for future sessions
</when_to_use>
## Commands
| Command | Purpose |
|---------|---------|
| `/trail:handoff` | Create structured handoff note for session continuity |
| `/trail:log <slug>` | Create freeform timestamped log note |
| `/trail:read [options]` | Read recent trail notes |
## Handoff Format
Handoffs are the atomic unit of session continuity. Create one at the end of each session.
```markdown
# Handoff
> YYYY-MM-DD HH:MM · Session `<short-id>`
## Done
- Completed item 1
- Completed item 2
## State
Current state of work:
- What's in progress
- What's blocked
- Key decisions made
## Next
- [ ] First priority task
- [ ] Second priority task
- [ ] Lower priority item
```
### Handoff Principles
- **Done**: Past tense, concrete accomplishments
- **State**: Present tense, current situation
- **Next**: Checkboxes for actionable items
- **Scannable**: Someone should grasp the session in 30 seconds
- **Honest**: Note blockers, uncertainties, and open questions
## Log Format
Logs are freeform notes for capturing anything worth preserving.
```markdown
# Title Derived From Slug
> YYYY-MM-DD HH:MM · Session `<short-id>`
[Freeform content - research findings, technical discoveries,
meeting notes, ideas, observations, etc.]
```
### Log Use Cases
- Research findings and documentation
- Technical discoveries and gotchas
- Meeting notes and decisions
- Ideas and observations
- Debugging sessions and root causes
### Log Principles
- **Descriptive slug**: Will become the title if none provided
- **Tag liberally**: Use frontmatter tags for discoverability
- **Link context**: Reference issues, PRs, or other notes
- **Future-proof**: Write for someone (including future you) with no context
## Subagent Context
When working as a subagent, pass the parent session ID to group related notes:
```bash
# Handoff with parent context
bun ${CLAUDE_PLUGIN_ROOT}/skills/trails/scripts/handoff.ts \
--session "$CHILD_SESSION" \
--parent "$PARENT_SESSION"
# Log with parent context
bun ${CLAUDE_PLUGIN_ROOT}/skills/trails/scripts/log.ts \
--slug "api-findings" \
--session "$CHILD_SESSION" \
--parent "$PARENT_SESSION"
```
This creates notes in a subdirectory: `.trail/notes/YYYY-MM-DD/<parent-session>/`
## Reading Notes
```bash
# Today's notes (all types)
/trail:read
# Just handoffs
/trail:read --type handoff
# Just logs
/trail:read --type log
# Last 3 days
/trail:read --days 3
# Limit output
/trail:read --lines 100
```
## Directory Structure
```
.trail/
├── notes/
│ └── YYYY-MM-DD/
│ ├── handoff-YYYYMMDDhhmm-<session>.md
│ ├── YYYYMMDDhhmm-<slug>.md
│ └── <parent-session>/ # Subagent notes
│ ├── handoff-YYYYMMDDhhmm-<child>.md
│ └── YYYYMMDDhhmm-<slug>.md
├── plans/ # Implementation plans
└── artifacts/ # Research, ADRs, etc.
```
## Filename Convention
Pattern: `[prefix-]YYYYMMDDhhmm[-suffix].md`
| Type | Prefix | Suffix | Example |
|------|--------|--------|---------|
| Handoff | `handoff` | session ID | `handoff-202601221430-f4b8aa3a.md` |
| Log | none | slug | `202601221430-api-research.md` |
The timestamp (`YYYYMMDDhhmm`) is the anchor — files remain sortable and portable even if moved.
## Session Start Ritual
When resuming work:
1. Run `/trail:read --type handoff` to see recent handoffs
2. Check the **Next** section for pending tasks
3. Continue where the previous session left off
## Session End Ritual
Before ending a session:
1. Run `/trail:handoff` to create a handoff note
2. Fill in **Done**, **State**, and **Next** sections
3. Be specific enough that a fresh session can continue seamlessly
@@ -0,0 +1,110 @@
#!/usr/bin/env bun
/**
* Trail context utilities
*
* Shared utilities for session context, timestamps, and directory resolution.
* Used by handoff, log, and other trail scripts.
*
* @module trail/context
*/
export interface TrailContext {
/** Current session ID */
sessionId: string;
/** Parent session ID if this is a subagent */
parentSessionId?: string;
/** Whether this is a subagent context */
isSubagent: boolean;
/** Current timestamp */
timestamp: Date;
/** Date directory name (YYYY-MM-DD) */
dateDir: string;
/** Full timestamp for filenames (YYYYMMDDhhmm) */
timeRoot: string;
}
/**
* Format date as YYYY-MM-DD for directory names
*/
export function formatDateDir(date: Date): string {
return date.toISOString().split("T")[0];
}
/**
* Format date as YYYYMMDDhhmm for filename roots
*/
export function formatTimeRoot(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}${month}${day}${hours}${minutes}`;
}
/**
* Format date as ISO 8601 for frontmatter
*/
export function formatISO(date: Date): string {
return date.toISOString();
}
/**
* Format time as HH:mm for display
*/
export function formatTime(date: Date): string {
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${hours}:${minutes}`;
}
/**
* Build trail context from environment and options
*/
export function buildContext(options?: {
sessionId?: string;
parentSessionId?: string;
timestamp?: Date;
}): TrailContext {
const timestamp = options?.timestamp ?? new Date();
const sessionId = options?.sessionId ?? "unknown";
const parentSessionId = options?.parentSessionId;
return {
sessionId,
parentSessionId,
isSubagent: !!parentSessionId,
timestamp,
dateDir: formatDateDir(timestamp),
timeRoot: formatTimeRoot(timestamp),
};
}
/**
* Get the trail root directory (project root where .trail/ lives)
*
* For plugin use, this returns process.cwd() since the plugin scripts
* run in the context of the user's project.
*/
export function getTrailRoot(): string {
return process.cwd();
}
/**
* Get the notes directory for a given date
*/
export function getNotesDir(
dateDir: string,
parentSessionId?: string,
): string {
const root = getTrailRoot();
const base = `${root}/.trail/notes/${dateDir}`;
// If subagent, nest under parent session directory
if (parentSessionId) {
return `${base}/${parentSessionId}`;
}
return base;
}
@@ -0,0 +1,122 @@
#!/usr/bin/env bun
/**
* Trail filename utilities
*
* Build filenames with configurable prefix-root-suffix pattern.
* Root is always the timestamp (YYYYMMDDhhmm), prefix and suffix are optional.
*
* @module trail/filename
*/
export interface FilenameOptions {
/** Optional prefix (e.g., "handoff", "log") */
prefix?: string;
/** Timestamp root (YYYYMMDDhhmm) - required */
root: string;
/** Optional suffix (e.g., session ID, slug) */
suffix?: string;
/** File extension (default: "md") */
ext?: string;
}
/**
* Build a filename from parts
*
* Pattern: [prefix-]root[-suffix].ext
*
* @example
* buildFilename({ root: "202601221430", prefix: "handoff", suffix: "f4b8aa3a" })
* // => "handoff-202601221430-f4b8aa3a.md"
*
* @example
* buildFilename({ root: "202601221445", suffix: "api-research" })
* // => "202601221445-api-research.md"
*
* @example
* buildFilename({ root: "202601221500", prefix: "handoff" })
* // => "handoff-202601221500.md"
*/
export function buildFilename(options: FilenameOptions): string {
const { prefix, root, suffix, ext = "md" } = options;
const parts: string[] = [];
if (prefix) {
parts.push(prefix);
}
parts.push(root);
if (suffix) {
parts.push(suffix);
}
return `${parts.join("-")}.${ext}`;
}
/**
* Parse a trail filename into its components
*
* Expects pattern: [prefix-]YYYYMMDDhhmm[-suffix].ext
* The 12-digit timestamp is the anchor for parsing.
*/
export function parseFilename(filename: string): {
prefix?: string;
root: string;
suffix?: string;
ext: string;
} | null {
// Remove extension
const dotIndex = filename.lastIndexOf(".");
if (dotIndex === -1) return null;
const ext = filename.slice(dotIndex + 1);
const base = filename.slice(0, dotIndex);
// Find the 12-digit timestamp (YYYYMMDDhhmm)
const timestampMatch = base.match(/(\d{12})/);
if (!timestampMatch) return null;
const root = timestampMatch[1];
const rootIndex = base.indexOf(root);
// Everything before timestamp is prefix
const beforeRoot = base.slice(0, rootIndex);
const prefix = beforeRoot.endsWith("-")
? beforeRoot.slice(0, -1)
: beforeRoot || undefined;
// Everything after timestamp is suffix
const afterRoot = base.slice(rootIndex + root.length);
const suffix = afterRoot.startsWith("-")
? afterRoot.slice(1)
: afterRoot || undefined;
return { prefix: prefix || undefined, root, suffix: suffix || undefined, ext };
}
/**
* Slugify a string for use in filenames
*
* Converts to lowercase, replaces spaces/special chars with hyphens,
* removes consecutive hyphens, trims hyphens from ends.
*/
export function slugify(input: string): string {
return input
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "") // Remove special chars except hyphens
.replace(/[\s_]+/g, "-") // Replace spaces/underscores with hyphens
.replace(/-+/g, "-") // Collapse consecutive hyphens
.replace(/^-|-$/g, ""); // Trim hyphens from ends
}
/**
* Truncate session ID for filename use
*
* Takes first 8 characters of session ID for brevity while maintaining uniqueness.
*/
export function truncateSessionId(sessionId: string): string {
return sessionId.slice(0, 8);
}
@@ -0,0 +1,170 @@
#!/usr/bin/env bun
/**
* Create a handoff note
*
* Handoffs are structured session notes with Done/State/Next sections.
* They serve as both a session log and continuity document.
*
* @example
* bun handoff.ts --session f4b8aa3a
*
* @example With parent session (subagent)
* bun handoff.ts --session b2c3d4e5 --parent f4b8aa3a
*
* @module trail/handoff
*/
import { parseArgs } from "util";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import {
buildContext,
formatISO,
formatTime,
getNotesDir,
} from "./context.ts";
import { buildFilename, truncateSessionId } from "./filename.ts";
/**
* Options for creating a handoff note.
*/
interface HandoffOptions {
/** Current session ID (required) */
sessionId: string;
/** Parent session ID if this is a subagent */
parentSessionId?: string;
}
/**
* Generate handoff content with frontmatter
*/
function generateHandoffContent(
ctx: ReturnType<typeof buildContext>,
options: HandoffOptions,
): string {
const time = formatTime(ctx.timestamp);
const created = formatISO(ctx.timestamp);
const sessionShort = truncateSessionId(options.sessionId);
let frontmatter = `---
created: ${created}
type: handoff
session: ${options.sessionId}`;
if (options.parentSessionId) {
frontmatter += `\nparent-session: ${options.parentSessionId}`;
}
frontmatter += `
---
# Handoff ${ctx.dateDir} ${time}
> Session \`${sessionShort}\`${options.parentSessionId ? ` (child of \`${truncateSessionId(options.parentSessionId)}\`)` : ""}
## Done
- { What was accomplished }
## State
- { Current state of work }
## Next
- [ ] { What should happen next }
`;
return frontmatter;
}
/**
* Parse CLI arguments
*/
function parseCliArgs(): HandoffOptions {
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
session: {
type: "string",
short: "s",
},
parent: {
type: "string",
short: "p",
},
help: {
type: "boolean",
short: "h",
},
},
strict: true,
allowPositionals: false,
});
if (values.help) {
console.log(`
Usage: bun handoff.ts [options]
Options:
-s, --session <id> Session ID (required)
-p, --parent <id> Parent session ID (if subagent)
-h, --help Show this help message
Examples:
bun handoff.ts --session f4b8aa3a
bun handoff.ts --session b2c3d4e5 --parent f4b8aa3a
`);
process.exit(0);
}
if (!values.session) {
console.error("Error: --session is required");
process.exit(1);
}
return {
sessionId: values.session,
parentSessionId: values.parent,
};
}
/**
* Main entry point
*/
async function main() {
const options = parseCliArgs();
const ctx = buildContext({
sessionId: options.sessionId,
parentSessionId: options.parentSessionId,
});
// Build paths
const notesDir = getNotesDir(ctx.dateDir, options.parentSessionId);
const filename = buildFilename({
prefix: "handoff",
root: ctx.timeRoot,
suffix: truncateSessionId(options.sessionId),
});
const filePath = join(notesDir, filename);
// Ensure directory exists
await mkdir(notesDir, { recursive: true });
// Check if file exists
const file = Bun.file(filePath);
if (await file.exists()) {
console.error(`Error: Handoff already exists: ${filePath}`);
process.exit(1);
}
// Generate and write content
const content = generateHandoffContent(ctx, options);
await Bun.write(filePath, content);
console.log(`Created: ${filePath}`);
}
main().catch(console.error);
@@ -0,0 +1,192 @@
#!/usr/bin/env bun
/**
* Create a log note
*
* Logs are freeform timestamped notes for capturing research, findings,
* and other session work. Designed to be composable with other skills/commands.
*
* @example Basic log with slug
* bun log.ts --slug api-research --session f4b8aa3a
*
* @example With parent session (subagent)
* bun log.ts --slug findings --session b2c3d4e5 --parent f4b8aa3a
*
* @example With title
* bun log.ts --slug api-research --title "API Research Notes" --session f4b8aa3a
*
* @module trail/log
*/
import { parseArgs } from "util";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import {
buildContext,
formatISO,
formatTime,
getNotesDir,
} from "./context.ts";
import { buildFilename, slugify, truncateSessionId } from "./filename.ts";
/**
* Options for creating a log note.
*/
interface LogOptions {
/** URL-safe slug for the log */
slug: string;
/** Current session ID */
sessionId: string;
/** Parent session ID if this is a subagent */
parentSessionId?: string;
/** Custom title (defaults to derived from slug) */
title?: string;
}
/**
* Generate log content with frontmatter
*/
function generateLogContent(
ctx: ReturnType<typeof buildContext>,
options: LogOptions,
): string {
const time = formatTime(ctx.timestamp);
const created = formatISO(ctx.timestamp);
const sessionShort = truncateSessionId(options.sessionId);
// Use title if provided, otherwise derive from slug
const title =
options.title ??
options.slug
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
let frontmatter = `---
created: ${created}
type: log
session: ${options.sessionId}`;
if (options.parentSessionId) {
frontmatter += `\nparent-session: ${options.parentSessionId}`;
}
frontmatter += `
tags: []
---
# ${title}
> ${ctx.dateDir} ${time} · Session \`${sessionShort}\`
`;
return frontmatter;
}
/**
* Parse CLI arguments
*/
function parseCliArgs(): LogOptions {
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
slug: {
type: "string",
short: "l",
},
session: {
type: "string",
short: "s",
},
parent: {
type: "string",
short: "p",
},
title: {
type: "string",
short: "t",
},
help: {
type: "boolean",
short: "h",
},
},
strict: true,
allowPositionals: false,
});
if (values.help) {
console.log(`
Usage: bun log.ts [options]
Options:
-l, --slug <slug> Slug for the log (required)
-s, --session <id> Session ID (required)
-p, --parent <id> Parent session ID (if subagent)
-t, --title <title> Custom title (default: derived from slug)
-h, --help Show this help message
Examples:
bun log.ts --slug api-research --session f4b8aa3a
bun log.ts --slug findings --session b2c3d4e5 --parent f4b8aa3a
bun log.ts --slug api-research --title "API Research Notes" --session f4b8aa3a
`);
process.exit(0);
}
if (!values.slug) {
console.error("Error: --slug is required");
process.exit(1);
}
if (!values.session) {
console.error("Error: --session is required");
process.exit(1);
}
return {
slug: slugify(values.slug),
sessionId: values.session,
parentSessionId: values.parent,
title: values.title,
};
}
/**
* Main entry point
*/
async function main() {
const options = parseCliArgs();
const ctx = buildContext({
sessionId: options.sessionId,
parentSessionId: options.parentSessionId,
});
// Build paths
const notesDir = getNotesDir(ctx.dateDir, options.parentSessionId);
const filename = buildFilename({
root: ctx.timeRoot,
suffix: options.slug,
});
const filePath = join(notesDir, filename);
// Ensure directory exists
await mkdir(notesDir, { recursive: true });
// Check if file exists
const file = Bun.file(filePath);
if (await file.exists()) {
console.error(`Error: Log already exists: ${filePath}`);
process.exit(1);
}
// Generate and write content
const content = generateLogContent(ctx, options);
await Bun.write(filePath, content);
console.log(`Created: ${filePath}`);
}
main().catch(console.error);
@@ -0,0 +1,272 @@
#!/usr/bin/env bun
/**
* Read trail notes
*
* Read recent handoffs, logs, or all notes from the trail.
* Supports filtering by type, date range, and output formatting.
*
* @example Read today's handoffs
* bun read.ts --type handoff
*
* @example Read last 3 days of all notes
* bun read.ts --days 3
*
* @example Read recent logs with limited output
* bun read.ts --type log --lines 50
*
* @module trail/read
*/
import { parseArgs } from "util";
import { existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { formatDateDir, getTrailRoot } from "./context.ts";
import { parseFilename } from "./filename.ts";
/**
* Type of trail notes to filter by.
*/
type NoteType = "handoff" | "log" | "all";
/**
* Options for reading trail notes.
*/
interface ReadOptions {
/** Filter by note type */
type: NoteType;
/** Number of days to look back */
days: number;
/** Max lines to output (null for unlimited) */
lines: number | null;
/** Whether to strip YAML frontmatter */
noFrontmatter: boolean;
}
/**
* Get recent date directories
*/
function getRecentDates(days: number): string[] {
const dates: string[] = [];
const now = new Date();
for (let i = 0; i < days; i++) {
const d = new Date(now);
d.setDate(d.getDate() - i);
dates.push(formatDateDir(d));
}
return dates;
}
/**
* Find all note files in a directory (recursive for subagent dirs)
*/
function findNotes(
baseDir: string,
type: NoteType,
prefix = "",
): { path: string; filename: string }[] {
const notes: { path: string; filename: string }[] = [];
if (!existsSync(baseDir)) return notes;
const entries = readdirSync(baseDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(baseDir, entry.name);
if (entry.isDirectory()) {
// Recurse into subagent directories
notes.push(...findNotes(fullPath, type, entry.name));
} else if (entry.name.endsWith(".md")) {
const parsed = parseFilename(entry.name);
if (!parsed) continue;
// Filter by type
if (type === "handoff" && parsed.prefix !== "handoff") continue;
if (type === "log" && parsed.prefix === "handoff") continue;
notes.push({
path: fullPath,
filename: prefix ? `${prefix}/${entry.name}` : entry.name,
});
}
}
// Sort by filename (which starts with timestamp)
return notes.sort((a, b) => a.filename.localeCompare(b.filename));
}
/**
* Strip YAML frontmatter from content
*/
function stripFrontmatter(content: string): string {
const lines = content.split("\n");
if (lines[0] !== "---") return content;
let endIndex = -1;
for (let i = 1; i < lines.length; i++) {
if (lines[i] === "---") {
endIndex = i;
break;
}
}
if (endIndex === -1) return content;
let startIndex = endIndex + 1;
while (startIndex < lines.length && lines[startIndex].trim() === "") {
startIndex++;
}
return lines.slice(startIndex).join("\n");
}
/**
* Limit output to N lines
*/
function limitLines(
content: string,
maxLines: number,
): { output: string; truncated: number } {
const lines = content.split("\n");
if (lines.length <= maxLines) {
return { output: content, truncated: 0 };
}
const output = lines.slice(0, maxLines).join("\n");
const truncated = lines.length - maxLines;
return { output, truncated };
}
/**
* Parse CLI arguments
*/
function parseCliArgs(): ReadOptions {
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
type: {
type: "string",
short: "t",
default: "all",
},
days: {
type: "string",
short: "d",
default: "1",
},
lines: {
type: "string",
short: "n",
},
"no-frontmatter": {
type: "boolean",
short: "f",
default: false,
},
help: {
type: "boolean",
short: "h",
},
},
strict: true,
allowPositionals: false,
});
if (values.help) {
console.log(`
Usage: bun read.ts [options]
Options:
-t, --type <type> Note type: handoff, log, all (default: all)
-d, --days <n> Number of days to include (default: 1)
-n, --lines <n> Max lines to output
-f, --no-frontmatter Strip YAML frontmatter
-h, --help Show this help message
Examples:
bun read.ts # Today's notes
bun read.ts --type handoff # Today's handoffs only
bun read.ts --days 3 # Last 3 days
bun read.ts --type log --lines 100 # Recent logs, max 100 lines
`);
process.exit(0);
}
const type = values.type as NoteType;
if (!["handoff", "log", "all"].includes(type)) {
console.error(`Error: Invalid type "${type}". Use: handoff, log, all`);
process.exit(1);
}
return {
type,
days: parseInt(values.days ?? "1", 10),
lines: values.lines ? parseInt(values.lines, 10) : null,
noFrontmatter: values["no-frontmatter"] ?? false,
};
}
/**
* Main entry point
*/
async function main() {
const options = parseCliArgs();
const trailRoot = getTrailRoot();
const notesRoot = join(trailRoot, ".trail", "notes");
// Find notes for recent dates
const dates = getRecentDates(options.days);
const allNotes: { path: string; filename: string; date: string }[] = [];
for (const date of dates) {
const dateDir = join(notesRoot, date);
const notes = findNotes(dateDir, options.type);
allNotes.push(...notes.map((n) => ({ ...n, date })));
}
if (allNotes.length === 0) {
const typeLabel = options.type === "all" ? "notes" : `${options.type}s`;
console.error(`No ${typeLabel} found for the last ${options.days} day(s)`);
process.exit(1);
}
// Read and combine notes
let combined = "";
let currentDate = "";
for (const note of allNotes) {
// Add date header when date changes
if (note.date !== currentDate) {
if (combined) combined += "\n\n---\n\n";
combined += `## ${note.date}\n\n`;
currentDate = note.date;
}
let content = await Bun.file(note.path).text();
if (options.noFrontmatter) {
content = stripFrontmatter(content);
}
combined += `**File**: ${note.filename}\n\n${content.trim()}\n\n`;
}
// Apply line limit
if (options.lines !== null) {
const { output, truncated } = limitLines(combined, options.lines);
console.log(output);
if (truncated > 0) {
console.error(`\n... (${truncated} more lines)`);
}
} else {
console.log(combined);
}
}
main().catch(console.error);