worclaude 2.4.5 → 2.4.6

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.
@@ -1,18 +1,28 @@
1
1
  import path from 'node:path';
2
2
  import { execSync } from 'node:child_process';
3
+ import fs from 'fs-extra';
3
4
  import inquirer from 'inquirer';
4
5
  import ora from 'ora';
5
6
  import { requireWorkflowMeta, writeWorkflowMeta, getPackageVersion } from '../core/config.js';
6
7
  import { createBackup } from '../core/backup.js';
7
- import { categorizeFiles } from '../core/file-categorizer.js';
8
+ import { categorizeFiles, resolveKeyPath } from '../core/file-categorizer.js';
8
9
  import { buildSettingsJson, mergeSettingsPermissionsAndHooks } from '../core/merger.js';
9
- import { readTemplate, updateGitignore } from '../core/scaffolder.js';
10
- import { writeFile, fileExists } from '../utils/file.js';
10
+ import { readTemplate, substituteVariables, updateGitignore } from '../core/scaffolder.js';
11
+ import { buildAgentsMdVariables } from '../core/variables.js';
12
+ import {
13
+ hasClaudeMdMemoryGuidance,
14
+ ensureLearningsDir,
15
+ writeMemoryGuidanceSidecar,
16
+ readClaudeMd,
17
+ } from '../core/drift-checks.js';
18
+ import { writeFile, readFile, fileExists } from '../utils/file.js';
11
19
  import { hashFile } from '../utils/hash.js';
12
20
  import { getLatestNpmVersion } from '../utils/npm.js';
13
21
  import * as display from '../utils/display.js';
14
22
  import { semverLessThan, migrateSkillFormat, patchAgentDescriptions } from '../core/migration.js';
15
23
 
24
+ const CONFLICT_CHECK_TYPES = new Set(['hook', 'root-file']);
25
+
16
26
  function selfUpdate(latestVersion) {
17
27
  const spinner = ora(`Updating worclaude to v${latestVersion}...`).start();
18
28
  try {
@@ -31,68 +41,100 @@ function selfUpdate(latestVersion) {
31
41
  }
32
42
  }
33
43
 
34
- export async function upgradeCommand() {
35
- const projectRoot = process.cwd();
36
-
37
- // 1. Check for CLI self-update from npm
38
- const cliVersion = await getPackageVersion();
39
- const latestVersion = await getLatestNpmVersion();
44
+ function sidecarPathFor(dest) {
45
+ const ext = path.extname(dest);
46
+ const base = ext ? dest.slice(0, dest.length - ext.length) : dest;
47
+ return `${base}.workflow-ref${ext}`;
48
+ }
40
49
 
41
- if (latestVersion && latestVersion !== cliVersion) {
42
- display.newline();
43
- display.info(
44
- `New worclaude version available: ${display.dimColor(`v${cliVersion}`)} → ${display.green(`v${latestVersion}`)}`
45
- );
46
- const { doUpdate } = await inquirer.prompt([
47
- {
48
- type: 'list',
49
- name: 'doUpdate',
50
- message: 'Update worclaude CLI?',
51
- choices: [
52
- { name: 'Yes, update and continue', value: true },
53
- { name: 'No, continue with current version', value: false },
54
- ],
55
- },
56
- ]);
50
+ async function renderTemplate({ templatePath, type }, variables) {
51
+ const templateContent = await readTemplate(templatePath);
52
+ return type === 'root-file' ? substituteVariables(templateContent, variables) : templateContent;
53
+ }
57
54
 
58
- if (doUpdate) {
59
- const updated = await selfUpdate(latestVersion);
60
- if (updated) {
61
- display.info('Re-run `worclaude upgrade` to apply the new workflow files.');
62
- return;
63
- }
64
- }
65
- }
55
+ async function writeTemplateToDest(entry, dest, variables) {
56
+ await fs.ensureDir(path.dirname(dest));
57
+ await writeFile(dest, await renderTemplate(entry, variables));
58
+ }
66
59
 
67
- // 2. Check prerequisite
68
- const { meta, error } = await requireWorkflowMeta(projectRoot);
69
- if (error === 'not-installed') {
70
- display.info('Workflow is not installed. Run `worclaude init` to set up.');
71
- return;
72
- }
73
- if (error === 'corrupted') {
74
- display.error('workflow-meta.json is corrupted. Run `worclaude init` to reinstall.');
75
- return;
76
- }
60
+ async function writeSidecarFor(entry, dest, variables) {
61
+ const sidecarPath = sidecarPathFor(dest);
62
+ await writeFile(sidecarPath, await renderTemplate(entry, variables));
63
+ return sidecarPath;
64
+ }
77
65
 
78
- // 3. Version comparison
79
- const currentVersion = await getPackageVersion();
80
- const installedVersion = meta.version;
66
+ async function diskContentMatchesTemplate(entry, dest, variables) {
67
+ const currentContent = await readFile(dest);
68
+ return currentContent === (await renderTemplate(entry, variables));
69
+ }
81
70
 
82
- if (installedVersion === currentVersion) {
83
- display.success(`Already up to date (v${currentVersion}).`);
84
- return;
85
- }
71
+ async function buildRepairPlan(projectRoot, categories, claudeMdContent) {
72
+ const migrationNewFiles = categories.newFiles.filter((f) => CONFLICT_CHECK_TYPES.has(f.type));
73
+ const templateNewFiles = categories.newFiles.filter((f) => !CONFLICT_CHECK_TYPES.has(f.type));
74
+ const learningsGitkeep = path.join(projectRoot, '.claude', 'learnings', '.gitkeep');
75
+ const learningsDirMissing = !(await fileExists(learningsGitkeep));
76
+ const claudeMdNeedsSidecar =
77
+ typeof claudeMdContent === 'string' && !hasClaudeMdMemoryGuidance(claudeMdContent);
78
+ return {
79
+ missingExpected: categories.missingExpected,
80
+ migrationNewFiles,
81
+ templateNewFiles,
82
+ learningsDirMissing,
83
+ claudeMdNeedsSidecar,
84
+ };
85
+ }
86
86
 
87
- // 4. Categorize files
88
- const categories = await categorizeFiles(projectRoot, meta);
87
+ function hasRepairWork(plan) {
88
+ return (
89
+ plan.missingExpected.length > 0 ||
90
+ plan.migrationNewFiles.length > 0 ||
91
+ plan.learningsDirMissing ||
92
+ plan.claudeMdNeedsSidecar
93
+ );
94
+ }
89
95
 
90
- // 5. Preview
91
- display.sectionHeader(`WORCLAUDE UPGRADE (v${installedVersion} → v${currentVersion})`);
92
- display.newline();
96
+ function hasTemplateWork(categories, plan) {
97
+ return (
98
+ categories.autoUpdate.length > 0 ||
99
+ categories.conflict.length > 0 ||
100
+ plan.templateNewFiles.length > 0
101
+ );
102
+ }
93
103
 
94
- display.barLine('Changes:');
104
+ function renderRepairPreview(plan) {
105
+ if (plan.missingExpected.length > 0) {
106
+ display.barLine(`${display.green('+')} Restore (missing from disk):`);
107
+ for (const { key } of plan.missingExpected) {
108
+ display.barLine(` ${display.green('+')} ${key}`);
109
+ }
110
+ display.newline();
111
+ }
112
+ if (plan.migrationNewFiles.length > 0) {
113
+ display.barLine(`${display.green('+')} Track & install (new file type in this CLI version):`);
114
+ for (const { key } of plan.migrationNewFiles) {
115
+ display.barLine(` ${display.green('+')} ${key}`);
116
+ }
117
+ display.newline();
118
+ }
119
+ const sidecarLines = [];
120
+ if (plan.learningsDirMissing) {
121
+ sidecarLines.push(' ~ `.claude/learnings/` directory missing (will be created)');
122
+ }
123
+ if (plan.claudeMdNeedsSidecar) {
124
+ sidecarLines.push(
125
+ ' ~ CLAUDE.md memory guidance missing (will write CLAUDE.md.workflow-ref.md)'
126
+ );
127
+ }
128
+ if (sidecarLines.length > 0) {
129
+ display.barLine(`${display.yellow('~')} Also:`);
130
+ for (const line of sidecarLines) {
131
+ display.barLine(line);
132
+ }
133
+ display.newline();
134
+ }
135
+ }
95
136
 
137
+ function renderTemplatePreview(categories, plan) {
96
138
  if (categories.autoUpdate.length > 0) {
97
139
  display.barLine(`${display.green('✓')} Auto-update (unchanged since install):`);
98
140
  const showCount = Math.min(categories.autoUpdate.length, 3);
@@ -104,7 +146,6 @@ export async function upgradeCommand() {
104
146
  }
105
147
  display.newline();
106
148
  }
107
-
108
149
  if (categories.conflict.length > 0) {
109
150
  display.barLine(`${display.yellow('~')} Needs review (you've customized these):`);
110
151
  for (const { key } of categories.conflict) {
@@ -114,22 +155,19 @@ export async function upgradeCommand() {
114
155
  }
115
156
  display.newline();
116
157
  }
117
-
118
- if (categories.newFiles.length > 0) {
158
+ if (plan.templateNewFiles.length > 0) {
119
159
  display.barLine(`${display.green('+')} New in this version:`);
120
- for (const { key } of categories.newFiles) {
160
+ for (const { key } of plan.templateNewFiles) {
121
161
  display.barLine(` ${display.green('+')} ${key}`);
122
162
  }
123
163
  display.newline();
124
164
  }
125
-
126
165
  if (categories.unchanged.length > 0) {
127
166
  display.barLine(
128
167
  `${display.dimColor('=')} Unchanged: ${display.dimColor(`${categories.unchanged.length} files`)}`
129
168
  );
130
169
  display.newline();
131
170
  }
132
-
133
171
  if (categories.modified.length > 0) {
134
172
  display.barLine(`${display.yellow('~')} Your customizations (no workflow updates available):`);
135
173
  for (const { key } of categories.modified) {
@@ -137,43 +175,244 @@ export async function upgradeCommand() {
137
175
  }
138
176
  display.newline();
139
177
  }
178
+ }
140
179
 
141
- const hasWork =
142
- categories.autoUpdate.length > 0 ||
143
- categories.conflict.length > 0 ||
144
- categories.newFiles.length > 0;
180
+ async function applyRepairPass(projectRoot, plan, variables) {
181
+ const result = {
182
+ restored: [],
183
+ migrated: [],
184
+ migrationConflicts: [],
185
+ createdDirs: [],
186
+ sidecars: [],
187
+ };
188
+
189
+ for (const entry of plan.missingExpected) {
190
+ const dest = resolveKeyPath(entry.key, projectRoot);
191
+ await writeTemplateToDest(entry, dest, variables);
192
+ result.restored.push({ key: entry.key, dest });
193
+ }
145
194
 
146
- if (!hasWork) {
147
- display.info('No file changes needed — only updating version metadata.');
148
- display.newline();
195
+ for (const entry of plan.migrationNewFiles) {
196
+ const dest = resolveKeyPath(entry.key, projectRoot);
197
+ if (await fileExists(dest)) {
198
+ const matches = await diskContentMatchesTemplate(entry, dest, variables);
199
+ if (!matches) {
200
+ const sidecarPath = await writeSidecarFor(entry, dest, variables);
201
+ result.migrationConflicts.push({ key: entry.key, dest, sidecarPath });
202
+ continue;
203
+ }
204
+ }
205
+ await writeTemplateToDest(entry, dest, variables);
206
+ result.migrated.push({ key: entry.key, dest });
149
207
  }
150
208
 
151
- // 6. Confirm
209
+ if (plan.learningsDirMissing) {
210
+ const created = await ensureLearningsDir(projectRoot);
211
+ if (created) {
212
+ result.createdDirs.push(path.join(projectRoot, '.claude', 'learnings'));
213
+ }
214
+ }
215
+
216
+ if (plan.claudeMdNeedsSidecar) {
217
+ const sidecarPath = await writeMemoryGuidanceSidecar(projectRoot);
218
+ result.sidecars.push(sidecarPath);
219
+ }
220
+
221
+ return result;
222
+ }
223
+
224
+ async function promptProceed(message) {
152
225
  const { proceed } = await inquirer.prompt([
153
226
  {
154
227
  type: 'list',
155
228
  name: 'proceed',
156
- message: 'Proceed with upgrade?',
229
+ message,
157
230
  choices: [
158
231
  { name: 'Yes', value: true },
159
232
  { name: 'No', value: false },
160
233
  ],
161
234
  },
162
235
  ]);
236
+ return proceed;
237
+ }
238
+
239
+ async function runRepairOnlyFlow({ projectRoot, meta, plan, variables, dryRun, yes }) {
240
+ display.sectionHeader(`WORCLAUDE REPAIR (v${meta.version})`);
241
+ display.newline();
242
+ display.barLine('Drift detected:');
243
+ renderRepairPreview(plan);
163
244
 
164
- if (!proceed) {
165
- display.info('Upgrade cancelled.');
245
+ if (dryRun) {
246
+ display.info('Dry run — no changes written.');
166
247
  return;
167
248
  }
168
249
 
169
- // 7. Execute
250
+ if (!yes) {
251
+ const proceed = await promptProceed('Repair drifted files?');
252
+ if (!proceed) {
253
+ display.info('Repair cancelled.');
254
+ return;
255
+ }
256
+ }
257
+
258
+ const spinner = ora('Repairing...').start();
259
+ try {
260
+ const backupDir = await createBackup(projectRoot);
261
+ spinner.text = 'Backup created, restoring files...';
262
+
263
+ const result = await applyRepairPass(projectRoot, plan, variables);
264
+
265
+ const fileHashes = { ...meta.fileHashes };
266
+ for (const { key, dest } of [...result.restored, ...result.migrated]) {
267
+ fileHashes[key] = await hashFile(dest);
268
+ }
269
+
270
+ meta.lastUpdated = new Date().toISOString();
271
+ meta.fileHashes = fileHashes;
272
+ await writeWorkflowMeta(projectRoot, meta);
273
+
274
+ spinner.succeed('Repair complete.');
275
+
276
+ display.newline();
277
+ if (result.restored.length > 0) {
278
+ display.barLine(`Restored: ${result.restored.length} files`);
279
+ }
280
+ if (result.migrated.length > 0) {
281
+ display.barLine(`Installed: ${result.migrated.length} files`);
282
+ }
283
+ if (result.migrationConflicts.length > 0) {
284
+ display.barLine(
285
+ `Conflicts: ${result.migrationConflicts.length} files ${display.dimColor('(saved as .workflow-ref)')}`
286
+ );
287
+ }
288
+ if (result.createdDirs.length > 0) {
289
+ display.barLine(`Created: ${result.createdDirs.length} directories`);
290
+ }
291
+ if (result.sidecars.length > 0) {
292
+ display.barLine(`Sidecar: ${result.sidecars.length} suggestion files`);
293
+ }
294
+ display.barLine(display.dimColor(`Backup: ${path.basename(backupDir)}/`));
295
+
296
+ if (result.migrationConflicts.length > 0 || result.sidecars.length > 0) {
297
+ display.newline();
298
+ display.barLine(`Review .workflow-ref files and merge what's useful.`);
299
+ }
300
+ } catch (err) {
301
+ spinner.fail('Repair failed.');
302
+ display.error(err.message);
303
+ }
304
+ }
305
+
306
+ export async function upgradeCommand(options = {}) {
307
+ const { dryRun = false, yes = false, repairOnly = false } = options;
308
+ const projectRoot = process.cwd();
309
+
310
+ // 1. Check for CLI self-update from npm
311
+ const cliVersion = await getPackageVersion();
312
+ const latestVersion = await getLatestNpmVersion();
313
+
314
+ if (latestVersion && latestVersion !== cliVersion) {
315
+ display.newline();
316
+ display.info(
317
+ `New worclaude version available: ${display.dimColor(`v${cliVersion}`)} → ${display.green(`v${latestVersion}`)}`
318
+ );
319
+ const { doUpdate } = await inquirer.prompt([
320
+ {
321
+ type: 'list',
322
+ name: 'doUpdate',
323
+ message: 'Update worclaude CLI?',
324
+ choices: [
325
+ { name: 'Yes, update and continue', value: true },
326
+ { name: 'No, continue with current version', value: false },
327
+ ],
328
+ },
329
+ ]);
330
+
331
+ if (doUpdate) {
332
+ const updated = await selfUpdate(latestVersion);
333
+ if (updated) {
334
+ display.info('Re-run `worclaude upgrade` to apply the new workflow files.');
335
+ return;
336
+ }
337
+ }
338
+ }
339
+
340
+ // 2. Check prerequisite
341
+ const { meta, error } = await requireWorkflowMeta(projectRoot);
342
+ if (error === 'not-installed') {
343
+ display.info('Workflow is not installed. Run `worclaude init` to set up.');
344
+ return;
345
+ }
346
+ if (error === 'corrupted') {
347
+ display.error('workflow-meta.json is corrupted. Run `worclaude init` to reinstall.');
348
+ return;
349
+ }
350
+
351
+ // 3. Version + categorize
352
+ const currentVersion = await getPackageVersion();
353
+ const installedVersion = meta.version;
354
+ const versionMatch = installedVersion === currentVersion;
355
+
356
+ const categories = await categorizeFiles(projectRoot, meta);
357
+ const claudeMdContent = await readClaudeMd(projectRoot);
358
+ const plan = await buildRepairPlan(projectRoot, categories, claudeMdContent);
359
+
360
+ const repairWork = hasRepairWork(plan);
361
+ const templateWork = hasTemplateWork(categories, plan);
362
+
363
+ // Version match + no repair + no template work → up to date.
364
+ // Early return keeps the clean-install fast path free of package.json I/O.
365
+ if (versionMatch && !repairWork && !templateWork) {
366
+ display.success(`Already up to date (v${currentVersion}).`);
367
+ return;
368
+ }
369
+
370
+ const variables = await buildAgentsMdVariables(meta, projectRoot);
371
+
372
+ // Version match + repair only OR explicit --repair-only → repair-only flow
373
+ if ((versionMatch && !templateWork) || repairOnly) {
374
+ await runRepairOnlyFlow({ projectRoot, meta, plan, variables, dryRun, yes });
375
+ return;
376
+ }
377
+
378
+ // Full upgrade flow (version mismatch)
379
+ display.sectionHeader(`WORCLAUDE UPGRADE (v${installedVersion} → v${currentVersion})`);
380
+ display.newline();
381
+ display.barLine('Changes:');
382
+
383
+ if (repairWork) {
384
+ renderRepairPreview(plan);
385
+ }
386
+ renderTemplatePreview(categories, plan);
387
+
388
+ if (!repairWork && !templateWork) {
389
+ display.info('No file changes needed — only updating version metadata.');
390
+ display.newline();
391
+ }
392
+
393
+ if (dryRun) {
394
+ display.info('Dry run — no changes written.');
395
+ return;
396
+ }
397
+
398
+ if (!yes) {
399
+ const proceed = await promptProceed('Proceed with upgrade?');
400
+ if (!proceed) {
401
+ display.info('Upgrade cancelled.');
402
+ return;
403
+ }
404
+ }
405
+
170
406
  const spinner = ora('Upgrading...').start();
171
407
 
172
408
  try {
173
- // Create backup first
174
409
  const backupDir = await createBackup(projectRoot);
175
410
  spinner.text = 'Backup created, applying updates...';
176
411
 
412
+ // Repair pass (restoration + migration) runs first so hash-prune below
413
+ // cannot drop entries we just rewrote.
414
+ const repairResult = await applyRepairPass(projectRoot, plan, variables);
415
+
177
416
  // v2.0.0 migrations (version-gated)
178
417
  let skillReport = { migrated: 0, skipped: 0, names: [] };
179
418
  let agentReport = { autoPatched: 0, prompted: 0, declined: 0, skipped: [] };
@@ -181,10 +420,8 @@ export async function upgradeCommand() {
181
420
  if (semverLessThan(installedVersion, '2.0.0')) {
182
421
  spinner.text = 'Running v2.0.0 migrations...';
183
422
 
184
- // Item 14: Skill format migration (flat .md → skill-name/SKILL.md)
185
423
  skillReport = await migrateSkillFormat(projectRoot, meta);
186
424
 
187
- // Item 15: Agent frontmatter patch (add missing description)
188
425
  spinner.stop();
189
426
  agentReport = await patchAgentDescriptions(projectRoot, meta, async (agentName) => {
190
427
  const { patch } = await inquirer.prompt([
@@ -206,20 +443,21 @@ export async function upgradeCommand() {
206
443
  // Auto-update files
207
444
  for (const { key, templatePath } of categories.autoUpdate) {
208
445
  const content = await readTemplate(templatePath);
209
- await writeFile(path.join(projectRoot, '.claude', ...key.split('/')), content);
446
+ await writeFile(resolveKeyPath(key, projectRoot), content);
210
447
  }
211
448
 
212
449
  // Conflict files: save as .workflow-ref.md
213
450
  for (const { key, templatePath } of categories.conflict) {
214
451
  const content = await readTemplate(templatePath);
215
452
  const refKey = key.replace(/\.md$/, '.workflow-ref.md');
216
- await writeFile(path.join(projectRoot, '.claude', ...refKey.split('/')), content);
453
+ await writeFile(resolveKeyPath(refKey, projectRoot), content);
217
454
  }
218
455
 
219
- // New files: add directly
220
- for (const { key, templatePath } of categories.newFiles) {
221
- const content = await readTemplate(templatePath);
222
- await writeFile(path.join(projectRoot, '.claude', ...key.split('/')), content);
456
+ // New files (non-migration types handled here; migration types were
457
+ // processed in applyRepairPass with conflict safety)
458
+ for (const entry of plan.templateNewFiles) {
459
+ const content = await readTemplate(entry.templatePath);
460
+ await writeFile(resolveKeyPath(entry.key, projectRoot), content);
223
461
  }
224
462
 
225
463
  // Settings.json merge: append new permissions and hooks
@@ -236,29 +474,26 @@ export async function upgradeCommand() {
236
474
  // Ensure sessions directory exists for session persistence
237
475
  await writeFile(path.join(projectRoot, '.claude', 'sessions', '.gitkeep'), '');
238
476
 
239
- // Partial hash update rehash ONLY files we just wrote. User-customized
240
- // ("modified") files and conflict-sidecar'd files keep their original
241
- // stored hash so their "install state" baseline is preserved across
242
- // upgrades. Otherwise the next template change would silently overwrite
243
- // the customization via the autoUpdate path.
477
+ // Hash refresh — files we just wrote (repair restored, repair migrated,
478
+ // autoUpdate, templateNewFiles). Modified / conflict / unchanged /
479
+ // userAdded / missingUntracked keep their prior hash; missingUntracked
480
+ // keys are dropped below.
244
481
  const fileHashes = { ...meta.fileHashes };
245
- for (const { key } of categories.autoUpdate) {
246
- const filePath = path.join(projectRoot, '.claude', ...key.split('/'));
247
- fileHashes[key] = await hashFile(filePath);
248
- }
249
- for (const { key } of categories.newFiles) {
250
- const filePath = path.join(projectRoot, '.claude', ...key.split('/'));
251
- fileHashes[key] = await hashFile(filePath);
482
+ const rehashTargets = [
483
+ ...repairResult.restored,
484
+ ...repairResult.migrated,
485
+ ...categories.autoUpdate.map(({ key }) => ({ key, dest: resolveKeyPath(key, projectRoot) })),
486
+ ...plan.templateNewFiles.map(({ key }) => ({ key, dest: resolveKeyPath(key, projectRoot) })),
487
+ ];
488
+ for (const { key, dest } of rehashTargets) {
489
+ fileHashes[key] = await hashFile(dest);
252
490
  }
253
- for (const { key } of categories.deleted) {
491
+ for (const { key } of categories.missingUntracked) {
254
492
  delete fileHashes[key];
255
493
  }
256
- // modified, conflict, unchanged, userAdded: stored hash deliberately left alone
257
494
 
258
- // Ensure .gitignore has worclaude entries
259
495
  await updateGitignore(projectRoot);
260
496
 
261
- // Update meta
262
497
  meta.version = currentVersion;
263
498
  meta.lastUpdated = new Date().toISOString();
264
499
  meta.fileHashes = fileHashes;
@@ -266,8 +501,18 @@ export async function upgradeCommand() {
266
501
 
267
502
  spinner.succeed(`Upgrade complete! (${installedVersion} → ${currentVersion})`);
268
503
 
269
- // 8. Display report
270
504
  display.newline();
505
+ if (repairResult.restored.length > 0) {
506
+ display.barLine(`Restored: ${repairResult.restored.length} files`);
507
+ }
508
+ if (repairResult.migrated.length > 0) {
509
+ display.barLine(`Installed: ${repairResult.migrated.length} files (migration)`);
510
+ }
511
+ if (repairResult.migrationConflicts.length > 0) {
512
+ display.barLine(
513
+ `Migration conflicts: ${repairResult.migrationConflicts.length} ${display.dimColor('(saved as .workflow-ref)')}`
514
+ );
515
+ }
271
516
  if (categories.autoUpdate.length > 0) {
272
517
  display.barLine(`Updated: ${categories.autoUpdate.length} files`);
273
518
  }
@@ -276,8 +521,8 @@ export async function upgradeCommand() {
276
521
  `Conflicts: ${categories.conflict.length} files ${display.dimColor('(saved as .workflow-ref.md)')}`
277
522
  );
278
523
  }
279
- if (categories.newFiles.length > 0) {
280
- display.barLine(`New: ${categories.newFiles.length} files added`);
524
+ if (plan.templateNewFiles.length > 0) {
525
+ display.barLine(`New: ${plan.templateNewFiles.length} files added`);
281
526
  }
282
527
  display.barLine(`Unchanged: ${categories.unchanged.length} files`);
283
528
  if (categories.modified.length > 0) {
@@ -285,6 +530,9 @@ export async function upgradeCommand() {
285
530
  `Customized: ${categories.modified.length} files ${display.dimColor('(no updates needed)')}`
286
531
  );
287
532
  }
533
+ if (repairResult.sidecars.length > 0) {
534
+ display.barLine(`Sidecar: ${repairResult.sidecars.length} suggestion files`);
535
+ }
288
536
  if (skillReport.migrated > 0) {
289
537
  display.barLine(`Migrated: ${skillReport.migrated} skills to directory format`);
290
538
  }
@@ -304,9 +552,13 @@ export async function upgradeCommand() {
304
552
  display.newline();
305
553
  display.barLine(display.dimColor(`Backup: ${path.basename(backupDir)}/`));
306
554
 
307
- if (categories.conflict.length > 0) {
555
+ if (
556
+ categories.conflict.length > 0 ||
557
+ repairResult.migrationConflicts.length > 0 ||
558
+ repairResult.sidecars.length > 0
559
+ ) {
308
560
  display.newline();
309
- display.barLine(`Review .workflow-ref.md files and merge what's useful.`);
561
+ display.barLine(`Review .workflow-ref files and merge what's useful.`);
310
562
  }
311
563
  } catch (err) {
312
564
  spinner.fail('Upgrade failed.');
@@ -70,6 +70,8 @@ export async function requireWorkflowMeta(projectRoot) {
70
70
  return { meta, error: null };
71
71
  }
72
72
 
73
+ const ROOT_TRACKED_FILES = ['AGENTS.md'];
74
+
73
75
  export async function computeFileHashes(projectRoot) {
74
76
  const claudeDir = path.join(projectRoot, '.claude');
75
77
  const allFiles = await listFilesRecursive(claudeDir);
@@ -84,5 +86,11 @@ export async function computeFileHashes(projectRoot) {
84
86
  fileHashes[relKey] = await hashFile(filePath);
85
87
  }
86
88
  }
89
+ for (const rel of ROOT_TRACKED_FILES) {
90
+ const filePath = path.join(projectRoot, rel);
91
+ if (await fileExists(filePath)) {
92
+ fileHashes[`root/${rel}`] = await hashFile(filePath);
93
+ }
94
+ }
87
95
  return fileHashes;
88
96
  }