📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user