jettypod 4.2.11 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,196 @@
1
+ # Security Audit: Catastrophic Codebase Deletion
2
+
3
+ **Date**: 2025-11-25
4
+ **Context**: Codebase was deleted while working on "chores not requiring a parent" feature
5
+ **Symptom**: Shell became stuck, entire codebase was deleted
6
+
7
+ ---
8
+
9
+ ## Executive Summary
10
+
11
+ The JettyPod codebase contains multiple unsafe deletion patterns that, when combined with incorrect path resolution, can cause catastrophic data loss. The root cause is **79 instances of `fs.rmSync()` with `{ recursive: true, force: true }` that lack path validation**.
12
+
13
+ ---
14
+
15
+ ## Phase 1: Immediate Fixes (Critical)
16
+
17
+ ### 1.1 Add Safe Delete Wrapper
18
+
19
+ - [x] Create `lib/safe-delete.js` with path validation
20
+ - [x] Validate target path is within expected boundaries
21
+ - [x] Reject deletion of repository root, home directory, or system paths
22
+ - [x] Log all deletion operations for forensic analysis
23
+ - [x] Never use `force: true` - let errors surface
24
+
25
+ ### 1.2 Fix `removeDirectoryResilient()`
26
+
27
+ Location: `lib/worktree-manager.js:477-536`
28
+
29
+ - [x] Add path validation before any deletion stage
30
+ - [x] Ensure `dirPath` starts with `.jettypod-work/`
31
+ - [x] Add explicit check that `dirPath !== gitRoot`
32
+ - [x] Replace direct `fs.rmSync()` with safe wrapper
33
+
34
+ ### 1.3 Fix Test Cleanup Script
35
+
36
+ Location: `scripts/test-cleanup.js`
37
+
38
+ - [x] Only delete worktrees/branches that match test naming patterns
39
+ - [x] Add dry-run mode that logs what would be deleted
40
+ - [x] Add protected branches list (main, master, develop, staging, production)
41
+
42
+ ---
43
+
44
+ ## Phase 2: Path Resolution Hardening (High Priority)
45
+
46
+ ### 2.1 Eliminate Dangerous `process.cwd()` Usage
47
+
48
+ - [ ] Audit all 94 instances of `process.cwd()` for deletion contexts
49
+ - [ ] Pass explicit `repoPath` to all functions
50
+ - [ ] Add safety checks that reject operations if `cwd` is inside `.jettypod-work/`
51
+ - [ ] Cache and validate the main repo path at startup
52
+
53
+ ### 2.2 Add Worktree Detection Guard
54
+
55
+ - [x] Create `ensureNotInWorktree()` helper function (in safe-delete.js)
56
+ - [x] Add guard to createWorktree() in `worktree-manager.js`
57
+ - [x] cleanupWorktree() intentionally DOES NOT have this guard - the merge workflow runs from within worktrees. Safety comes from explicit repoPath requirement + path validation in removeDirectoryResilient()
58
+
59
+ ---
60
+
61
+ ## Phase 3: Deletion Audit Trail (Medium Priority)
62
+
63
+ ### 3.1 Create Deletion Logger
64
+
65
+ - [x] Add logging to `lib/safe-delete.js`
66
+ - [x] Log timestamp, path, caller stack trace, success/failure
67
+ - [x] Write to `.jettypod/deletion-log.json`
68
+
69
+ ### 3.2 Add Pre-Delete Snapshot
70
+
71
+ - [x] Count files/directories before recursive delete (countContents in safe-delete.js)
72
+ - [x] If count exceeds threshold (default: 100), require force flag
73
+ - [x] Log manifest of what will be deleted (count logged in deletion log)
74
+
75
+ ---
76
+
77
+ ## Phase 4: Test Infrastructure Safety (Medium Priority)
78
+
79
+ ### 4.1 Isolate Test Deletions to `/tmp/`
80
+
81
+ - [ ] Validate all test cleanup paths match `/tmp/jettypod-test-*`
82
+ - [ ] Update `lib/test-helpers.js` to enforce boundary
83
+ - [ ] Update all step definition files to use safe patterns
84
+
85
+ ### 4.2 Add Test Environment Validation
86
+
87
+ - [ ] Verify `NODE_ENV === 'test'` before test cleanup
88
+ - [ ] Verify current directory is a test directory
89
+ - [ ] Verify no production database exists in path
90
+
91
+ ### 4.3 Fix Cucumber After Hooks
92
+
93
+ Location: `test-templates.hidden/hooks.js`
94
+
95
+ - [ ] Validate all tracked paths are within `/tmp/`
96
+ - [ ] Clear tracking on test failure to prevent partial cleanup
97
+
98
+ ---
99
+
100
+ ## Phase 5: Backup & Recovery (Low Priority)
101
+
102
+ ### 5.1 Enhance Automatic Backups
103
+
104
+ - [x] Create backup before ANY destructive operation (existing behavior preserved)
105
+ - [x] Store backups outside the repository (new `options.global` flag stores in `~/.jettypod-backups/`)
106
+ - [x] Enhanced listBackups to show both local and global backups
107
+
108
+ ### 5.2 Add Deletion Undo
109
+
110
+ - [x] Implement `.jettypod-trash/` directory
111
+ - [x] Move deleted files to trash instead of immediate delete (`moveToTrash()` in safe-delete.js)
112
+ - [x] Auto-purge after 24 hours (`purgeOldTrash()` with configurable hours)
113
+ - [x] Add `jettypod undelete` command
114
+ - [x] Add `jettypod trash` command (list, empty, purge subcommands)
115
+
116
+ ---
117
+
118
+ ## Files Requiring Changes
119
+
120
+ | Status | File | Risk Level | Changes Needed |
121
+ |--------|------|------------|----------------|
122
+ | [x] | `lib/safe-delete.js` | NEW | Create safe deletion wrapper |
123
+ | [x] | `lib/worktree-manager.js` | CRITICAL | Add path validation to all delete operations |
124
+ | [x] | `scripts/test-cleanup.js` | CRITICAL | Restrict to test-pattern paths only |
125
+ | [x] | `lib/jettypod-backup.js` | HIGH | Validate paths before delete |
126
+ | [x] | `lib/test-helpers.js` | HIGH | Ensure cleanup only targets `/tmp/` |
127
+ | [x] | `jettypod.js` (line 908) | HIGH | Validate `skillsDestDir` before delete |
128
+ | [x] | `test-templates.hidden/hooks.js` | MEDIUM | Validate tracked paths |
129
+
130
+ ---
131
+
132
+ ## Dangerous Code Patterns Found
133
+
134
+ ### Pattern 1: Unvalidated Recursive Delete
135
+ ```javascript
136
+ // DANGEROUS - no path validation
137
+ fs.rmSync(dirPath, { recursive: true, force: true });
138
+ ```
139
+
140
+ ### Pattern 2: Force Flag Suppresses Errors
141
+ ```javascript
142
+ // DANGEROUS - errors are silently ignored
143
+ fs.rmSync(path, { force: true });
144
+ ```
145
+
146
+ ### Pattern 3: Path from Untrusted Source
147
+ ```javascript
148
+ // DANGEROUS - process.cwd() can be manipulated
149
+ const targetPath = path.join(process.cwd(), 'some-dir');
150
+ fs.rmSync(targetPath, { recursive: true });
151
+ ```
152
+
153
+ ### Pattern 4: Cleanup Without Boundaries
154
+ ```javascript
155
+ // DANGEROUS - deletes ALL worktrees, not just test ones
156
+ for (const worktree of worktrees) {
157
+ execSync(`git worktree remove --force "${worktree}"`);
158
+ }
159
+ ```
160
+
161
+ ---
162
+
163
+ ## Root Cause Analysis
164
+
165
+ The catastrophic deletion likely occurred through this sequence:
166
+
167
+ 1. User was modifying chore validation in `features/work-tracking/index.js`
168
+ 2. A test run or command was executed
169
+ 3. Path resolution returned unexpected value (possibly due to worktree context)
170
+ 4. `fs.rmSync()` with `{ recursive: true, force: true }` executed on wrong path
171
+ 5. `force: true` suppressed all warnings
172
+ 6. Shell appeared "stuck" while deleting thousands of files
173
+ 7. By the time the operation completed, codebase was gone
174
+
175
+ ---
176
+
177
+ ## Implementation Order
178
+
179
+ - [x] **Step 1**: Create `lib/safe-delete.js` wrapper
180
+ - [x] **Step 2**: Patch `removeDirectoryResilient()` with path validation
181
+ - [x] **Step 3**: Fix `scripts/test-cleanup.js`
182
+ - [x] **Step 4**: Add worktree detection guard to destructive operations
183
+ - [x] **Step 5**: Implement deletion audit trail
184
+ - [x] **Step 6**: Fix remaining unsafe `fs.rmSync()` instances (production code fixed; safeTestCleanup helper created for test files)
185
+ - [x] **Step 7**: Enhanced backup system (global backups in ~/.jettypod-backups/)
186
+ - [x] **Step 8**: Deletion undo/trash functionality (`.jettypod-trash/`, `jettypod trash`, `jettypod undelete`)
187
+
188
+ ---
189
+
190
+ ## Testing the Fixes
191
+
192
+ - [ ] Attempt to delete repository root - should be blocked
193
+ - [ ] Attempt to delete from within worktree - should be blocked
194
+ - [ ] Attempt to delete path outside repo - should be blocked
195
+ - [ ] Run test suite - should only clean up `/tmp/` paths
196
+ - [ ] Check deletion log after operations - should show audit trail
package/TEST_HOOK.md ADDED
@@ -0,0 +1 @@
1
+ # Test commit to verify post-merge hook
@@ -1,13 +1,88 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ /**
4
+ * Post-Checkout Hook
5
+ *
6
+ * 1. Prevents checking out default branch in JettyPod worktrees
7
+ * 2. Imports database snapshots after checkout
8
+ */
9
+
3
10
  const { importAll } = require('../lib/db-import');
11
+ const { execSync } = require('child_process');
4
12
 
5
13
  (async () => {
14
+ const cwd = process.cwd();
15
+
16
+ // FIRST: Check if we're in a JettyPod worktree and prevent default branch checkout
17
+ if (cwd.includes('.jettypod-work')) {
18
+ try {
19
+ // Get the branch we just checked out
20
+ const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {
21
+ encoding: 'utf8',
22
+ stdio: ['pipe', 'pipe', 'pipe']
23
+ }).trim();
24
+
25
+ // Detect default branch
26
+ let defaultBranch;
27
+ try {
28
+ // Try to get the default branch from git config
29
+ defaultBranch = execSync('git symbolic-ref refs/remotes/origin/HEAD', {
30
+ encoding: 'utf8',
31
+ stdio: ['pipe', 'pipe', 'pipe']
32
+ }).trim().replace('refs/remotes/origin/', '');
33
+ } catch {
34
+ // Fallback: check which common branch names exist
35
+ try {
36
+ execSync('git rev-parse --verify main', { stdio: ['pipe', 'pipe', 'pipe'] });
37
+ defaultBranch = 'main';
38
+ } catch {
39
+ try {
40
+ execSync('git rev-parse --verify master', { stdio: ['pipe', 'pipe', 'pipe'] });
41
+ defaultBranch = 'master';
42
+ } catch {
43
+ // Can't determine default branch - skip check
44
+ defaultBranch = null;
45
+ }
46
+ }
47
+ }
48
+
49
+ // Check if we checked out the default branch
50
+ if (defaultBranch && currentBranch === defaultBranch) {
51
+ console.error('');
52
+ console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
53
+ console.error('❌ ERROR: Cannot checkout default branch in worktree');
54
+ console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
55
+ console.error('');
56
+ console.error(`You are in a JettyPod worktree for isolated work.`);
57
+ console.error(`Checking out ${defaultBranch} in a worktree bypasses the merge workflow.`);
58
+ console.error('');
59
+ console.error('To merge your changes:');
60
+ console.error(' 1. Commit your changes: git add . && git commit -m "..."');
61
+ console.error(' 2. Use JettyPod merge: jettypod work merge');
62
+ console.error('');
63
+ console.error('This ensures proper worktree cleanup and database updates.');
64
+ console.error('');
65
+ console.error('Reverting checkout...');
66
+ console.error('');
67
+
68
+ // Revert to previous branch
69
+ try {
70
+ execSync('git checkout -', { stdio: 'inherit' });
71
+ } catch (revertErr) {
72
+ console.error('Failed to revert checkout. You may need to manually switch branches.');
73
+ }
74
+
75
+ process.exit(1);
76
+ }
77
+ } catch (err) {
78
+ // If we can't determine the branch, allow the checkout
79
+ // (better to be permissive than block legitimate operations)
80
+ }
81
+ }
82
+
83
+ // SECOND: Import database snapshots
6
84
  try {
7
- // Import JSON snapshots into databases after checkout
8
85
  await importAll();
9
-
10
- // Exit successfully - checkout should not be blocked
11
86
  process.exit(0);
12
87
  } catch (err) {
13
88
  // Log error but don't block checkout
package/hooks/pre-push ADDED
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Pre-Push Hook
5
+ *
6
+ * Prevents pushing directly from JettyPod worktrees.
7
+ * Forces use of `jettypod work merge` to ensure proper cleanup.
8
+ */
9
+
10
+ const cwd = process.cwd();
11
+
12
+ // Check if we're in a JettyPod worktree
13
+ if (cwd.includes('.jettypod-work')) {
14
+ console.error('');
15
+ console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
16
+ console.error('❌ ERROR: Cannot push directly from worktree');
17
+ console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
18
+ console.error('');
19
+ console.error('You are in a JettyPod worktree for isolated work.');
20
+ console.error('Pushing directly bypasses the merge workflow.');
21
+ console.error('');
22
+ console.error('To merge and push your changes:');
23
+ console.error(' 1. Commit your changes: git add . && git commit -m "..."');
24
+ console.error(' 2. Use JettyPod merge: jettypod work merge');
25
+ console.error('');
26
+ console.error('This ensures proper worktree cleanup and database updates.');
27
+ console.error('');
28
+
29
+ process.exit(1);
30
+ }
31
+
32
+ // Allow pushes from main repo
33
+ process.exit(0);
package/jettypod.js CHANGED
@@ -8,6 +8,7 @@ const config = require('./lib/config');
8
8
  // CRITICAL: Calculate and cache the REAL git root BEFORE any worktree operations
9
9
  // This prevents catastrophic bugs where process.cwd() returns a worktree path
10
10
  const { getGitRoot } = require('./lib/git-root');
11
+ const safeDelete = require('./lib/safe-delete');
11
12
  let GIT_ROOT;
12
13
  try {
13
14
  GIT_ROOT = getGitRoot();
@@ -16,6 +17,66 @@ try {
16
17
  GIT_ROOT = process.cwd();
17
18
  }
18
19
 
20
+ /**
21
+ * Format bytes to human-readable string
22
+ */
23
+ function formatBytes(bytes) {
24
+ if (bytes === 0) return '0 B';
25
+ const k = 1024;
26
+ const sizes = ['B', 'KB', 'MB', 'GB'];
27
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
28
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
29
+ }
30
+
31
+ /**
32
+ * Detect if project has existing code (mid-project adoption)
33
+ * Checks for common code directories and package.json dependencies
34
+ */
35
+ function hasExistingCode() {
36
+ // Check for common code directories with actual code files
37
+ const codeDirectories = ['src', 'lib', 'app'];
38
+ const hasCodeDir = codeDirectories.some(dir => {
39
+ if (!fs.existsSync(dir)) {
40
+ return false;
41
+ }
42
+
43
+ // Check if directory contains any code files (not just .gitkeep)
44
+ try {
45
+ const files = fs.readdirSync(dir, { recursive: true, withFileTypes: true });
46
+ const codeFiles = files.filter(file => {
47
+ if (file.isDirectory()) return false;
48
+ // Exclude .gitkeep and other dotfiles
49
+ if (file.name.startsWith('.')) return false;
50
+ return true;
51
+ });
52
+ return codeFiles.length > 0;
53
+ } catch (e) {
54
+ // Permission denied or other error - treat as no code
55
+ return false;
56
+ }
57
+ });
58
+
59
+ if (hasCodeDir) {
60
+ return true;
61
+ }
62
+
63
+ // Check for package.json with dependencies
64
+ if (fs.existsSync('package.json')) {
65
+ try {
66
+ const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
67
+ const hasDeps = (pkg.dependencies && Object.keys(pkg.dependencies).length > 0) ||
68
+ (pkg.devDependencies && Object.keys(pkg.devDependencies).length > 0);
69
+ if (hasDeps) {
70
+ return true;
71
+ }
72
+ } catch (e) {
73
+ // Invalid package.json - treat as no code
74
+ }
75
+ }
76
+
77
+ return false;
78
+ }
79
+
19
80
  /**
20
81
  * Ensure jettypod-specific paths are gitignored and untracked
21
82
  * Called during init and update to fix existing projects
@@ -24,7 +85,8 @@ function ensureJettypodGitignores() {
24
85
  const gitignorePath = path.join(process.cwd(), '.gitignore');
25
86
  const entriesToIgnore = [
26
87
  '.claude/session.md',
27
- '.jettypod-work/'
88
+ '.jettypod-work/',
89
+ '.jettypod-trash/'
28
90
  ];
29
91
 
30
92
  // Add entries to .gitignore if not present
@@ -525,29 +587,33 @@ ${communicationStyle}`;
525
587
 
526
588
  detectTechStack() {
527
589
  const stacks = [];
528
-
590
+
529
591
  if (fs.existsSync('package.json')) {
530
- const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
531
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
532
-
533
- // Detect major frameworks
534
- if (deps.react) stacks.push('React');
535
- if (deps.vue) stacks.push('Vue');
536
- if (deps.angular) stacks.push('Angular');
537
- if (deps.svelte) stacks.push('Svelte');
538
- if (deps.next) stacks.push('Next.js');
539
- if (deps.express) stacks.push('Express');
540
- if (deps.fastify) stacks.push('Fastify');
541
- if (deps.nestjs) stacks.push('NestJS');
542
-
543
- // Testing frameworks
544
- if (deps.jest) stacks.push('Jest');
545
- if (deps.mocha) stacks.push('Mocha');
546
- if (deps.vitest) stacks.push('Vitest');
547
- if (deps.cucumber) stacks.push('Cucumber');
548
-
549
- // Node.js is implied
550
- stacks.unshift('Node.js');
592
+ try {
593
+ const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
594
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
595
+
596
+ // Detect major frameworks
597
+ if (deps.react) stacks.push('React');
598
+ if (deps.vue) stacks.push('Vue');
599
+ if (deps.angular) stacks.push('Angular');
600
+ if (deps.svelte) stacks.push('Svelte');
601
+ if (deps.next) stacks.push('Next.js');
602
+ if (deps.express) stacks.push('Express');
603
+ if (deps.fastify) stacks.push('Fastify');
604
+ if (deps.nestjs) stacks.push('NestJS');
605
+
606
+ // Testing frameworks
607
+ if (deps.jest) stacks.push('Jest');
608
+ if (deps.mocha) stacks.push('Mocha');
609
+ if (deps.vitest) stacks.push('Vitest');
610
+ if (deps.cucumber) stacks.push('Cucumber');
611
+
612
+ // Node.js is implied
613
+ stacks.unshift('Node.js');
614
+ } catch (e) {
615
+ // Invalid or malformed package.json - ignore and continue with other detection
616
+ }
551
617
  }
552
618
 
553
619
  if (fs.existsSync('Cargo.toml')) stacks.push('Rust');
@@ -639,6 +705,14 @@ async function initializeProject() {
639
705
  name: projectName,
640
706
  project_state: 'internal'
641
707
  };
708
+
709
+ // Auto-detect mid-project adoption and skip discovery
710
+ if (hasExistingCode()) {
711
+ initialConfig.project_discovery = {
712
+ status: 'completed'
713
+ };
714
+ }
715
+
642
716
  config.write(initialConfig);
643
717
  }
644
718
 
@@ -844,7 +918,13 @@ async function initializeProject() {
844
918
  try {
845
919
  // Remove partial copy if it exists
846
920
  if (fs.existsSync(skillsDestDir)) {
847
- fs.rmSync(skillsDestDir, { recursive: true, force: true });
921
+ // SAFETY: Validate path before deletion
922
+ const resolvedSkillsDir = path.resolve(skillsDestDir);
923
+ const resolvedCwd = path.resolve(process.cwd());
924
+ if (!resolvedSkillsDir.startsWith(resolvedCwd) || !resolvedSkillsDir.includes('.claude')) {
925
+ throw new Error(`SAFETY: Refusing to delete ${skillsDestDir} - not within .claude directory`);
926
+ }
927
+ fs.rmSync(skillsDestDir, { recursive: true });
848
928
  }
849
929
  fs.renameSync(backupDir, skillsDestDir);
850
930
  console.log('✅ Previous skills restored successfully');
@@ -1153,7 +1233,11 @@ switch (command) {
1153
1233
  } else if (subcommand === 'merge') {
1154
1234
  const workCommands = require('./features/work-commands/index.js');
1155
1235
  try {
1156
- await workCommands.mergeWork();
1236
+ // Parse merge flags from args
1237
+ const withTransition = args.includes('--with-transition');
1238
+ const releaseLock = args.includes('--release-lock');
1239
+
1240
+ await workCommands.mergeWork({ withTransition, releaseLock });
1157
1241
  } catch (err) {
1158
1242
  console.error(`Error: ${err.message}`);
1159
1243
  process.exit(1);
@@ -1872,6 +1956,112 @@ Quick commands:
1872
1956
  }
1873
1957
  break;
1874
1958
 
1959
+ case 'trash': {
1960
+ // Trash management commands
1961
+ const trashSubcommand = args[0];
1962
+ const gitRoot = process.cwd();
1963
+
1964
+ if (trashSubcommand === 'list' || !trashSubcommand) {
1965
+ // List items in trash
1966
+ const trashItems = safeDelete.listTrash(gitRoot);
1967
+
1968
+ if (trashItems.length === 0) {
1969
+ console.log('🗑️ Trash is empty');
1970
+ } else {
1971
+ console.log('🗑️ Trash contents:');
1972
+ console.log('');
1973
+ trashItems.forEach((item, index) => {
1974
+ const age = Math.round((Date.now() - item.trashedAt.getTime()) / (60 * 60 * 1000));
1975
+ const ageStr = age < 1 ? 'just now' : age < 24 ? `${age}h ago` : `${Math.round(age / 24)}d ago`;
1976
+ const typeIcon = item.isDirectory ? '📁' : '📄';
1977
+ console.log(` ${index + 1}. ${typeIcon} ${item.relativePath}`);
1978
+ console.log(` Deleted: ${ageStr} | Size: ${item.isDirectory ? item.size + ' items' : formatBytes(item.size)}`);
1979
+ });
1980
+ console.log('');
1981
+ console.log(`Total: ${trashItems.length} item(s)`);
1982
+ console.log('');
1983
+ console.log('Use "jettypod undelete" to restore the most recent item');
1984
+ console.log('Use "jettypod undelete <path>" to restore a specific item');
1985
+ }
1986
+ } else if (trashSubcommand === 'empty') {
1987
+ // Empty the trash
1988
+ const result = safeDelete.emptyTrash(gitRoot);
1989
+ if (result.success) {
1990
+ console.log(`🗑️ Trash emptied (${result.count} items removed)`);
1991
+ } else {
1992
+ console.error(`❌ Failed to empty trash: ${result.error}`);
1993
+ process.exit(1);
1994
+ }
1995
+ } else if (trashSubcommand === 'purge') {
1996
+ // Purge old items (older than 24 hours by default)
1997
+ const hoursArg = args[1];
1998
+ const maxAgeHours = hoursArg ? parseInt(hoursArg, 10) : 24;
1999
+ const dryRun = args.includes('--dry-run');
2000
+
2001
+ if (dryRun) {
2002
+ console.log(`🔍 Dry run: Would purge items older than ${maxAgeHours} hours`);
2003
+ }
2004
+
2005
+ const result = safeDelete.purgeOldTrash(gitRoot, { maxAgeHours, dryRun });
2006
+
2007
+ if (result.purged.length === 0) {
2008
+ console.log(`✅ No items older than ${maxAgeHours} hours to purge`);
2009
+ } else {
2010
+ console.log(`🗑️ ${dryRun ? 'Would purge' : 'Purged'} ${result.purged.length} item(s):`);
2011
+ result.purged.forEach(item => {
2012
+ console.log(` - ${item.originalPath}`);
2013
+ });
2014
+ }
2015
+
2016
+ if (result.errors.length > 0) {
2017
+ console.error('');
2018
+ console.error('Errors:');
2019
+ result.errors.forEach(err => {
2020
+ console.error(` - ${err.name}: ${err.error}`);
2021
+ });
2022
+ }
2023
+
2024
+ console.log(`\nRemaining in trash: ${result.remaining} item(s)`);
2025
+ } else {
2026
+ console.log('Usage: jettypod trash [list|empty|purge]');
2027
+ console.log('');
2028
+ console.log('Commands:');
2029
+ console.log(' list List items in trash (default)');
2030
+ console.log(' empty Permanently delete all items in trash');
2031
+ console.log(' purge [hours] Delete items older than N hours (default: 24)');
2032
+ console.log(' Add --dry-run to preview what would be deleted');
2033
+ }
2034
+ break;
2035
+ }
2036
+
2037
+ case 'undelete': {
2038
+ // Restore from trash
2039
+ const gitRoot = process.cwd();
2040
+ const itemName = args[0] || 'latest';
2041
+
2042
+ const trashItems = safeDelete.listTrash(gitRoot);
2043
+ if (trashItems.length === 0) {
2044
+ console.log('🗑️ Trash is empty - nothing to restore');
2045
+ process.exit(0);
2046
+ }
2047
+
2048
+ const result = safeDelete.restoreFromTrash(gitRoot, itemName);
2049
+
2050
+ if (result.success) {
2051
+ console.log(`✅ Restored: ${result.restoredTo}`);
2052
+ } else {
2053
+ console.error(`❌ Failed to restore: ${result.error}`);
2054
+
2055
+ if (result.error.includes('path already exists')) {
2056
+ console.log('');
2057
+ console.log('The original location already has a file/directory.');
2058
+ console.log('Delete it first or use a different restore location.');
2059
+ }
2060
+ process.exit(1);
2061
+ }
2062
+ break;
2063
+ }
2064
+
1875
2065
  default:
1876
2066
  // Smart mode: auto-initialize if needed, otherwise show guidance
1877
2067
  if (!fs.existsSync('.jettypod')) {
package/lib/claudemd.js CHANGED
@@ -65,11 +65,19 @@ function updateCurrentWork(currentWork, mode) {
65
65
 
66
66
  // Write to session file (gitignored) instead of CLAUDE.md
67
67
  // This prevents merge conflicts and stale context on main branch
68
+ // Only write session file if we're in a worktree (not root directory)
68
69
  if (currentMode !== null) {
69
- writeSessionFile(currentWork, currentMode, {
70
- epicId: currentWork.epic_id,
71
- epicTitle: currentWork.epic_title
72
- });
70
+ try {
71
+ writeSessionFile(currentWork, currentMode, {
72
+ epicId: currentWork.epic_id,
73
+ epicTitle: currentWork.epic_title
74
+ });
75
+ } catch (err) {
76
+ // Skip if not in worktree - validation will throw error
77
+ if (!err.message.includes('Cannot create session.md in root directory')) {
78
+ throw err; // Re-throw unexpected errors
79
+ }
80
+ }
73
81
  }
74
82
  }
75
83