@boyingliu01/xp-gate 0.9.3 → 0.9.5

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 (38) 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 +199 -96
  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 +77 -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/delphi-review/SKILL.md +34 -0
  18. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  19. package/plugins/claude-code/skills/sprint-flow/SKILL.md +53 -1
  20. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  21. package/plugins/opencode/__tests__/xp-gate-update.test.ts +123 -4
  22. package/plugins/opencode/index.ts +27 -3
  23. package/plugins/opencode/package.json +1 -1
  24. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  25. package/plugins/opencode/skills/delphi-review/SKILL.md +34 -0
  26. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  27. package/plugins/opencode/skills/sprint-flow/SKILL.md +53 -1
  28. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  29. package/plugins/opencode/tui-plugin.ts +49 -76
  30. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  31. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  32. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  33. package/principles/AGENTS.md +3 -3
  34. package/skills/delphi-review/AGENTS.md +3 -3
  35. package/skills/delphi-review/SKILL.md +34 -0
  36. package/skills/sprint-flow/AGENTS.md +3 -3
  37. package/skills/sprint-flow/SKILL.md +53 -1
  38. 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,70 +297,80 @@ 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;
310
327
  }
328
+ return false;
329
+ }
311
330
 
312
- // Fix missing hooks
313
- if (config.mode === 'local') {
314
- const gitDir = getGitDir();
315
- if (gitDir) {
316
- 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;
319
- }
320
- } 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;
324
- }
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
+ }
325
347
 
326
- // Fix core.hooksPath (global mode only)
327
- if (config.mode === 'global') {
328
- const hooksPath = getCurrentHooksPath();
329
- 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
- }
339
- }
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;
340
363
  }
364
+ }
341
365
 
342
- // Fix missing adapters
343
- const adaptersDir = config.mode === 'local'
344
- ? path.join(path.dirname(getGitDir() || ''), 'githooks', 'adapters')
345
- : GLOBAL_ADAPTERS_DIR;
346
-
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) {
347
374
  if (adaptersDir && (!fs.existsSync(adaptersDir) || fs.readdirSync(adaptersDir).filter(f => f.endsWith('.sh')).length === 0)) {
348
375
  const pkgAdaptersDir = path.join(srcDir, 'adapters');
349
376
  if (fs.existsSync(pkgAdaptersDir)) {
@@ -353,9 +380,64 @@ function fixIssues(checks, config) {
353
380
  fs.copyFileSync(path.join(pkgAdaptersDir, f), path.join(adaptersDir, f));
354
381
  }
355
382
  console.log(` ✓ Restored ${adapterFiles.length} adapter(s)`);
356
- fixed = true;
383
+ return true;
357
384
  }
358
385
  }
386
+ return false;
387
+ }
388
+
389
+ function fixConfigMismatches(config) {
390
+ let fixed = false;
391
+ fixed = fixVersionMismatch(config, getPackageVersion()) || fixed;
392
+ fixed = fixTemplateDirMismatch(config, getTemplateDir()) || fixed;
393
+ return fixed;
394
+ }
395
+
396
+ function fixHooksByMode(config, srcDir) {
397
+ let fixed = false;
398
+ if (config.mode === 'local') {
399
+ const gitDir = getGitDir();
400
+ if (gitDir) {
401
+ const hooksDir = path.join(gitDir, 'hooks');
402
+ fixed = fixMissingHooks('local', srcDir, hooksDir) || fixed;
403
+ }
404
+ } else {
405
+ fixed = fixMissingHooks('global', srcDir, GLOBAL_HOOKS_DIR) || fixed;
406
+ }
407
+ return fixed;
408
+ }
409
+
410
+ function fixGlobalHooksPath(config) {
411
+ if (config.mode !== 'global') return false;
412
+ const hooksPath = getCurrentHooksPath();
413
+ if (hooksPath !== GLOBAL_HOOKS_DIR) {
414
+ return fixCoreHooksPath(GLOBAL_HOOKS_DIR);
415
+ }
416
+ return false;
417
+ }
418
+
419
+ function getAdaptersDirByMode(config) {
420
+ return config.mode === 'local'
421
+ ? path.join(path.dirname(getGitDir() || ''), 'githooks', 'adapters')
422
+ : GLOBAL_ADAPTERS_DIR;
423
+ }
424
+
425
+ /**
426
+ * Attempt to fix known issues.
427
+ * Only operates when mode === 'active' (local or global).
428
+ */
429
+ function fixIssues(checks, config) {
430
+ console.log('');
431
+ console.log('Attempting fixes...');
432
+ console.log('-------------------');
433
+
434
+ const srcDir = PKG_DIR;
435
+ let fixed = false;
436
+
437
+ fixed = fixConfigMismatches(config) || fixed;
438
+ fixed = fixHooksByMode(config, srcDir) || fixed;
439
+ fixed = fixGlobalHooksPath(config) || fixed;
440
+ fixed = fixMissingAdapters(config.mode, srcDir, getAdaptersDirByMode(config)) || fixed;
359
441
 
360
442
  if (!fixed) {
361
443
  console.log(' No fixable issues found.');
@@ -374,31 +456,10 @@ function isUninstalledMode(config) {
374
456
  return config && config !== 'corrupt' && config.mode === 'uninstalled';
375
457
  }
376
458
 
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 ---
459
+ /**
460
+ * Check 7: Version upgrade check (non-blocking).
461
+ */
462
+ async function diagnoseUpgrade() {
402
463
  try {
403
464
  const upgradeResult = await checkUpgrade();
404
465
  const msg = formatUpgradeMsg(upgradeResult, 'doctor');
@@ -406,8 +467,13 @@ async function doctor(args) {
406
467
  console.log(`\n ℹ ${msg}`);
407
468
  }
408
469
  } catch { /* non-blocking — don't fail doctor on network issue */ }
470
+ }
409
471
 
410
- // --- Check 8: OpenCode plugin version check ---
472
+ /**
473
+ * Check 8: OpenCode plugin version check.
474
+ * @returns {number} issue count
475
+ */
476
+ function diagnoseOpenCodePlugin(checks) {
411
477
  const pluginPath = path.join(HOME_DIR, '.config', 'opencode', 'node_modules', '@boyingliu01', 'opencode-plugin', 'package.json');
412
478
  if (fs.existsSync(pluginPath)) {
413
479
  try {
@@ -423,13 +489,43 @@ async function doctor(args) {
423
489
  status: 'WARN',
424
490
  detail: `plugin: ${pluginVersion}, xp-gate CLI: ${pkgVersion} — run 'cd ~/.config/opencode && npm update @boyingliu01/opencode-plugin'`
425
491
  });
426
- issues++;
492
+ return 1;
427
493
  }
428
494
  }
429
495
  } catch { /* skip */ }
430
496
  } else {
431
497
  checks.push({ name: 'OpenCode plugin', status: 'SKIP', detail: 'Not installed in OpenCode config' });
432
498
  }
499
+ return 0;
500
+ }
501
+
502
+ async function doctor(args) {
503
+ const fixMode = args.includes('--fix');
504
+
505
+ console.log('XP-Gate Doctor');
506
+ console.log('==============');
507
+
508
+ const config = getConfig();
509
+
510
+ // §4.8: mode === "uninstalled" → print "xp-gate is not installed"
511
+ if (isUninstalledMode(config)) {
512
+ console.log('xp-gate is not installed.');
513
+ console.log('Run xp-gate init to install.');
514
+ return 0;
515
+ }
516
+
517
+ // §4.13: --fix only when mode === "active"
518
+ if (fixMode && isActiveMode(config)) {
519
+ fixIssues(null, config);
520
+ }
521
+
522
+ const { checks, issues: diagnosedIssues } = diagnose();
523
+ let issues = diagnosedIssues;
524
+
525
+ printReport(checks);
526
+
527
+ await diagnoseUpgrade();
528
+ issues += diagnoseOpenCodePlugin(checks);
433
529
 
434
530
  if (issues === 0) {
435
531
  console.log('\n✓ All checks passed');
@@ -448,4 +544,11 @@ async function doctor(args) {
448
544
  return issues > 0 ? 1 : 0;
449
545
  }
450
546
 
451
- module.exports = { doctor, isXpGateFile, SIGNATURES };
547
+ module.exports = {
548
+ doctor, isXpGateFile, SIGNATURES,
549
+ fixVersionMismatch,
550
+ fixTemplateDirMismatch,
551
+ fixMissingHooks,
552
+ fixCoreHooksPath,
553
+ fixMissingAdapters,
554
+ };
@@ -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,77 @@
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
+ * Safely parse a date-like value into epoch ms.
29
+ * @param {*} value - Timestamp to parse
30
+ * @returns {number} Epoch ms, or NaN if invalid
31
+ */
32
+ function parseTime(value) {
33
+ return new Date(value).getTime();
34
+ }
35
+
36
+ /**
37
+ * Get the max of an existing value and a new timestamp (if valid and larger).
38
+ * @param {number} current - Current best value
39
+ * @param {*} candidate - Candidate timestamp
40
+ * @returns {number} Max value
41
+ */
42
+ function maxValid(current, candidate) {
43
+ if (!candidate) return current;
44
+ const t = parseTime(candidate);
45
+ return !isNaN(t) && t > current ? t : current;
46
+ }
47
+
48
+ /**
49
+ * Find the most recent timestamp across started_at and phase_history.
50
+ * @param {object} state - Sprint state object
51
+ * @returns {number} Latest timestamp in ms, or 0 if none found
52
+ */
53
+ function getLatestTimestamp(state) {
54
+ if (!state || !state.started_at) return 0;
55
+ const started = parseTime(state.started_at);
56
+ if (isNaN(started)) return 0;
57
+ let latest = started;
58
+ if (Array.isArray(state.phase_history)) {
59
+ for (const ph of state.phase_history) {
60
+ latest = maxValid(maxValid(latest, ph.completed_at), ph.started_at);
61
+ }
62
+ }
63
+ return latest;
64
+ }
65
+
66
+ /**
67
+ * Check if the sprint state is stale (>1h since last activity).
68
+ * @param {object} state - Sprint state object
69
+ * @returns {boolean}
70
+ */
71
+ function isStale(state) {
72
+ if (!state || !state.started_at) return false;
73
+ const latest = getLatestTimestamp(state);
74
+ return latest > 0 && Date.now() - latest > 3600000;
75
+ }
76
+
77
+ module.exports = { PHASE_NAMES, PHASE_ORDER, getLatestTimestamp, isStale };