@mindfoldhq/trellis 0.1.9 → 0.2.2

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.
Files changed (102) hide show
  1. package/dist/cli/index.js +2 -0
  2. package/dist/cli/index.js.map +1 -1
  3. package/dist/commands/init.d.ts.map +1 -1
  4. package/dist/commands/init.js +12 -6
  5. package/dist/commands/init.js.map +1 -1
  6. package/dist/commands/update.d.ts +1 -0
  7. package/dist/commands/update.d.ts.map +1 -1
  8. package/dist/commands/update.js +684 -42
  9. package/dist/commands/update.js.map +1 -1
  10. package/dist/configurators/opencode.js +1 -1
  11. package/dist/configurators/opencode.js.map +1 -1
  12. package/dist/configurators/workflow.d.ts +4 -3
  13. package/dist/configurators/workflow.d.ts.map +1 -1
  14. package/dist/configurators/workflow.js +23 -20
  15. package/dist/configurators/workflow.js.map +1 -1
  16. package/dist/constants/paths.d.ts +29 -30
  17. package/dist/constants/paths.d.ts.map +1 -1
  18. package/dist/constants/paths.js +32 -35
  19. package/dist/constants/paths.js.map +1 -1
  20. package/dist/migrations/index.d.ts +35 -0
  21. package/dist/migrations/index.d.ts.map +1 -0
  22. package/dist/migrations/index.js +124 -0
  23. package/dist/migrations/index.js.map +1 -0
  24. package/dist/migrations/manifests/0.1.9.json +30 -0
  25. package/dist/migrations/manifests/0.2.0.json +43 -0
  26. package/dist/templates/claude/agents/check.md +3 -3
  27. package/dist/templates/claude/agents/debug.md +1 -1
  28. package/dist/templates/claude/agents/dispatch.md +12 -12
  29. package/dist/templates/claude/agents/implement.md +6 -6
  30. package/dist/templates/claude/agents/plan.md +37 -37
  31. package/dist/templates/claude/agents/research.md +1 -1
  32. package/dist/templates/claude/commands/before-backend-dev.md +5 -5
  33. package/dist/templates/claude/commands/before-frontend-dev.md +5 -5
  34. package/dist/templates/claude/commands/break-loop.md +2 -2
  35. package/dist/templates/claude/commands/check-backend.md +6 -6
  36. package/dist/templates/claude/commands/check-cross-layer.md +5 -5
  37. package/dist/templates/claude/commands/check-frontend.md +6 -6
  38. package/dist/templates/claude/commands/create-command.md +3 -3
  39. package/dist/templates/claude/commands/finish-work.md +6 -6
  40. package/dist/templates/claude/commands/integrate-skill.md +11 -11
  41. package/dist/templates/claude/commands/{onboard-developer.md → onboard.md} +31 -28
  42. package/dist/templates/claude/commands/parallel.md +17 -17
  43. package/dist/templates/claude/commands/{record-agent-flow.md → record-session.md} +7 -7
  44. package/dist/templates/claude/commands/start.md +36 -36
  45. package/dist/templates/claude/hooks/inject-subagent-context.py +77 -76
  46. package/dist/templates/claude/hooks/ralph-loop.py +18 -18
  47. package/dist/templates/claude/hooks/session-start.py +4 -4
  48. package/dist/templates/cursor/commands/before-backend-dev.md +5 -5
  49. package/dist/templates/cursor/commands/before-frontend-dev.md +5 -5
  50. package/dist/templates/cursor/commands/break-loop.md +2 -2
  51. package/dist/templates/cursor/commands/check-backend.md +6 -6
  52. package/dist/templates/cursor/commands/check-cross-layer.md +5 -5
  53. package/dist/templates/cursor/commands/check-frontend.md +6 -6
  54. package/dist/templates/cursor/commands/create-command.md +3 -3
  55. package/dist/templates/cursor/commands/finish-work.md +6 -6
  56. package/dist/templates/cursor/commands/integrate-skill.md +11 -11
  57. package/dist/templates/cursor/commands/{onboard-developer.md → onboard.md} +31 -28
  58. package/dist/templates/cursor/commands/{record-agent-flow.md → record-session.md} +7 -7
  59. package/dist/templates/cursor/commands/start.md +25 -25
  60. package/dist/templates/extract.d.ts +2 -2
  61. package/dist/templates/extract.js +2 -2
  62. package/dist/templates/markdown/agents.md +2 -2
  63. package/dist/templates/markdown/gitignore.txt +2 -2
  64. package/dist/templates/markdown/index.d.ts +1 -0
  65. package/dist/templates/markdown/index.d.ts.map +1 -1
  66. package/dist/templates/markdown/index.js +4 -2
  67. package/dist/templates/markdown/index.js.map +1 -1
  68. package/dist/templates/markdown/{agent-traces-index.md → workspace-index.md} +14 -14
  69. package/dist/templates/trellis/index.d.ts +7 -1
  70. package/dist/templates/trellis/index.d.ts.map +1 -1
  71. package/dist/templates/trellis/index.js +14 -2
  72. package/dist/templates/trellis/index.js.map +1 -1
  73. package/dist/templates/trellis/scripts/add-session.sh +26 -26
  74. package/dist/templates/trellis/scripts/common/developer.sh +20 -21
  75. package/dist/templates/trellis/scripts/common/git-context.sh +90 -115
  76. package/dist/templates/trellis/scripts/common/paths.sh +53 -63
  77. package/dist/templates/trellis/scripts/common/phase.sh +40 -40
  78. package/dist/templates/trellis/scripts/common/registry.sh +13 -13
  79. package/dist/templates/trellis/scripts/common/task-queue.sh +142 -0
  80. package/dist/templates/trellis/scripts/common/task-utils.sh +151 -0
  81. package/dist/templates/trellis/scripts/common/worktree.sh +3 -3
  82. package/dist/templates/trellis/scripts/create-bootstrap.sh +43 -42
  83. package/dist/templates/trellis/scripts/init-developer.sh +1 -1
  84. package/dist/templates/trellis/scripts/multi-agent/cleanup.sh +33 -33
  85. package/dist/templates/trellis/scripts/multi-agent/create-pr.sh +30 -30
  86. package/dist/templates/trellis/scripts/multi-agent/plan.sh +28 -28
  87. package/dist/templates/trellis/scripts/multi-agent/start.sh +56 -56
  88. package/dist/templates/trellis/scripts/multi-agent/status.sh +59 -59
  89. package/dist/templates/trellis/scripts/{feature.sh → task.sh} +235 -185
  90. package/dist/templates/trellis/workflow.md +71 -74
  91. package/dist/types/migration.d.ts +74 -0
  92. package/dist/types/migration.d.ts.map +1 -0
  93. package/dist/types/migration.js +8 -0
  94. package/dist/types/migration.js.map +1 -0
  95. package/dist/utils/template-hash.d.ts +78 -0
  96. package/dist/utils/template-hash.d.ts.map +1 -0
  97. package/dist/utils/template-hash.js +234 -0
  98. package/dist/utils/template-hash.js.map +1 -0
  99. package/package.json +1 -1
  100. package/dist/templates/trellis/scripts/common/backlog.sh +0 -220
  101. package/dist/templates/trellis/scripts/common/feature-utils.sh +0 -194
  102. /package/dist/templates/trellis/{backlog → tasks}/.gitkeep +0 -0
@@ -4,18 +4,21 @@ import chalk from "chalk";
4
4
  import inquirer from "inquirer";
5
5
  import { PATHS, DIR_NAMES } from "../constants/paths.js";
6
6
  import { VERSION, PACKAGE_NAME } from "../cli/index.js";
7
+ import { getMigrationsForVersion } from "../migrations/index.js";
8
+ import { loadHashes, saveHashes, updateHashes, isTemplateModified, removeHash, renameHash, computeHash, } from "../utils/template-hash.js";
7
9
  // Import templates for comparison
8
- import { commonPathsScript, commonDeveloperScript, commonGitContextScript, commonWorktreeScript, multiAgentStartScript, multiAgentCleanupScript, multiAgentStatusScript, worktreeYamlTemplate, workflowMdTemplate, gitignoreTemplate, initDeveloperScript, getDeveloperScript, featureScript, getContextScript, addSessionScript, createBootstrapScript, } from "../templates/trellis/index.js";
10
+ import { commonPathsScript, commonDeveloperScript, commonGitContextScript, commonWorktreeScript, commonTaskQueueScript, commonTaskUtilsScript, commonPhaseScript, commonRegistryScript, multiAgentStartScript, multiAgentCleanupScript, multiAgentStatusScript, multiAgentCreatePrScript, multiAgentPlanScript, worktreeYamlTemplate, workflowMdTemplate, gitignoreTemplate, initDeveloperScript, getDeveloperScript, taskScript, getContextScript, addSessionScript, createBootstrapScript, } from "../templates/trellis/index.js";
9
11
  import { guidesIndexContent, guidesCrossLayerThinkingGuideContent, guidesCodeReuseThinkingGuideContent, } from "../templates/markdown/index.js";
10
12
  import { getCommandTemplates } from "../configurators/templates.js";
11
13
  import { getAllAgents, getAllHooks, getSettingsTemplate, } from "../templates/claude/index.js";
12
14
  // Paths that should never be touched
13
15
  const PROTECTED_PATHS = [
14
- `${DIR_NAMES.WORKFLOW}/${DIR_NAMES.PROGRESS}`, // agent-traces/
16
+ `${DIR_NAMES.WORKFLOW}/${DIR_NAMES.WORKSPACE}`, // workspace/
17
+ `${DIR_NAMES.WORKFLOW}/${DIR_NAMES.TASKS}`, // tasks/
15
18
  `${DIR_NAMES.WORKFLOW}/.developer`,
16
- `${DIR_NAMES.WORKFLOW}/.current-feature`,
17
- `${PATHS.STRUCTURE}/frontend`,
18
- `${PATHS.STRUCTURE}/backend`,
19
+ `${DIR_NAMES.WORKFLOW}/.current-task`,
20
+ `${PATHS.SPEC}/frontend`,
21
+ `${PATHS.SPEC}/backend`,
19
22
  ];
20
23
  /**
21
24
  * Collect all template files that should be managed by update
@@ -27,14 +30,20 @@ function collectTemplateFiles(_cwd) {
27
30
  files.set(`${PATHS.SCRIPTS}/common/developer.sh`, commonDeveloperScript);
28
31
  files.set(`${PATHS.SCRIPTS}/common/git-context.sh`, commonGitContextScript);
29
32
  files.set(`${PATHS.SCRIPTS}/common/worktree.sh`, commonWorktreeScript);
33
+ files.set(`${PATHS.SCRIPTS}/common/task-queue.sh`, commonTaskQueueScript);
34
+ files.set(`${PATHS.SCRIPTS}/common/task-utils.sh`, commonTaskUtilsScript);
35
+ files.set(`${PATHS.SCRIPTS}/common/phase.sh`, commonPhaseScript);
36
+ files.set(`${PATHS.SCRIPTS}/common/registry.sh`, commonRegistryScript);
30
37
  // Scripts - multi-agent
31
38
  files.set(`${PATHS.SCRIPTS}/multi-agent/start.sh`, multiAgentStartScript);
32
39
  files.set(`${PATHS.SCRIPTS}/multi-agent/cleanup.sh`, multiAgentCleanupScript);
33
40
  files.set(`${PATHS.SCRIPTS}/multi-agent/status.sh`, multiAgentStatusScript);
41
+ files.set(`${PATHS.SCRIPTS}/multi-agent/create-pr.sh`, multiAgentCreatePrScript);
42
+ files.set(`${PATHS.SCRIPTS}/multi-agent/plan.sh`, multiAgentPlanScript);
34
43
  // Scripts - main
35
44
  files.set(`${PATHS.SCRIPTS}/init-developer.sh`, initDeveloperScript);
36
45
  files.set(`${PATHS.SCRIPTS}/get-developer.sh`, getDeveloperScript);
37
- files.set(`${PATHS.SCRIPTS}/feature.sh`, featureScript);
46
+ files.set(`${PATHS.SCRIPTS}/task.sh`, taskScript);
38
47
  files.set(`${PATHS.SCRIPTS}/get-context.sh`, getContextScript);
39
48
  files.set(`${PATHS.SCRIPTS}/add-session.sh`, addSessionScript);
40
49
  files.set(`${PATHS.SCRIPTS}/create-bootstrap.sh`, createBootstrapScript);
@@ -42,10 +51,10 @@ function collectTemplateFiles(_cwd) {
42
51
  files.set(`${DIR_NAMES.WORKFLOW}/worktree.yaml`, worktreeYamlTemplate);
43
52
  files.set(`${DIR_NAMES.WORKFLOW}/.gitignore`, gitignoreTemplate);
44
53
  files.set(PATHS.WORKFLOW_GUIDE_FILE, workflowMdTemplate);
45
- // Structure - guides only (frontend/backend are protected)
46
- files.set(`${PATHS.STRUCTURE}/guides/index.md`, guidesIndexContent);
47
- files.set(`${PATHS.STRUCTURE}/guides/cross-layer-thinking-guide.md`, guidesCrossLayerThinkingGuideContent);
48
- files.set(`${PATHS.STRUCTURE}/guides/code-reuse-thinking-guide.md`, guidesCodeReuseThinkingGuideContent);
54
+ // Spec - guides only (frontend/backend are protected)
55
+ files.set(`${PATHS.SPEC}/guides/index.md`, guidesIndexContent);
56
+ files.set(`${PATHS.SPEC}/guides/cross-layer-thinking-guide.md`, guidesCrossLayerThinkingGuideContent);
57
+ files.set(`${PATHS.SPEC}/guides/code-reuse-thinking-guide.md`, guidesCodeReuseThinkingGuideContent);
49
58
  // Claude commands
50
59
  const claudeCommands = getCommandTemplates("claude-code");
51
60
  for (const [name, content] of Object.entries(claudeCommands)) {
@@ -73,12 +82,17 @@ function collectTemplateFiles(_cwd) {
73
82
  }
74
83
  /**
75
84
  * Analyze changes between current files and templates
85
+ *
86
+ * Uses hash tracking to distinguish between:
87
+ * - User didn't modify + template same = skip (unchangedFiles)
88
+ * - User didn't modify + template updated = auto-update (autoUpdateFiles)
89
+ * - User modified = needs confirmation (changedFiles)
76
90
  */
77
- function analyzeChanges(cwd) {
78
- const templates = collectTemplateFiles(cwd);
91
+ function analyzeChanges(cwd, hashes, templates) {
79
92
  const result = {
80
93
  newFiles: [],
81
94
  unchangedFiles: [],
95
+ autoUpdateFiles: [],
82
96
  changedFiles: [],
83
97
  protectedPaths: PROTECTED_PATHS,
84
98
  };
@@ -98,12 +112,26 @@ function analyzeChanges(cwd) {
98
112
  else {
99
113
  const existingContent = fs.readFileSync(fullPath, "utf-8");
100
114
  if (existingContent === newContent) {
115
+ // Content same as template - already up to date
101
116
  change.status = "unchanged";
102
117
  result.unchangedFiles.push(change);
103
118
  }
104
119
  else {
105
- change.status = "changed";
106
- result.changedFiles.push(change);
120
+ // Content differs - check if user modified or template updated
121
+ const storedHash = hashes[relativePath];
122
+ const currentHash = computeHash(existingContent);
123
+ if (storedHash && storedHash === currentHash) {
124
+ // Hash matches stored hash - user didn't modify, template was updated
125
+ // Safe to auto-update
126
+ change.status = "changed";
127
+ result.autoUpdateFiles.push(change);
128
+ }
129
+ else {
130
+ // Hash differs (or no stored hash) - user modified the file
131
+ // Needs confirmation
132
+ change.status = "changed";
133
+ result.changedFiles.push(change);
134
+ }
107
135
  }
108
136
  }
109
137
  }
@@ -121,6 +149,13 @@ function printChangeSummary(changes) {
121
149
  }
122
150
  console.log("");
123
151
  }
152
+ if (changes.autoUpdateFiles.length > 0) {
153
+ console.log(chalk.cyan(" Template updated (will auto-update):"));
154
+ for (const file of changes.autoUpdateFiles) {
155
+ console.log(chalk.cyan(` ↑ ${file.relativePath}`));
156
+ }
157
+ console.log("");
158
+ }
124
159
  if (changes.unchangedFiles.length > 0) {
125
160
  console.log(chalk.gray(" Unchanged files (will skip):"));
126
161
  for (const file of changes.unchangedFiles.slice(0, 5)) {
@@ -132,17 +167,24 @@ function printChangeSummary(changes) {
132
167
  console.log("");
133
168
  }
134
169
  if (changes.changedFiles.length > 0) {
135
- console.log(chalk.yellow(" Changed files (need your decision):"));
170
+ console.log(chalk.yellow(" Modified by you (need your decision):"));
136
171
  for (const file of changes.changedFiles) {
137
172
  console.log(chalk.yellow(` ? ${file.relativePath}`));
138
173
  }
139
174
  console.log("");
140
175
  }
141
- console.log(chalk.gray(" Protected (never touched):"));
142
- for (const protectedPath of changes.protectedPaths) {
143
- console.log(chalk.gray(` ○ ${protectedPath}/`));
176
+ // Only show protected paths that actually exist
177
+ const existingProtectedPaths = changes.protectedPaths.filter((p) => {
178
+ const fullPath = path.join(process.cwd(), p);
179
+ return fs.existsSync(fullPath);
180
+ });
181
+ if (existingProtectedPaths.length > 0) {
182
+ console.log(chalk.gray(" User data (preserved):"));
183
+ for (const protectedPath of existingProtectedPaths) {
184
+ console.log(chalk.gray(` ○ ${protectedPath}/`));
185
+ }
186
+ console.log("");
144
187
  }
145
- console.log("");
146
188
  }
147
189
  /**
148
190
  * Prompt user for conflict resolution
@@ -200,26 +242,88 @@ async function promptConflictResolution(file, options, applyToAll) {
200
242
  return action;
201
243
  }
202
244
  /**
203
- * Create backup of files that will be changed
245
+ * Create a timestamped backup directory path
204
246
  */
205
- function createBackup(cwd, changes) {
206
- const filesToBackup = changes.changedFiles;
207
- if (filesToBackup.length === 0) {
208
- return null;
209
- }
247
+ function createBackupDirPath(cwd) {
210
248
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
211
- const backupDir = path.join(cwd, DIR_NAMES.WORKFLOW, `.backup-${timestamp}`);
212
- fs.mkdirSync(backupDir, { recursive: true });
213
- for (const file of filesToBackup) {
214
- if (fs.existsSync(file.path)) {
215
- const relativePath = file.relativePath;
216
- const backupPath = path.join(backupDir, relativePath);
217
- const backupParent = path.dirname(backupPath);
218
- fs.mkdirSync(backupParent, { recursive: true });
219
- fs.copyFileSync(file.path, backupPath);
220
- }
221
- }
222
- return backupDir;
249
+ return path.join(cwd, DIR_NAMES.WORKFLOW, `.backup-${timestamp}`);
250
+ }
251
+ /**
252
+ * Backup a single file to the backup directory
253
+ */
254
+ function backupFile(cwd, backupDir, relativePath) {
255
+ const srcPath = path.join(cwd, relativePath);
256
+ if (!fs.existsSync(srcPath))
257
+ return;
258
+ const backupPath = path.join(backupDir, relativePath);
259
+ fs.mkdirSync(path.dirname(backupPath), { recursive: true });
260
+ fs.copyFileSync(srcPath, backupPath);
261
+ }
262
+ /**
263
+ * Backup an entire directory recursively
264
+ */
265
+ function backupDirectory(cwd, backupDir, relativeDirPath) {
266
+ const srcDir = path.join(cwd, relativeDirPath);
267
+ if (!fs.existsSync(srcDir))
268
+ return;
269
+ const files = collectAllFiles(srcDir);
270
+ for (const fullPath of files) {
271
+ const relativePath = path.relative(cwd, fullPath);
272
+ backupFile(cwd, backupDir, relativePath);
273
+ }
274
+ }
275
+ /**
276
+ * Directories to backup as complete snapshot
277
+ */
278
+ const BACKUP_DIRS = [".trellis", ".claude", ".cursor"];
279
+ /**
280
+ * Patterns to exclude from backup (user data that shouldn't be backed up)
281
+ */
282
+ const BACKUP_EXCLUDE_PATTERNS = [
283
+ ".backup-", // Previous backups
284
+ "/workspace/", // Developer workspace (user data)
285
+ "/tasks/", // Task data (user data)
286
+ "/backlog/", // Backlog data (user data)
287
+ "/agent-traces/", // Agent traces (user data, legacy name)
288
+ ];
289
+ /**
290
+ * Check if a path should be excluded from backup
291
+ */
292
+ function shouldExcludeFromBackup(relativePath) {
293
+ for (const pattern of BACKUP_EXCLUDE_PATTERNS) {
294
+ if (relativePath.includes(pattern)) {
295
+ return true;
296
+ }
297
+ }
298
+ return false;
299
+ }
300
+ /**
301
+ * Create complete snapshot backup of all managed directories
302
+ * Backs up .trellis, .claude, .cursor directories entirely
303
+ * (excluding user data like workspace/, tasks/, backlog/)
304
+ */
305
+ function createFullBackup(cwd) {
306
+ const backupDir = createBackupDirPath(cwd);
307
+ let hasFiles = false;
308
+ for (const dir of BACKUP_DIRS) {
309
+ const dirPath = path.join(cwd, dir);
310
+ if (!fs.existsSync(dirPath))
311
+ continue;
312
+ const files = collectAllFiles(dirPath);
313
+ for (const fullPath of files) {
314
+ const relativePath = path.relative(cwd, fullPath);
315
+ // Skip excluded paths
316
+ if (shouldExcludeFromBackup(relativePath))
317
+ continue;
318
+ // Create backup
319
+ if (!hasFiles) {
320
+ fs.mkdirSync(backupDir, { recursive: true });
321
+ hasFiles = true;
322
+ }
323
+ backupFile(cwd, backupDir, relativePath);
324
+ }
325
+ }
326
+ return hasFiles ? backupDir : null;
223
327
  }
224
328
  /**
225
329
  * Update version file
@@ -273,6 +377,447 @@ function compareVersions(a, b) {
273
377
  }
274
378
  return 0;
275
379
  }
380
+ /**
381
+ * Recursively collect all files in a directory
382
+ */
383
+ function collectAllFiles(dirPath) {
384
+ if (!fs.existsSync(dirPath))
385
+ return [];
386
+ const files = [];
387
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
388
+ for (const entry of entries) {
389
+ const fullPath = path.join(dirPath, entry.name);
390
+ if (entry.isDirectory()) {
391
+ files.push(...collectAllFiles(fullPath));
392
+ }
393
+ else if (entry.isFile()) {
394
+ files.push(fullPath);
395
+ }
396
+ }
397
+ return files;
398
+ }
399
+ /**
400
+ * Check if a directory only contains unmodified template files
401
+ * Returns true if safe to delete:
402
+ * - All files are tracked and unmodified, OR
403
+ * - All files match current template content (even if not tracked)
404
+ */
405
+ function isDirectorySafeToReplace(cwd, dirRelativePath, hashes, templates) {
406
+ const dirFullPath = path.join(cwd, dirRelativePath);
407
+ if (!fs.existsSync(dirFullPath))
408
+ return true;
409
+ const files = collectAllFiles(dirFullPath);
410
+ if (files.length === 0)
411
+ return true; // Empty directory is safe
412
+ for (const fullPath of files) {
413
+ const relativePath = path.relative(cwd, fullPath);
414
+ const storedHash = hashes[relativePath];
415
+ const templateContent = templates.get(relativePath);
416
+ // Check if file matches template content (handles untracked files)
417
+ if (templateContent) {
418
+ const currentContent = fs.readFileSync(fullPath, "utf-8");
419
+ if (currentContent === templateContent) {
420
+ // File matches template - safe
421
+ continue;
422
+ }
423
+ }
424
+ // Check if file is tracked and unmodified
425
+ if (storedHash && !isTemplateModified(cwd, relativePath, hashes)) {
426
+ // Tracked and unmodified - safe
427
+ continue;
428
+ }
429
+ // File is either user-created or user-modified - not safe
430
+ return false;
431
+ }
432
+ return true;
433
+ }
434
+ /**
435
+ * Recursively delete a directory
436
+ */
437
+ function removeDirectoryRecursive(dirPath) {
438
+ if (!fs.existsSync(dirPath))
439
+ return;
440
+ fs.rmSync(dirPath, { recursive: true, force: true });
441
+ }
442
+ /**
443
+ * Check if a file is safe to overwrite (matches template content)
444
+ */
445
+ function isFileSafeToReplace(cwd, relativePath, templates) {
446
+ const fullPath = path.join(cwd, relativePath);
447
+ if (!fs.existsSync(fullPath))
448
+ return true;
449
+ const templateContent = templates.get(relativePath);
450
+ if (!templateContent)
451
+ return false; // Not a template file
452
+ const currentContent = fs.readFileSync(fullPath, "utf-8");
453
+ return currentContent === templateContent;
454
+ }
455
+ /**
456
+ * Classify migrations based on file state and user modifications
457
+ */
458
+ function classifyMigrations(migrations, cwd, hashes, templates) {
459
+ const result = {
460
+ auto: [],
461
+ confirm: [],
462
+ conflict: [],
463
+ skip: [],
464
+ };
465
+ for (const item of migrations) {
466
+ const oldPath = path.join(cwd, item.from);
467
+ const oldExists = fs.existsSync(oldPath);
468
+ if (!oldExists) {
469
+ // Old file doesn't exist, nothing to migrate
470
+ result.skip.push(item);
471
+ continue;
472
+ }
473
+ if (item.type === "rename" && item.to) {
474
+ const newPath = path.join(cwd, item.to);
475
+ const newExists = fs.existsSync(newPath);
476
+ if (newExists) {
477
+ // Both exist - check if new file matches template (safe to overwrite)
478
+ if (isFileSafeToReplace(cwd, item.to, templates)) {
479
+ // New file is just template content - safe to delete and rename
480
+ result.auto.push(item);
481
+ }
482
+ else {
483
+ // New file has user content - conflict
484
+ result.conflict.push(item);
485
+ }
486
+ }
487
+ else if (isTemplateModified(cwd, item.from, hashes)) {
488
+ // User has modified the file - needs confirmation
489
+ result.confirm.push(item);
490
+ }
491
+ else {
492
+ // Unmodified template - safe to auto-migrate
493
+ result.auto.push(item);
494
+ }
495
+ }
496
+ else if (item.type === "rename-dir" && item.to) {
497
+ const newPath = path.join(cwd, item.to);
498
+ const newExists = fs.existsSync(newPath);
499
+ if (newExists) {
500
+ // Target exists - check if it only contains unmodified template files
501
+ if (isDirectorySafeToReplace(cwd, item.to, hashes, templates)) {
502
+ // Safe to delete target and rename source
503
+ result.auto.push(item);
504
+ }
505
+ else {
506
+ // Target has user modifications - conflict
507
+ result.conflict.push(item);
508
+ }
509
+ }
510
+ else {
511
+ // Directory rename - always auto (includes user files)
512
+ result.auto.push(item);
513
+ }
514
+ }
515
+ else if (item.type === "delete") {
516
+ if (isTemplateModified(cwd, item.from, hashes)) {
517
+ // User has modified - needs confirmation before delete
518
+ result.confirm.push(item);
519
+ }
520
+ else {
521
+ // Unmodified - safe to auto-delete
522
+ result.auto.push(item);
523
+ }
524
+ }
525
+ }
526
+ return result;
527
+ }
528
+ /**
529
+ * Print migration summary
530
+ */
531
+ function printMigrationSummary(classified) {
532
+ const total = classified.auto.length +
533
+ classified.confirm.length +
534
+ classified.conflict.length +
535
+ classified.skip.length;
536
+ if (total === 0) {
537
+ console.log(chalk.gray(" No migrations to apply.\n"));
538
+ return;
539
+ }
540
+ if (classified.auto.length > 0) {
541
+ console.log(chalk.green(" ✓ Auto-migrate (unmodified):"));
542
+ for (const item of classified.auto) {
543
+ if (item.type === "rename") {
544
+ console.log(chalk.green(` ${item.from} → ${item.to}`));
545
+ }
546
+ else if (item.type === "rename-dir") {
547
+ console.log(chalk.green(` [dir] ${item.from}/ → ${item.to}/`));
548
+ }
549
+ else {
550
+ console.log(chalk.green(` ✕ ${item.from}`));
551
+ }
552
+ }
553
+ console.log("");
554
+ }
555
+ if (classified.confirm.length > 0) {
556
+ console.log(chalk.yellow(" ⚠ Requires confirmation (modified by user):"));
557
+ for (const item of classified.confirm) {
558
+ if (item.type === "rename") {
559
+ console.log(chalk.yellow(` ${item.from} → ${item.to}`));
560
+ }
561
+ else {
562
+ console.log(chalk.yellow(` ✕ ${item.from}`));
563
+ }
564
+ }
565
+ console.log("");
566
+ }
567
+ if (classified.conflict.length > 0) {
568
+ console.log(chalk.red(" ⊘ Conflict (both old and new exist):"));
569
+ for (const item of classified.conflict) {
570
+ if (item.type === "rename-dir") {
571
+ console.log(chalk.red(` [dir] ${item.from}/ ↔ ${item.to}/`));
572
+ }
573
+ else {
574
+ console.log(chalk.red(` ${item.from} ↔ ${item.to}`));
575
+ }
576
+ }
577
+ console.log(chalk.gray(" → Resolve manually: merge or delete one, then re-run update"));
578
+ console.log("");
579
+ }
580
+ if (classified.skip.length > 0) {
581
+ console.log(chalk.gray(" ○ Skipping (old file not found):"));
582
+ for (const item of classified.skip.slice(0, 3)) {
583
+ console.log(chalk.gray(` ${item.from}`));
584
+ }
585
+ if (classified.skip.length > 3) {
586
+ console.log(chalk.gray(` ... and ${classified.skip.length - 3} more`));
587
+ }
588
+ console.log("");
589
+ }
590
+ }
591
+ /**
592
+ * Prompt user for migration action on a single item
593
+ */
594
+ async function promptMigrationAction(item) {
595
+ const action = item.type === "rename" ? `${item.from} → ${item.to}` : `Delete ${item.from}`;
596
+ const { choice } = await inquirer.prompt([
597
+ {
598
+ type: "list",
599
+ name: "choice",
600
+ message: `${action}\nThis file has been modified. What would you like to do?`,
601
+ choices: [
602
+ {
603
+ name: item.type === "rename" ? "[r] Rename anyway" : "[d] Delete anyway",
604
+ value: "rename",
605
+ },
606
+ {
607
+ name: "[b] Backup original, then proceed",
608
+ value: "backup-rename",
609
+ },
610
+ {
611
+ name: "[s] Skip this migration",
612
+ value: "skip",
613
+ },
614
+ ],
615
+ default: "skip",
616
+ },
617
+ ]);
618
+ return choice;
619
+ }
620
+ /**
621
+ * Clean up empty directories after file migration
622
+ * Recursively removes empty parent directories up to .trellis root
623
+ */
624
+ function cleanupEmptyDirs(cwd, dirPath) {
625
+ const fullPath = path.join(cwd, dirPath);
626
+ // Safety: don't delete outside of expected directories
627
+ if (!dirPath.startsWith(".trellis/") && !dirPath.startsWith(".claude/") && !dirPath.startsWith(".cursor/")) {
628
+ return;
629
+ }
630
+ // Check if directory exists and is empty
631
+ if (!fs.existsSync(fullPath))
632
+ return;
633
+ try {
634
+ const stat = fs.statSync(fullPath);
635
+ if (!stat.isDirectory())
636
+ return;
637
+ const contents = fs.readdirSync(fullPath);
638
+ if (contents.length === 0) {
639
+ fs.rmdirSync(fullPath);
640
+ // Recursively check parent (but stop at root directories)
641
+ const parent = path.dirname(dirPath);
642
+ if (parent !== "." && parent !== dirPath && parent !== ".trellis" && parent !== ".claude" && parent !== ".cursor") {
643
+ cleanupEmptyDirs(cwd, parent);
644
+ }
645
+ }
646
+ }
647
+ catch {
648
+ // Ignore errors (permission issues, etc.)
649
+ }
650
+ }
651
+ /**
652
+ * Sort migrations for safe execution order
653
+ * - rename-dir with deeper paths first (to handle nested directories)
654
+ * - rename-dir before rename/delete
655
+ */
656
+ function sortMigrationsForExecution(migrations) {
657
+ return [...migrations].sort((a, b) => {
658
+ // rename-dir should be sorted by path depth (deeper first)
659
+ if (a.type === "rename-dir" && b.type === "rename-dir") {
660
+ const aDepth = a.from.split("/").length;
661
+ const bDepth = b.from.split("/").length;
662
+ return bDepth - aDepth; // Deeper paths first
663
+ }
664
+ // rename-dir before rename/delete (directories first)
665
+ if (a.type === "rename-dir" && b.type !== "rename-dir")
666
+ return -1;
667
+ if (a.type !== "rename-dir" && b.type === "rename-dir")
668
+ return 1;
669
+ return 0;
670
+ });
671
+ }
672
+ /**
673
+ * Execute classified migrations
674
+ *
675
+ * @param options.force - Force migrate modified files without asking
676
+ * @param options.skipAll - Skip all modified files without asking
677
+ * If neither is set, prompts interactively for modified files
678
+ */
679
+ async function executeMigrations(classified, cwd, options) {
680
+ const result = {
681
+ renamed: 0,
682
+ deleted: 0,
683
+ skipped: 0,
684
+ conflicts: classified.conflict.length,
685
+ };
686
+ // Sort migrations for safe execution order
687
+ const sortedAuto = sortMigrationsForExecution(classified.auto);
688
+ // 1. Execute auto migrations (unmodified files and directories)
689
+ for (const item of sortedAuto) {
690
+ if (item.type === "rename" && item.to) {
691
+ const oldPath = path.join(cwd, item.from);
692
+ const newPath = path.join(cwd, item.to);
693
+ // Ensure target directory exists
694
+ fs.mkdirSync(path.dirname(newPath), { recursive: true });
695
+ fs.renameSync(oldPath, newPath);
696
+ // Update hash tracking
697
+ renameHash(cwd, item.from, item.to);
698
+ // Make executable if it's a script
699
+ if (item.to.endsWith(".sh")) {
700
+ fs.chmodSync(newPath, "755");
701
+ }
702
+ // Clean up empty source directory
703
+ cleanupEmptyDirs(cwd, path.dirname(item.from));
704
+ result.renamed++;
705
+ }
706
+ else if (item.type === "rename-dir" && item.to) {
707
+ const oldPath = path.join(cwd, item.from);
708
+ const newPath = path.join(cwd, item.to);
709
+ // If target exists (safe to replace, already checked in classification)
710
+ // delete it first before renaming
711
+ if (fs.existsSync(newPath)) {
712
+ removeDirectoryRecursive(newPath);
713
+ }
714
+ // Ensure parent directory exists
715
+ fs.mkdirSync(path.dirname(newPath), { recursive: true });
716
+ // Rename the entire directory (includes all user files)
717
+ fs.renameSync(oldPath, newPath);
718
+ // Batch update hash tracking for all files in the directory
719
+ const hashes = loadHashes(cwd);
720
+ const oldPrefix = item.from.endsWith("/") ? item.from : item.from + "/";
721
+ const newPrefix = item.to.endsWith("/") ? item.to : item.to + "/";
722
+ const updatedHashes = {};
723
+ for (const [hashPath, hashValue] of Object.entries(hashes)) {
724
+ if (hashPath.startsWith(oldPrefix)) {
725
+ // Rename path: old prefix -> new prefix
726
+ const newHashPath = newPrefix + hashPath.slice(oldPrefix.length);
727
+ updatedHashes[newHashPath] = hashValue;
728
+ }
729
+ else if (hashPath.startsWith(newPrefix)) {
730
+ // Skip old hashes from deleted target directory
731
+ // (they will be replaced by renamed source files)
732
+ continue;
733
+ }
734
+ else {
735
+ // Keep unchanged
736
+ updatedHashes[hashPath] = hashValue;
737
+ }
738
+ }
739
+ saveHashes(cwd, updatedHashes);
740
+ result.renamed++;
741
+ }
742
+ else if (item.type === "delete") {
743
+ const filePath = path.join(cwd, item.from);
744
+ fs.unlinkSync(filePath);
745
+ // Remove from hash tracking
746
+ removeHash(cwd, item.from);
747
+ // Clean up empty directory
748
+ cleanupEmptyDirs(cwd, path.dirname(item.from));
749
+ result.deleted++;
750
+ }
751
+ }
752
+ // 2. Handle confirm items (modified files)
753
+ // Note: All files are already backed up by createMigrationBackup before execution
754
+ for (const item of classified.confirm) {
755
+ let action;
756
+ if (options.force) {
757
+ // Force mode: proceed (already backed up)
758
+ action = "rename";
759
+ }
760
+ else if (options.skipAll) {
761
+ // Skip mode: skip all modified files
762
+ action = "skip";
763
+ }
764
+ else {
765
+ // Default: interactive prompt
766
+ action = await promptMigrationAction(item);
767
+ }
768
+ if (action === "skip") {
769
+ result.skipped++;
770
+ continue;
771
+ }
772
+ // For backup-rename, just proceed (backup already done)
773
+ // Proceed with rename or delete
774
+ if (item.type === "rename" && item.to) {
775
+ const oldPath = path.join(cwd, item.from);
776
+ const newPath = path.join(cwd, item.to);
777
+ fs.mkdirSync(path.dirname(newPath), { recursive: true });
778
+ fs.renameSync(oldPath, newPath);
779
+ renameHash(cwd, item.from, item.to);
780
+ if (item.to.endsWith(".sh")) {
781
+ fs.chmodSync(newPath, "755");
782
+ }
783
+ // Clean up empty source directory
784
+ cleanupEmptyDirs(cwd, path.dirname(item.from));
785
+ result.renamed++;
786
+ }
787
+ else if (item.type === "delete") {
788
+ const filePath = path.join(cwd, item.from);
789
+ fs.unlinkSync(filePath);
790
+ removeHash(cwd, item.from);
791
+ // Clean up empty directory
792
+ cleanupEmptyDirs(cwd, path.dirname(item.from));
793
+ result.deleted++;
794
+ }
795
+ }
796
+ // 3. Skip count already tracked (old files not found)
797
+ result.skipped += classified.skip.length;
798
+ return result;
799
+ }
800
+ /**
801
+ * Print migration result summary
802
+ */
803
+ function printMigrationResult(result) {
804
+ const parts = [];
805
+ if (result.renamed > 0) {
806
+ parts.push(`${result.renamed} renamed`);
807
+ }
808
+ if (result.deleted > 0) {
809
+ parts.push(`${result.deleted} deleted`);
810
+ }
811
+ if (result.skipped > 0) {
812
+ parts.push(`${result.skipped} skipped`);
813
+ }
814
+ if (result.conflicts > 0) {
815
+ parts.push(`${result.conflicts} conflict${result.conflicts > 1 ? "s" : ""}`);
816
+ }
817
+ if (parts.length > 0) {
818
+ console.log(chalk.cyan(`Migration complete: ${parts.join(", ")}`));
819
+ }
820
+ }
276
821
  /**
277
822
  * Main update command
278
823
  */
@@ -322,15 +867,65 @@ export async function update(options) {
322
867
  }
323
868
  console.log(chalk.yellow("⚠️ --allow-downgrade flag set. Proceeding with downgrade...\n"));
324
869
  }
325
- // Analyze changes
326
- const changes = analyzeChanges(cwd);
870
+ // Load template hashes for modification detection
871
+ const hashes = loadHashes(cwd);
872
+ const isFirstHashTracking = Object.keys(hashes).length === 0;
873
+ // Handle unknown version - skip migrations but continue with template updates
874
+ const isUnknownVersion = projectVersion === "unknown";
875
+ if (isUnknownVersion) {
876
+ console.log(chalk.yellow("⚠️ No version file found. Skipping migrations."));
877
+ console.log(chalk.gray(" Template updates will still be applied."));
878
+ console.log(chalk.gray(" If your project used old file paths, you may need to rename them manually.\n"));
879
+ }
880
+ // Collect templates (used for both migration classification and change analysis)
881
+ const templates = collectTemplateFiles(cwd);
882
+ // Check for pending migrations (skip if unknown version)
883
+ const pendingMigrations = isUnknownVersion
884
+ ? []
885
+ : getMigrationsForVersion(projectVersion, cliVersion);
886
+ const hasMigrations = pendingMigrations.length > 0;
887
+ // Classify migrations (stored for later backup creation)
888
+ let classifiedMigrations = null;
889
+ if (hasMigrations) {
890
+ console.log(chalk.cyan("Analyzing migrations...\n"));
891
+ classifiedMigrations = classifyMigrations(pendingMigrations, cwd, hashes, templates);
892
+ printMigrationSummary(classifiedMigrations);
893
+ // Show hint about --migrate flag (execution happens later after backup)
894
+ if (!options.migrate) {
895
+ const autoCount = classifiedMigrations.auto.length;
896
+ const confirmCount = classifiedMigrations.confirm.length;
897
+ if (autoCount > 0 || confirmCount > 0) {
898
+ console.log(chalk.gray(`Tip: Use --migrate to apply migrations (prompts for modified files).`));
899
+ if (confirmCount > 0) {
900
+ console.log(chalk.gray(` Use --migrate -f to force all, or --migrate -s to skip modified.\n`));
901
+ }
902
+ else {
903
+ console.log("");
904
+ }
905
+ }
906
+ }
907
+ }
908
+ // Analyze changes (pass hashes for modification detection)
909
+ const changes = analyzeChanges(cwd, hashes, templates);
327
910
  // Print summary
328
911
  printChangeSummary(changes);
912
+ // First-time hash tracking hint
913
+ if (isFirstHashTracking && changes.changedFiles.length > 0) {
914
+ console.log(chalk.cyan("ℹ️ First update with hash tracking enabled."));
915
+ console.log(chalk.gray(" Changed files shown above may not be actual user modifications."));
916
+ console.log(chalk.gray(" After this update, hash tracking will accurately detect changes.\n"));
917
+ }
329
918
  // Check if there's anything to do
330
919
  const isUpgrade = cliVsProject > 0;
331
920
  const isDowngrade = cliVsProject < 0;
332
921
  const isSameVersion = cliVsProject === 0;
333
- if (changes.newFiles.length === 0 && changes.changedFiles.length === 0) {
922
+ // Check if we have pending migrations that need to be applied
923
+ const hasPendingMigrations = options.migrate && classifiedMigrations && (classifiedMigrations.auto.length > 0 ||
924
+ classifiedMigrations.confirm.length > 0);
925
+ if (changes.newFiles.length === 0 &&
926
+ changes.autoUpdateFiles.length === 0 &&
927
+ changes.changedFiles.length === 0 &&
928
+ !hasPendingMigrations) {
334
929
  if (isSameVersion) {
335
930
  console.log(chalk.green("✓ Already up to date!"));
336
931
  }
@@ -364,10 +959,19 @@ export async function update(options) {
364
959
  console.log(chalk.yellow("Update cancelled."));
365
960
  return;
366
961
  }
367
- // Create backup if needed
368
- const backupDir = createBackup(cwd, changes);
962
+ // Create complete backup of .trellis, .claude, .cursor directories
963
+ const backupDir = createFullBackup(cwd);
964
+ if (backupDir) {
965
+ console.log(chalk.gray(`\nBackup created: ${path.relative(cwd, backupDir)}/`));
966
+ }
967
+ // Execute migrations if --migrate flag is set
968
+ if (options.migrate && classifiedMigrations) {
969
+ const migrationResult = await executeMigrations(classifiedMigrations, cwd, { force: options.force, skipAll: options.skipAll });
970
+ printMigrationResult(migrationResult);
971
+ }
369
972
  // Track results
370
973
  let added = 0;
974
+ let autoUpdated = 0;
371
975
  let updated = 0;
372
976
  let skipped = 0;
373
977
  let createdNew = 0;
@@ -386,6 +990,19 @@ export async function update(options) {
386
990
  added++;
387
991
  }
388
992
  }
993
+ // Auto-update files (template updated, user didn't modify)
994
+ if (changes.autoUpdateFiles.length > 0) {
995
+ console.log(chalk.blue("\nAuto-updating template files..."));
996
+ for (const file of changes.autoUpdateFiles) {
997
+ fs.writeFileSync(file.path, file.newContent);
998
+ // Make scripts executable
999
+ if (file.relativePath.endsWith(".sh")) {
1000
+ fs.chmodSync(file.path, "755");
1001
+ }
1002
+ console.log(chalk.cyan(` ↑ ${file.relativePath}`));
1003
+ autoUpdated++;
1004
+ }
1005
+ }
389
1006
  // Handle changed files
390
1007
  if (changes.changedFiles.length > 0) {
391
1008
  console.log(chalk.blue("\n--- Resolving conflicts ---\n"));
@@ -414,11 +1031,36 @@ export async function update(options) {
414
1031
  }
415
1032
  // Update version file
416
1033
  updateVersionFile(cwd);
1034
+ // Update template hashes for new, auto-updated, and overwritten files
1035
+ const filesToHash = new Map();
1036
+ for (const file of changes.newFiles) {
1037
+ filesToHash.set(file.relativePath, file.newContent);
1038
+ }
1039
+ // Auto-updated files always get new hash
1040
+ for (const file of changes.autoUpdateFiles) {
1041
+ filesToHash.set(file.relativePath, file.newContent);
1042
+ }
1043
+ // Only hash overwritten files (not skipped or .new copies)
1044
+ for (const file of changes.changedFiles) {
1045
+ const fullPath = path.join(cwd, file.relativePath);
1046
+ if (fs.existsSync(fullPath)) {
1047
+ const content = fs.readFileSync(fullPath, "utf-8");
1048
+ if (content === file.newContent) {
1049
+ filesToHash.set(file.relativePath, file.newContent);
1050
+ }
1051
+ }
1052
+ }
1053
+ if (filesToHash.size > 0) {
1054
+ updateHashes(cwd, filesToHash);
1055
+ }
417
1056
  // Print summary
418
1057
  console.log(chalk.cyan("\n--- Summary ---\n"));
419
1058
  if (added > 0) {
420
1059
  console.log(` Added: ${added} file(s)`);
421
1060
  }
1061
+ if (autoUpdated > 0) {
1062
+ console.log(` Auto-updated: ${autoUpdated} file(s)`);
1063
+ }
422
1064
  if (updated > 0) {
423
1065
  console.log(` Updated: ${updated} file(s)`);
424
1066
  }