refacil-sdd-ai 5.2.2 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/NOTICE.md +46 -0
  2. package/README.md +209 -42
  3. package/agents/auditor.md +46 -0
  4. package/agents/debugger.md +41 -1
  5. package/agents/implementer.md +76 -10
  6. package/agents/investigator.md +36 -0
  7. package/agents/proposer.md +46 -2
  8. package/agents/tester.md +45 -8
  9. package/agents/validator.md +67 -13
  10. package/bin/cli.js +428 -83
  11. package/bin/postinstall.js +20 -0
  12. package/lib/bus/broker.js +121 -3
  13. package/lib/bus/spawn.js +189 -121
  14. package/lib/check-review.js +102 -0
  15. package/lib/codegraph-telemetry.js +135 -0
  16. package/lib/codegraph.js +273 -0
  17. package/lib/commands/autopilot.js +120 -0
  18. package/lib/commands/bus.js +29 -36
  19. package/lib/commands/compact.js +185 -46
  20. package/lib/commands/read-spec.js +352 -0
  21. package/lib/commands/sdd.js +429 -44
  22. package/lib/compact-guidance.js +122 -77
  23. package/lib/config.js +136 -0
  24. package/lib/global-paths.js +56 -20
  25. package/lib/hooks.js +32 -4
  26. package/lib/ide-detection.js +1 -1
  27. package/lib/ignore-files.js +5 -1
  28. package/lib/installer.js +202 -19
  29. package/lib/kapso.js +241 -0
  30. package/lib/methodology-migration-pending.js +13 -0
  31. package/lib/open-browser.js +32 -0
  32. package/lib/opencode-migrate.js +148 -0
  33. package/lib/opencode-plugin/index.js +84 -104
  34. package/lib/opencode-plugin/rules.js +236 -0
  35. package/lib/project-root.js +154 -0
  36. package/lib/repo-ide-sync.js +5 -0
  37. package/lib/spec-reader/lang.js +72 -0
  38. package/lib/spec-reader/md-parser.js +299 -0
  39. package/lib/spec-reader/session.js +139 -0
  40. package/lib/spec-reader/ui/app.js +685 -0
  41. package/lib/spec-reader/ui/index.html +59 -0
  42. package/lib/spec-reader/ui/mixed-lang.js +200 -0
  43. package/lib/spec-reader/ui/model-cache.js +117 -0
  44. package/lib/spec-reader/ui/style.css +294 -0
  45. package/lib/spec-reader/ui/supertonic-helper.js +565 -0
  46. package/lib/spec-sync.js +258 -0
  47. package/lib/test-scope.js +713 -0
  48. package/lib/testing-policy-sync.js +14 -2
  49. package/package.json +6 -3
  50. package/skills/apply/SKILL.md +39 -64
  51. package/skills/archive/SKILL.md +74 -48
  52. package/skills/ask/SKILL.md +43 -8
  53. package/skills/autopilot/SKILL.md +476 -0
  54. package/skills/bug/SKILL.md +52 -53
  55. package/skills/explore/SKILL.md +48 -1
  56. package/skills/guide/SKILL.md +31 -13
  57. package/skills/inbox/SKILL.md +9 -0
  58. package/skills/join/SKILL.md +1 -1
  59. package/skills/prereqs/BUS-CROSS-REPO.md +33 -16
  60. package/skills/prereqs/METHODOLOGY-CONTRACT.md +96 -17
  61. package/skills/prereqs/SKILL.md +1 -1
  62. package/skills/propose/SKILL.md +74 -19
  63. package/skills/read-spec/SKILL.md +76 -0
  64. package/skills/reply/SKILL.md +42 -9
  65. package/skills/review/SKILL.md +63 -25
  66. package/skills/review/checklist.md +2 -2
  67. package/skills/say/SKILL.md +40 -4
  68. package/skills/setup/SKILL.md +59 -5
  69. package/skills/setup/troubleshooting.md +11 -3
  70. package/skills/stats/SKILL.md +157 -0
  71. package/skills/test/SKILL.md +35 -10
  72. package/skills/up-code/SKILL.md +20 -13
  73. package/skills/update/SKILL.md +32 -1
  74. package/skills/verify/SKILL.md +78 -41
  75. package/templates/compact-guidance.md +10 -0
  76. package/templates/methodology-guide.md +5 -0
package/lib/installer.js CHANGED
@@ -3,7 +3,17 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
- const { globalClaudeDir, globalCursorDir, globalOpenCodeDir, globalCodexDir, globalSddVersionPath } = require('./global-paths');
6
+ const {
7
+ globalClaudeDir,
8
+ globalCursorDir,
9
+ globalOpenCodeDir,
10
+ globalCodexDir,
11
+ globalSddVersionPath,
12
+ validateOpenCodeConfigDir,
13
+ } = require('./global-paths');
14
+ const { migrateOpenCodeLegacyArtifacts } = require('./opencode-migrate');
15
+
16
+ const DEFAULT_IDE_DIRS = ['claude', 'cursor', 'opencode', 'codex'];
7
17
  const { convertAgentToToml } = require('./toml-converter');
8
18
 
9
19
  const SKILLS = [
@@ -19,6 +29,8 @@ const SKILLS = [
19
29
  'archive',
20
30
  'bug',
21
31
  'up-code',
32
+ 'autopilot',
33
+ 'read-spec',
22
34
  'join',
23
35
  'say',
24
36
  'ask',
@@ -26,6 +38,7 @@ const SKILLS = [
26
38
  'inbox',
27
39
  'attend',
28
40
  'update',
41
+ 'stats',
29
42
  ];
30
43
 
31
44
  const AGENTS = [
@@ -70,12 +83,17 @@ function copyDir(src, dest) {
70
83
  * Install skills into global IDE directories.
71
84
  * @param {string} packageRoot - path to the refacil-sdd-ai package
72
85
  * @param {string} homeDir - user home directory (injectable for testing; default: os.homedir())
73
- * @param {string[]} [ideDirs] - which IDEs to install for (['claude','cursor','opencode'])
86
+ * @param {string[]} [ideDirs] - which IDEs to install for (default: all four IDEs)
74
87
  * @returns {number} number of skills installed
75
88
  */
76
89
  function installSkills(packageRoot, homeDir, ideDirs) {
77
90
  const resolvedHome = homeDir || os.homedir();
78
- const dirs = ideDirs || ['claude', 'cursor', 'opencode'];
91
+ const dirs = ideDirs || DEFAULT_IDE_DIRS;
92
+ const installOpenCode =
93
+ (dirs.includes('opencode') || dirs.includes('.opencode')) && validateOpenCodeConfigDir();
94
+ if (installOpenCode) {
95
+ migrateOpenCodeLegacyArtifacts(resolvedHome);
96
+ }
79
97
  let installed = 0;
80
98
 
81
99
  for (const skill of SKILLS) {
@@ -88,7 +106,7 @@ function installSkills(packageRoot, homeDir, ideDirs) {
88
106
  if (dirs.includes('cursor') || dirs.includes('.cursor')) {
89
107
  copyDir(srcDir, path.join(globalCursorDir(resolvedHome), 'skills', `refacil-${skill}`));
90
108
  }
91
- if (dirs.includes('opencode') || dirs.includes('.opencode')) {
109
+ if (installOpenCode) {
92
110
  // OpenCode: byte-for-byte copy (same as Claude Code — no transformation needed)
93
111
  copyDir(srcDir, path.join(globalOpenCodeDir(resolvedHome), 'skills', `refacil-${skill}`));
94
112
  }
@@ -129,7 +147,17 @@ function installOpenCodeJson(projectRoot) {
129
147
  merged['$schema'] = sddKeys['$schema'];
130
148
  }
131
149
 
132
- fs.writeFileSync(ocJsonPath, JSON.stringify(merged, null, 2) + '\n');
150
+ // CA-16: idempotency guard skip write if serialized content is identical to what is on disk.
151
+ const proposed = JSON.stringify(merged, null, 2) + '\n';
152
+ if (fs.existsSync(ocJsonPath)) {
153
+ try {
154
+ const onDisk = fs.readFileSync(ocJsonPath, 'utf8');
155
+ if (onDisk === proposed) return;
156
+ } catch (_) {
157
+ // CR-02 pattern: if read fails, proceed to write.
158
+ }
159
+ }
160
+ fs.writeFileSync(ocJsonPath, proposed);
133
161
  }
134
162
 
135
163
  // Claude Code: tools allowlist granular, model: sonnet|opus|haiku
@@ -239,12 +267,17 @@ function transformFrontmatterForOpenCode(content) {
239
267
  * Install agents into global IDE directories.
240
268
  * @param {string} packageRoot - path to the refacil-sdd-ai package
241
269
  * @param {string} homeDir - user home directory (injectable for testing; default: os.homedir())
242
- * @param {string[]} [ideDirs] - which IDEs to install for (['claude','cursor','opencode'])
270
+ * @param {string[]} [ideDirs] - which IDEs to install for (default: all four IDEs)
243
271
  * @returns {number} number of agents installed
244
272
  */
245
273
  function installAgents(packageRoot, homeDir, ideDirs) {
246
274
  const resolvedHome = homeDir || os.homedir();
247
- const dirs = ideDirs || ['claude', 'cursor', 'opencode'];
275
+ const dirs = ideDirs || DEFAULT_IDE_DIRS;
276
+ const installOpenCode =
277
+ (dirs.includes('opencode') || dirs.includes('.opencode')) && validateOpenCodeConfigDir();
278
+ if (installOpenCode) {
279
+ migrateOpenCodeLegacyArtifacts(resolvedHome);
280
+ }
248
281
 
249
282
  const claudeAgentsDir = path.join(globalClaudeDir(resolvedHome), 'agents');
250
283
  const cursorAgentsDir = path.join(globalCursorDir(resolvedHome), 'agents');
@@ -253,7 +286,7 @@ function installAgents(packageRoot, homeDir, ideDirs) {
253
286
 
254
287
  if (dirs.includes('claude') || dirs.includes('.claude')) fs.mkdirSync(claudeAgentsDir, { recursive: true });
255
288
  if (dirs.includes('cursor') || dirs.includes('.cursor')) fs.mkdirSync(cursorAgentsDir, { recursive: true });
256
- if (dirs.includes('opencode') || dirs.includes('.opencode')) fs.mkdirSync(openCodeAgentsDir, { recursive: true });
289
+ if (installOpenCode) fs.mkdirSync(openCodeAgentsDir, { recursive: true });
257
290
  if (dirs.includes('codex') || dirs.includes('.codex')) fs.mkdirSync(codexAgentsDir, { recursive: true });
258
291
 
259
292
  let installed = 0;
@@ -273,7 +306,7 @@ function installAgents(packageRoot, homeDir, ideDirs) {
273
306
  transformFrontmatterForCursor(content),
274
307
  );
275
308
  }
276
- if (dirs.includes('opencode') || dirs.includes('.opencode')) {
309
+ if (installOpenCode) {
277
310
  fs.writeFileSync(
278
311
  path.join(openCodeAgentsDir, `refacil-${agent}.md`),
279
312
  transformFrontmatterForOpenCode(content),
@@ -300,6 +333,13 @@ function installAgents(packageRoot, homeDir, ideDirs) {
300
333
  * @param {string} projectRoot
301
334
  */
302
335
  function removeProjectLevelArtifacts(projectRoot) {
336
+ // Safety guard: never remove artifacts from the home directory itself.
337
+ // If projectRoot resolves to home (e.g. findProjectRoot() fallback when cwd is ~),
338
+ // projectRoot/.claude/ would be ~/ .claude/ — i.e. the global installation dirs.
339
+ const resolvedRoot = path.resolve(projectRoot);
340
+ const resolvedHome = path.resolve(os.homedir());
341
+ if (resolvedRoot === resolvedHome) return 0;
342
+
303
343
  const ideDirs = ['.claude', '.cursor', '.opencode'];
304
344
  const subDirs = ['skills', 'agents'];
305
345
  let removed = 0;
@@ -358,10 +398,12 @@ function removeProjectLevelArtifacts(projectRoot) {
358
398
  } catch (_) {}
359
399
  }
360
400
 
361
- // Remove .opencode/plugins/refacil-hooks.js if present
362
- const ocPlugin = path.join(projectRoot, '.opencode', 'plugins', 'refacil-hooks.js');
363
- if (fs.existsSync(ocPlugin)) {
364
- try { fs.unlinkSync(ocPlugin); removed++; } catch (_) {}
401
+ // Remove .opencode/plugins/refacil-hooks.js and refacil-check-review.js if present
402
+ for (const name of ['refacil-hooks.js', 'refacil-check-review.js']) {
403
+ const ocPlugin = path.join(projectRoot, '.opencode', 'plugins', name);
404
+ if (fs.existsSync(ocPlugin)) {
405
+ try { fs.unlinkSync(ocPlugin); removed++; } catch (_) {}
406
+ }
365
407
  }
366
408
  // Remove .opencode/plugins/ if now empty
367
409
  const ocPluginsDir = path.join(projectRoot, '.opencode', 'plugins');
@@ -405,10 +447,12 @@ function removeOpenCodeArtifacts(projectRoot) {
405
447
  } catch (_) {}
406
448
  }
407
449
 
408
- // Remove .opencode/plugins/refacil-hooks.js
409
- const pluginFile = path.join(projectRoot, '.opencode', 'plugins', 'refacil-hooks.js');
410
- if (fs.existsSync(pluginFile)) {
411
- try { fs.unlinkSync(pluginFile); } catch (_) {}
450
+ // Remove .opencode/plugins/refacil-hooks.js and refacil-check-review.js
451
+ for (const name of ['refacil-hooks.js', 'refacil-check-review.js']) {
452
+ const pluginFile = path.join(projectRoot, '.opencode', 'plugins', name);
453
+ if (fs.existsSync(pluginFile)) {
454
+ try { fs.unlinkSync(pluginFile); } catch (_) {}
455
+ }
412
456
  }
413
457
 
414
458
  // Revert SDD-AI keys from .opencode/opencode.json (currently only $schema key, leave file if other keys remain)
@@ -432,6 +476,23 @@ function writeGuideFile(destPath, header, label) {
432
476
  `# ${header}\n\n` +
433
477
  'Contexto completo del proyecto: ver `AGENTS.md`.\n' +
434
478
  'Si no existe, ejecuta `/refacil:setup`.\n';
479
+
480
+ // Idempotency guard: skip write if normalized content is identical (CA-01, CA-02, CR-05).
481
+ // Normalization is comparison-only — the content written to disk is never altered.
482
+ if (fs.existsSync(destPath)) {
483
+ try {
484
+ const existing = fs.readFileSync(destPath, 'utf8');
485
+ const normalizedExisting = existing.replace(/\r\n/g, '\n');
486
+ const normalizedNew = content.replace(/\r\n/g, '\n');
487
+ if (normalizedExisting === normalizedNew) {
488
+ // Content is identical (or differs only in CRLF vs LF) — skip write.
489
+ return false;
490
+ }
491
+ } catch (_) {
492
+ // CR-02: if read fails, treat as non-identical and proceed to write.
493
+ }
494
+ }
495
+
435
496
  fs.writeFileSync(destPath, content);
436
497
  console.log(` ${label} generado.`);
437
498
  return true;
@@ -566,12 +627,12 @@ function removeSkills(projectRoot) {
566
627
  /**
567
628
  * Remove skills from global IDE directories.
568
629
  * @param {string} homeDir - user home directory (injectable for testing)
569
- * @param {string[]} [ideDirs] - which IDEs to remove from (['claude','cursor','opencode'])
630
+ * @param {string[]} [ideDirs] - which IDEs to remove from (default: all four IDEs)
570
631
  * @returns {number} number of skill directories removed
571
632
  */
572
633
  function removeGlobalSkills(homeDir, ideDirs) {
573
634
  const resolvedHome = homeDir || os.homedir();
574
- const dirs = ideDirs || ['claude', 'cursor', 'opencode'];
635
+ const dirs = ideDirs || DEFAULT_IDE_DIRS;
575
636
  let removed = 0;
576
637
 
577
638
  for (const skill of SKILLS) {
@@ -603,6 +664,42 @@ function removeGlobalSkills(homeDir, ideDirs) {
603
664
  return removed;
604
665
  }
605
666
 
667
+ /**
668
+ * Remove all refacil-* OpenCode artifacts from the global OpenCode config directory.
669
+ * Tolerant: each removal is wrapped in try/catch.
670
+ * @param {string} [homeDir] - user home directory (injectable for testing)
671
+ */
672
+ function removeOpenCodeGlobalArtifacts(homeDir) {
673
+ const resolvedHome = homeDir || os.homedir();
674
+ const ocDir = globalOpenCodeDir(resolvedHome);
675
+
676
+ for (const skill of SKILLS) {
677
+ const skillDir = path.join(ocDir, 'skills', `refacil-${skill}`);
678
+ if (fs.existsSync(skillDir)) {
679
+ try { fs.rmSync(skillDir, { recursive: true }); } catch (_) {}
680
+ }
681
+ }
682
+
683
+ const agentsDir = path.join(ocDir, 'agents');
684
+ if (fs.existsSync(agentsDir)) {
685
+ try {
686
+ const entries = fs.readdirSync(agentsDir, { withFileTypes: true });
687
+ for (const entry of entries) {
688
+ if (entry.isFile() && entry.name.startsWith('refacil-') && entry.name.endsWith('.md')) {
689
+ try { fs.unlinkSync(path.join(agentsDir, entry.name)); } catch (_) {}
690
+ }
691
+ }
692
+ } catch (_) {}
693
+ }
694
+
695
+ for (const name of ['refacil-hooks.js', 'refacil-check-review.js', 'rules.js']) {
696
+ const pluginFile = path.join(ocDir, 'plugins', name);
697
+ if (fs.existsSync(pluginFile)) {
698
+ try { fs.unlinkSync(pluginFile); } catch (_) {}
699
+ }
700
+ }
701
+ }
702
+
606
703
  /**
607
704
  * Remove all refacil-* Codex artifacts from the global ~/.codex directory.
608
705
  * Tolerant: each removal is wrapped in try/catch.
@@ -723,6 +820,89 @@ function checkNodeVersion() {
723
820
  return true;
724
821
  }
725
822
 
823
+ /**
824
+ * Prompt the user for their preferred CodeGraph integration mode.
825
+ * Skippable with --yes / --defaults flags or when stdout is not a TTY.
826
+ * Persists the answer via writeConfigValue from lib/config.js.
827
+ *
828
+ * @param {string} homeDir - user home directory
829
+ * @returns {Promise<void>}
830
+ */
831
+ async function promptCodegraphMode(homeDir) {
832
+ const { writeConfigValue, CODEGRAPH_MODES, DEFAULT_CODEGRAPH_MODE } = require('./config');
833
+ const resolvedHome = homeDir || os.homedir();
834
+
835
+ const skipFlags = ['--yes', '--defaults'];
836
+ if (skipFlags.some((f) => process.argv.includes(f)) || !process.stdout.isTTY) {
837
+ writeConfigValue('codegraphMode', DEFAULT_CODEGRAPH_MODE, resolvedHome);
838
+ if (DEFAULT_CODEGRAPH_MODE !== 'disabled') {
839
+ try {
840
+ const cg = require('./codegraph');
841
+ const { readSelectedIDEs } = require('./global-paths');
842
+ cg.registerMcp(readSelectedIDEs(resolvedHome) || ['.claude', '.cursor', '.opencode', '.codex'], resolvedHome);
843
+ if (DEFAULT_CODEGRAPH_MODE === 'enabled' && cg.isInstalled()) {
844
+ cg.init(require('path').resolve('.'));
845
+ }
846
+ } catch (_) {}
847
+ }
848
+ return;
849
+ }
850
+
851
+ console.log('\n CodeGraph integration (optional — https://github.com/colbymchenry/codegraph)');
852
+ console.log(' When enabled, exploratory sub-agents (investigate, propose, debug) will');
853
+ console.log(' prefer CodeGraph symbol queries over raw file reads, reducing token usage ~71%.');
854
+ console.log(` Mode options: ${CODEGRAPH_MODES.join(' | ')} (default: ${DEFAULT_CODEGRAPH_MODE})\n`);
855
+
856
+ let selectedMode;
857
+
858
+ try {
859
+ const clack = require('@clack/prompts');
860
+ const result = await clack.select({
861
+ message: 'CodeGraph integration mode:',
862
+ options: [
863
+ { value: 'enabled', label: 'enabled — auto-index every repo on setup (recommended)' },
864
+ { value: 'per-repo', label: 'per-repo — ask once per project (set via /refacil:setup)' },
865
+ { value: 'disabled', label: 'disabled — never use CodeGraph' },
866
+ ],
867
+ initialValue: DEFAULT_CODEGRAPH_MODE,
868
+ });
869
+ if (clack.isCancel(result)) {
870
+ console.log(' CodeGraph config prompt cancelled. Using default (enabled).\n');
871
+ selectedMode = DEFAULT_CODEGRAPH_MODE;
872
+ } else {
873
+ selectedMode = result;
874
+ }
875
+ } catch (_) {
876
+ // @clack/prompts not available — use inline readline fallback
877
+ const readline = require('readline');
878
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
879
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
880
+
881
+ const answer = await ask(
882
+ ` CodeGraph mode [${DEFAULT_CODEGRAPH_MODE}] (${CODEGRAPH_MODES.join('/')}): `,
883
+ );
884
+ rl.close();
885
+ const trimmed = answer.trim().toLowerCase();
886
+ selectedMode = CODEGRAPH_MODES.includes(trimmed) ? trimmed : DEFAULT_CODEGRAPH_MODE;
887
+ }
888
+
889
+ writeConfigValue('codegraphMode', selectedMode, resolvedHome);
890
+ if (selectedMode !== 'disabled') {
891
+ try {
892
+ const cg = require('./codegraph');
893
+ const { readSelectedIDEs } = require('./global-paths');
894
+ cg.registerMcp(readSelectedIDEs(resolvedHome) || ['.claude', '.cursor', '.opencode', '.codex'], resolvedHome);
895
+ // Auto-index current repo in background when mode is enabled and CLI is installed
896
+ if (selectedMode === 'enabled' && cg.isInstalled()) {
897
+ const projectRoot = require('path').resolve('.');
898
+ cg.init(projectRoot);
899
+ console.log(' CodeGraph: indexing current repo in background (~30s).');
900
+ }
901
+ } catch (_) {}
902
+ }
903
+ console.log(` CodeGraph mode set to: ${selectedMode}\n`);
904
+ }
905
+
726
906
  module.exports = {
727
907
  SKILLS,
728
908
  AGENTS,
@@ -734,10 +914,12 @@ module.exports = {
734
914
  transformFrontmatterForOpenCode,
735
915
  installAgents,
736
916
  removeOpenCodeArtifacts,
917
+ removeOpenCodeGlobalArtifacts,
737
918
  removeCodexArtifacts,
738
919
  removeProjectLevelArtifacts,
739
920
  createClaudeMd,
740
921
  createCursorRules,
922
+ writeGuideFile,
741
923
  readRepoVersion,
742
924
  writeRepoVersion,
743
925
  readGlobalVersion,
@@ -748,4 +930,5 @@ module.exports = {
748
930
  removeOpenspecLegacyAssets,
749
931
  checkClaudeCodeVersion,
750
932
  checkNodeVersion,
933
+ promptCodegraphMode,
751
934
  };
package/lib/kapso.js ADDED
@@ -0,0 +1,241 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const https = require('https');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const readline = require('readline/promises');
8
+
9
+ const KAPSO_ENV_DIR = path.join(os.homedir(), '.refacil-sdd-ai');
10
+ const KAPSO_ENV_FILE = path.join(KAPSO_ENV_DIR, 'kapso.env');
11
+ const KAPSO_LOG_FILE = path.join(KAPSO_ENV_DIR, 'autopilot.log');
12
+ const KAPSO_API_BASE = 'https://api.kapso.ai/meta/whatsapp/v24.0';
13
+
14
+ const PHONE_REGEX = /^\+\d{7,15}$/;
15
+
16
+ // ─── credentials ─────────────────────────────────────────────────────────────
17
+
18
+ function readCredentials() {
19
+ if (!fs.existsSync(KAPSO_ENV_FILE)) return null;
20
+ const creds = {};
21
+ for (const line of fs.readFileSync(KAPSO_ENV_FILE, 'utf8').split('\n')) {
22
+ const m = line.match(/^([^#][^=]+)=(.+)$/);
23
+ if (m) creds[m[1].trim()] = m[2].trim();
24
+ }
25
+ const { KAPSO_API_KEY, KAPSO_PHONE_NUMBER_ID, NOTIFY_PHONE } = creds;
26
+ if (!KAPSO_API_KEY || !KAPSO_PHONE_NUMBER_ID || !NOTIFY_PHONE) return null;
27
+ return { apiKey: KAPSO_API_KEY, phoneNumberId: KAPSO_PHONE_NUMBER_ID, notifyPhone: NOTIFY_PHONE };
28
+ }
29
+
30
+ // ─── http ─────────────────────────────────────────────────────────────────────
31
+
32
+ function postMessage(creds, bodyText) {
33
+ return new Promise((resolve, reject) => {
34
+ const payload = JSON.stringify({
35
+ messaging_product: 'whatsapp',
36
+ recipient_type: 'individual',
37
+ to: creds.notifyPhone,
38
+ type: 'text',
39
+ text: { body: bodyText },
40
+ });
41
+
42
+ const urlPath = `${KAPSO_API_BASE}/${creds.phoneNumberId}/messages`;
43
+ const parsed = new URL(urlPath);
44
+
45
+ const req = https.request(
46
+ {
47
+ hostname: parsed.hostname,
48
+ path: parsed.pathname,
49
+ method: 'POST',
50
+ headers: {
51
+ 'Content-Type': 'application/json',
52
+ 'X-API-Key': creds.apiKey,
53
+ 'Content-Length': Buffer.byteLength(payload),
54
+ },
55
+ },
56
+ (res) => {
57
+ let data = '';
58
+ res.on('data', (chunk) => (data += chunk));
59
+ res.on('end', () => resolve({ status: res.statusCode, body: data }));
60
+ },
61
+ );
62
+
63
+ req.on('error', reject);
64
+ req.write(payload);
65
+ req.end();
66
+ });
67
+ }
68
+
69
+ // ─── log ─────────────────────────────────────────────────────────────────────
70
+
71
+ function appendLog(msg) {
72
+ try {
73
+ fs.mkdirSync(KAPSO_ENV_DIR, { recursive: true });
74
+ fs.appendFileSync(KAPSO_LOG_FILE, `${new Date().toISOString()} ${msg}\n`, 'utf8');
75
+ } catch (_) {}
76
+ }
77
+
78
+ // ─── public commands ──────────────────────────────────────────────────────────
79
+
80
+ async function setup() {
81
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
82
+
83
+ try {
84
+ console.log('\n Kapso setup — WhatsApp notification credentials\n');
85
+
86
+ let apiKey = '';
87
+ while (!apiKey.trim()) {
88
+ apiKey = await rl.question(' KAPSO_API_KEY: ');
89
+ if (!apiKey.trim()) console.log(' KAPSO_API_KEY cannot be empty. Try again.');
90
+ }
91
+
92
+ let phoneNumberId = '';
93
+ while (!phoneNumberId.trim()) {
94
+ phoneNumberId = await rl.question(' KAPSO_PHONE_NUMBER_ID: ');
95
+ if (!phoneNumberId.trim()) console.log(' KAPSO_PHONE_NUMBER_ID cannot be empty. Try again.');
96
+ }
97
+
98
+ let notifyPhone = '';
99
+ while (true) {
100
+ notifyPhone = await rl.question(
101
+ " NOTIFY_PHONE (E.164 format, e.g. +5731XXXXXXXX, or 'cancelar' to abort): ",
102
+ );
103
+ if (notifyPhone.trim().toLowerCase() === 'cancelar') {
104
+ console.log('\n Setup cancelled.\n');
105
+ rl.close();
106
+ return;
107
+ }
108
+ if (PHONE_REGEX.test(notifyPhone.trim())) break;
109
+ console.log(' Invalid format. Must start with + followed by 7-15 digits (E.164). Try again.');
110
+ }
111
+
112
+ fs.mkdirSync(KAPSO_ENV_DIR, { recursive: true });
113
+
114
+ const content =
115
+ [`KAPSO_API_KEY=${apiKey.trim()}`, `KAPSO_PHONE_NUMBER_ID=${phoneNumberId.trim()}`, `NOTIFY_PHONE=${notifyPhone.trim()}`].join('\n') +
116
+ '\n';
117
+
118
+ fs.writeFileSync(KAPSO_ENV_FILE, content, { encoding: 'utf8' });
119
+
120
+ try {
121
+ fs.chmodSync(KAPSO_ENV_FILE, 0o600);
122
+ } catch (_) {}
123
+
124
+ console.log('\n ✅ Kapso configurado en ~/.refacil-sdd-ai/kapso.env\n');
125
+ } finally {
126
+ rl.close();
127
+ }
128
+ }
129
+
130
+ // kapso preflight
131
+ // Returns credential status and a 24h window notice (sync, always exits 0).
132
+ // sync — no network I/O
133
+ function preflight() {
134
+ const creds = readCredentials();
135
+ if (!creds) {
136
+ return { kapsoEnabled: false };
137
+ }
138
+ const preflightMessage =
139
+ 'WhatsApp solo entrega mensajes de texto libre dentro de las **24 horas** de tu último mensaje **entrante** al número de notificación. ' +
140
+ 'Si han pasado más de 24 horas desde que enviaste un mensaje a ese número, envía cualquier mensaje (p.ej. `ok`) antes de una ejecución larga. ' +
141
+ 'Sin eso, el pipeline igual corre pero la alerta final de WhatsApp puede no llegar.';
142
+ return { kapsoEnabled: true, notifyPhone: creds.notifyPhone, preflightMessage };
143
+ }
144
+
145
+ // kapso notify success --repo <slug> --change <name> --branch <branch>
146
+ // --tasks <done>/<total> --duration <min>
147
+ // [--pr <url>] [--improvements <n>] [--apply <n>] [--test <n>] [--review <n>]
148
+ // [--warnings "<w1>|<w2>"]
149
+ //
150
+ // kapso notify failure --repo <slug> --change <name> --branch <branch>
151
+ // --phase <phase> --last-commit "<commit>" --error "<summary>"
152
+ async function notify(type, opts) {
153
+ const creds = readCredentials();
154
+ if (!creds) {
155
+ appendLog('kapso notify skipped: no credentials');
156
+ return;
157
+ }
158
+
159
+ let message;
160
+
161
+ if (type === 'success') {
162
+ const {
163
+ repo = '',
164
+ change = '',
165
+ branch = '',
166
+ tasks = '0/0',
167
+ duration = '0',
168
+ pr = null,
169
+ apply = 0,
170
+ test = 0,
171
+ review = 0,
172
+ warnings = '',
173
+ } = opts;
174
+
175
+ const totalImprovements = Number(apply) + Number(test) + Number(review);
176
+ const warningList = warnings ? warnings.split('|').map((w) => w.trim()).filter(Boolean) : [];
177
+
178
+ let msg = [
179
+ '✅ SDD Autopilot completado',
180
+ '',
181
+ `Repo: ${repo}`,
182
+ `Change: ${change}`,
183
+ `Branch: ${branch}`,
184
+ `Tareas: ${tasks}`,
185
+ 'Tests: pass',
186
+ 'Review: ✓',
187
+ `Mejoras aplicadas: ${totalImprovements} (apply: ${apply}, test: ${test}, review: ${review})`,
188
+ `Duración: ${duration} min`,
189
+ ].join('\n');
190
+
191
+ if (warningList.length > 0) {
192
+ msg += `\n\n⚠️ Warnings (${warningList.length}):\n` + warningList.slice(0, 3).join('\n');
193
+ }
194
+
195
+ msg += `\n\nPR: ${pr || 'sin PR (solo push)'}`;
196
+ message = msg;
197
+ } else if (type === 'failure') {
198
+ const {
199
+ repo = '',
200
+ change = '',
201
+ branch = '',
202
+ phase = '',
203
+ lastCommit = '',
204
+ error = '',
205
+ } = opts;
206
+
207
+ message = [
208
+ '❌ SDD Autopilot falló',
209
+ '',
210
+ `Repo: ${repo}`,
211
+ `Change: ${change}`,
212
+ `Fase fallida: ${phase}`,
213
+ `Branch: ${branch}`,
214
+ `Último commit: ${lastCommit}`,
215
+ '',
216
+ 'Detalle:',
217
+ error,
218
+ '',
219
+ 'El árbol de trabajo quedó en su estado actual para revisión.',
220
+ ].join('\n');
221
+ } else {
222
+ console.error(` Unknown notify type: "${type}". Use "success" or "failure".`);
223
+ process.exit(1);
224
+ }
225
+
226
+ try {
227
+ const result = await postMessage(creds, message);
228
+ if (result.status >= 200 && result.status < 300) {
229
+ appendLog(`kapso notify ${type} sent OK`);
230
+ console.log(` ✅ Kapso: ${type} notification sent`);
231
+ } else {
232
+ appendLog(`kapso notify ${type} FAILED (HTTP ${result.status}): ${result.body}`);
233
+ console.warn(` ⚠️ Kapso API error (HTTP ${result.status}): ${result.body}`);
234
+ }
235
+ } catch (err) {
236
+ appendLog(`kapso notify ${type} NETWORK ERROR: ${err.message}`);
237
+ console.warn(` ⚠️ Kapso network error: ${err.message}`);
238
+ }
239
+ }
240
+
241
+ module.exports = { setup, preflight, notify, readCredentials, PHONE_REGEX };
@@ -95,6 +95,19 @@ function methodologyMigrationPending(root) {
95
95
  reasons.push(`commands opsx sobrantes: ${extraOpsx.join(', ')}`);
96
96
  }
97
97
 
98
+ // CodeGraph: CLI not installed when user has opted in (enabled or per-repo)
99
+ try {
100
+ const { loadBranchConfigWithSources } = require('./config');
101
+ const { isInstalled: cgIsInstalled } = require('./codegraph');
102
+ const cfgInfo = loadBranchConfigWithSources(root);
103
+ const cgMode = cfgInfo.codegraphMode;
104
+ if ((cgMode === 'enabled' || cgMode === 'per-repo') && !cgIsInstalled()) {
105
+ reasons.push(`CodeGraph CLI no instalado (modo: ${cgMode}) — ejecuta /refacil:update para instalarlo`);
106
+ }
107
+ } catch (_) {
108
+ // Tolerant — CodeGraph check must not break migration detection
109
+ }
110
+
98
111
  return { pending: reasons.length > 0, reasons };
99
112
  }
100
113
 
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('child_process');
4
+
5
+ /**
6
+ * Open a URL in the default system browser (cross-platform, no npm deps).
7
+ * @param {string} url
8
+ * @returns {boolean} true if spawn succeeded
9
+ */
10
+ function openInBrowser(url) {
11
+ const platform = process.platform;
12
+ let cmd;
13
+ let cmdArgs;
14
+ if (platform === 'win32') {
15
+ cmd = 'cmd';
16
+ cmdArgs = ['/c', 'start', '""', url];
17
+ } else if (platform === 'darwin') {
18
+ cmd = 'open';
19
+ cmdArgs = [url];
20
+ } else {
21
+ cmd = 'xdg-open';
22
+ cmdArgs = [url];
23
+ }
24
+ try {
25
+ spawn(cmd, cmdArgs, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
26
+ return true;
27
+ } catch (_) {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ module.exports = { openInBrowser };