diff --git a/.beads/metadata.json b/.beads/metadata.json index c787975..f581edc 100644 --- a/.beads/metadata.json +++ b/.beads/metadata.json @@ -1,4 +1,4 @@ { "database": "beads.db", "jsonl_export": "issues.jsonl" -} \ No newline at end of file +} diff --git a/.claude/skills/prpm-json-best-practices-skill/examples/single-package.json b/.claude/skills/prpm-json-best-practices-skill/examples/single-package.json index e0e6fc4..a656ec8 100644 --- a/.claude/skills/prpm-json-best-practices-skill/examples/single-package.json +++ b/.claude/skills/prpm-json-best-practices-skill/examples/single-package.json @@ -9,7 +9,5 @@ "format": "claude", "subtype": "skill", "tags": ["typescript", "best-practices", "code-quality"], - "files": [ - ".claude/skills/my-awesome-skill/SKILL.md" - ] + "files": [".claude/skills/my-awesome-skill/SKILL.md"] } diff --git a/.claude/skills/prpm-json-best-practices-skill/prpm-manifest.schema.json b/.claude/skills/prpm-json-best-practices-skill/prpm-manifest.schema.json index da52703..dc41c08 100644 --- a/.claude/skills/prpm-json-best-practices-skill/prpm-manifest.schema.json +++ b/.claude/skills/prpm-json-best-practices-skill/prpm-manifest.schema.json @@ -4,12 +4,7 @@ "title": "PRPM Package Manifest", "description": "Schema for PRPM package manifest (prpm.json)", "type": "object", - "required": [ - "name", - "version", - "description", - "format" - ], + "required": ["name", "version", "description", "format"], "properties": { "name": { "type": "string", @@ -27,11 +22,7 @@ "type": "string", "description": "Semantic version (semver)", "pattern": "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.-]+)?(\\+[a-zA-Z0-9.-]+)?$", - "examples": [ - "1.0.0", - "2.1.3", - "1.0.0-beta.1" - ] + "examples": ["1.0.0", "2.1.3", "1.0.0-beta.1"] }, "description": { "type": "string", @@ -79,16 +70,11 @@ "oneOf": [ { "type": "string", - "examples": [ - "John Doe", - "Jane Smith" - ] + "examples": ["John Doe", "Jane Smith"] }, { "type": "object", - "required": [ - "name" - ], + "required": ["name"], "properties": { "name": { "type": "string", @@ -111,12 +97,7 @@ "license": { "type": "string", "description": "SPDX license identifier", - "examples": [ - "MIT", - "Apache-2.0", - "GPL-3.0", - "BSD-3-Clause" - ] + "examples": ["MIT", "Apache-2.0", "GPL-3.0", "BSD-3-Clause"] }, "license_text": { "type": "string", @@ -131,9 +112,7 @@ "type": "string", "format": "uri", "description": "Repository URL", - "examples": [ - "https://github.com/username/repo" - ] + "examples": ["https://github.com/username/repo"] }, "homepage": { "type": "string", @@ -148,10 +127,7 @@ "organization": { "type": "string", "description": "Organization name or ID to publish this package under. If not specified, publishes to personal account.", - "examples": [ - "my-team", - "my-company" - ] + "examples": ["my-team", "my-company"] }, "private": { "type": "boolean", @@ -180,9 +156,7 @@ "prepublish": { "type": "string", "description": "Script to run before publishing and on npm install (not recommended - use prepublishOnly instead)", - "examples": [ - "npm run build" - ] + "examples": ["npm run build"] } }, "additionalProperties": false @@ -195,16 +169,7 @@ }, "maxItems": 10, "uniqueItems": true, - "examples": [ - [ - "productivity", - "coding" - ], - [ - "testing", - "quality" - ] - ] + "examples": [["productivity", "coding"], ["testing", "quality"]] }, "keywords": { "type": "array", @@ -214,22 +179,12 @@ }, "maxItems": 20, "uniqueItems": true, - "examples": [ - [ - "ai", - "prompts", - "development" - ] - ] + "examples": [["ai", "prompts", "development"]] }, "category": { "type": "string", "description": "Package category", - "examples": [ - "development", - "productivity", - "testing" - ] + "examples": ["development", "productivity", "testing"] }, "files": { "description": "Files to include in package. Can be simple paths or enhanced file objects with metadata.", @@ -242,14 +197,8 @@ }, "minItems": 1, "examples": [ - [ - "skill.md", - "README.md" - ], - [ - ".cursor/rules/react.mdc", - "LICENSE" - ] + ["skill.md", "README.md"], + [".cursor/rules/react.mdc", "LICENSE"] ] }, { @@ -257,10 +206,7 @@ "description": "Enhanced format: array of file objects with metadata", "items": { "type": "object", - "required": [ - "path", - "format" - ], + "required": ["path", "format"], "properties": { "path": { "type": "string", @@ -309,10 +255,7 @@ "name": { "type": "string", "description": "Display name for this file", - "examples": [ - "React Rules", - "Test-Driven Development" - ] + "examples": ["React Rules", "Test-Driven Development"] }, "description": { "type": "string", @@ -325,16 +268,7 @@ "type": "string" }, "uniqueItems": true, - "examples": [ - [ - "react", - "typescript" - ], - [ - "testing", - "tdd" - ] - ] + "examples": [["react", "typescript"], ["testing", "tdd"]] }, "eager": { "type": "boolean", @@ -351,10 +285,7 @@ "main": { "type": "string", "description": "Main entry file (for single-file packages)", - "examples": [ - "index.md", - "skill.md" - ] + "examples": ["index.md", "skill.md"] }, "dependencies": { "type": "object", @@ -425,7 +356,11 @@ "pattern": "^[a-z0-9-]+$", "minLength": 3, "maxLength": 100, - "examples": ["nextjs-complete", "fullstack-setup", "react-essentials"] + "examples": [ + "nextjs-complete", + "fullstack-setup", + "react-essentials" + ] }, "name": { "type": "string", @@ -449,7 +384,18 @@ "category": { "type": "string", "description": "Collection category", - "enum": ["development", "testing", "deployment", "data-science", "devops", "design", "documentation", "security", "performance", "general"], + "enum": [ + "development", + "testing", + "deployment", + "data-science", + "devops", + "design", + "documentation", + "security", + "performance", + "general" + ], "examples": ["development", "testing"] }, "tags": { @@ -461,7 +407,10 @@ }, "minItems": 1, "maxItems": 10, - "examples": [["react", "typescript", "nextjs"], ["python", "data-science", "ml"]] + "examples": [ + ["react", "typescript", "nextjs"], + ["python", "data-science", "ml"] + ] }, "icon": { "type": "string", @@ -496,7 +445,10 @@ "type": "string", "description": "Explanation of why this package is included", "maxLength": 200, - "examples": ["Enforces strict TypeScript type safety", "React component best practices"] + "examples": [ + "Enforces strict TypeScript type safety", + "React component best practices" + ] } } }, @@ -513,13 +465,11 @@ "name": "@username/simple-package", "version": "1.0.0", "description": "A simple package with basic files", - "format": "claude", "subtype": "skill", + "format": "claude", + "subtype": "skill", "author": "Your Name", "license": "MIT", - "files": [ - "skill.md", - "README.md" - ] + "files": ["skill.md", "README.md"] }, { "name": "@company/coding-standards", @@ -531,9 +481,7 @@ "organization": "my-company", "license": "MIT", "eager": true, - "files": [ - ".claude/skills/coding-standards/SKILL.md" - ] + "files": [".claude/skills/coding-standards/SKILL.md"] }, { "name": "@company/team-package", @@ -543,10 +491,7 @@ "author": "Team Name", "organization": "my-company", "license": "MIT", - "files": [ - ".cursor/rules/guidelines.mdc", - "README.md" - ] + "files": [".cursor/rules/guidelines.mdc", "README.md"] }, { "name": "@username/cursor-rules", @@ -559,29 +504,19 @@ }, "license": "MIT", "repository": "https://github.com/username/cursor-rules", - "tags": [ - "cursor", - "rules", - "multi-language" - ], + "tags": ["cursor", "rules", "multi-language"], "files": [ { "path": ".cursor/rules/typescript.mdc", "format": "cursor", "name": "TypeScript Rules", - "tags": [ - "typescript", - "frontend" - ] + "tags": ["typescript", "frontend"] }, { "path": ".cursor/rules/python.mdc", "format": "cursor", "name": "Python Rules", - "tags": [ - "python", - "backend" - ] + "tags": ["python", "backend"] } ] }, @@ -589,22 +524,22 @@ "name": "@community/testing-suite", "version": "2.0.0", "description": "Complete testing suite with skills and agents", - "format": "generic", "subtype": "collection", + "format": "generic", + "subtype": "collection", "author": "Community", "license": "MIT", - "tags": [ - "testing", - "quality" - ], + "tags": ["testing", "quality"], "files": [ { "path": ".claude/skills/tdd.md", - "format": "claude", "subtype": "skill", + "format": "claude", + "subtype": "skill", "name": "Test-Driven Development" }, { "path": ".claude/agents/test-generator.md", - "format": "claude", "subtype": "agent", + "format": "claude", + "subtype": "agent", "name": "Test Generator" } ] @@ -616,14 +551,8 @@ "format": "copilot", "author": "Your Name", "license": "MIT", - "tags": [ - "copilot", - "api", - "backend" - ], - "files": [ - "api-guidelines.md" - ] + "tags": ["copilot", "api", "backend"], + "files": ["api-guidelines.md"] }, { "name": "@username/kiro-steering", @@ -632,14 +561,8 @@ "format": "kiro", "author": "Your Name", "license": "MIT", - "tags": [ - "kiro", - "testing", - "quality" - ], - "files": [ - "testing.md" - ] + "tags": ["kiro", "testing", "quality"], + "files": ["testing.md"] }, { "name": "@username/windsurf-rules", @@ -648,14 +571,8 @@ "format": "windsurf", "author": "Your Name", "license": "MIT", - "tags": [ - "windsurf", - "react", - "frontend" - ], - "files": [ - ".windsurfrules" - ] + "tags": ["windsurf", "react", "frontend"], + "files": [".windsurfrules"] }, { "name": "@company/private-package", @@ -667,10 +584,7 @@ "organization": "my-company", "private": true, "license": "Proprietary", - "files": [ - "internal-skill.md", - "README.md" - ] + "files": ["internal-skill.md", "README.md"] }, { "name": "@username/multi-package-example", @@ -686,9 +600,7 @@ "description": "First package in the multi-package manifest", "format": "claude", "subtype": "skill", - "files": [ - "package-one/SKILL.md" - ] + "files": ["package-one/SKILL.md"] }, { "name": "@username/package-two", @@ -696,9 +608,7 @@ "description": "Second package with different settings", "format": "cursor", "private": true, - "files": [ - "package-two/.cursor/rules/main.mdc" - ] + "files": ["package-two/.cursor/rules/main.mdc"] } ] }, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5bd1294 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,111 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run linter + run: npm run lint + + typecheck: + name: Type Check + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run type check + run: npm run typecheck + + test: + name: Test (Node ${{ matrix.node-version }}) + runs-on: ubuntu-latest + strategy: + matrix: + node-version: ["20", "22"] + fail-fast: false + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm run test:run + + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Verify CLI is executable + run: | + node dist/cli/index.js --version + node dist/cli/index.js --help + + # Summary job that depends on all others - useful for branch protection + ci-success: + name: CI Success + needs: [lint, typecheck, test, build] + runs-on: ubuntu-latest + if: always() + steps: + - name: Check all jobs passed + run: | + if [[ "${{ needs.lint.result }}" != "success" ]] || \ + [[ "${{ needs.typecheck.result }}" != "success" ]] || \ + [[ "${{ needs.test.result }}" != "success" ]] || \ + [[ "${{ needs.build.result }}" != "success" ]]; then + echo "One or more jobs failed" + exit 1 + fi + echo "All CI jobs passed!" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..334267b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,45 @@ +name: Release + +on: + push: + tags: + - "v*" + +jobs: + release: + name: Release + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + registry-url: "https://registry.npmjs.org" + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm run test:run + + - name: Build + run: npm run build + + - name: Publish to npm + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.openskills/prpm-json-best-practices-skill/prpm-json-best-practices-skill/examples/single-package.json b/.openskills/prpm-json-best-practices-skill/prpm-json-best-practices-skill/examples/single-package.json index e0e6fc4..a656ec8 100644 --- a/.openskills/prpm-json-best-practices-skill/prpm-json-best-practices-skill/examples/single-package.json +++ b/.openskills/prpm-json-best-practices-skill/prpm-json-best-practices-skill/examples/single-package.json @@ -9,7 +9,5 @@ "format": "claude", "subtype": "skill", "tags": ["typescript", "best-practices", "code-quality"], - "files": [ - ".claude/skills/my-awesome-skill/SKILL.md" - ] + "files": [".claude/skills/my-awesome-skill/SKILL.md"] } diff --git a/.openskills/prpm-json-best-practices-skill/prpm-json-best-practices-skill/prpm-manifest.schema.json b/.openskills/prpm-json-best-practices-skill/prpm-json-best-practices-skill/prpm-manifest.schema.json index da52703..dc41c08 100644 --- a/.openskills/prpm-json-best-practices-skill/prpm-json-best-practices-skill/prpm-manifest.schema.json +++ b/.openskills/prpm-json-best-practices-skill/prpm-json-best-practices-skill/prpm-manifest.schema.json @@ -4,12 +4,7 @@ "title": "PRPM Package Manifest", "description": "Schema for PRPM package manifest (prpm.json)", "type": "object", - "required": [ - "name", - "version", - "description", - "format" - ], + "required": ["name", "version", "description", "format"], "properties": { "name": { "type": "string", @@ -27,11 +22,7 @@ "type": "string", "description": "Semantic version (semver)", "pattern": "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.-]+)?(\\+[a-zA-Z0-9.-]+)?$", - "examples": [ - "1.0.0", - "2.1.3", - "1.0.0-beta.1" - ] + "examples": ["1.0.0", "2.1.3", "1.0.0-beta.1"] }, "description": { "type": "string", @@ -79,16 +70,11 @@ "oneOf": [ { "type": "string", - "examples": [ - "John Doe", - "Jane Smith" - ] + "examples": ["John Doe", "Jane Smith"] }, { "type": "object", - "required": [ - "name" - ], + "required": ["name"], "properties": { "name": { "type": "string", @@ -111,12 +97,7 @@ "license": { "type": "string", "description": "SPDX license identifier", - "examples": [ - "MIT", - "Apache-2.0", - "GPL-3.0", - "BSD-3-Clause" - ] + "examples": ["MIT", "Apache-2.0", "GPL-3.0", "BSD-3-Clause"] }, "license_text": { "type": "string", @@ -131,9 +112,7 @@ "type": "string", "format": "uri", "description": "Repository URL", - "examples": [ - "https://github.com/username/repo" - ] + "examples": ["https://github.com/username/repo"] }, "homepage": { "type": "string", @@ -148,10 +127,7 @@ "organization": { "type": "string", "description": "Organization name or ID to publish this package under. If not specified, publishes to personal account.", - "examples": [ - "my-team", - "my-company" - ] + "examples": ["my-team", "my-company"] }, "private": { "type": "boolean", @@ -180,9 +156,7 @@ "prepublish": { "type": "string", "description": "Script to run before publishing and on npm install (not recommended - use prepublishOnly instead)", - "examples": [ - "npm run build" - ] + "examples": ["npm run build"] } }, "additionalProperties": false @@ -195,16 +169,7 @@ }, "maxItems": 10, "uniqueItems": true, - "examples": [ - [ - "productivity", - "coding" - ], - [ - "testing", - "quality" - ] - ] + "examples": [["productivity", "coding"], ["testing", "quality"]] }, "keywords": { "type": "array", @@ -214,22 +179,12 @@ }, "maxItems": 20, "uniqueItems": true, - "examples": [ - [ - "ai", - "prompts", - "development" - ] - ] + "examples": [["ai", "prompts", "development"]] }, "category": { "type": "string", "description": "Package category", - "examples": [ - "development", - "productivity", - "testing" - ] + "examples": ["development", "productivity", "testing"] }, "files": { "description": "Files to include in package. Can be simple paths or enhanced file objects with metadata.", @@ -242,14 +197,8 @@ }, "minItems": 1, "examples": [ - [ - "skill.md", - "README.md" - ], - [ - ".cursor/rules/react.mdc", - "LICENSE" - ] + ["skill.md", "README.md"], + [".cursor/rules/react.mdc", "LICENSE"] ] }, { @@ -257,10 +206,7 @@ "description": "Enhanced format: array of file objects with metadata", "items": { "type": "object", - "required": [ - "path", - "format" - ], + "required": ["path", "format"], "properties": { "path": { "type": "string", @@ -309,10 +255,7 @@ "name": { "type": "string", "description": "Display name for this file", - "examples": [ - "React Rules", - "Test-Driven Development" - ] + "examples": ["React Rules", "Test-Driven Development"] }, "description": { "type": "string", @@ -325,16 +268,7 @@ "type": "string" }, "uniqueItems": true, - "examples": [ - [ - "react", - "typescript" - ], - [ - "testing", - "tdd" - ] - ] + "examples": [["react", "typescript"], ["testing", "tdd"]] }, "eager": { "type": "boolean", @@ -351,10 +285,7 @@ "main": { "type": "string", "description": "Main entry file (for single-file packages)", - "examples": [ - "index.md", - "skill.md" - ] + "examples": ["index.md", "skill.md"] }, "dependencies": { "type": "object", @@ -425,7 +356,11 @@ "pattern": "^[a-z0-9-]+$", "minLength": 3, "maxLength": 100, - "examples": ["nextjs-complete", "fullstack-setup", "react-essentials"] + "examples": [ + "nextjs-complete", + "fullstack-setup", + "react-essentials" + ] }, "name": { "type": "string", @@ -449,7 +384,18 @@ "category": { "type": "string", "description": "Collection category", - "enum": ["development", "testing", "deployment", "data-science", "devops", "design", "documentation", "security", "performance", "general"], + "enum": [ + "development", + "testing", + "deployment", + "data-science", + "devops", + "design", + "documentation", + "security", + "performance", + "general" + ], "examples": ["development", "testing"] }, "tags": { @@ -461,7 +407,10 @@ }, "minItems": 1, "maxItems": 10, - "examples": [["react", "typescript", "nextjs"], ["python", "data-science", "ml"]] + "examples": [ + ["react", "typescript", "nextjs"], + ["python", "data-science", "ml"] + ] }, "icon": { "type": "string", @@ -496,7 +445,10 @@ "type": "string", "description": "Explanation of why this package is included", "maxLength": 200, - "examples": ["Enforces strict TypeScript type safety", "React component best practices"] + "examples": [ + "Enforces strict TypeScript type safety", + "React component best practices" + ] } } }, @@ -513,13 +465,11 @@ "name": "@username/simple-package", "version": "1.0.0", "description": "A simple package with basic files", - "format": "claude", "subtype": "skill", + "format": "claude", + "subtype": "skill", "author": "Your Name", "license": "MIT", - "files": [ - "skill.md", - "README.md" - ] + "files": ["skill.md", "README.md"] }, { "name": "@company/coding-standards", @@ -531,9 +481,7 @@ "organization": "my-company", "license": "MIT", "eager": true, - "files": [ - ".claude/skills/coding-standards/SKILL.md" - ] + "files": [".claude/skills/coding-standards/SKILL.md"] }, { "name": "@company/team-package", @@ -543,10 +491,7 @@ "author": "Team Name", "organization": "my-company", "license": "MIT", - "files": [ - ".cursor/rules/guidelines.mdc", - "README.md" - ] + "files": [".cursor/rules/guidelines.mdc", "README.md"] }, { "name": "@username/cursor-rules", @@ -559,29 +504,19 @@ }, "license": "MIT", "repository": "https://github.com/username/cursor-rules", - "tags": [ - "cursor", - "rules", - "multi-language" - ], + "tags": ["cursor", "rules", "multi-language"], "files": [ { "path": ".cursor/rules/typescript.mdc", "format": "cursor", "name": "TypeScript Rules", - "tags": [ - "typescript", - "frontend" - ] + "tags": ["typescript", "frontend"] }, { "path": ".cursor/rules/python.mdc", "format": "cursor", "name": "Python Rules", - "tags": [ - "python", - "backend" - ] + "tags": ["python", "backend"] } ] }, @@ -589,22 +524,22 @@ "name": "@community/testing-suite", "version": "2.0.0", "description": "Complete testing suite with skills and agents", - "format": "generic", "subtype": "collection", + "format": "generic", + "subtype": "collection", "author": "Community", "license": "MIT", - "tags": [ - "testing", - "quality" - ], + "tags": ["testing", "quality"], "files": [ { "path": ".claude/skills/tdd.md", - "format": "claude", "subtype": "skill", + "format": "claude", + "subtype": "skill", "name": "Test-Driven Development" }, { "path": ".claude/agents/test-generator.md", - "format": "claude", "subtype": "agent", + "format": "claude", + "subtype": "agent", "name": "Test Generator" } ] @@ -616,14 +551,8 @@ "format": "copilot", "author": "Your Name", "license": "MIT", - "tags": [ - "copilot", - "api", - "backend" - ], - "files": [ - "api-guidelines.md" - ] + "tags": ["copilot", "api", "backend"], + "files": ["api-guidelines.md"] }, { "name": "@username/kiro-steering", @@ -632,14 +561,8 @@ "format": "kiro", "author": "Your Name", "license": "MIT", - "tags": [ - "kiro", - "testing", - "quality" - ], - "files": [ - "testing.md" - ] + "tags": ["kiro", "testing", "quality"], + "files": ["testing.md"] }, { "name": "@username/windsurf-rules", @@ -648,14 +571,8 @@ "format": "windsurf", "author": "Your Name", "license": "MIT", - "tags": [ - "windsurf", - "react", - "frontend" - ], - "files": [ - ".windsurfrules" - ] + "tags": ["windsurf", "react", "frontend"], + "files": [".windsurfrules"] }, { "name": "@company/private-package", @@ -667,10 +584,7 @@ "organization": "my-company", "private": true, "license": "Proprietary", - "files": [ - "internal-skill.md", - "README.md" - ] + "files": ["internal-skill.md", "README.md"] }, { "name": "@username/multi-package-example", @@ -686,9 +600,7 @@ "description": "First package in the multi-package manifest", "format": "claude", "subtype": "skill", - "files": [ - "package-one/SKILL.md" - ] + "files": ["package-one/SKILL.md"] }, { "name": "@username/package-two", @@ -696,9 +608,7 @@ "description": "Second package with different settings", "format": "cursor", "private": true, - "files": [ - "package-two/.cursor/rules/main.mdc" - ] + "files": ["package-two/.cursor/rules/main.mdc"] } ] }, diff --git a/biome.json b/biome.json index d5a0745..57e85bb 100644 --- a/biome.json +++ b/biome.json @@ -6,7 +6,14 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "style": { + "noNonNullAssertion": "off" + }, + "suspicious": { + "noExplicitAny": "off", + "noImplicitAnyLet": "off" + } } }, "formatter": { @@ -15,6 +22,13 @@ "indentWidth": 2 }, "files": { - "ignore": ["node_modules", "dist", "*.md"] + "ignore": [ + "node_modules", + "dist", + "*.md", + ".beads", + ".openskills", + ".claude" + ] } } diff --git a/package.json b/package.json index 50ed19d..7cf998e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-trajectories", - "version": "0.1.1", + "version": "0.2.1", "description": "Capture the complete train of thought of agent work as first-class artifacts", "type": "module", "main": "dist/index.js", @@ -29,9 +29,7 @@ ], "author": "", "license": "MIT", - "files": [ - "dist" - ], + "files": ["dist"], "engines": { "node": ">=20.0.0" }, diff --git a/prpm.json b/prpm.json index 650cc35..55dd585 100644 --- a/prpm.json +++ b/prpm.json @@ -26,9 +26,7 @@ "retrospective", "tracing" ], - "files": [ - "docs/trail-snippet.md" - ] + "files": ["docs/trail-snippet.md"] } ] } diff --git a/src/cli/commands/complete.ts b/src/cli/commands/complete.ts index 840d021..6bf79fd 100644 --- a/src/cli/commands/complete.ts +++ b/src/cli/commands/complete.ts @@ -12,7 +12,7 @@ export function registerCompleteCommand(program: Command): void { .description("Complete the active trajectory with retrospective") .option("--summary ", "Summary of what was accomplished") .option("--approach ", "How the work was approached") - .option("--confidence ", "Confidence level 0-1", parseFloat) + .option("--confidence ", "Confidence level 0-1", Number.parseFloat) .action(async (options) => { const storage = new FileStorage(); await storage.initialize(); @@ -20,7 +20,7 @@ export function registerCompleteCommand(program: Command): void { const active = await storage.getActive(); if (!active) { console.error("Error: No active trajectory"); - console.error("Start one with: trail start \"Task description\""); + console.error('Start one with: trail start "Task description"'); throw new Error("No active trajectory"); } diff --git a/src/cli/commands/decision.ts b/src/cli/commands/decision.ts index bd6c132..dcc493f 100644 --- a/src/cli/commands/decision.ts +++ b/src/cli/commands/decision.ts @@ -10,8 +10,14 @@ export function registerDecisionCommand(program: Command): void { program .command("decision ") .description("Record a decision") - .option("-r, --reasoning ", "Why this choice was made (optional for minor decisions)") - .option("-a, --alternatives ", "Comma-separated alternatives considered") + .option( + "-r, --reasoning ", + "Why this choice was made (optional for minor decisions)", + ) + .option( + "-a, --alternatives ", + "Comma-separated alternatives considered", + ) .action(async (choice: string, options) => { const storage = new FileStorage(); await storage.initialize(); @@ -19,7 +25,7 @@ export function registerDecisionCommand(program: Command): void { const active = await storage.getActive(); if (!active) { console.error("Error: No active trajectory"); - console.error("Start one with: trail start \"Task description\""); + console.error('Start one with: trail start "Task description"'); throw new Error("No active trajectory"); } diff --git a/src/cli/commands/export.ts b/src/cli/commands/export.ts index cb8486f..3ac81c5 100644 --- a/src/cli/commands/export.ts +++ b/src/cli/commands/export.ts @@ -2,21 +2,25 @@ * trail export command */ -import type { Command } from "commander"; -import { writeFile, mkdir } from "node:fs/promises"; -import { join } from "node:path"; import { exec } from "node:child_process"; -import { FileStorage } from "../../storage/file.js"; -import { exportToMarkdown } from "../../export/markdown.js"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { Command } from "commander"; import { exportToJSON } from "../../export/json.js"; +import { exportToMarkdown } from "../../export/markdown.js"; import { exportToTimeline } from "../../export/timeline.js"; +import { FileStorage } from "../../storage/file.js"; import { generateTrajectoryHtml } from "../../web/generator.js"; export function registerExportCommand(program: Command): void { program .command("export [id]") .description("Export a trajectory") - .option("-f, --format ", "Export format (md, json, timeline, html)", "md") + .option( + "-f, --format ", + "Export format (md, json, timeline, html)", + "md", + ) .option("-o, --output ", "Output file path") .option("--open", "Open in browser (html format only)") .action(async (id: string | undefined, options) => { @@ -35,7 +39,9 @@ export function registerExportCommand(program: Command): void { trajectory = await storage.getActive(); if (!trajectory) { console.error("Error: No active trajectory and no ID provided"); - console.error("Usage: trail export or trail export (with active trajectory)"); + console.error( + "Usage: trail export or trail export (with active trajectory)", + ); throw new Error("No trajectory specified"); } } @@ -52,8 +58,6 @@ export function registerExportCommand(program: Command): void { case "html": output = generateTrajectoryHtml(trajectory); break; - case "md": - case "markdown": default: output = exportToMarkdown(trajectory); break; diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index 36eeaa1..9dfb4de 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -15,14 +15,14 @@ */ import type { Command } from "commander"; -import { registerStartCommand } from "./start.js"; -import { registerStatusCommand } from "./status.js"; -import { registerCompleteCommand } from "./complete.js"; import { registerAbandonCommand } from "./abandon.js"; +import { registerCompleteCommand } from "./complete.js"; import { registerDecisionCommand } from "./decision.js"; +import { registerExportCommand } from "./export.js"; import { registerListCommand } from "./list.js"; import { registerShowCommand } from "./show.js"; -import { registerExportCommand } from "./export.js"; +import { registerStartCommand } from "./start.js"; +import { registerStatusCommand } from "./status.js"; /** * Register all CLI commands diff --git a/src/cli/commands/list.ts b/src/cli/commands/list.ts index f0c3370..d833ec2 100644 --- a/src/cli/commands/list.ts +++ b/src/cli/commands/list.ts @@ -2,44 +2,88 @@ * trail list command */ +import { existsSync } from "node:fs"; import type { Command } from "commander"; -import { FileStorage } from "../../storage/file.js"; -import type { TrajectoryStatus } from "../../core/types.js"; +import type { TrajectoryStatus, TrajectorySummary } from "../../core/types.js"; +import { FileStorage, getSearchPaths } from "../../storage/file.js"; export function registerListCommand(program: Command): void { program .command("list") .description("List and search trajectories") - .option("-s, --status ", "Filter by status (active, completed, abandoned)") - .option("-l, --limit ", "Limit results", parseInt) + .option( + "-s, --status ", + "Filter by status (active, completed, abandoned)", + ) + .option("-l, --limit ", "Limit results", Number.parseInt) .option("--search ", "Search trajectories by title or content") .action(async (options) => { - const storage = new FileStorage(); - await storage.initialize(); + // Get all search paths and aggregate results + const searchPaths = getSearchPaths(); + let allTrajectories: TrajectorySummary[] = []; + const seenIds = new Set(); - let trajectories = await storage.list({ - status: options.status as TrajectoryStatus | undefined, - limit: options.search ? undefined : options.limit, // Apply limit after search - }); + for (const searchPath of searchPaths) { + // Skip paths that don't exist + if (!existsSync(searchPath)) { + continue; + } + + // Create storage pointing to this path directly + // We set TRAJECTORIES_DATA_DIR temporarily to use this path + const originalDataDir = process.env.TRAJECTORIES_DATA_DIR; + process.env.TRAJECTORIES_DATA_DIR = searchPath; + + try { + const storage = new FileStorage(); + await storage.initialize(); + + const trajectories = await storage.list({ + status: options.status as TrajectoryStatus | undefined, + limit: options.search ? undefined : undefined, // Don't limit per-path + }); + + // Add to results, avoiding duplicates + for (const traj of trajectories) { + if (!seenIds.has(traj.id)) { + seenIds.add(traj.id); + allTrajectories.push(traj); + } + } + } finally { + // Restore original env var + if (originalDataDir !== undefined) { + process.env.TRAJECTORIES_DATA_DIR = originalDataDir; + } else { + process.env.TRAJECTORIES_DATA_DIR = undefined; + } + } + } + + // Sort by startedAt descending (most recent first) + allTrajectories.sort( + (a, b) => + new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(), + ); // Apply search filter if provided if (options.search) { const query = options.search.toLowerCase(); - trajectories = trajectories.filter((traj) => { + allTrajectories = allTrajectories.filter((traj) => { // Search in title if (traj.title.toLowerCase().includes(query)) return true; // Search in ID if (traj.id.toLowerCase().includes(query)) return true; return false; }); + } - // Apply limit after search - if (options.limit) { - trajectories = trajectories.slice(0, options.limit); - } + // Apply limit after aggregation and search + if (options.limit) { + allTrajectories = allTrajectories.slice(0, options.limit); } - if (trajectories.length === 0) { + if (allTrajectories.length === 0) { if (options.search) { console.log(`No trajectories found matching "${options.search}"`); } else { @@ -49,9 +93,11 @@ export function registerListCommand(program: Command): void { } const searchNote = options.search ? ` matching "${options.search}"` : ""; - console.log(`Found ${trajectories.length} trajectories${searchNote}:\n`); + console.log( + `Found ${allTrajectories.length} trajectories${searchNote}:\n`, + ); - for (const traj of trajectories) { + for (const traj of allTrajectories) { const statusIcon = getStatusIcon(traj.status); const confidence = traj.confidence ? ` (${Math.round(traj.confidence * 100)}%)` diff --git a/src/cli/commands/show.ts b/src/cli/commands/show.ts index 92db1f5..c5dc444 100644 --- a/src/cli/commands/show.ts +++ b/src/cli/commands/show.ts @@ -2,9 +2,47 @@ * trail show command */ +import { existsSync } from "node:fs"; import type { Command } from "commander"; -import { FileStorage } from "../../storage/file.js"; -import type { Decision } from "../../core/types.js"; +import type { Decision, Trajectory } from "../../core/types.js"; +import { FileStorage, getSearchPaths } from "../../storage/file.js"; + +/** + * Search for a trajectory across all search paths + */ +async function findTrajectory(id: string): Promise { + const searchPaths = getSearchPaths(); + + for (const searchPath of searchPaths) { + // Skip paths that don't exist + if (!existsSync(searchPath)) { + continue; + } + + // Create storage pointing to this path directly + const originalDataDir = process.env.TRAJECTORIES_DATA_DIR; + process.env.TRAJECTORIES_DATA_DIR = searchPath; + + try { + const storage = new FileStorage(); + await storage.initialize(); + + const trajectory = await storage.get(id); + if (trajectory) { + return trajectory; + } + } finally { + // Restore original env var + if (originalDataDir !== undefined) { + process.env.TRAJECTORIES_DATA_DIR = originalDataDir; + } else { + process.env.TRAJECTORIES_DATA_DIR = undefined; + } + } + } + + return null; +} export function registerShowCommand(program: Command): void { program @@ -12,10 +50,7 @@ export function registerShowCommand(program: Command): void { .description("Show trajectory details") .option("-d, --decisions", "Show decisions only") .action(async (id: string, options) => { - const storage = new FileStorage(); - await storage.initialize(); - - const trajectory = await storage.get(id); + const trajectory = await findTrajectory(id); if (!trajectory) { console.error(`Error: Trajectory not found: ${id}`); @@ -46,7 +81,7 @@ export function registerShowCommand(program: Command): void { // Show full details console.log(`Trajectory: ${trajectory.id}`); - console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`); + console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); console.log(`Title: ${trajectory.task.title}`); console.log(`Status: ${trajectory.status}`); console.log(`Started: ${trajectory.startedAt}`); @@ -55,7 +90,9 @@ export function registerShowCommand(program: Command): void { } if (trajectory.task.source) { - console.log(`Source: ${trajectory.task.source.system}:${trajectory.task.source.id}`); + console.log( + `Source: ${trajectory.task.source.system}:${trajectory.task.source.id}`, + ); } console.log(`\nChapters: ${trajectory.chapters.length}`); @@ -65,9 +102,11 @@ export function registerShowCommand(program: Command): void { } if (trajectory.retrospective) { - console.log(`\nRetrospective:`); + console.log("\nRetrospective:"); console.log(` Summary: ${trajectory.retrospective.summary}`); - console.log(` Confidence: ${Math.round(trajectory.retrospective.confidence * 100)}%`); + console.log( + ` Confidence: ${Math.round(trajectory.retrospective.confidence * 100)}%`, + ); } }); } diff --git a/src/cli/commands/start.ts b/src/cli/commands/start.ts index f3ce769..ee67a76 100644 --- a/src/cli/commands/start.ts +++ b/src/cli/commands/start.ts @@ -3,17 +3,23 @@ */ import type { Command } from "commander"; -import { createTrajectory } from "../../core/trajectory.js"; -import { FileStorage } from "../../storage/file.js"; +import { addChapter, createTrajectory } from "../../core/trajectory.js"; import type { TaskSource } from "../../core/types.js"; +import { FileStorage } from "../../storage/file.js"; export function registerStartCommand(program: Command): void { program .command("start ") .description("Start a new trajectory") .option("-t, --task <id>", "External task ID") - .option("-s, --source <system>", "Task system (github, linear, jira, beads)") + .option( + "-s, --source <system>", + "Task system (github, linear, jira, beads)", + ) .option("--url <url>", "URL to external task") + .option("-a, --agent <name>", "Agent name (or set TRAJECTORIES_AGENT)") + .option("-p, --project <id>", "Project ID (or set TRAJECTORIES_PROJECT)") + .option("-q, --quiet", "Only output trajectory ID (for scripting)") .action(async (title: string, options) => { const storage = new FileStorage(); await storage.initialize(); @@ -21,8 +27,12 @@ export function registerStartCommand(program: Command): void { // Check if there's already an active trajectory const active = await storage.getActive(); if (active) { - console.error(`Error: Trajectory already active: ${active.id}`); - console.error(`Complete or abandon it first with: trail complete or trail abandon`); + if (!options.quiet) { + console.error(`Error: Trajectory already active: ${active.id}`); + console.error( + "Complete or abandon it first with: trail complete or trail abandon", + ); + } throw new Error("Trajectory already active"); } @@ -36,18 +46,46 @@ export function registerStartCommand(program: Command): void { }; } + // Resolve agent name from CLI flag or env var + const agentName = + options.agent ?? process.env.TRAJECTORIES_AGENT ?? undefined; + + // Resolve project ID from CLI flag or env var + const projectId = + options.project ?? process.env.TRAJECTORIES_PROJECT ?? undefined; + // Create the trajectory - const trajectory = createTrajectory({ + let trajectory = createTrajectory({ title, source, + projectId, }); + // If agent specified, add initial chapter with agent name + if (agentName) { + trajectory = addChapter(trajectory, { + title: "Initial work", + agentName, + }); + } + await storage.save(trajectory); - console.log(`✓ Trajectory started: ${trajectory.id}`); - console.log(` Title: ${title}`); - if (source) { - console.log(` Linked to: ${source.id} (${source.system})`); + if (options.quiet) { + // Only output trajectory ID for scripting + console.log(trajectory.id); + } else { + console.log(`✓ Trajectory started: ${trajectory.id}`); + console.log(` Title: ${title}`); + if (agentName) { + console.log(` Agent: ${agentName}`); + } + if (projectId) { + console.log(` Project: ${projectId}`); + } + if (source) { + console.log(` Linked to: ${source.id} (${source.system})`); + } } }); } diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index e57d204..4aed1a5 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -17,29 +17,32 @@ export function registerStatusCommand(program: Command): void { if (!active) { console.log("No active trajectory"); - console.log("Start one with: trail start \"Task description\""); + console.log('Start one with: trail start "Task description"'); return; } const duration = formatDuration( - new Date().getTime() - new Date(active.startedAt).getTime() + new Date().getTime() - new Date(active.startedAt).getTime(), ); const eventCount = active.chapters.reduce( (sum, ch) => sum + ch.events.length, - 0 + 0, ); const decisionCount = active.chapters.reduce( - (sum, ch) => sum + ch.events.filter((e) => e.type === "decision").length, - 0 + (sum, ch) => + sum + ch.events.filter((e) => e.type === "decision").length, + 0, ); console.log(`Active Trajectory: ${active.id}`); - console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`); + console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); console.log(`Task: ${active.task.title}`); if (active.task.source) { - console.log(`Source: ${active.task.source.system}:${active.task.source.id}`); + console.log( + `Source: ${active.task.source.system}:${active.task.source.id}`, + ); } console.log(`Status: ${active.status}`); console.log(`Started: ${duration} ago`); diff --git a/src/cli/index.ts b/src/cli/index.ts index d528936..f67a8e1 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -15,7 +15,18 @@ import { registerCommands } from "./commands/index.js"; program .name("trail") .description("Leave a trail of your work for others to follow") - .version("0.1.0"); + .version("0.1.0") + .option( + "--data-dir <path>", + "Override trajectory storage directory (or set TRAJECTORIES_DATA_DIR)", + ) + .hook("preAction", (thisCommand) => { + // If --data-dir flag is set, override the env var before commands run + const opts = thisCommand.opts(); + if (opts.dataDir) { + process.env.TRAJECTORIES_DATA_DIR = opts.dataDir; + } + }); // Register all commands registerCommands(program); diff --git a/src/cli/runner.ts b/src/cli/runner.ts index 0a03d3b..49ee75e 100644 --- a/src/cli/runner.ts +++ b/src/cli/runner.ts @@ -27,10 +27,10 @@ export async function runCommand(args: string[]): Promise<CommandResult> { const originalLog = console.log; const originalError = console.error; console.log = (...args) => { - output += args.join(" ") + "\n"; + output += `${args.join(" ")}\n`; }; console.error = (...args) => { - error += args.join(" ") + "\n"; + error += `${args.join(" ")}\n`; }; try { @@ -40,8 +40,12 @@ export async function runCommand(args: string[]): Promise<CommandResult> { .version("0.1.0") .exitOverride() // Don't exit process on error .configureOutput({ - writeOut: (str) => { output += str; }, - writeErr: (str) => { error += str; }, + writeOut: (str) => { + output += str; + }, + writeErr: (str) => { + error += str; + }, }); registerCommands(program); diff --git a/src/core/trajectory.ts b/src/core/trajectory.ts index 419bd16..d2bfdc8 100644 --- a/src/core/trajectory.ts +++ b/src/core/trajectory.ts @@ -5,20 +5,20 @@ * These functions return new trajectory objects (immutable updates). */ +import { generateChapterId, generateTrajectoryId } from "./id.js"; +import { + CompleteTrajectoryInputSchema, + CreateTrajectoryInputSchema, +} from "./schema.js"; import type { - Trajectory, - Chapter, - CreateTrajectoryInput, AddChapterInput, AddEventInput, + Chapter, CompleteTrajectoryInput, + CreateTrajectoryInput, Decision, + Trajectory, } from "./types.js"; -import { generateTrajectoryId, generateChapterId } from "./id.js"; -import { - CreateTrajectoryInputSchema, - CompleteTrajectoryInputSchema, -} from "./schema.js"; /** * Custom error class for trajectory operations @@ -27,7 +27,7 @@ export class TrajectoryError extends Error { constructor( message: string, public code: string, - public suggestion?: string + public suggestion?: string, ) { super(message); this.name = "TrajectoryError"; @@ -48,7 +48,7 @@ export function createTrajectory(input: CreateTrajectoryInput): Trajectory { throw new TrajectoryError( firstError.message, "VALIDATION_ERROR", - "Check your input and try again" + "Check your input and try again", ); } @@ -82,13 +82,13 @@ export function createTrajectory(input: CreateTrajectoryInput): Trajectory { */ export function addChapter( trajectory: Trajectory, - input: AddChapterInput + input: AddChapterInput, ): Trajectory { if (trajectory.status === "completed") { throw new TrajectoryError( "Cannot add chapter to completed trajectory", "TRAJECTORY_ALREADY_COMPLETED", - "Start a new trajectory instead" + "Start a new trajectory instead", ); } @@ -125,7 +125,7 @@ export function addChapter( */ export function addEvent( trajectory: Trajectory, - input: AddEventInput + input: AddEventInput, ): Trajectory { // Auto-create a chapter if none exists let updatedTrajectory = trajectory; @@ -166,7 +166,7 @@ export function addEvent( */ export function addDecision( trajectory: Trajectory, - decision: Decision + decision: Decision, ): Trajectory { return addEvent(trajectory, { type: "decision", @@ -185,13 +185,13 @@ export function addDecision( */ export function completeTrajectory( trajectory: Trajectory, - input: CompleteTrajectoryInput + input: CompleteTrajectoryInput, ): Trajectory { if (trajectory.status === "completed") { throw new TrajectoryError( "Trajectory is already completed", "TRAJECTORY_ALREADY_COMPLETED", - "Start a new trajectory instead" + "Start a new trajectory instead", ); } @@ -202,7 +202,7 @@ export function completeTrajectory( throw new TrajectoryError( firstError.message, "VALIDATION_ERROR", - "Check your input and try again" + "Check your input and try again", ); } @@ -241,7 +241,7 @@ export function completeTrajectory( */ export function abandonTrajectory( trajectory: Trajectory, - reason?: string + reason?: string, ): Trajectory { const now = new Date().toISOString(); diff --git a/src/export/json.ts b/src/export/json.ts index 50b8ea1..1a4e4f7 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -17,7 +17,7 @@ export interface JSONExportOptions { */ export function exportToJSON( trajectory: Trajectory, - options?: JSONExportOptions + options?: JSONExportOptions, ): string { if (options?.compact) { return JSON.stringify(trajectory); diff --git a/src/export/markdown.ts b/src/export/markdown.ts index 33af1ed..a443907 100644 --- a/src/export/markdown.ts +++ b/src/export/markdown.ts @@ -4,7 +4,7 @@ * Generates human-readable Notion-style documentation. */ -import type { Trajectory, Decision } from "../core/types.js"; +import type { Decision, Trajectory } from "../core/types.js"; /** * Export a trajectory to markdown format @@ -19,7 +19,7 @@ export function exportToMarkdown(trajectory: Trajectory): string { lines.push(""); // Metadata block - lines.push("> **Status:** " + formatStatus(trajectory.status)); + lines.push(`> **Status:** ${formatStatus(trajectory.status)}`); if (trajectory.task.source) { const linkText = trajectory.task.source.url ? `[${trajectory.task.source.id}](${trajectory.task.source.url})` @@ -28,7 +28,7 @@ export function exportToMarkdown(trajectory: Trajectory): string { } if (trajectory.retrospective?.confidence !== undefined) { lines.push( - `> **Confidence:** ${Math.round(trajectory.retrospective.confidence * 100)}%` + `> **Confidence:** ${Math.round(trajectory.retrospective.confidence * 100)}%`, ); } lines.push(`> **Started:** ${formatDate(trajectory.startedAt)}`); @@ -47,7 +47,7 @@ export function exportToMarkdown(trajectory: Trajectory): string { lines.push(""); if (trajectory.retrospective.approach) { - lines.push("**Approach:** " + trajectory.retrospective.approach); + lines.push(`**Approach:** ${trajectory.retrospective.approach}`); lines.push(""); } } @@ -88,7 +88,7 @@ export function exportToMarkdown(trajectory: Trajectory): string { (e) => e.significance === "high" || e.significance === "critical" || - e.type === "decision" + e.type === "decision", ); if (significantEvents.length > 0) { for (const event of significantEvents) { diff --git a/src/export/pr-summary.ts b/src/export/pr-summary.ts index b710edf..f8059e1 100644 --- a/src/export/pr-summary.ts +++ b/src/export/pr-summary.ts @@ -19,7 +19,7 @@ export interface PRSummaryOptions { */ export function exportToPRSummary( trajectory: Trajectory, - options?: PRSummaryOptions + options?: PRSummaryOptions, ): string { const lines: string[] = []; @@ -36,14 +36,14 @@ export function exportToPRSummary( const decisionCount = trajectory.chapters.reduce( (count, chapter) => count + chapter.events.filter((e) => e.type === "decision").length, - 0 + 0, ); - lines.push("**Key decisions:** " + decisionCount); + lines.push(`**Key decisions:** ${decisionCount}`); if (trajectory.retrospective?.confidence !== undefined) { lines.push( - `**Confidence:** ${Math.round(trajectory.retrospective.confidence * 100)}%` + `**Confidence:** ${Math.round(trajectory.retrospective.confidence * 100)}%`, ); } diff --git a/src/export/timeline.ts b/src/export/timeline.ts index 3afcd84..7f83452 100644 --- a/src/export/timeline.ts +++ b/src/export/timeline.ts @@ -15,12 +15,16 @@ export function exportToTimeline(trajectory: Trajectory): string { const lines: string[] = []; // Start marker - lines.push(`● ${formatTime(trajectory.startedAt)} Started: ${trajectory.task.title}`); + lines.push( + `● ${formatTime(trajectory.startedAt)} Started: ${trajectory.task.title}`, + ); lines.push("│"); // Chapters and events for (const chapter of trajectory.chapters) { - lines.push(`├─ ${formatTime(chapter.startedAt)} Chapter: ${chapter.title}`); + lines.push( + `├─ ${formatTime(chapter.startedAt)} Chapter: ${chapter.title}`, + ); lines.push(`│ Agent: ${chapter.agentName}`); lines.push("│"); @@ -45,14 +49,15 @@ export function exportToTimeline(trajectory: Trajectory): string { // End marker if (trajectory.completedAt) { - const status = trajectory.status === "completed" ? "Completed" : "Abandoned"; + const status = + trajectory.status === "completed" ? "Completed" : "Abandoned"; lines.push(`○ ${formatTime(trajectory.completedAt)} ${status}`); if (trajectory.retrospective) { lines.push(""); - lines.push(" Summary: " + trajectory.retrospective.summary); + lines.push(` Summary: ${trajectory.retrospective.summary}`); lines.push( - ` Confidence: ${Math.round(trajectory.retrospective.confidence * 100)}%` + ` Confidence: ${Math.round(trajectory.retrospective.confidence * 100)}%`, ); } } diff --git a/src/storage/file.ts b/src/storage/file.ts index a1fa814..87420cc 100644 --- a/src/storage/file.ts +++ b/src/storage/file.ts @@ -5,17 +5,51 @@ * Active trajectories go in active/, completed in completed/YYYY-MM/. */ -import { mkdir, readdir, readFile, writeFile, unlink } from "node:fs/promises"; import { existsSync } from "node:fs"; +import { mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import type { StorageAdapter } from "./interface.js"; +import { validateTrajectory } from "../core/schema.js"; import type { Trajectory, - TrajectorySummary, TrajectoryQuery, + TrajectorySummary, } from "../core/types.js"; -import { validateTrajectory } from "../core/schema.js"; import { exportToMarkdown } from "../export/markdown.js"; +import type { StorageAdapter } from "./interface.js"; + +/** + * Expand ~ to home directory in a path + */ +function expandPath(path: string): string { + if (path.startsWith("~")) { + return join(process.env.HOME ?? "", path.slice(1)); + } + return path; +} + +/** + * Get trajectory search paths from environment variable + * TRAJECTORIES_SEARCH_PATHS is colon-separated (like PATH) + * Falls back to current directory's .trajectories if not set + */ +export function getSearchPaths(): string[] { + const searchPathsEnv = process.env.TRAJECTORIES_SEARCH_PATHS; + if (searchPathsEnv) { + return searchPathsEnv + .split(":") + .map((p) => p.trim()) + .filter(Boolean) + .map(expandPath); + } + + // Default: check for TRAJECTORIES_DATA_DIR, then fall back to ./.trajectories + const dataDir = process.env.TRAJECTORIES_DATA_DIR; + if (dataDir) { + return [expandPath(dataDir)]; + } + + return [join(process.cwd(), ".trajectories")]; +} /** * Index file structure for quick lookups @@ -47,7 +81,16 @@ export class FileStorage implements StorageAdapter { constructor(baseDir?: string) { this.baseDir = baseDir ?? process.cwd(); - this.trajectoriesDir = join(this.baseDir, ".trajectories"); + + // Check for TRAJECTORIES_DATA_DIR env var first + // When set, use the path directly (no .trajectories suffix) + const dataDir = process.env.TRAJECTORIES_DATA_DIR; + if (dataDir) { + this.trajectoriesDir = expandPath(dataDir); + } else { + this.trajectoriesDir = join(this.baseDir, ".trajectories"); + } + this.activeDir = join(this.trajectoriesDir, "active"); this.completedDir = join(this.trajectoriesDir, "completed"); this.indexPath = join(this.trajectoriesDir, "index.json"); @@ -84,7 +127,7 @@ export class FileStorage implements StorageAdapter { const date = new Date(trajectory.completedAt ?? trajectory.startedAt); const monthDir = join( this.completedDir, - `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}` + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`, ); await mkdir(monthDir, { recursive: true }); filePath = join(monthDir, `${trajectory.id}.json`); @@ -164,7 +207,7 @@ export class FileStorage implements StorageAdapter { for (const file of jsonFiles) { const trajectory = await this.readTrajectoryFile( - join(this.activeDir, file) + join(this.activeDir, file), ); if (trajectory) { const startTime = new Date(trajectory.startedAt).getTime(); @@ -203,13 +246,13 @@ export class FileStorage implements StorageAdapter { if (query.since) { const sinceTime = new Date(query.since).getTime(); entries = entries.filter( - ([, entry]) => new Date(entry.startedAt).getTime() >= sinceTime + ([, entry]) => new Date(entry.startedAt).getTime() >= sinceTime, ); } if (query.until) { const untilTime = new Date(query.until).getTime(); entries = entries.filter( - ([, entry]) => new Date(entry.startedAt).getTime() <= untilTime + ([, entry]) => new Date(entry.startedAt).getTime() <= untilTime, ); } @@ -217,8 +260,8 @@ export class FileStorage implements StorageAdapter { const sortBy = query.sortBy ?? "startedAt"; const sortOrder = query.sortOrder ?? "desc"; entries.sort((a, b) => { - const aVal = a[1][sortBy as keyof typeof a[1]] ?? ""; - const bVal = b[1][sortBy as keyof typeof b[1]] ?? ""; + const aVal = a[1][sortBy as keyof (typeof a)[1]] ?? ""; + const bVal = b[1][sortBy as keyof (typeof b)[1]] ?? ""; const cmp = String(aVal).localeCompare(String(bVal)); return sortOrder === "asc" ? cmp : -cmp; }); @@ -246,10 +289,10 @@ export class FileStorage implements StorageAdapter { (count, chapter) => count + chapter.events.filter((e) => e.type === "decision").length, - 0 + 0, ) ?? 0, }; - }) + }), ); } @@ -285,7 +328,7 @@ export class FileStorage implements StorageAdapter { */ async search( text: string, - options?: { limit?: number } + options?: { limit?: number }, ): Promise<TrajectorySummary[]> { const allTrajectories = await this.list({}); const searchLower = text.toLowerCase(); @@ -319,8 +362,8 @@ export class FileStorage implements StorageAdapter { chapter.events.some( (event) => event.type === "decision" && - event.content.toLowerCase().includes(searchLower) - ) + event.content.toLowerCase().includes(searchLower), + ), ); if (hasMatchingDecision) { matches.push(summary); @@ -362,7 +405,10 @@ export class FileStorage implements StorageAdapter { } catch (error) { // ENOENT means index doesn't exist yet - this is expected on first run if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - console.error("Error loading trajectory index, using empty index:", error); + console.error( + "Error loading trajectory index, using empty index:", + error, + ); } return { version: 1, @@ -379,7 +425,7 @@ export class FileStorage implements StorageAdapter { private async updateIndex( trajectory: Trajectory, - filePath: string + filePath: string, ): Promise<void> { const index = await this.loadIndex(); index.trajectories[trajectory.id] = { diff --git a/src/storage/index.ts b/src/storage/index.ts index c84a0b6..df62d45 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -2,5 +2,5 @@ * Storage module exports */ -export { FileStorage } from "./file.js"; +export { FileStorage, getSearchPaths } from "./file.js"; export type { StorageAdapter, StorageConfig } from "./interface.js"; diff --git a/src/storage/interface.ts b/src/storage/interface.ts index ae921db..4f3d0d2 100644 --- a/src/storage/interface.ts +++ b/src/storage/interface.ts @@ -7,8 +7,8 @@ import type { Trajectory, - TrajectorySummary, TrajectoryQuery, + TrajectorySummary, } from "../core/types.js"; /** @@ -66,7 +66,7 @@ export interface StorageAdapter { */ search( text: string, - options?: { limit?: number } + options?: { limit?: number }, ): Promise<TrajectorySummary[]>; /** diff --git a/src/web/generator.ts b/src/web/generator.ts index 357073f..7cb84cb 100644 --- a/src/web/generator.ts +++ b/src/web/generator.ts @@ -2,7 +2,12 @@ * Static HTML generator for trajectory viewing */ -import type { Trajectory, Chapter, TrajectoryEvent, Decision } from "../core/types.js"; +import type { + Chapter, + Decision, + Trajectory, + TrajectoryEvent, +} from "../core/types.js"; import { styles } from "./styles.js"; function escapeHtml(text: string): string { @@ -64,7 +69,7 @@ function renderDecision(decision: Decision): string { return ` <div class="decision"> - <div class="decision-title">${escapeHtml(decision.title)}</div> + <div class="decision-title">${escapeHtml(decision.question)}: ${escapeHtml(decision.chosen)}</div> <div class="decision-reasoning">${escapeHtml(decision.reasoning)}</div> ${alternatives} </div> @@ -72,27 +77,37 @@ function renderDecision(decision: Decision): string { } function renderEvent(event: TrajectoryEvent): string { - const time = formatDate(event.timestamp); + const time = formatDate(new Date(event.ts).toISOString()); let content = ""; let typeClass = ""; + const rawData = event.raw as Record<string, unknown> | undefined; switch (event.type) { case "decision": typeClass = "decision"; content = ` <strong>Decision:</strong> ${escapeHtml(event.content)} - ${event.metadata?.reasoning ? `<div class="decision-reasoning">${escapeHtml(event.metadata.reasoning)}</div>` : ""} + ${rawData?.reasoning ? `<div class="decision-reasoning">${escapeHtml(String(rawData.reasoning))}</div>` : ""} `; break; - case "observation": - content = `<strong>Observed:</strong> ${escapeHtml(event.content)}`; + case "thinking": + content = `<strong>Thinking:</strong> ${escapeHtml(event.content)}`; break; - case "action": - content = `<strong>Action:</strong> ${escapeHtml(event.content)}`; + case "prompt": + content = `<strong>Prompt:</strong> ${escapeHtml(event.content)}`; break; case "tool_call": content = `<strong>Tool:</strong> <code>${escapeHtml(event.content)}</code>`; break; + case "tool_result": + content = `<strong>Result:</strong> ${escapeHtml(event.content)}`; + break; + case "message_sent": + content = `<strong>Sent:</strong> ${escapeHtml(event.content)}`; + break; + case "message_received": + content = `<strong>Received:</strong> ${escapeHtml(event.content)}`; + break; case "error": content = `<strong style="color: var(--error)">Error:</strong> ${escapeHtml(event.content)}`; break; @@ -120,7 +135,6 @@ function renderChapter(chapter: Chapter, index: number): string { Chapter ${index + 1}: ${escapeHtml(chapter.title)} </div> <div class="chapter-agent">Agent: ${escapeHtml(chapter.agentName)}</div> - ${chapter.summary ? `<p>${escapeHtml(chapter.summary)}</p>` : ""} ${ chapter.events.length > 0 ? ` @@ -143,16 +157,20 @@ function renderRetrospective(trajectory: Trajectory): string { const retro = trajectory.retrospective; const confidencePercent = Math.round(retro.confidence * 100); - const wentWell = retro.wentWell?.length - ? `<div><strong>What went well:</strong><ul class="list">${retro.wentWell.map((w) => `<li>${escapeHtml(w)}</li>`).join("")}</ul></div>` + const approach = retro.approach + ? `<div><strong>Approach:</strong><p>${escapeHtml(retro.approach)}</p></div>` + : ""; + + const learnings = retro.learnings?.length + ? `<div><strong>Learnings:</strong><ul class="list">${retro.learnings.map((l) => `<li>${escapeHtml(l)}</li>`).join("")}</ul></div>` : ""; const challenges = retro.challenges?.length ? `<div><strong>Challenges:</strong><ul class="list">${retro.challenges.map((c) => `<li>${escapeHtml(c)}</li>`).join("")}</ul></div>` : ""; - const wouldDoDifferently = retro.wouldDoDifferently?.length - ? `<div><strong>Would do differently:</strong><ul class="list">${retro.wouldDoDifferently.map((w) => `<li>${escapeHtml(w)}</li>`).join("")}</ul></div>` + const suggestions = retro.suggestions?.length + ? `<div><strong>Suggestions:</strong><ul class="list">${retro.suggestions.map((s) => `<li>${escapeHtml(s)}</li>`).join("")}</ul></div>` : ""; return ` @@ -168,9 +186,10 @@ function renderRetrospective(trajectory: Trajectory): string { <span>${confidencePercent}%</span> </div> - ${wentWell} + ${approach} + ${learnings} ${challenges} - ${wouldDoDifferently} + ${suggestions} </div> `; } @@ -180,14 +199,13 @@ export function generateTrajectoryHtml(trajectory: Trajectory): string { const duration = formatDuration(trajectory.startedAt, trajectory.completedAt); // Extract all decisions from chapters - const decisions = trajectory.chapters.flatMap((ch) => + const decisions: Decision[] = trajectory.chapters.flatMap((ch) => ch.events - .filter((e) => e.type === "decision") - .map((e) => ({ - title: e.content, - reasoning: e.metadata?.reasoning || "", - alternatives: e.metadata?.alternatives as string[] | undefined, - })) + .filter((e) => e.type === "decision" && e.raw) + .map((e) => e.raw as Decision) + .filter( + (d): d is Decision => d !== undefined && typeof d.question === "string", + ), ); const decisionsHtml = decisions.length diff --git a/src/workspace/storage.ts b/src/workspace/storage.ts index 800bde8..3bb9fd6 100644 --- a/src/workspace/storage.ts +++ b/src/workspace/storage.ts @@ -2,18 +2,18 @@ * Workspace storage - persists decisions, patterns, knowledge */ -import { mkdir, readFile, writeFile, readdir } from "node:fs/promises"; import { existsSync } from "node:fs"; +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { generateRandomId } from "../core/id.js"; import type { Workspace, WorkspaceDecision, - WorkspacePattern, WorkspaceKnowledge, + WorkspacePattern, WorkspaceQuery, WorkspaceQueryResult, } from "./types.js"; -import { generateRandomId } from "../core/id.js"; export class WorkspaceStorage { private basePath: string; @@ -37,13 +37,13 @@ export class WorkspaceStorage { // Decisions async addDecision( - decision: Omit<WorkspaceDecision, "id"> + decision: Omit<WorkspaceDecision, "id">, ): Promise<WorkspaceDecision> { const id = `dec_${generateRandomId()}`; const full: WorkspaceDecision = { ...decision, id }; await writeFile( join(this.decisionsPath, `${id}.json`), - JSON.stringify(full, null, 2) + JSON.stringify(full, null, 2), ); return full; } @@ -67,20 +67,20 @@ export class WorkspaceStorage { } return decisions.sort( (a, b) => - new Date(b.sourceDate).getTime() - new Date(a.sourceDate).getTime() + new Date(b.sourceDate).getTime() - new Date(a.sourceDate).getTime(), ); } async updateDecision( id: string, - updates: Partial<WorkspaceDecision> + updates: Partial<WorkspaceDecision>, ): Promise<WorkspaceDecision | null> { const existing = await this.getDecision(id); if (!existing) return null; const updated = { ...existing, ...updates, id }; await writeFile( join(this.decisionsPath, `${id}.json`), - JSON.stringify(updated, null, 2) + JSON.stringify(updated, null, 2), ); return updated; } @@ -88,7 +88,7 @@ export class WorkspaceStorage { // Patterns async addPattern( - pattern: Omit<WorkspacePattern, "id" | "createdAt" | "updatedAt"> + pattern: Omit<WorkspacePattern, "id" | "createdAt" | "updatedAt">, ): Promise<WorkspacePattern> { const id = `pat_${generateRandomId()}`; const now = new Date().toISOString(); @@ -100,7 +100,7 @@ export class WorkspaceStorage { }; await writeFile( join(this.patternsPath, `${id}.json`), - JSON.stringify(full, null, 2) + JSON.stringify(full, null, 2), ); return full; } @@ -128,7 +128,7 @@ export class WorkspaceStorage { // Knowledge async addKnowledge( - knowledge: Omit<WorkspaceKnowledge, "id" | "createdAt" | "updatedAt"> + knowledge: Omit<WorkspaceKnowledge, "id" | "createdAt" | "updatedAt">, ): Promise<WorkspaceKnowledge> { const id = `know_${generateRandomId()}`; const now = new Date().toISOString(); @@ -140,7 +140,7 @@ export class WorkspaceStorage { }; await writeFile( join(this.knowledgePath, `${id}.json`), - JSON.stringify(full, null, 2) + JSON.stringify(full, null, 2), ); return full; } diff --git a/tests/cli/commands.test.ts b/tests/cli/commands.test.ts index bca9be3..233644f 100644 --- a/tests/cli/commands.test.ts +++ b/tests/cli/commands.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; /** * Test stubs for CLI commands @@ -228,7 +228,11 @@ describe("CLI Commands", () => { await runCommand(["start", "Test task"]); // Act - const result = await runCommand(["abandon", "--reason", "Requirements changed"]); + const result = await runCommand([ + "abandon", + "--reason", + "Requirements changed", + ]); // Assert expect(result.success).toBe(true); @@ -385,17 +389,145 @@ describe("CLI Commands", () => { }); }); + describe("trail start (new features)", () => { + it("should support --agent flag", async () => { + // Arrange + const { runCommand } = await import("../../src/cli/runner.js"); + + // Act + const result = await runCommand([ + "start", + "Test task", + "--agent", + "Backend", + ]); + + // Assert + expect(result.success).toBe(true); + expect(result.output).toContain("Agent: Backend"); + + // Verify the trajectory has an agent in the chapter + const { FileStorage } = await import("../../src/storage/file.js"); + const storage = new FileStorage(tempDir); + await storage.initialize(); + const active = await storage.getActive(); + expect(active?.chapters[0]?.agentName).toBe("Backend"); + }); + + it("should support --project flag", async () => { + // Arrange + const { runCommand } = await import("../../src/cli/runner.js"); + + // Act + const result = await runCommand([ + "start", + "Test task", + "--project", + "relay", + ]); + + // Assert + expect(result.success).toBe(true); + expect(result.output).toContain("Project: relay"); + + // Verify the trajectory has the projectId + const { FileStorage } = await import("../../src/storage/file.js"); + const storage = new FileStorage(tempDir); + await storage.initialize(); + const active = await storage.getActive(); + expect(active?.projectId).toBe("relay"); + }); + + it("should support --quiet flag for scripting", async () => { + // Arrange + const { runCommand } = await import("../../src/cli/runner.js"); + + // Act + const result = await runCommand(["start", "Test task", "--quiet"]); + + // Assert + expect(result.success).toBe(true); + // Should only output the trajectory ID + expect(result.output).toMatch(/^traj_[a-z0-9]+$/); + expect(result.output).not.toContain("Trajectory started"); + }); + + it("should read TRAJECTORIES_AGENT env var", async () => { + // Arrange + const { runCommand } = await import("../../src/cli/runner.js"); + const originalAgent = process.env.TRAJECTORIES_AGENT; + process.env.TRAJECTORIES_AGENT = "Frontend"; + + // Act + const result = await runCommand(["start", "Test task"]); + + // Assert + expect(result.success).toBe(true); + expect(result.output).toContain("Agent: Frontend"); + + // Cleanup + if (originalAgent !== undefined) { + process.env.TRAJECTORIES_AGENT = originalAgent; + } else { + process.env.TRAJECTORIES_AGENT = undefined; + } + }); + + it("should read TRAJECTORIES_PROJECT env var", async () => { + // Arrange + const { runCommand } = await import("../../src/cli/runner.js"); + const originalProject = process.env.TRAJECTORIES_PROJECT; + process.env.TRAJECTORIES_PROJECT = "my-project"; + + // Act + const result = await runCommand(["start", "Test task"]); + + // Assert + expect(result.success).toBe(true); + expect(result.output).toContain("Project: my-project"); + + // Cleanup + if (originalProject !== undefined) { + process.env.TRAJECTORIES_PROJECT = originalProject; + } else { + process.env.TRAJECTORIES_PROJECT = undefined; + } + }); + + it("CLI flags should override env vars", async () => { + // Arrange + const { runCommand } = await import("../../src/cli/runner.js"); + const originalAgent = process.env.TRAJECTORIES_AGENT; + process.env.TRAJECTORIES_AGENT = "EnvAgent"; + + // Act + const result = await runCommand([ + "start", + "Test task", + "--agent", + "CLIAgent", + ]); + + // Assert + expect(result.success).toBe(true); + expect(result.output).toContain("Agent: CLIAgent"); + expect(result.output).not.toContain("EnvAgent"); + + // Cleanup + if (originalAgent !== undefined) { + process.env.TRAJECTORIES_AGENT = originalAgent; + } else { + process.env.TRAJECTORIES_AGENT = undefined; + } + }); + }); + describe("trail export", () => { it("should export trajectory as markdown", async () => { // Arrange const { runCommand } = await import("../../src/cli/runner.js"); await runCommand(["start", "Test task"]); - await runCommand([ - "decision", - "Chose A", - "--reasoning", - "Because", - ]); + await runCommand(["decision", "Chose A", "--reasoning", "Because"]); await runCommand([ "complete", "--summary", diff --git a/tests/core/trajectory.test.ts b/tests/core/trajectory.test.ts index da4cdac..efef5ef 100644 --- a/tests/core/trajectory.test.ts +++ b/tests/core/trajectory.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; /** * Test stubs for core Trajectory functionality @@ -69,7 +69,7 @@ describe("Trajectory", () => { // Act & Assert expect(() => createTrajectory({ title: "" })).toThrow( - "Trajectory title is required" + "Trajectory title is required", ); }); @@ -80,7 +80,7 @@ describe("Trajectory", () => { // Act & Assert expect(() => createTrajectory({ title: longTitle })).toThrow( - "Trajectory title must be 500 characters or less" + "Trajectory title must be 500 characters or less", ); }); }); @@ -145,7 +145,7 @@ describe("Trajectory", () => { // Act & Assert expect(() => - addChapter(trajectory, { title: "New chapter", agentName: "Alice" }) + addChapter(trajectory, { title: "New chapter", agentName: "Alice" }), ).toThrow("Cannot add chapter to completed trajectory"); }); }); @@ -301,7 +301,7 @@ describe("Trajectory", () => { summary: "", approach: "Test", confidence: 0.5, - }) + }), ).toThrow("Retrospective summary is required"); }); @@ -318,7 +318,7 @@ describe("Trajectory", () => { summary: "Done", approach: "Test", confidence: 1.5, - }) + }), ).toThrow("Confidence must be between 0 and 1"); }); @@ -340,7 +340,7 @@ describe("Trajectory", () => { summary: "Done again", approach: "Test", confidence: 0.9, - }) + }), ).toThrow("Trajectory is already completed"); }); }); diff --git a/tests/export/export.test.ts b/tests/export/export.test.ts index 3a8c9ad..c36a0af 100644 --- a/tests/export/export.test.ts +++ b/tests/export/export.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; /** * Test stubs for Export functionality @@ -101,7 +101,10 @@ describe("Markdown Export", () => { const { createTrajectory, addChapter, addDecision, completeTrajectory } = await import("../../src/core/trajectory.js"); let trajectory = createTrajectory({ title: "Test" }); - trajectory = addChapter(trajectory, { title: "Work", agentName: "Alice" }); + trajectory = addChapter(trajectory, { + title: "Work", + agentName: "Alice", + }); trajectory = addDecision(trajectory, { question: "Auth strategy", chosen: "JWT with refresh tokens", @@ -122,7 +125,9 @@ describe("Markdown Export", () => { expect(markdown).toContain("### Auth strategy"); expect(markdown).toContain("**Chose:** JWT with refresh tokens"); expect(markdown).toContain("**Rejected:** Sessions, OAuth only"); - expect(markdown).toContain("**Reasoning:** Stateless for horizontal scaling"); + expect(markdown).toContain( + "**Reasoning:** Stateless for horizontal scaling", + ); }); it("should include chapters section", async () => { @@ -244,7 +249,10 @@ describe("Timeline Export", () => { const { createTrajectory, addChapter, addEvent, completeTrajectory } = await import("../../src/core/trajectory.js"); let trajectory = createTrajectory({ title: "Test task" }); - trajectory = addChapter(trajectory, { title: "Work", agentName: "Alice" }); + trajectory = addChapter(trajectory, { + title: "Work", + agentName: "Alice", + }); trajectory = addEvent(trajectory, { type: "tool_call", content: "Read file", @@ -302,7 +310,10 @@ describe("Timeline Export", () => { "../../src/core/trajectory.js" ); let trajectory = createTrajectory({ title: "Test" }); - trajectory = addChapter(trajectory, { title: "Work", agentName: "Alice" }); + trajectory = addChapter(trajectory, { + title: "Work", + agentName: "Alice", + }); trajectory = addDecision(trajectory, { question: "Auth strategy", chosen: "JWT", @@ -324,11 +335,16 @@ describe("PR Summary Export", () => { describe("exportToPRSummary", () => { it("should generate concise PR-friendly summary", async () => { // Arrange - const { exportToPRSummary } = await import("../../src/export/pr-summary.js"); + const { exportToPRSummary } = await import( + "../../src/export/pr-summary.js" + ); const { createTrajectory, addChapter, addDecision, completeTrajectory } = await import("../../src/core/trajectory.js"); let trajectory = createTrajectory({ title: "Add user authentication" }); - trajectory = addChapter(trajectory, { title: "Work", agentName: "Alice" }); + trajectory = addChapter(trajectory, { + title: "Work", + agentName: "Alice", + }); trajectory = addDecision(trajectory, { question: "Auth strategy", chosen: "JWT", @@ -353,7 +369,9 @@ describe("PR Summary Export", () => { it("should include link to full trajectory", async () => { // Arrange - const { exportToPRSummary } = await import("../../src/export/pr-summary.js"); + const { exportToPRSummary } = await import( + "../../src/export/pr-summary.js" + ); const { createTrajectory, completeTrajectory } = await import( "../../src/core/trajectory.js" ); diff --git a/tests/storage/storage.test.ts b/tests/storage/storage.test.ts index 1590583..cdc6eda 100644 --- a/tests/storage/storage.test.ts +++ b/tests/storage/storage.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; /** * Test stubs for Storage functionality @@ -38,7 +38,7 @@ describe("FileStorage", () => { expect(existsSync(join(tempDir, ".trajectories"))).toBe(true); expect(existsSync(join(tempDir, ".trajectories", "active"))).toBe(true); expect(existsSync(join(tempDir, ".trajectories", "completed"))).toBe( - true + true, ); }); @@ -71,7 +71,7 @@ describe("FileStorage", () => { tempDir, ".trajectories", "active", - `${trajectory.id}.json` + `${trajectory.id}.json`, ); expect(existsSync(filePath)).toBe(true); const saved = JSON.parse(readFileSync(filePath, "utf-8")); @@ -103,13 +103,13 @@ describe("FileStorage", () => { tempDir, ".trajectories", "active", - `${trajectory.id}.json` + `${trajectory.id}.json`, ); const completedDir = join(tempDir, ".trajectories", "completed"); expect(existsSync(activeFile)).toBe(false); // Should be in a date-based subdirectory const files = await import("node:fs/promises").then((fs) => - fs.readdir(completedDir, { recursive: true }) + fs.readdir(completedDir, { recursive: true }), ); expect(files.some((f) => f.includes(trajectory.id))).toBe(true); }); @@ -136,7 +136,7 @@ describe("FileStorage", () => { const files = await import("node:fs/promises").then((fs) => fs.readdir(join(tempDir, ".trajectories", "completed"), { recursive: true, - }) + }), ); expect(files.some((f) => f.endsWith(".md"))).toBe(true); }); @@ -408,3 +408,133 @@ describe("StorageAdapter Interface", () => { expect(StorageAdapter).toBeDefined(); }); }); + +describe("Environment Variable Support", () => { + let tempDir: string; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "trail-env-test-")); + originalEnv = { ...process.env }; + }); + + afterEach(async () => { + // Restore original env + process.env = originalEnv; + await rm(tempDir, { recursive: true, force: true }); + }); + + describe("TRAJECTORIES_DATA_DIR", () => { + it("should use TRAJECTORIES_DATA_DIR when set", async () => { + // Arrange + process.env.TRAJECTORIES_DATA_DIR = tempDir; + // Re-import to pick up new env var + const { FileStorage } = await import("../../src/storage/file.js"); + const { createTrajectory } = await import("../../src/core/trajectory.js"); + const storage = new FileStorage(); + await storage.initialize(); + const trajectory = createTrajectory({ title: "Test" }); + + // Act + await storage.save(trajectory); + + // Assert - file should be at tempDir/active, not tempDir/.trajectories/active + const { existsSync } = await import("node:fs"); + expect(existsSync(join(tempDir, "active", `${trajectory.id}.json`))).toBe( + true, + ); + expect(existsSync(join(tempDir, ".trajectories"))).toBe(false); + }); + + it("should not add .trajectories suffix when TRAJECTORIES_DATA_DIR is set", async () => { + // Arrange + const customDir = join(tempDir, "custom-path"); + process.env.TRAJECTORIES_DATA_DIR = customDir; + const { FileStorage } = await import("../../src/storage/file.js"); + const storage = new FileStorage(); + + // Act + await storage.initialize(); + + // Assert + const { existsSync } = await import("node:fs"); + expect(existsSync(join(customDir, "active"))).toBe(true); + expect(existsSync(join(customDir, ".trajectories"))).toBe(false); + }); + + it("should expand ~ in TRAJECTORIES_DATA_DIR", async () => { + // Arrange - we can't easily test ~ expansion without mocking HOME + // but we can test that a path starting with ~ doesn't throw + const originalHome = process.env.HOME; + process.env.HOME = tempDir; + process.env.TRAJECTORIES_DATA_DIR = "~/trajectories"; + const { FileStorage } = await import("../../src/storage/file.js"); + const storage = new FileStorage(); + + // Act & Assert - should not throw + await expect(storage.initialize()).resolves.not.toThrow(); + + // Cleanup + process.env.HOME = originalHome; + }); + }); + + describe("getSearchPaths", () => { + it("should return TRAJECTORIES_SEARCH_PATHS when set", async () => { + // Arrange + const path1 = join(tempDir, "path1"); + const path2 = join(tempDir, "path2"); + process.env.TRAJECTORIES_SEARCH_PATHS = `${path1}:${path2}`; + process.env.TRAJECTORIES_DATA_DIR = undefined; + + // Re-import to pick up new env var + const { getSearchPaths } = await import("../../src/storage/file.js"); + + // Act + const paths = getSearchPaths(); + + // Assert + expect(paths).toEqual([path1, path2]); + }); + + it("should fall back to TRAJECTORIES_DATA_DIR when SEARCH_PATHS not set", async () => { + // Arrange + process.env.TRAJECTORIES_SEARCH_PATHS = undefined; + process.env.TRAJECTORIES_DATA_DIR = tempDir; + const { getSearchPaths } = await import("../../src/storage/file.js"); + + // Act + const paths = getSearchPaths(); + + // Assert + expect(paths).toEqual([tempDir]); + }); + + it("should fall back to .trajectories when no env vars set", async () => { + // Arrange + process.env.TRAJECTORIES_SEARCH_PATHS = undefined; + process.env.TRAJECTORIES_DATA_DIR = undefined; + const { getSearchPaths } = await import("../../src/storage/file.js"); + + // Act + const paths = getSearchPaths(); + + // Assert + expect(paths[0]).toContain(".trajectories"); + }); + + it("should filter empty paths from TRAJECTORIES_SEARCH_PATHS", async () => { + // Arrange + const path1 = join(tempDir, "path1"); + process.env.TRAJECTORIES_SEARCH_PATHS = `${path1}:: :`; + process.env.TRAJECTORIES_DATA_DIR = undefined; + const { getSearchPaths } = await import("../../src/storage/file.js"); + + // Act + const paths = getSearchPaths(); + + // Assert + expect(paths).toEqual([path1]); + }); + }); +}); diff --git a/tests/web/generator.test.ts b/tests/web/generator.test.ts index b502386..f03854b 100644 --- a/tests/web/generator.test.ts +++ b/tests/web/generator.test.ts @@ -1,9 +1,9 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; +import type { Trajectory } from "../../src/core/types.js"; import { - generateTrajectoryHtml, generateIndexHtml, + generateTrajectoryHtml, } from "../../src/web/generator.js"; -import type { Trajectory } from "../../src/core/types.js"; describe("Web Generator", () => { const mockTrajectory: Trajectory = { @@ -20,7 +20,9 @@ describe("Web Generator", () => { status: "completed", startedAt: "2024-01-15T10:00:00Z", completedAt: "2024-01-15T12:30:00Z", - agents: [{ name: "Claude", role: "implementer", joinedAt: "2024-01-15T10:00:00Z" }], + agents: [ + { name: "Claude", role: "lead", joinedAt: "2024-01-15T10:00:00Z" }, + ], chapters: [ { id: "ch_1", @@ -29,19 +31,19 @@ describe("Web Generator", () => { startedAt: "2024-01-15T10:00:00Z", events: [ { - id: "evt_1", + ts: new Date("2024-01-15T10:15:00Z").getTime(), type: "decision", - timestamp: "2024-01-15T10:15:00Z", content: "Use JWT over sessions", - metadata: { + raw: { + question: "Auth strategy", + chosen: "JWT", reasoning: "Stateless scaling requirement", alternatives: ["Sessions", "OAuth"], }, }, { - id: "evt_2", + ts: new Date("2024-01-15T10:20:00Z").getTime(), type: "note", - timestamp: "2024-01-15T10:20:00Z", content: "Found existing auth middleware", }, ], @@ -56,10 +58,11 @@ describe("Web Generator", () => { ], retrospective: { summary: "Successfully implemented JWT auth", + approach: "Used existing middleware patterns", confidence: 0.85, - wentWell: ["Clean implementation", "Good test coverage"], + learnings: ["Clean implementation", "Good test coverage"], challenges: ["Token refresh logic was tricky"], - wouldDoDifferently: ["Start with integration tests"], + suggestions: ["Start with integration tests"], }, commits: ["abc123", "def456"], filesChanged: ["src/auth/jwt.ts", "src/middleware/auth.ts"], @@ -98,7 +101,8 @@ describe("Web Generator", () => { it("should include decisions", () => { const html = generateTrajectoryHtml(mockTrajectory); - expect(html).toContain("Use JWT over sessions"); + expect(html).toContain("Auth strategy"); + expect(html).toContain("JWT"); expect(html).toContain("Stateless scaling requirement"); expect(html).toContain("Sessions"); }); @@ -172,8 +176,16 @@ describe("Web Generator", () => { }); it("should group by status", () => { - const active: Trajectory = { ...mockTrajectory, id: "traj_active", status: "active" }; - const completed: Trajectory = { ...mockTrajectory, id: "traj_done", status: "completed" }; + const active: Trajectory = { + ...mockTrajectory, + id: "traj_active", + status: "active", + }; + const completed: Trajectory = { + ...mockTrajectory, + id: "traj_done", + status: "completed", + }; const html = generateIndexHtml([active, completed]); diff --git a/tests/workspace/storage.test.ts b/tests/workspace/storage.test.ts index 9d2c94f..3eb63f6 100644 --- a/tests/workspace/storage.test.ts +++ b/tests/workspace/storage.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { WorkspaceStorage } from "../../src/workspace/storage.js"; describe("WorkspaceStorage", () => {