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
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+
8
+ // Only auto-reinstall if a previous `init` was done — indicated by selected-ides.json
9
+ const selPath = path.join(os.homedir(), '.refacil-sdd-ai', 'selected-ides.json');
10
+ if (!fs.existsSync(selPath)) process.exit(0);
11
+
12
+ const { execFileSync } = require('child_process');
13
+ try {
14
+ execFileSync(process.execPath, [path.join(__dirname, 'cli.js'), 'update'], {
15
+ stdio: 'inherit',
16
+ timeout: 120000,
17
+ });
18
+ } catch (_) {
19
+ // Non-fatal: postinstall must never break npm install
20
+ }
package/lib/bus/broker.js CHANGED
@@ -6,13 +6,17 @@ const crypto = require('crypto');
6
6
 
7
7
  const HOME_DIR = path.join(os.homedir(), '.refacil-sdd-ai');
8
8
  const BUS_INFO_PATH = path.join(HOME_DIR, 'bus.json');
9
+ const BUS_STARTUP_ERROR_PATH = path.join(HOME_DIR, 'bus-startup-error.json');
9
10
  const SESSIONS_PATH = path.join(HOME_DIR, 'sessions.json');
11
+ const BUS_INFO_OWNER = 'refacil-sdd-ai';
12
+ const BUS_INFO_SERVICE = 'refacil-bus';
10
13
 
11
14
  const PORT_CANDIDATES = [7821, 7822, 7823];
12
15
  const HOST = '127.0.0.1';
13
16
 
14
17
  const storage = require('./storage');
15
18
  const { askHasMatchingReply } = require('./askFulfillment');
19
+ // session.js is lazy-required inside serveReadSpecApi to avoid crashing the CLI on every invocation
16
20
 
17
21
  let WebSocketServer;
18
22
  try {
@@ -27,7 +31,10 @@ function ensureHomeDir() {
27
31
 
28
32
  function writeBusInfo(port) {
29
33
  ensureHomeDir();
34
+ removeBusStartupError();
30
35
  const info = {
36
+ owner: BUS_INFO_OWNER,
37
+ service: BUS_INFO_SERVICE,
31
38
  port,
32
39
  pid: process.pid,
33
40
  startedAt: new Date().toISOString(),
@@ -36,6 +43,26 @@ function writeBusInfo(port) {
36
43
  return info;
37
44
  }
38
45
 
46
+ function writeBusStartupError(err) {
47
+ ensureHomeDir();
48
+ const payload = {
49
+ pid: process.pid,
50
+ code: err && err.code ? err.code : null,
51
+ message: err && err.message ? err.message : String(err),
52
+ ts: new Date().toISOString(),
53
+ };
54
+ fs.writeFileSync(BUS_STARTUP_ERROR_PATH, JSON.stringify(payload, null, 2) + '\n');
55
+ return payload;
56
+ }
57
+
58
+ function removeBusStartupError() {
59
+ try {
60
+ fs.unlinkSync(BUS_STARTUP_ERROR_PATH);
61
+ } catch (_) {
62
+ // ignore
63
+ }
64
+ }
65
+
39
66
  function removeBusInfo() {
40
67
  try {
41
68
  fs.unlinkSync(BUS_INFO_PATH);
@@ -74,6 +101,23 @@ function tryListen(server, port) {
74
101
  }
75
102
 
76
103
  async function pickPort(server) {
104
+ // If REFACIL_BUS_PORT is set, use it exclusively:
105
+ // - "0" → bind an OS-assigned ephemeral port (port 0)
106
+ // - any other numeric string → use that fixed port
107
+ const envPort = process.env.REFACIL_BUS_PORT;
108
+ if (envPort !== undefined) {
109
+ const portNum = parseInt(envPort, 10);
110
+ if (!Number.isNaN(portNum) && portNum >= 0) {
111
+ await tryListen(server, portNum);
112
+ // When port 0 is requested the OS assigns the actual port; read it back.
113
+ if (portNum === 0) {
114
+ return server.address().port;
115
+ }
116
+ return portNum;
117
+ }
118
+ }
119
+
120
+ // Default: try each candidate in order.
77
121
  for (const port of PORT_CANDIDATES) {
78
122
  try {
79
123
  await tryListen(server, port);
@@ -83,9 +127,16 @@ async function pickPort(server) {
83
127
  throw err;
84
128
  }
85
129
  }
86
- throw new Error(
87
- `No hay puertos disponibles (intentados: ${PORT_CANDIDATES.join(', ')})`,
130
+
131
+ // If all fixed candidates are occupied by external processes, bind an
132
+ // OS-assigned port instead of killing or blaming those processes.
133
+ await tryListen(server, 0);
134
+ const ephemeralPort = server.address().port;
135
+ process.stderr.write(
136
+ `[refacil-bus] All fixed port candidates (${PORT_CANDIDATES.join(', ')}) are occupied by external processes. ` +
137
+ `Broker started on ephemeral port ${ephemeralPort}.\n`,
88
138
  );
139
+ return ephemeralPort;
89
140
  }
90
141
 
91
142
  function newId() {
@@ -481,15 +532,60 @@ function onClose(state, ws) {
481
532
  }
482
533
 
483
534
  const UI_DIR = path.join(__dirname, 'ui');
535
+ const READ_SPEC_UI_DIR = path.join(__dirname, '..', 'spec-reader', 'ui');
484
536
  const UI_MIME = {
485
537
  '.html': 'text/html; charset=utf-8',
486
538
  '.css': 'text/css; charset=utf-8',
487
539
  '.js': 'application/javascript; charset=utf-8',
540
+ '.mjs': 'application/javascript; charset=utf-8',
488
541
  '.svg': 'image/svg+xml',
489
542
  '.png': 'image/png',
490
543
  '.ico': 'image/x-icon',
491
544
  };
492
545
 
546
+ function serveReadSpecApi(sessionId, res) {
547
+ const { readSession, isValidSessionId } = require('../spec-reader/session');
548
+ if (!isValidSessionId(sessionId)) {
549
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' });
550
+ res.end(JSON.stringify({ error: 'invalid session id' }));
551
+ return;
552
+ }
553
+ const payload = readSession(sessionId);
554
+ if (!payload) {
555
+ res.writeHead(404, { 'content-type': 'application/json; charset=utf-8' });
556
+ res.end(JSON.stringify({ error: 'session not found' }));
557
+ return;
558
+ }
559
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
560
+ res.end(JSON.stringify(payload));
561
+ }
562
+
563
+ function serveReadSpecUi(req, res) {
564
+ let urlPath = req.url.split('?')[0];
565
+ if (urlPath === '/read-spec' || urlPath === '/read-spec/') {
566
+ urlPath = '/read-spec/index.html';
567
+ }
568
+ const rel = urlPath.replace(/^\/read-spec\/?/, '');
569
+ const safePath = rel.replace(/\.\./g, '');
570
+ const filePath = path.join(READ_SPEC_UI_DIR, safePath || 'index.html');
571
+ if (!filePath.startsWith(READ_SPEC_UI_DIR)) {
572
+ res.writeHead(403);
573
+ res.end('forbidden');
574
+ return;
575
+ }
576
+ fs.readFile(filePath, (err, data) => {
577
+ if (err) {
578
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
579
+ res.end('not found');
580
+ return;
581
+ }
582
+ const ext = path.extname(filePath).toLowerCase();
583
+ const mime = UI_MIME[ext] || 'application/octet-stream';
584
+ res.writeHead(200, { 'content-type': mime });
585
+ res.end(data);
586
+ });
587
+ }
588
+
493
589
  function serveUi(req, res) {
494
590
  let urlPath = req.url.split('?')[0];
495
591
  if (urlPath === '/' || urlPath === '') urlPath = '/index.html';
@@ -515,9 +611,11 @@ function serveUi(req, res) {
515
611
 
516
612
  async function start() {
517
613
  if (!WebSocketServer) {
518
- throw new Error(
614
+ const err = new Error(
519
615
  "Dependencia 'ws' no encontrada. Instala con: npm install -g ws (o npm install ws en el paquete)",
520
616
  );
617
+ err.code = 'WS_MISSING';
618
+ throw err;
521
619
  }
522
620
 
523
621
  const state = createState();
@@ -542,6 +640,16 @@ async function start() {
542
640
 
543
641
  const server = http.createServer((req, res) => {
544
642
  if (req.method === 'GET') {
643
+ const urlPath = req.url.split('?')[0];
644
+ const apiMatch = urlPath.match(/^\/api\/read-spec\/([^/]+)$/);
645
+ if (apiMatch) {
646
+ serveReadSpecApi(apiMatch[1], res);
647
+ return;
648
+ }
649
+ if (urlPath === '/read-spec' || urlPath.startsWith('/read-spec/')) {
650
+ serveReadSpecUi(req, res);
651
+ return;
652
+ }
545
653
  serveUi(req, res);
546
654
  return;
547
655
  }
@@ -578,11 +686,18 @@ async function start() {
578
686
 
579
687
  module.exports = {
580
688
  start,
689
+ serveReadSpecApi,
690
+ serveReadSpecUi,
581
691
  BUS_INFO_PATH,
692
+ BUS_STARTUP_ERROR_PATH,
693
+ BUS_INFO_OWNER,
694
+ BUS_INFO_SERVICE,
582
695
  SESSIONS_PATH,
583
696
  HOME_DIR,
584
697
  PORT_CANDIDATES,
585
698
  HOST,
699
+ writeBusStartupError,
700
+ removeBusStartupError,
586
701
  };
587
702
 
588
703
  if (require.main === module) {
@@ -593,6 +708,9 @@ if (require.main === module) {
593
708
  );
594
709
  })
595
710
  .catch((err) => {
711
+ try {
712
+ writeBusStartupError(err);
713
+ } catch (_) {}
596
714
  process.stderr.write(`Error arrancando broker: ${err.message}\n`);
597
715
  process.exit(1);
598
716
  });
package/lib/bus/spawn.js CHANGED
@@ -1,121 +1,189 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const net = require('net');
4
- const { spawn } = require('child_process');
5
- const { BUS_INFO_PATH, HOST } = require('./broker');
6
-
7
- function readBusInfo() {
8
- try {
9
- const raw = fs.readFileSync(BUS_INFO_PATH, 'utf8');
10
- return JSON.parse(raw);
11
- } catch (_) {
12
- return null;
13
- }
14
- }
15
-
16
- function isProcessAlive(pid) {
17
- if (!pid) return false;
18
- try {
19
- process.kill(pid, 0);
20
- return true;
21
- } catch (err) {
22
- return err.code === 'EPERM';
23
- }
24
- }
25
-
26
- function checkPort(port, timeoutMs = 500) {
27
- return new Promise((resolve) => {
28
- const socket = new net.Socket();
29
- let done = false;
30
- const finish = (ok) => {
31
- if (done) return;
32
- done = true;
33
- try { socket.destroy(); } catch (_) {}
34
- resolve(ok);
35
- };
36
- socket.setTimeout(timeoutMs);
37
- socket.once('connect', () => finish(true));
38
- socket.once('timeout', () => finish(false));
39
- socket.once('error', () => finish(false));
40
- socket.connect(port, HOST);
41
- });
42
- }
43
-
44
- async function isBrokerAlive() {
45
- const info = readBusInfo();
46
- if (!info) return { alive: false };
47
- if (!isProcessAlive(info.pid)) return { alive: false, staleInfo: info };
48
- const portOk = await checkPort(info.port);
49
- if (!portOk) return { alive: false, staleInfo: info };
50
- return { alive: true, info };
51
- }
52
-
53
- function sleep(ms) {
54
- return new Promise((r) => setTimeout(r, ms));
55
- }
56
-
57
- async function waitForBroker(maxMs = 3000) {
58
- const start = Date.now();
59
- while (Date.now() - start < maxMs) {
60
- const status = await isBrokerAlive();
61
- if (status.alive) return status.info;
62
- await sleep(100);
63
- }
64
- return null;
65
- }
66
-
67
- function cleanStaleInfo() {
68
- try {
69
- fs.unlinkSync(BUS_INFO_PATH);
70
- } catch (_) {}
71
- }
72
-
73
- async function ensureBroker(packageRoot) {
74
- const status = await isBrokerAlive();
75
- if (status.alive) return { info: status.info, started: false };
76
-
77
- if (status.staleInfo) cleanStaleInfo();
78
-
79
- const cliPath = path.join(packageRoot, 'bin', 'cli.js');
80
- const child = spawn(process.execPath, [cliPath, 'bus', 'serve'], {
81
- detached: true,
82
- stdio: 'ignore',
83
- windowsHide: true,
84
- });
85
- child.unref();
86
-
87
- const info = await waitForBroker(5000);
88
- if (!info) {
89
- throw new Error(
90
- 'El broker no respondió en 5s. Verifica que la dependencia "ws" esté instalada: npm install -g ws',
91
- );
92
- }
93
- return { info, started: true };
94
- }
95
-
96
- function stopBroker() {
97
- const info = readBusInfo();
98
- if (!info) return { stopped: false, reason: 'no-info' };
99
- if (!isProcessAlive(info.pid)) {
100
- cleanStaleInfo();
101
- return { stopped: false, reason: 'not-alive', info };
102
- }
103
- try {
104
- process.kill(info.pid, 'SIGTERM');
105
- } catch (err) {
106
- return { stopped: false, reason: err.message, info };
107
- }
108
- // En Windows, SIGTERM termina sin ejecutar handlers del broker, así que
109
- // limpiamos el bus.json desde aquí para dejar el estado consistente.
110
- cleanStaleInfo();
111
- return { stopped: true, info };
112
- }
113
-
114
- module.exports = {
115
- readBusInfo,
116
- isBrokerAlive,
117
- ensureBroker,
118
- stopBroker,
119
- waitForBroker,
120
- cleanStaleInfo,
121
- };
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const net = require('net');
4
+ const { spawn } = require('child_process');
5
+ const {
6
+ BUS_INFO_PATH,
7
+ BUS_STARTUP_ERROR_PATH,
8
+ BUS_INFO_OWNER,
9
+ BUS_INFO_SERVICE,
10
+ HOST,
11
+ } = require('./broker');
12
+
13
+ function readBusInfo() {
14
+ try {
15
+ const raw = fs.readFileSync(BUS_INFO_PATH, 'utf8');
16
+ return JSON.parse(raw);
17
+ } catch (_) {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ function readStartupError() {
23
+ try {
24
+ const raw = fs.readFileSync(BUS_STARTUP_ERROR_PATH, 'utf8');
25
+ return JSON.parse(raw);
26
+ } catch (_) {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ function isRefacilBrokerInfo(info) {
32
+ return !!(
33
+ info &&
34
+ info.owner === BUS_INFO_OWNER &&
35
+ info.service === BUS_INFO_SERVICE
36
+ );
37
+ }
38
+
39
+ function cleanStartupError() {
40
+ try {
41
+ fs.unlinkSync(BUS_STARTUP_ERROR_PATH);
42
+ } catch (_) {}
43
+ }
44
+
45
+ function isProcessAlive(pid) {
46
+ if (!pid) return false;
47
+ try {
48
+ process.kill(pid, 0);
49
+ return true;
50
+ } catch (err) {
51
+ return err.code === 'EPERM';
52
+ }
53
+ }
54
+
55
+ function checkPort(port, timeoutMs = 500) {
56
+ return new Promise((resolve) => {
57
+ const socket = new net.Socket();
58
+ let done = false;
59
+ const finish = (ok) => {
60
+ if (done) return;
61
+ done = true;
62
+ try { socket.destroy(); } catch (_) {}
63
+ resolve(ok);
64
+ };
65
+ socket.setTimeout(timeoutMs);
66
+ socket.once('connect', () => finish(true));
67
+ socket.once('timeout', () => finish(false));
68
+ socket.once('error', () => finish(false));
69
+ socket.connect(port, HOST);
70
+ });
71
+ }
72
+
73
+ async function isBrokerAlive() {
74
+ const info = readBusInfo();
75
+ if (!info) return { alive: false };
76
+ if (!isRefacilBrokerInfo(info)) return { alive: false, staleInfo: info };
77
+ if (!isProcessAlive(info.pid)) return { alive: false, staleInfo: info };
78
+ const portOk = await checkPort(info.port);
79
+ if (!portOk) return { alive: false, staleInfo: info };
80
+ return { alive: true, info };
81
+ }
82
+
83
+ function sleep(ms) {
84
+ return new Promise((r) => setTimeout(r, ms));
85
+ }
86
+
87
+ async function waitForBroker(maxMs = 3000) {
88
+ const start = Date.now();
89
+ while (Date.now() - start < maxMs) {
90
+ const status = await isBrokerAlive();
91
+ if (status.alive) return status.info;
92
+ await sleep(100);
93
+ }
94
+ return null;
95
+ }
96
+
97
+ function cleanStaleInfo() {
98
+ try {
99
+ fs.unlinkSync(BUS_INFO_PATH);
100
+ } catch (_) {}
101
+ }
102
+
103
+ function hasWsDependency(packageRoot) {
104
+ try {
105
+ require.resolve('ws', { paths: [packageRoot] });
106
+ return true;
107
+ } catch (_) {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ function buildBrokerStartError(packageRoot, startupError) {
113
+ if (!hasWsDependency(packageRoot) || (startupError && startupError.code === 'WS_MISSING')) {
114
+ return new Error(
115
+ 'No se pudo iniciar el broker porque falta la dependencia "ws". Instala las dependencias del paquete refacil-sdd-ai.',
116
+ );
117
+ }
118
+
119
+ if (startupError && startupError.code === 'EADDRINUSE') {
120
+ return new Error(
121
+ `No se pudo iniciar el broker porque el puerto solicitado está ocupado: ${startupError.message}`,
122
+ );
123
+ }
124
+
125
+ if (startupError && startupError.message) {
126
+ return new Error(`No se pudo iniciar el broker: ${startupError.message}`);
127
+ }
128
+
129
+ return new Error(
130
+ 'No se pudo iniciar el broker: el proceso no publicó bus.json ni reportó una causa de arranque.',
131
+ );
132
+ }
133
+
134
+ async function ensureBroker(packageRoot) {
135
+ const status = await isBrokerAlive();
136
+ if (status.alive) return { info: status.info, started: false };
137
+
138
+ if (status.staleInfo) cleanStaleInfo();
139
+ cleanStartupError();
140
+
141
+ const cliPath = path.join(packageRoot, 'bin', 'cli.js');
142
+ const child = spawn(process.execPath, [cliPath, 'bus', 'serve'], {
143
+ detached: true,
144
+ stdio: 'ignore',
145
+ windowsHide: true,
146
+ });
147
+ child.unref();
148
+
149
+ const info = await waitForBroker(5000);
150
+ if (!info) {
151
+ throw buildBrokerStartError(packageRoot, readStartupError());
152
+ }
153
+ return { info, started: true };
154
+ }
155
+
156
+ function stopBroker() {
157
+ const info = readBusInfo();
158
+ if (!info) return { stopped: false, reason: 'no-info' };
159
+ if (!isRefacilBrokerInfo(info)) {
160
+ cleanStaleInfo();
161
+ return { stopped: false, reason: 'foreign-info', info };
162
+ }
163
+ if (!isProcessAlive(info.pid)) {
164
+ cleanStaleInfo();
165
+ return { stopped: false, reason: 'not-alive', info };
166
+ }
167
+ try {
168
+ process.kill(info.pid, 'SIGTERM');
169
+ } catch (err) {
170
+ return { stopped: false, reason: err.message, info };
171
+ }
172
+ // En Windows, SIGTERM termina sin ejecutar handlers del broker, así que
173
+ // limpiamos el bus.json desde aquí para dejar el estado consistente.
174
+ cleanStaleInfo();
175
+ return { stopped: true, info };
176
+ }
177
+
178
+ module.exports = {
179
+ readBusInfo,
180
+ isBrokerAlive,
181
+ ensureBroker,
182
+ stopBroker,
183
+ waitForBroker,
184
+ cleanStaleInfo,
185
+ readStartupError,
186
+ cleanStartupError,
187
+ isRefacilBrokerInfo,
188
+ buildBrokerStartError,
189
+ };
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * Active changes directory: refacil-sdd/changes, or openspec/changes as legacy fallback.
8
+ * @param {string} projectRoot
9
+ * @returns {string|null}
10
+ */
11
+ function resolveChangesDir(projectRoot) {
12
+ const sddChangesDir = path.join(projectRoot, 'refacil-sdd', 'changes');
13
+ const legacyChangesDir = path.join(projectRoot, 'openspec', 'changes');
14
+ if (fs.existsSync(sddChangesDir)) return sddChangesDir;
15
+ if (fs.existsSync(legacyChangesDir)) return legacyChangesDir;
16
+ return null;
17
+ }
18
+
19
+ /**
20
+ * @param {string} changesDir
21
+ * @param {string} changeName
22
+ * @returns {boolean}
23
+ */
24
+ function changeNeedsReview(changesDir, changeName) {
25
+ const changePath = path.join(changesDir, changeName);
26
+ if (fs.existsSync(path.join(changePath, '.review-passed'))) return false;
27
+
28
+ // Only block if implementation has started (at least one task done).
29
+ // Pure proposals (all tasks pending) and bug fixes without tasks.md do not block push.
30
+ const tasksPath = path.join(changePath, 'tasks.md');
31
+ if (!fs.existsSync(tasksPath)) return false;
32
+
33
+ const tasksContent = fs.readFileSync(tasksPath, 'utf8');
34
+ const done = (tasksContent.match(/^- \[x\]/gm) || []).length;
35
+ return done > 0;
36
+ }
37
+
38
+ /**
39
+ * @param {string} projectRoot
40
+ * @returns {string[]}
41
+ */
42
+ function listActiveChangesMissingReview(projectRoot) {
43
+ const changesDir = resolveChangesDir(projectRoot);
44
+ if (!changesDir || !fs.existsSync(changesDir)) return [];
45
+
46
+ let entries;
47
+ try {
48
+ entries = fs.readdirSync(changesDir, { withFileTypes: true });
49
+ } catch (_) {
50
+ return [];
51
+ }
52
+
53
+ const active = entries.filter((e) => e.isDirectory() && e.name !== 'archive');
54
+ return active.filter((e) => changeNeedsReview(changesDir, e.name)).map((e) => e.name);
55
+ }
56
+
57
+ /**
58
+ * @param {string[]} names
59
+ * @returns {string}
60
+ */
61
+ function buildReviewBlockReason(names) {
62
+ if (names.length === 0) return '';
63
+
64
+ const joined = names.join(', ');
65
+ if (names.length === 1) {
66
+ return (
67
+ `[refacil-sdd-ai] Review pending for: ${joined}. ` +
68
+ 'Stop the push and run /refacil:review on that change before pushing code. ' +
69
+ 'If the review passes, retry the git push. ' +
70
+ 'If the review requires corrections, report the findings to the user and DO NOT retry the push.'
71
+ );
72
+ }
73
+
74
+ return (
75
+ `[refacil-sdd-ai] Multiple changes without approved review: ${joined}. ` +
76
+ 'Stop the push and ask the user to explicitly select which change they want to push. ' +
77
+ 'Then run /refacil:review <change-name> for that specific change and retry the push. ' +
78
+ 'Do not run automatic review without explicit selection when there is more than one pending change.'
79
+ );
80
+ }
81
+
82
+ /**
83
+ * @param {string} command
84
+ * @param {string} projectRoot
85
+ * @returns {{ decision: 'block', reason: string }|null}
86
+ */
87
+ function evaluateGitPushReview(command, projectRoot) {
88
+ if (!command || !/git\s+push/.test(command)) return null;
89
+
90
+ const missing = listActiveChangesMissingReview(projectRoot);
91
+ if (missing.length === 0) return null;
92
+
93
+ return { decision: 'block', reason: buildReviewBlockReason(missing) };
94
+ }
95
+
96
+ module.exports = {
97
+ resolveChangesDir,
98
+ changeNeedsReview,
99
+ listActiveChangesMissingReview,
100
+ buildReviewBlockReason,
101
+ evaluateGitPushReview,
102
+ };