@boyingliu01/xp-gate 0.9.3 → 0.9.4

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 (32) hide show
  1. package/lib/__tests__/baseline.test.js +89 -0
  2. package/lib/__tests__/doctor-helpers.test.js +139 -0
  3. package/lib/__tests__/upgrade-exec.test.js +42 -33
  4. package/lib/baseline.js +75 -48
  5. package/lib/doctor.js +180 -93
  6. package/lib/install-skill.js +81 -62
  7. package/lib/shared-phase-constants.d.ts +17 -0
  8. package/lib/shared-phase-constants.js +63 -0
  9. package/lib/shared-utils.js +14 -3
  10. package/lib/sprint-status.js +75 -83
  11. package/lib/upgrade.js +128 -85
  12. package/mock-policy/AGENTS.md +3 -3
  13. package/mutation/AGENTS.md +3 -3
  14. package/package.json +1 -1
  15. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  16. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  17. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  18. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  19. package/plugins/opencode/__tests__/xp-gate-update.test.ts +133 -4
  20. package/plugins/opencode/index.ts +28 -3
  21. package/plugins/opencode/package.json +1 -1
  22. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  23. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  24. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  25. package/plugins/opencode/tui-plugin.ts +44 -75
  26. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  27. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  28. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  29. package/principles/AGENTS.md +3 -3
  30. package/skills/delphi-review/AGENTS.md +3 -3
  31. package/skills/sprint-flow/AGENTS.md +3 -3
  32. package/skills/test-specification-alignment/AGENTS.md +3 -3
package/lib/doctor.js CHANGED
@@ -211,7 +211,32 @@ function diagnose() {
211
211
  issues += configResult.issues;
212
212
 
213
213
  // --- Check 2: Version mismatch ---
214
- const pkgVersion = getPackageVersion();
214
+ issues += diagnoseVersion(config, getPackageVersion(), checks);
215
+
216
+ // --- Check 3: templateDir validation ---
217
+ issues += diagnoseTemplateDir(config, checks);
218
+
219
+ // --- Check 4: Hooks files ---
220
+ if (config.mode === 'local') {
221
+ issues += checkLocalHooks(checks);
222
+ } else {
223
+ issues += checkGlobalHooks(checks);
224
+ }
225
+
226
+ // --- Check 5: Adapters directory ---
227
+ issues += checkAdapters(checks, config.mode, getGitDir());
228
+
229
+ // --- Check 6: Environment dependencies ---
230
+ checkEnv(checks);
231
+
232
+ return { checks, issues };
233
+ }
234
+
235
+ /**
236
+ * Check 2: Version mismatch between config and package.
237
+ * @returns {number} issue count (0 or 1)
238
+ */
239
+ function diagnoseVersion(config, pkgVersion, checks) {
215
240
  const configVersion = config.version;
216
241
  if (configVersion && pkgVersion && configVersion !== pkgVersion) {
217
242
  checks.push({
@@ -219,10 +244,16 @@ function diagnose() {
219
244
  status: 'FAIL',
220
245
  detail: `config: ${configVersion}, package: ${pkgVersion} — run 'xp-gate doctor --fix' to sync`
221
246
  });
222
- issues++;
247
+ return 1;
223
248
  }
249
+ return 0;
250
+ }
224
251
 
225
- // --- Check 3: templateDir validation ---
252
+ /**
253
+ * Check 3: templateDir validation against platform expectation.
254
+ * @returns {number} issue count (0 or 1)
255
+ */
256
+ function diagnoseTemplateDir(config, checks) {
226
257
  const configTemplateDir = config.templateDir;
227
258
  if (configTemplateDir) {
228
259
  const expectedTemplateDir = getTemplateDir();
@@ -232,24 +263,10 @@ function diagnose() {
232
263
  status: 'FAIL',
233
264
  detail: `points to ${configTemplateDir}, expected ${expectedTemplateDir} for current platform`
234
265
  });
235
- issues++;
266
+ return 1;
236
267
  }
237
268
  }
238
-
239
- // --- Check 4: Hooks files ---
240
- if (config.mode === 'local') {
241
- issues += checkLocalHooks(checks);
242
- } else {
243
- issues += checkGlobalHooks(checks);
244
- }
245
-
246
- // --- Check 5: Adapters directory ---
247
- issues += checkAdapters(checks, config.mode, getGitDir());
248
-
249
- // --- Check 6: Environment dependencies ---
250
- checkEnv(checks);
251
-
252
- return { checks, issues };
269
+ return 0;
253
270
  }
254
271
 
255
272
  /**
@@ -280,82 +297,131 @@ function restoreHook(srcFile, destFile, label) {
280
297
  }
281
298
 
282
299
  /**
283
- * Attempt to fix known issues.
284
- * Only operates when mode === 'active' (local or global).
300
+ * Fix version mismatch — update config.version to match package version.
301
+ * @param {object} config
302
+ * @param {string|null} pkgVersion
303
+ * @returns {boolean} Whether a fix was applied
285
304
  */
286
- function fixIssues(checks, config) {
287
- console.log('');
288
- console.log('Attempting fixes...');
289
- console.log('-------------------');
290
-
291
- const srcDir = PKG_DIR;
292
- let fixed = false;
293
-
294
- // Fix version mismatch — update config version to match package
295
- const pkgVersion = getPackageVersion();
305
+ function fixVersionMismatch(config, pkgVersion) {
296
306
  if (pkgVersion && config.version !== pkgVersion) {
297
307
  config.version = pkgVersion;
298
308
  saveConfig(config);
299
309
  console.log(` ✓ Updated config version to ${pkgVersion}`);
300
- fixed = true;
310
+ return true;
301
311
  }
312
+ return false;
313
+ }
302
314
 
303
- // Fix templateDir mismatch
304
- const expectedTemplateDir = getTemplateDir();
315
+ /**
316
+ * Fix templateDir mismatch — update config.templateDir to match expected.
317
+ * @param {object} config
318
+ * @param {string} expectedTemplateDir
319
+ * @returns {boolean} Whether a fix was applied
320
+ */
321
+ function fixTemplateDirMismatch(config, expectedTemplateDir) {
305
322
  if (config.templateDir && config.templateDir !== expectedTemplateDir) {
306
323
  config.templateDir = expectedTemplateDir;
307
324
  saveConfig(config);
308
325
  console.log(` ✓ Updated templateDir to ${expectedTemplateDir}`);
309
- fixed = true;
326
+ return true;
327
+ }
328
+ return false;
329
+ }
330
+
331
+ /**
332
+ * Restore missing hooks from package source.
333
+ * @param {'local'|'global'} mode
334
+ * @param {string} srcDir - Package source directory
335
+ * @param {string} hooksDir - Target hooks directory
336
+ * @returns {boolean} Whether any hooks were restored
337
+ */
338
+ function fixMissingHooks(mode, srcDir, hooksDir) {
339
+ let fixed = false;
340
+ const label = mode === 'local' ? '' : 'global ';
341
+ const preCommitLabel = `${label}pre-commit hook`;
342
+ const prePushLabel = `${label}pre-push hook`;
343
+ fixed = restoreHook(path.join(srcDir, 'hooks', 'pre-commit'), path.join(hooksDir, 'pre-commit'), preCommitLabel) || fixed;
344
+ fixed = restoreHook(path.join(srcDir, 'hooks', 'pre-push'), path.join(hooksDir, 'pre-push'), prePushLabel) || fixed;
345
+ return fixed;
346
+ }
347
+
348
+ /**
349
+ * Fix core.hooksPath for global mode.
350
+ * @param {string} globalHooksDir
351
+ * @returns {boolean} Whether the fix was applied
352
+ */
353
+ function fixCoreHooksPath(globalHooksDir) {
354
+ try {
355
+ execSync(`git config --global core.hooksPath "${globalHooksDir}"`, {
356
+ stdio: ['pipe', 'pipe', 'pipe']
357
+ });
358
+ console.log(` ✓ Set core.hooksPath to ${globalHooksDir}`);
359
+ return true;
360
+ } catch (e) {
361
+ console.log(` ✗ Could not set core.hooksPath: ${e.message}`);
362
+ return false;
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Restore missing adapters from package source.
368
+ * @param {'local'|'global'} mode
369
+ * @param {string} srcDir - Package source directory
370
+ * @param {string} adaptersDir - Target adapters directory
371
+ * @returns {boolean} Whether adapters were restored
372
+ */
373
+ function fixMissingAdapters(mode, srcDir, adaptersDir) {
374
+ if (adaptersDir && (!fs.existsSync(adaptersDir) || fs.readdirSync(adaptersDir).filter(f => f.endsWith('.sh')).length === 0)) {
375
+ const pkgAdaptersDir = path.join(srcDir, 'adapters');
376
+ if (fs.existsSync(pkgAdaptersDir)) {
377
+ fs.mkdirSync(adaptersDir, { recursive: true });
378
+ const adapterFiles = fs.readdirSync(pkgAdaptersDir).filter(f => f.endsWith('.sh'));
379
+ for (const f of adapterFiles) {
380
+ fs.copyFileSync(path.join(pkgAdaptersDir, f), path.join(adaptersDir, f));
381
+ }
382
+ console.log(` ✓ Restored ${adapterFiles.length} adapter(s)`);
383
+ return true;
384
+ }
310
385
  }
386
+ return false;
387
+ }
388
+
389
+ /**
390
+ * Attempt to fix known issues.
391
+ * Only operates when mode === 'active' (local or global).
392
+ */
393
+ function fixIssues(checks, config) {
394
+ console.log('');
395
+ console.log('Attempting fixes...');
396
+ console.log('-------------------');
397
+
398
+ const srcDir = PKG_DIR;
399
+ let fixed = false;
400
+
401
+ fixed = fixVersionMismatch(config, getPackageVersion()) || fixed;
402
+ fixed = fixTemplateDirMismatch(config, getTemplateDir()) || fixed;
311
403
 
312
- // Fix missing hooks
313
404
  if (config.mode === 'local') {
314
405
  const gitDir = getGitDir();
315
406
  if (gitDir) {
316
407
  const hooksDir = path.join(gitDir, 'hooks');
317
- fixed = restoreHook(path.join(srcDir, 'hooks', 'pre-commit'), path.join(hooksDir, 'pre-commit'), 'pre-commit hook') || fixed;
318
- fixed = restoreHook(path.join(srcDir, 'hooks', 'pre-push'), path.join(hooksDir, 'pre-push'), 'pre-push hook') || fixed;
408
+ fixed = fixMissingHooks('local', srcDir, hooksDir) || fixed;
319
409
  }
320
410
  } else {
321
- const hooksDir = GLOBAL_HOOKS_DIR;
322
- fixed = restoreHook(path.join(srcDir, 'hooks', 'pre-commit'), path.join(hooksDir, 'pre-commit'), 'global pre-commit hook') || fixed;
323
- fixed = restoreHook(path.join(srcDir, 'hooks', 'pre-push'), path.join(hooksDir, 'pre-push'), 'global pre-push hook') || fixed;
411
+ fixed = fixMissingHooks('global', srcDir, GLOBAL_HOOKS_DIR) || fixed;
324
412
  }
325
413
 
326
- // Fix core.hooksPath (global mode only)
327
414
  if (config.mode === 'global') {
328
415
  const hooksPath = getCurrentHooksPath();
329
416
  if (hooksPath !== GLOBAL_HOOKS_DIR) {
330
- try {
331
- execSync(`git config --global core.hooksPath "${GLOBAL_HOOKS_DIR}"`, {
332
- stdio: ['pipe', 'pipe', 'pipe']
333
- });
334
- console.log(` ✓ Set core.hooksPath to ${GLOBAL_HOOKS_DIR}`);
335
- fixed = true;
336
- } catch (e) {
337
- console.log(` ✗ Could not set core.hooksPath: ${e.message}`);
338
- }
417
+ fixed = fixCoreHooksPath(GLOBAL_HOOKS_DIR) || fixed;
339
418
  }
340
419
  }
341
420
 
342
- // Fix missing adapters
343
421
  const adaptersDir = config.mode === 'local'
344
422
  ? path.join(path.dirname(getGitDir() || ''), 'githooks', 'adapters')
345
423
  : GLOBAL_ADAPTERS_DIR;
346
-
347
- if (adaptersDir && (!fs.existsSync(adaptersDir) || fs.readdirSync(adaptersDir).filter(f => f.endsWith('.sh')).length === 0)) {
348
- const pkgAdaptersDir = path.join(srcDir, 'adapters');
349
- if (fs.existsSync(pkgAdaptersDir)) {
350
- fs.mkdirSync(adaptersDir, { recursive: true });
351
- const adapterFiles = fs.readdirSync(pkgAdaptersDir).filter(f => f.endsWith('.sh'));
352
- for (const f of adapterFiles) {
353
- fs.copyFileSync(path.join(pkgAdaptersDir, f), path.join(adaptersDir, f));
354
- }
355
- console.log(` ✓ Restored ${adapterFiles.length} adapter(s)`);
356
- fixed = true;
357
- }
358
- }
424
+ fixed = fixMissingAdapters(config.mode, srcDir, adaptersDir) || fixed;
359
425
 
360
426
  if (!fixed) {
361
427
  console.log(' No fixable issues found.');
@@ -374,31 +440,10 @@ function isUninstalledMode(config) {
374
440
  return config && config !== 'corrupt' && config.mode === 'uninstalled';
375
441
  }
376
442
 
377
- async function doctor(args) {
378
- const fixMode = args.includes('--fix');
379
-
380
- console.log('XP-Gate Doctor');
381
- console.log('==============');
382
-
383
- const config = getConfig();
384
-
385
- // §4.8: mode === "uninstalled" → print "xp-gate is not installed"
386
- if (isUninstalledMode(config)) {
387
- console.log('xp-gate is not installed.');
388
- console.log('Run xp-gate init to install.');
389
- return 0;
390
- }
391
-
392
- // §4.13: --fix only when mode === "active"
393
- if (fixMode && isActiveMode(config)) {
394
- fixIssues(null, config);
395
- }
396
-
397
- const { checks, issues } = diagnose();
398
-
399
- printReport(checks);
400
-
401
- // --- Check 7: Version upgrade check ---
443
+ /**
444
+ * Check 7: Version upgrade check (non-blocking).
445
+ */
446
+ async function diagnoseUpgrade() {
402
447
  try {
403
448
  const upgradeResult = await checkUpgrade();
404
449
  const msg = formatUpgradeMsg(upgradeResult, 'doctor');
@@ -406,8 +451,13 @@ async function doctor(args) {
406
451
  console.log(`\n ℹ ${msg}`);
407
452
  }
408
453
  } catch { /* non-blocking — don't fail doctor on network issue */ }
454
+ }
409
455
 
410
- // --- Check 8: OpenCode plugin version check ---
456
+ /**
457
+ * Check 8: OpenCode plugin version check.
458
+ * @returns {number} issue count
459
+ */
460
+ function diagnoseOpenCodePlugin(checks) {
411
461
  const pluginPath = path.join(HOME_DIR, '.config', 'opencode', 'node_modules', '@boyingliu01', 'opencode-plugin', 'package.json');
412
462
  if (fs.existsSync(pluginPath)) {
413
463
  try {
@@ -423,13 +473,43 @@ async function doctor(args) {
423
473
  status: 'WARN',
424
474
  detail: `plugin: ${pluginVersion}, xp-gate CLI: ${pkgVersion} — run 'cd ~/.config/opencode && npm update @boyingliu01/opencode-plugin'`
425
475
  });
426
- issues++;
476
+ return 1;
427
477
  }
428
478
  }
429
479
  } catch { /* skip */ }
430
480
  } else {
431
481
  checks.push({ name: 'OpenCode plugin', status: 'SKIP', detail: 'Not installed in OpenCode config' });
432
482
  }
483
+ return 0;
484
+ }
485
+
486
+ async function doctor(args) {
487
+ const fixMode = args.includes('--fix');
488
+
489
+ console.log('XP-Gate Doctor');
490
+ console.log('==============');
491
+
492
+ const config = getConfig();
493
+
494
+ // §4.8: mode === "uninstalled" → print "xp-gate is not installed"
495
+ if (isUninstalledMode(config)) {
496
+ console.log('xp-gate is not installed.');
497
+ console.log('Run xp-gate init to install.');
498
+ return 0;
499
+ }
500
+
501
+ // §4.13: --fix only when mode === "active"
502
+ if (fixMode && isActiveMode(config)) {
503
+ fixIssues(null, config);
504
+ }
505
+
506
+ const { checks, issues: diagnosedIssues } = diagnose();
507
+ let issues = diagnosedIssues;
508
+
509
+ printReport(checks);
510
+
511
+ await diagnoseUpgrade();
512
+ issues += diagnoseOpenCodePlugin(checks);
433
513
 
434
514
  if (issues === 0) {
435
515
  console.log('\n✓ All checks passed');
@@ -448,4 +528,11 @@ async function doctor(args) {
448
528
  return issues > 0 ? 1 : 0;
449
529
  }
450
530
 
451
- module.exports = { doctor, isXpGateFile, SIGNATURES };
531
+ module.exports = {
532
+ doctor, isXpGateFile, SIGNATURES,
533
+ fixVersionMismatch,
534
+ fixTemplateDirMismatch,
535
+ fixMissingHooks,
536
+ fixCoreHooksPath,
537
+ fixMissingAdapters,
538
+ };
@@ -7,6 +7,7 @@ const { checkDeps } = require('./detect-deps.js');
7
7
  const { downloadFromGitHub } = require('./download-skill.js');
8
8
  const { rollback } = require('./rollback.js');
9
9
  const { HOME_DIR, CONFIG_DIR, detectPlatform } = require('./shared-paths.js');
10
+ const { copyDirRecursive } = require('./shared-utils');
10
11
 
11
12
  function getSkillsDir() {
12
13
  const platform = detectPlatform();
@@ -28,7 +29,7 @@ const SKILLS_REGISTRY = {
28
29
 
29
30
  async function installSkill(name, options = {}) {
30
31
  const { offline = false, verbose = false, force = false } = options;
31
-
32
+
32
33
  const platform = detectPlatform();
33
34
  const depCheck = await checkDeps(platform);
34
35
  if (!depCheck.ok) {
@@ -44,84 +45,104 @@ async function installSkill(name, options = {}) {
44
45
  return 1;
45
46
  }
46
47
  }
47
-
48
+
49
+ const skillInfo = validateSkillRegistry(name);
50
+ if (!skillInfo) return 1;
51
+
52
+ const targetDir = path.join(getSkillsDir(), name);
53
+ const dupError = checkDuplicateInstall(targetDir, force);
54
+ if (dupError) {
55
+ console.error(dupError);
56
+ return 1;
57
+ }
58
+
59
+ const installId = `${name}-${Date.now()}`;
60
+ const backupDir = path.join(CONFIG_DIR, 'backup', installId);
61
+ backupExisting(targetDir, installId, backupDir);
62
+
63
+ try {
64
+ const result = await performInstall(skillInfo, name, targetDir, offline, verbose);
65
+ if (result !== 0) return result;
66
+ return 0;
67
+ } catch (err) {
68
+ console.error(`Error: Install failed - ${err.message}`);
69
+ await rollback(installId);
70
+ return 1;
71
+ }
72
+ }
73
+
74
+ function validateSkillRegistry(name) {
48
75
  const skillInfo = SKILLS_REGISTRY[name];
49
76
  if (!skillInfo) {
50
77
  console.error(`Error: Unknown skill: ${name}`);
51
78
  console.error('Available skills: ' + Object.keys(SKILLS_REGISTRY).join(', '));
52
- return 1;
79
+ return null;
53
80
  }
54
-
55
- const targetDir = path.join(getSkillsDir(), name);
81
+ return skillInfo;
82
+ }
83
+
84
+ function checkDuplicateInstall(targetDir, force) {
56
85
  if (fs.existsSync(targetDir) && !force) {
57
- console.error(`Error: ${name} is already installed`);
58
- console.error('Use --force to overwrite');
59
- return 1;
86
+ return `Error: ${path.basename(targetDir)} is already installed\nUse --force to overwrite`;
60
87
  }
61
-
62
- const installId = `${name}-${Date.now()}`;
63
- const backupDir = path.join(CONFIG_DIR, 'backup', installId);
64
-
88
+ return null;
89
+ }
90
+
91
+ function backupExisting(targetDir, installId, backupDir) {
65
92
  if (fs.existsSync(targetDir)) {
66
93
  fs.mkdirSync(backupDir, { recursive: true });
67
94
  copyDirRecursive(targetDir, backupDir);
68
95
  }
69
-
70
- try {
71
- console.log(`Installing ${name}...`);
72
-
73
- const skillUrl = `https://raw.githubusercontent.com/${skillInfo.repo}/main/${skillInfo.path}/SKILL.md`;
74
- const targetFile = path.join(targetDir, 'SKILL.md');
75
-
76
- fs.mkdirSync(path.dirname(targetFile), { recursive: true });
77
-
78
- let downloaded = false;
79
- if (!offline) {
80
- try {
81
- await downloadFile(skillUrl, targetFile, verbose);
82
- downloaded = true;
83
- } catch (err) {
84
- if (verbose) console.warn(`Download failed: ${err.message}`);
85
- }
96
+ }
97
+
98
+ async function performInstall(skillInfo, name, targetDir, offline, verbose) {
99
+ console.log(`Installing ${name}...`);
100
+
101
+ const skillUrl = `https://raw.githubusercontent.com/${skillInfo.repo}/main/${skillInfo.path}/SKILL.md`;
102
+ const targetFile = path.join(targetDir, 'SKILL.md');
103
+ fs.mkdirSync(path.dirname(targetFile), { recursive: true });
104
+
105
+ let downloaded = false;
106
+ if (!offline) {
107
+ try {
108
+ await downloadFile(skillUrl, targetFile, verbose);
109
+ downloaded = true;
110
+ } catch (err) {
111
+ if (verbose) console.warn(`Download failed: ${err.message}`);
86
112
  }
87
-
88
- if (!downloaded) {
89
- if (offline) {
90
- console.error(`Error: --offline specified but ${name} not in cache`);
91
- return 2;
92
- }
93
- console.error(`Error: Failed to download ${name}`);
94
- console.error('Check network connection');
95
- return 1;
113
+ }
114
+
115
+ if (!downloaded) {
116
+ if (offline) {
117
+ console.error(`Error: --offline specified but ${name} not in cache`);
118
+ return 2;
96
119
  }
97
-
98
- ensureConfigDir();
99
- updateConfig({
100
- installedSkills: {
101
- ...(getConfig().installedSkills || {}),
102
- [name]: { version: '1.0.0', installedAt: new Date().toISOString() }
103
- }
104
- });
105
-
106
- if (verbose) console.log(`Installed to ${targetDir}`);
107
- console.log(`✓ ${name} installed`);
108
-
109
- return 0;
110
- } catch (err) {
111
- console.error(`Error: Install failed - ${err.message}`);
112
- await rollback(installId);
120
+ console.error(`Error: Failed to download ${name}`);
121
+ console.error('Check network connection');
113
122
  return 1;
114
123
  }
124
+
125
+ ensureConfigDir();
126
+ updateConfig({
127
+ installedSkills: {
128
+ ...(getConfig().installedSkills || {}),
129
+ [name]: { version: '1.0.0', installedAt: new Date().toISOString() }
130
+ }
131
+ });
132
+
133
+ if (verbose) console.log(`Installed to ${targetDir}`);
134
+ console.log(`✓ ${name} installed`);
135
+ return 0;
115
136
  }
116
137
 
117
138
  async function downloadFile(url, dest, verbose) {
118
139
  return new Promise((resolve, reject) => {
119
140
  const file = fs.createWriteStream(dest);
120
-
141
+
121
142
  const protocol = url.startsWith('https') ? https : http;
122
-
143
+
123
144
  if (verbose) console.log(`Downloading ${url}...`);
124
-
145
+
125
146
  protocol.get(url, { timeout: 30000 }, (response) => {
126
147
  if (response.statusCode === 301 || response.statusCode === 302) {
127
148
  const redirectUrl = response.headers.location;
@@ -130,13 +151,13 @@ async function downloadFile(url, dest, verbose) {
130
151
  downloadFile(redirectUrl, dest, verbose).then(resolve).catch(reject);
131
152
  return;
132
153
  }
133
-
154
+
134
155
  if (response.statusCode !== 200) {
135
156
  file.close();
136
157
  reject(new Error(`HTTP ${response.statusCode}`));
137
158
  return;
138
159
  }
139
-
160
+
140
161
  response.pipe(file);
141
162
  file.on('finish', () => {
142
163
  file.close();
@@ -150,8 +171,6 @@ async function downloadFile(url, dest, verbose) {
150
171
  });
151
172
  }
152
173
 
153
- const { copyDirRecursive } = require('./shared-utils');
154
-
155
174
  function ensureConfigDir() {
156
175
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
157
176
  }
@@ -173,4 +192,4 @@ function updateConfig(updates) {
173
192
  fs.writeFileSync(configFile, JSON.stringify(config, null, 2));
174
193
  }
175
194
 
176
- module.exports = { installSkill };
195
+ module.exports = { installSkill };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Type declarations for shared-phase-constants (CommonJS module).
3
+ * Consumed by both sprint-status.js (CJS) and tui-plugin.ts (ESM via tsx).
4
+ */
5
+
6
+ export const PHASE_NAMES: Record<string, string>;
7
+ export const PHASE_ORDER: string[];
8
+
9
+ /**
10
+ * Find the most recent timestamp across started_at and phase_history.
11
+ */
12
+ export function getLatestTimestamp(state: object): number;
13
+
14
+ /**
15
+ * Check if the sprint state is stale (>1h since last activity).
16
+ */
17
+ export function isStale(state: object): boolean;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Shared Sprint Flow phase constants.
3
+ *
4
+ * Single source of truth for PHASE_NAMES and PHASE_ORDER, consumed by both
5
+ * the CLI (sprint-status.js, CommonJS) and the OpenCode TUI plugin
6
+ * (tui-plugin.ts, ESM via tsx).
7
+ *
8
+ * @module shared-phase-constants
9
+ */
10
+
11
+ const PHASE_NAMES = {
12
+ '-1': 'ISOLATE',
13
+ '-0.5': 'AUTO-ESTIMATE',
14
+ '0': 'THINK',
15
+ '1': 'PLAN',
16
+ '2': 'BUILD',
17
+ '3': 'REVIEW',
18
+ '4': 'USER ACCEPT',
19
+ '5': 'FEEDBACK',
20
+ '6': 'SHIP',
21
+ '7': 'LAND',
22
+ '8': 'CLEANUP',
23
+ };
24
+
25
+ const PHASE_ORDER = ['-1', '-0.5', '0', '1', '2', '3', '4', '5', '6', '7', '8'];
26
+
27
+ /**
28
+ * Find the most recent timestamp across started_at and phase_history.
29
+ * @param {object} state - Sprint state object
30
+ * @returns {number} Latest timestamp in ms, or 0 if none found
31
+ */
32
+ function getLatestTimestamp(state) {
33
+ if (!state || !state.started_at) return 0;
34
+ const started = new Date(state.started_at).getTime();
35
+ if (isNaN(started)) return 0;
36
+ let latest = started;
37
+ if (Array.isArray(state.phase_history)) {
38
+ for (const ph of state.phase_history) {
39
+ if (ph.completed_at) {
40
+ const t = new Date(ph.completed_at).getTime();
41
+ if (!isNaN(t) && t > latest) latest = t;
42
+ }
43
+ if (ph.started_at) {
44
+ const t = new Date(ph.started_at).getTime();
45
+ if (!isNaN(t) && t > latest) latest = t;
46
+ }
47
+ }
48
+ }
49
+ return latest;
50
+ }
51
+
52
+ /**
53
+ * Check if the sprint state is stale (>1h since last activity).
54
+ * @param {object} state - Sprint state object
55
+ * @returns {boolean}
56
+ */
57
+ function isStale(state) {
58
+ if (!state || !state.started_at) return false;
59
+ const latest = getLatestTimestamp(state);
60
+ return latest > 0 && Date.now() - latest > 3600000;
61
+ }
62
+
63
+ module.exports = { PHASE_NAMES, PHASE_ORDER, getLatestTimestamp, isStale };
@@ -3,6 +3,7 @@
3
3
  * No module-level state — safe for tests that mock fs/path/os.
4
4
  */
5
5
  const fs = require('fs');
6
+ const path = require('path');
6
7
 
7
8
  /**
8
9
  * Recursively copy a directory.
@@ -12,8 +13,8 @@ function copyDirRecursive(src, dest) {
12
13
  fs.mkdirSync(dest, { recursive: true });
13
14
  const entries = fs.readdirSync(src, { withFileTypes: true });
14
15
  for (const entry of entries) {
15
- const srcPath = require('path').join(src, entry.name);
16
- const destPath = require('path').join(dest, entry.name);
16
+ const srcPath = path.join(src, entry.name);
17
+ const destPath = path.join(dest, entry.name);
17
18
  if (entry.isDirectory()) {
18
19
  copyDirRecursive(srcPath, destPath);
19
20
  } else {
@@ -22,4 +23,14 @@ function copyDirRecursive(src, dest) {
22
23
  }
23
24
  }
24
25
 
25
- module.exports = { copyDirRecursive };
26
+ function readXpGateConfig() {
27
+ const cfgPath = path.join(require('os').homedir(), '.xp-gate', 'config.json');
28
+ try {
29
+ if (!fs.existsSync(cfgPath)) return null;
30
+ return JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ module.exports = { copyDirRecursive, readXpGateConfig };