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
@@ -1,77 +1,122 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
-
4
- const MARKER_START = '<!-- refacil-sdd-ai:compact-guidance:start -->';
5
- const MARKER_END = '<!-- refacil-sdd-ai:compact-guidance:end -->';
6
-
7
- function readTemplate(packageRoot) {
8
- const tplPath = path.join(packageRoot, 'templates', 'compact-guidance.md');
9
- return fs.readFileSync(tplPath, 'utf8').trimEnd();
10
- }
11
-
12
- function buildBlock(templateContent) {
13
- return `${MARKER_START}\n${templateContent}\n${MARKER_END}`;
14
- }
15
-
16
- function syncCompactGuidance(projectRoot, packageRoot) {
17
- const agentsPath = path.join(projectRoot, 'AGENTS.md');
18
- if (!fs.existsSync(agentsPath)) {
19
- return { status: 'skipped-no-agents-md' };
20
- }
21
-
22
- const template = readTemplate(packageRoot);
23
- const block = buildBlock(template);
24
- const existing = fs.readFileSync(agentsPath, 'utf8');
25
-
26
- const startIdx = existing.indexOf(MARKER_START);
27
- const endIdx = existing.indexOf(MARKER_END);
28
-
29
- let next;
30
- let action;
31
-
32
- if (startIdx === -1 || endIdx === -1) {
33
- next = existing.trimEnd() + '\n\n' + block + '\n';
34
- action = 'appended';
35
- } else {
36
- const before = existing.substring(0, startIdx);
37
- const after = existing.substring(endIdx + MARKER_END.length);
38
- next = before + block + after;
39
- action = 'replaced';
40
- }
41
-
42
- if (next === existing) {
43
- return { status: 'unchanged' };
44
- }
45
-
46
- fs.writeFileSync(agentsPath, next);
47
- return { status: action };
48
- }
49
-
50
- function removeCompactGuidance(projectRoot) {
51
- const agentsPath = path.join(projectRoot, 'AGENTS.md');
52
- if (!fs.existsSync(agentsPath)) {
53
- return { status: 'skipped-no-agents-md' };
54
- }
55
-
56
- const existing = fs.readFileSync(agentsPath, 'utf8');
57
- const startIdx = existing.indexOf(MARKER_START);
58
- const endIdx = existing.indexOf(MARKER_END);
59
-
60
- if (startIdx === -1 || endIdx === -1) {
61
- return { status: 'not-present' };
62
- }
63
-
64
- const before = existing.substring(0, startIdx).trimEnd();
65
- const after = existing.substring(endIdx + MARKER_END.length);
66
- const next = (before + '\n' + after.replace(/^\s+/, '')).trimEnd() + '\n';
67
-
68
- fs.writeFileSync(agentsPath, next);
69
- return { status: 'removed' };
70
- }
71
-
72
- module.exports = {
73
- syncCompactGuidance,
74
- removeCompactGuidance,
75
- MARKER_START,
76
- MARKER_END,
77
- };
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const MARKER_START = '<!-- refacil-sdd-ai:compact-guidance:start -->';
5
+ const MARKER_END = '<!-- refacil-sdd-ai:compact-guidance:end -->';
6
+ const LEGACY_MARKER_START = '<!-- compact-guidance:start -->';
7
+ const LEGACY_MARKER_END = '<!-- compact-guidance:end -->';
8
+
9
+ /**
10
+ * Removes obsolete empty placeholder pair (pre-refacil-sdd-ai naming).
11
+ * Preserves the block if non-whitespace content exists between legacy markers.
12
+ */
13
+ function stripLegacyCompactGuidanceMarkers(content) {
14
+ const startIdx = content.indexOf(LEGACY_MARKER_START);
15
+ if (startIdx === -1) return content;
16
+
17
+ const endIdx = content.indexOf(LEGACY_MARKER_END, startIdx + LEGACY_MARKER_START.length);
18
+ if (endIdx === -1) return content;
19
+
20
+ const inner = content.substring(startIdx + LEGACY_MARKER_START.length, endIdx);
21
+ if (inner.trim() !== '') return content;
22
+
23
+ const before = content.substring(0, startIdx).trimEnd();
24
+ const after = content.substring(endIdx + LEGACY_MARKER_END.length).replace(/^\s+/, '');
25
+ if (!before) return after.trimStart();
26
+ if (!after) return `${before}\n`;
27
+ return `${before}\n\n${after}`;
28
+ }
29
+
30
+ function readTemplate(packageRoot) {
31
+ const tplPath = path.join(packageRoot, 'templates', 'compact-guidance.md');
32
+ return fs.readFileSync(tplPath, 'utf8').trimEnd();
33
+ }
34
+
35
+ function buildBlock(templateContent) {
36
+ return `${MARKER_START}\n${templateContent}\n${MARKER_END}`;
37
+ }
38
+
39
+ /** Normalize line endings for idempotent compare (CA-02 / Windows CRLF). */
40
+ function normalizeEol(text) {
41
+ return text.replace(/\r\n/g, '\n');
42
+ }
43
+
44
+ function contentUnchanged(next, raw) {
45
+ const a = normalizeEol(next).trimEnd() + '\n';
46
+ const b = normalizeEol(raw).trimEnd() + '\n';
47
+ return a === b;
48
+ }
49
+
50
+ function syncCompactGuidance(projectRoot, packageRoot) {
51
+ const agentsPath = path.join(projectRoot, 'AGENTS.md');
52
+ if (!fs.existsSync(agentsPath)) {
53
+ return { status: 'skipped-no-agents-md' };
54
+ }
55
+
56
+ const template = readTemplate(packageRoot);
57
+ const block = buildBlock(template);
58
+ const raw = fs.readFileSync(agentsPath, 'utf8');
59
+ const existing = stripLegacyCompactGuidanceMarkers(raw);
60
+
61
+ const startIdx = existing.indexOf(MARKER_START);
62
+ const endIdx = existing.indexOf(MARKER_END);
63
+
64
+ let next;
65
+ let action;
66
+
67
+ if (startIdx === -1 || endIdx === -1) {
68
+ next = existing.trimEnd() + '\n\n' + block + '\n';
69
+ action = 'appended';
70
+ } else {
71
+ const before = existing.substring(0, startIdx);
72
+ const after = existing.substring(endIdx + MARKER_END.length);
73
+ next = before + block + after;
74
+ action = 'replaced';
75
+ }
76
+
77
+ const normalized = next.trimEnd() + '\n';
78
+ if (contentUnchanged(normalized, raw)) {
79
+ return { status: 'unchanged' };
80
+ }
81
+
82
+ fs.writeFileSync(agentsPath, normalized);
83
+ return { status: action };
84
+ }
85
+
86
+ function removeCompactGuidance(projectRoot) {
87
+ const agentsPath = path.join(projectRoot, 'AGENTS.md');
88
+ if (!fs.existsSync(agentsPath)) {
89
+ return { status: 'skipped-no-agents-md' };
90
+ }
91
+
92
+ const raw = fs.readFileSync(agentsPath, 'utf8');
93
+ const existing = stripLegacyCompactGuidanceMarkers(raw);
94
+ const startIdx = existing.indexOf(MARKER_START);
95
+ const endIdx = existing.indexOf(MARKER_END);
96
+
97
+ if (startIdx === -1 || endIdx === -1) {
98
+ const legacyOnly = stripLegacyCompactGuidanceMarkers(raw);
99
+ if (legacyOnly !== raw) {
100
+ fs.writeFileSync(agentsPath, legacyOnly.trimEnd() + '\n');
101
+ return { status: 'legacy-removed' };
102
+ }
103
+ return { status: 'not-present' };
104
+ }
105
+
106
+ const before = existing.substring(0, startIdx).trimEnd();
107
+ const after = existing.substring(endIdx + MARKER_END.length);
108
+ const next = (before + '\n' + after.replace(/^\s+/, '')).trimEnd() + '\n';
109
+
110
+ fs.writeFileSync(agentsPath, next);
111
+ return { status: 'removed' };
112
+ }
113
+
114
+ module.exports = {
115
+ syncCompactGuidance,
116
+ removeCompactGuidance,
117
+ stripLegacyCompactGuidanceMarkers,
118
+ MARKER_START,
119
+ MARKER_END,
120
+ LEGACY_MARKER_START,
121
+ LEGACY_MARKER_END,
122
+ };
package/lib/config.js CHANGED
@@ -4,6 +4,9 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
6
 
7
+ const CODEGRAPH_MODES = ['enabled', 'per-repo', 'disabled'];
8
+ const DEFAULT_CODEGRAPH_MODE = 'enabled';
9
+
7
10
  const DEFAULT_PROTECTED_BRANCHES = ['master', 'main', 'develop', 'dev', 'testing', 'qa'];
8
11
  const DEFAULT_BASE_BRANCH = 'develop';
9
12
  const SUPPORTED_LANGUAGES = ['english', 'spanish'];
@@ -236,14 +239,38 @@ function loadBranchConfigWithSources(projectRoot) {
236
239
  artifactLanguageSource = 'default';
237
240
  }
238
241
 
242
+ // --- codegraphMode ---
243
+ let codegraphMode = null;
244
+ let codegraphModeSource = 'default';
245
+
246
+ if (projectCfg !== null) {
247
+ const val = extractCodegraphMode(projectCfg, 'project');
248
+ if (val !== null) {
249
+ codegraphMode = val;
250
+ codegraphModeSource = 'project';
251
+ }
252
+ }
253
+
254
+ if (codegraphMode === null && globalCfg !== null) {
255
+ const val = extractCodegraphMode(globalCfg, 'global');
256
+ if (val !== null) {
257
+ codegraphMode = val;
258
+ codegraphModeSource = 'global';
259
+ }
260
+ }
261
+
262
+ // codegraphMode has no default — null means "no preference yet" (suggestion not yet answered)
263
+
239
264
  return {
240
265
  protectedBranches,
241
266
  baseBranch,
242
267
  artifactLanguage,
268
+ codegraphMode,
243
269
  sources: {
244
270
  protectedBranches: protectedBranchesSource,
245
271
  baseBranch: baseBranchSource,
246
272
  artifactLanguage: artifactLanguageSource,
273
+ codegraphMode: codegraphModeSource,
247
274
  },
248
275
  };
249
276
  }
@@ -260,14 +287,123 @@ function loadBranchConfig(projectRoot) {
260
287
  return { protectedBranches, baseBranch, artifactLanguage };
261
288
  }
262
289
 
290
+ /**
291
+ * Validate `codegraphMode` from a parsed config object.
292
+ * Valid values: 'enabled' | 'per-repo' | 'disabled'. Default: 'enabled'.
293
+ * Returns the value if valid, or null + emits a warning if invalid.
294
+ * @param {object} cfg — parsed YAML object
295
+ * @param {string} src — source label for the warning ('project' | 'global')
296
+ * @returns {string|null}
297
+ */
298
+ function extractCodegraphMode(cfg, src) {
299
+ if (!('codegraphMode' in cfg)) return null;
300
+ const val = cfg.codegraphMode;
301
+ if (typeof val !== 'string' || val.trim() === '') {
302
+ process.stderr.write(
303
+ `[refacil-sdd-ai] warning: codegraphMode in ${src} config must be a non-empty string — ignoring.\n`,
304
+ );
305
+ return null;
306
+ }
307
+ const trimmed = val.trim();
308
+ if (!CODEGRAPH_MODES.includes(trimmed)) {
309
+ process.stderr.write(
310
+ `[refacil-sdd-ai] warning: codegraphMode "${trimmed}" in ${src} config is not a valid value (${CODEGRAPH_MODES.join(', ')}) — ignoring.\n`,
311
+ );
312
+ return null;
313
+ }
314
+ return trimmed;
315
+ }
316
+
317
+ /**
318
+ * Read CodeGraph suggestion UI state from a parsed config object.
319
+ * Keys read: codegraph-suggest-shown (boolean), codegraph-suggest-snooze (ISO date string).
320
+ * Returns { shown: bool|null, snoozeUntil: string|null }.
321
+ * Never warns — these are internal state keys that may simply be absent.
322
+ * @param {object} cfg — parsed YAML object
323
+ * @param {string} _src — source label (unused, kept for API consistency)
324
+ * @returns {{ shown: boolean|null, snoozeUntil: string|null }}
325
+ */
326
+ function extractCodegraphSuggestState(cfg, _src) {
327
+ let shown = null;
328
+ let snoozeUntil = null;
329
+
330
+ if ('codegraph-suggest-shown' in cfg) {
331
+ const raw = cfg['codegraph-suggest-shown'];
332
+ if (raw === 'true' || raw === true) shown = true;
333
+ else if (raw === 'false' || raw === false) shown = false;
334
+ }
335
+
336
+ if ('codegraph-suggest-snooze' in cfg) {
337
+ const raw = cfg['codegraph-suggest-snooze'];
338
+ if (typeof raw === 'string' && raw.trim() !== '') {
339
+ snoozeUntil = raw.trim();
340
+ }
341
+ }
342
+
343
+ return { shown, snoozeUntil };
344
+ }
345
+
346
+ /**
347
+ * Write (set or update) a single flat key:value pair in a YAML config file.
348
+ * Uses the same minimal YAML format as the existing parser — only flat key:value scalars.
349
+ * If the key already exists, its line is replaced in-place (preserving other lines).
350
+ * If the key does not exist, it is appended.
351
+ * Creates the file and parent directories if they do not exist.
352
+ * Never throws — all errors are swallowed silently.
353
+ *
354
+ * @param {string} key — config key (e.g. 'codegraphMode')
355
+ * @param {string} value — scalar string value to set
356
+ * @param {string} homeDir — path to the user home directory (e.g. os.homedir())
357
+ */
358
+ function writeConfigValue(key, value, homeDir) {
359
+ try {
360
+ const globalConfigPath = path.join(homeDir || os.homedir(), '.refacil-sdd-ai', 'config.yaml');
361
+ fs.mkdirSync(path.dirname(globalConfigPath), { recursive: true });
362
+
363
+ let lines = [];
364
+ if (fs.existsSync(globalConfigPath)) {
365
+ lines = fs.readFileSync(globalConfigPath, 'utf8').split('\n');
366
+ }
367
+
368
+ // Determine if key already exists (match "key:" or "key: value" at line start)
369
+ const keyPattern = new RegExp(`^${key.replace(/[-]/g, '\\$&')}:`);
370
+ let found = false;
371
+ const updated = lines.map((line) => {
372
+ if (keyPattern.test(line)) {
373
+ found = true;
374
+ return `${key}: ${value}`;
375
+ }
376
+ return line;
377
+ });
378
+
379
+ if (!found) {
380
+ // Remove trailing empty line before appending to avoid double blank lines
381
+ while (updated.length > 0 && updated[updated.length - 1].trim() === '') {
382
+ updated.pop();
383
+ }
384
+ updated.push(`${key}: ${value}`);
385
+ updated.push('');
386
+ }
387
+
388
+ fs.writeFileSync(globalConfigPath, updated.join('\n'));
389
+ } catch (_) {
390
+ // Silently swallow — config writes must never break caller flow
391
+ }
392
+ }
393
+
263
394
  module.exports = {
264
395
  parseYaml,
265
396
  readConfigFile,
266
397
  loadBranchConfig,
267
398
  loadBranchConfigWithSources,
268
399
  extractArtifactLanguage,
400
+ extractCodegraphMode,
401
+ extractCodegraphSuggestState,
402
+ writeConfigValue,
269
403
  DEFAULT_PROTECTED_BRANCHES,
270
404
  DEFAULT_BASE_BRANCH,
271
405
  SUPPORTED_LANGUAGES,
272
406
  DEFAULT_ARTIFACT_LANGUAGE,
407
+ CODEGRAPH_MODES,
408
+ DEFAULT_CODEGRAPH_MODE,
273
409
  };
@@ -25,38 +25,72 @@ function globalCursorDir(homeDir) {
25
25
  }
26
26
 
27
27
  /**
28
- * Returns the global OpenCode user directory.
29
- * Production paths:
30
- * Windows: %APPDATA%\opencode (falls back to ~/AppData/Roaming/opencode)
31
- * macOS/Linux: ~/.config/opencode
28
+ * Returns legacy OpenCode config directories that may contain pre-migration artifacts.
29
+ * @param {string} [homeDir] - injectable for testing (default: os.homedir())
30
+ * @returns {string[]}
31
+ */
32
+ function legacyOpenCodeDirs(homeDir) {
33
+ const resolvedHome = homeDir || os.homedir();
34
+ const dirs = [path.join(resolvedHome, '.opencode')];
35
+ if (process.platform === 'win32') {
36
+ const appData = process.env.APPDATA || path.join(resolvedHome, 'AppData', 'Roaming');
37
+ dirs.push(path.join(appData, 'opencode'));
38
+ }
39
+ return dirs;
40
+ }
41
+
42
+ /**
43
+ * Returns the global OpenCode user directory (official upstream path).
44
+ * Production:
45
+ * - OPENCODE_CONFIG_DIR when set (absolute)
46
+ * - Otherwise ~/.config/opencode on all platforms (Windows: %USERPROFILE%\.config\opencode)
32
47
  * Test injection:
33
- * When appDataDir is explicitly provided, uses appDataDir\opencode (Windows-style).
34
- * When only homeDir is explicitly provided (no appDataDir), uses homeDir/.opencode
35
- * for cross-platform test portability.
48
+ * When homeDir is explicitly provided, returns homeDir/.config/opencode.
36
49
  * @param {string} [homeDir] - injectable for testing (default: os.homedir())
37
- * @param {string} [appDataDir] - injectable Windows APPDATA dir for testing
38
50
  * @returns {string}
39
51
  */
40
- function globalOpenCodeDir(homeDir, appDataDir) {
41
- // When appDataDir is explicitly provided, always use appDataDir\opencode
42
- if (appDataDir) {
43
- return path.join(appDataDir, 'opencode');
52
+ function globalOpenCodeDir(homeDir) {
53
+ const envDir = process.env.OPENCODE_CONFIG_DIR;
54
+ if (envDir && String(envDir).trim()) {
55
+ return path.resolve(envDir.trim());
44
56
  }
45
57
 
46
- // When homeDir is explicitly provided (test injection without appDataDir),
47
- // always use homeDir/.opencode for cross-platform test portability
48
58
  if (homeDir) {
49
- return path.join(homeDir, '.opencode');
59
+ return path.join(homeDir, '.config', 'opencode');
50
60
  }
51
61
 
52
- // Production default (no injection)
53
- if (process.platform === 'win32') {
54
- const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
55
- return path.join(appData, 'opencode');
56
- }
57
62
  return path.join(os.homedir(), '.config', 'opencode');
58
63
  }
59
64
 
65
+ /**
66
+ * When OPENCODE_CONFIG_DIR is set, verify the directory exists and is writable.
67
+ * Emits a clear stderr message on failure. No-op when the env var is unset.
68
+ * @returns {boolean} true when OpenCode global installs may proceed
69
+ */
70
+ function validateOpenCodeConfigDir() {
71
+ const envDir = process.env.OPENCODE_CONFIG_DIR;
72
+ if (!envDir || !String(envDir).trim()) return true;
73
+
74
+ const dir = path.resolve(envDir.trim());
75
+ if (!fs.existsSync(dir)) {
76
+ process.stderr.write(
77
+ `[refacil-sdd-ai] OPENCODE_CONFIG_DIR is not accessible: directory does not exist: ${dir}\n`,
78
+ );
79
+ return false;
80
+ }
81
+
82
+ try {
83
+ fs.accessSync(dir, fs.constants.W_OK);
84
+ } catch (err) {
85
+ process.stderr.write(
86
+ `[refacil-sdd-ai] OPENCODE_CONFIG_DIR is not writable: ${dir} (${err.message})\n`,
87
+ );
88
+ return false;
89
+ }
90
+
91
+ return true;
92
+ }
93
+
60
94
  /**
61
95
  * Returns the global Codex CLI user directory.
62
96
  * Always ~/.codex regardless of OS.
@@ -118,6 +152,8 @@ module.exports = {
118
152
  globalClaudeDir,
119
153
  globalCursorDir,
120
154
  globalOpenCodeDir,
155
+ validateOpenCodeConfigDir,
156
+ legacyOpenCodeDirs,
121
157
  globalCodexDir,
122
158
  globalSddVersionPath,
123
159
  globalSelectedIDEsPath,
package/lib/hooks.js CHANGED
@@ -54,6 +54,14 @@ function installCursorHooks(homeDir, projectRoot) {
54
54
  command: 'refacil-sdd-ai check-update',
55
55
  });
56
56
 
57
+ // Remove legacy workspaceOpen check-update (duplicated sessionStart → double CodeGraph index).
58
+ if (config.hooks.workspaceOpen) {
59
+ const before = config.hooks.workspaceOpen.length;
60
+ config.hooks.workspaceOpen = config.hooks.workspaceOpen.filter((h) => h._sdd_workspace !== true);
61
+ if (config.hooks.workspaceOpen.length !== before) changed = true;
62
+ if (config.hooks.workspaceOpen.length === 0) delete config.hooks.workspaceOpen;
63
+ }
64
+
57
65
  // compact-bash must be BEFORE check-review
58
66
  if (!config.hooks.preToolUse) config.hooks.preToolUse = [];
59
67
  if (!config.hooks.preToolUse.some((h) => h._sdd_compact === true)) {
@@ -96,7 +104,7 @@ function uninstallCursorHooks(homeDir, projectRoot) {
96
104
  try { config = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); } catch (_) { config = null; }
97
105
 
98
106
  if (config && config.hooks) {
99
- const sddMarkers = ['_sdd', '_sdd_compact', '_sdd_review', '_sdd_notify'];
107
+ const sddMarkers = ['_sdd', '_sdd_workspace', '_sdd_compact', '_sdd_review', '_sdd_notify'];
100
108
  for (const event of Object.keys(config.hooks)) {
101
109
  if (!Array.isArray(config.hooks[event])) continue;
102
110
  const before = config.hooks[event].length;
@@ -249,7 +257,7 @@ function uninstallClaudeHooks(homeDir) {
249
257
  // ── Limpieza de hooks SDD en settings.json (migracion legacy) ───────────────
250
258
 
251
259
  function cleanLegacySettingsHooks(projectRoot) {
252
- const sddMarkers = ['_sdd', '_sdd_compact', '_sdd_review', '_sdd_notify'];
260
+ const sddMarkers = ['_sdd', '_sdd_workspace', '_sdd_compact', '_sdd_review', '_sdd_notify'];
253
261
  const evts = ['SessionStart', 'PreToolUse', 'UserPromptSubmit', 'beforeSubmitPrompt', 'Stop', 'afterAgentResponse'];
254
262
 
255
263
  for (const ideDir of ['.cursor']) {
@@ -289,9 +297,17 @@ function installOpenCodePlugin(homeDir) {
289
297
 
290
298
  const srcPlugin = path.join(__dirname, 'opencode-plugin', 'index.js');
291
299
  const destPlugin = path.join(pluginsDir, 'refacil-hooks.js');
300
+ const srcCheckReview = path.join(__dirname, 'check-review.js');
301
+ const destCheckReview = path.join(pluginsDir, 'refacil-check-review.js');
302
+ const srcRules = path.join(__dirname, 'opencode-plugin', 'rules.js');
303
+ const destRules = path.join(pluginsDir, 'rules.js');
292
304
 
293
305
  try {
294
306
  fs.copyFileSync(srcPlugin, destPlugin);
307
+ fs.copyFileSync(srcCheckReview, destCheckReview);
308
+ if (fs.existsSync(srcRules)) {
309
+ fs.copyFileSync(srcRules, destRules);
310
+ }
295
311
  return true;
296
312
  } catch (err) {
297
313
  process.stderr.write(`[refacil-sdd-ai] Could not install OpenCode plugin: ${err.message}\n`);
@@ -307,9 +323,15 @@ function uninstallOpenCodePlugin(homeDir) {
307
323
  const resolvedHome = homeDir || os.homedir();
308
324
  const ocDir = globalOpenCodeDir(resolvedHome);
309
325
  const pluginPath = path.join(ocDir, 'plugins', 'refacil-hooks.js');
310
- if (!fs.existsSync(pluginPath)) return false;
326
+ const checkReviewPath = path.join(ocDir, 'plugins', 'refacil-check-review.js');
327
+ const rulesPath = path.join(ocDir, 'plugins', 'rules.js');
328
+ if (!fs.existsSync(pluginPath) && !fs.existsSync(checkReviewPath) && !fs.existsSync(rulesPath)) {
329
+ return false;
330
+ }
311
331
  try {
312
- fs.unlinkSync(pluginPath);
332
+ if (fs.existsSync(pluginPath)) fs.unlinkSync(pluginPath);
333
+ if (fs.existsSync(checkReviewPath)) fs.unlinkSync(checkReviewPath);
334
+ if (fs.existsSync(rulesPath)) fs.unlinkSync(rulesPath);
313
335
  return true;
314
336
  } catch (err) {
315
337
  process.stderr.write(`[refacil-sdd-ai] Could not remove OpenCode plugin: ${err.message}\n`);
@@ -326,6 +348,12 @@ function uninstallOpenCodePlugin(homeDir) {
326
348
  * @param {string} projectRoot
327
349
  */
328
350
  function removeProjectLevelHooks(projectRoot) {
351
+ // Safety guard: never strip hooks from the global ~/.claude/settings.json.
352
+ // If projectRoot is the home directory (findProjectRoot() fallback), skip entirely.
353
+ const resolvedRoot = require('path').resolve(projectRoot);
354
+ const resolvedHome = require('path').resolve(require('os').homedir());
355
+ if (resolvedRoot === resolvedHome) return;
356
+
329
357
  const sddMarkers = ['_sdd', '_sdd_compact', '_sdd_review', '_sdd_notify'];
330
358
 
331
359
  // .claude/settings.json
@@ -6,7 +6,7 @@ const { spawnSync } = require('child_process');
6
6
  * Detects which IDEs are installed on the current system.
7
7
  * Uses `where` on Windows and `which` on macOS/Linux.
8
8
  * Error-tolerant: catches per-IDE errors silently.
9
- * @returns {string[]} subset of ['claude', 'cursor', 'opencode']
9
+ * @returns {string[]} subset of ['claude', 'cursor', 'opencode', 'codex']
10
10
  */
11
11
  function detectInstalledIDEs() {
12
12
  const candidates = [
@@ -64,12 +64,16 @@ function syncIgnoreFile(filePath) {
64
64
  }
65
65
 
66
66
  const existing = fs.readFileSync(filePath, 'utf8');
67
+ // Trim each line before comparison — handles CRLF (Windows) vs LF differences
68
+ // so a file that differs only in line endings is treated as up-to-date (CA-05).
67
69
  const existingLines = existing.split('\n').map((l) => l.trim());
68
70
 
69
71
  const missing = BASE_ENTRIES.filter(
70
72
  (entry) => isSignificant(entry) && !existingLines.includes(entry.trim()),
71
73
  );
72
74
 
75
+ // No new entries needed — noop path. No additional idempotency guard is required
76
+ // because the initial creation path (file absent) is always correct when missing.
73
77
  if (missing.length === 0) {
74
78
  return { status: 'noop', added: 0 };
75
79
  }
@@ -94,4 +98,4 @@ function syncIgnoreFiles(projectRoot, ideDirs) {
94
98
  return result;
95
99
  }
96
100
 
97
- module.exports = { syncIgnoreFiles, BASE_ENTRIES };
101
+ module.exports = { syncIgnoreFiles, syncIgnoreFile, BASE_ENTRIES };