refacil-sdd-ai 5.2.3 → 5.3.1

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 +210 -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 +56 -2
  8. package/agents/tester.md +45 -8
  9. package/agents/validator.md +67 -13
  10. package/bin/cli.js +396 -84
  11. package/lib/bus/broker.js +121 -3
  12. package/lib/bus/spawn.js +189 -121
  13. package/lib/check-review.js +102 -0
  14. package/lib/codegraph-telemetry.js +135 -0
  15. package/lib/codegraph.js +273 -0
  16. package/lib/commands/autopilot.js +120 -0
  17. package/lib/commands/bus.js +29 -36
  18. package/lib/commands/compact.js +185 -46
  19. package/lib/commands/read-spec.js +352 -0
  20. package/lib/commands/sdd.js +600 -47
  21. package/lib/compact-guidance.js +122 -77
  22. package/lib/config.js +136 -0
  23. package/lib/global-paths.js +56 -20
  24. package/lib/hooks.js +26 -4
  25. package/lib/ide-detection.js +1 -1
  26. package/lib/ignore-files.js +5 -1
  27. package/lib/installer.js +196 -19
  28. package/lib/kapso.js +308 -0
  29. package/lib/methodology-migration-pending.js +13 -0
  30. package/lib/open-browser.js +32 -0
  31. package/lib/opencode-migrate.js +148 -0
  32. package/lib/opencode-plugin/index.js +84 -104
  33. package/lib/opencode-plugin/rules.js +236 -0
  34. package/lib/project-root.js +154 -0
  35. package/lib/repo-ide-sync.js +5 -0
  36. package/lib/spec-reader/lang.js +72 -0
  37. package/lib/spec-reader/md-parser.js +299 -0
  38. package/lib/spec-reader/session.js +139 -0
  39. package/lib/spec-reader/ui/app.js +685 -0
  40. package/lib/spec-reader/ui/index.html +59 -0
  41. package/lib/spec-reader/ui/mixed-lang.js +200 -0
  42. package/lib/spec-reader/ui/model-cache.js +117 -0
  43. package/lib/spec-reader/ui/style.css +294 -0
  44. package/lib/spec-reader/ui/supertonic-helper.js +565 -0
  45. package/lib/spec-sync.js +258 -0
  46. package/lib/test-scope.js +713 -0
  47. package/lib/testing-policy-sync.js +14 -2
  48. package/package.json +5 -3
  49. package/skills/apply/SKILL.md +50 -65
  50. package/skills/archive/SKILL.md +84 -50
  51. package/skills/ask/SKILL.md +43 -8
  52. package/skills/autopilot/SKILL.md +505 -0
  53. package/skills/bug/SKILL.md +52 -53
  54. package/skills/explore/SKILL.md +48 -1
  55. package/skills/guide/SKILL.md +35 -13
  56. package/skills/inbox/SKILL.md +9 -0
  57. package/skills/join/SKILL.md +1 -1
  58. package/skills/prereqs/BUS-CROSS-REPO.md +33 -16
  59. package/skills/prereqs/METHODOLOGY-CONTRACT.md +96 -17
  60. package/skills/prereqs/SKILL.md +1 -1
  61. package/skills/propose/SKILL.md +82 -19
  62. package/skills/read-spec/SKILL.md +76 -0
  63. package/skills/reply/SKILL.md +42 -9
  64. package/skills/review/SKILL.md +71 -25
  65. package/skills/review/checklist.md +2 -2
  66. package/skills/say/SKILL.md +40 -4
  67. package/skills/setup/SKILL.md +59 -5
  68. package/skills/setup/troubleshooting.md +11 -3
  69. package/skills/stats/SKILL.md +160 -0
  70. package/skills/status/SKILL.md +116 -0
  71. package/skills/test/SKILL.md +38 -11
  72. package/skills/up-code/SKILL.md +20 -13
  73. package/skills/update/SKILL.md +32 -1
  74. package/skills/verify/SKILL.md +85 -40
  75. package/templates/compact-guidance.md +10 -0
  76. package/templates/methodology-guide.md +5 -0
@@ -3,21 +3,9 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
- const { loadBranchConfigWithSources, parseYaml, readConfigFile, SUPPORTED_LANGUAGES } = require('../config');
7
-
8
- function findProjectRoot() {
9
- let dir = process.cwd();
10
- const { root } = path.parse(dir);
11
- while (dir !== root) {
12
- if (fs.existsSync(path.join(dir, 'refacil-sdd')) || fs.existsSync(path.join(dir, '.git'))) {
13
- return dir;
14
- }
15
- const parent = path.dirname(dir);
16
- if (parent === dir) break;
17
- dir = parent;
18
- }
19
- return process.cwd();
20
- }
6
+ const { loadBranchConfigWithSources, parseYaml, readConfigFile, SUPPORTED_LANGUAGES, CODEGRAPH_MODES } = require('../config');
7
+ const { findProjectRoot } = require('../project-root');
8
+ const { collectSpecSourceFiles } = require('../spec-sync');
21
9
 
22
10
  // --- Helpers ---
23
11
 
@@ -42,6 +30,11 @@ function parseArgs(argv) {
42
30
  return args;
43
31
  }
44
32
 
33
+ /** Bug-fix changes from refacil:bug use fix-* and only carry summary.md (no specs/). */
34
+ function isBugFixChangeName(name) {
35
+ return typeof name === 'string' && name.startsWith('fix-');
36
+ }
37
+
45
38
  function validateChangeName(name) {
46
39
  if (!name || name.trim() === '') {
47
40
  return { valid: false, reason: 'El nombre del cambio no puede estar vacío.' };
@@ -64,6 +57,59 @@ function validateChangeName(name) {
64
57
  return { valid: true };
65
58
  }
66
59
 
60
+ /**
61
+ * Looks up a change name inside the archive directory.
62
+ * Archive entry format: refacil-sdd/changes/archive/YYYY-MM-DD-<changeName>/
63
+ *
64
+ * Rules:
65
+ * - Matches by exact suffix `-<name>` (not substring).
66
+ * - Validates prefix matches /^\d{4}-\d{2}-\d{2}-/.
67
+ * - Returns most recent directory path (sorted descending by date prefix).
68
+ * - If two entries share the same suffix (same name, different dates) → returns most recent.
69
+ * - If the name is ambiguous (two entries with truly different names both match) → returns null and emits error.
70
+ * - Returns null if no match or archive dir doesn't exist.
71
+ */
72
+ function resolveArchivedChangeName(projectRoot, name) {
73
+ if (!name || typeof name !== 'string') return null;
74
+
75
+ const archiveDir = path.join(projectRoot, 'refacil-sdd', 'changes', 'archive');
76
+ if (!fs.existsSync(archiveDir)) return null;
77
+
78
+ let entries;
79
+ try {
80
+ entries = fs.readdirSync(archiveDir, { withFileTypes: true })
81
+ .filter((e) => e.isDirectory())
82
+ .map((e) => e.name);
83
+ } catch (_) {
84
+ return null;
85
+ }
86
+
87
+ // Date prefix pattern
88
+ const datePrefix = /^\d{4}-\d{2}-\d{2}-/;
89
+
90
+ // Filter by exact suffix match AND valid date prefix
91
+ const suffix = `-${name}`;
92
+ const matches = entries.filter((entry) => {
93
+ if (!datePrefix.test(entry)) return false;
94
+ return entry.endsWith(suffix);
95
+ });
96
+
97
+ if (matches.length === 0) return null;
98
+
99
+ // Check for ambiguity: extract the clean name (strip date prefix) and verify all match the same name
100
+ const cleanNames = new Set(matches.map((entry) => entry.replace(datePrefix, '')));
101
+ if (cleanNames.size > 1) {
102
+ // Multiple distinct clean names matched — truly ambiguous
103
+ console.error(`Nombre de cambio archivado ambiguo: '${name}'. Coincidencias: ${matches.join(', ')}`);
104
+ return null;
105
+ }
106
+
107
+ // Sort descending by date prefix so most recent is first
108
+ matches.sort((a, b) => b.localeCompare(a));
109
+
110
+ return path.join(archiveDir, matches[0]);
111
+ }
112
+
67
113
  function resolveExistingChangeName(projectRoot, inputName) {
68
114
  if (!inputName || typeof inputName !== 'string') {
69
115
  return { ok: false, reason: 'El nombre del cambio no puede estar vacío.' };
@@ -137,6 +183,88 @@ function serializeMemoryYaml(obj) {
137
183
  return lines.join('\n') + '\n';
138
184
  }
139
185
 
186
+ // --- State tracking ---
187
+
188
+ const VALID_STATES = [
189
+ 'proposed',
190
+ 'approved',
191
+ 'apply-in-progress',
192
+ 'applied',
193
+ 'tested',
194
+ 'verified',
195
+ 'reviewed',
196
+ 'archived',
197
+ ];
198
+
199
+ /**
200
+ * Infers the current state of a change from its artifacts when no explicit
201
+ * currentState is stored in memory. Retrocompatible — never writes to disk.
202
+ *
203
+ * Priority (descending):
204
+ * 1. .review-passed exists → 'reviewed'
205
+ * 2. lastStep === 'verify' or 'verified' → 'verified'
206
+ * 3. lastStep === 'test' → 'tested'
207
+ * 4. lastStep === 'apply' → 'applied'
208
+ * 5. tasks.md has at least one [x] and lastStep is absent → 'apply-in-progress'
209
+ * 6. only proposal.md exists → 'proposed'
210
+ * 7. none of the above → 'unknown'
211
+ *
212
+ * NOTE — 'approved' is intentionally absent from this table.
213
+ * There is no artifact signal that distinguishes an approved proposal from a
214
+ * merely proposed one: both have proposal.md, specs.md, design.md, tasks.md.
215
+ * 'approved' is set exclusively via `set-memory --state approved --actor propose-skill`
216
+ * when the human approves the artifacts in /refacil:propose Step 4.
217
+ * Inference can only detect 'proposed' (artifact exists but no explicit state recorded).
218
+ *
219
+ * @param {string} changeDir Absolute path to the change directory.
220
+ * @param {object} memory Parsed memory.yaml object (may be empty).
221
+ * @returns {{ currentState: string, inferred: boolean }}
222
+ */
223
+ function inferCurrentState(changeDir, memory) {
224
+ // 1. .review-passed
225
+ if (fs.existsSync(path.join(changeDir, '.review-passed'))) {
226
+ return { currentState: 'reviewed', inferred: true };
227
+ }
228
+
229
+ const lastStep = memory.lastStep || null;
230
+
231
+ // 2. lastStep verify/verified
232
+ if (lastStep === 'verify' || lastStep === 'verified') {
233
+ return { currentState: 'verified', inferred: true };
234
+ }
235
+
236
+ // 3. lastStep test
237
+ if (lastStep === 'test') {
238
+ return { currentState: 'tested', inferred: true };
239
+ }
240
+
241
+ // 4. lastStep apply
242
+ if (lastStep === 'apply') {
243
+ return { currentState: 'applied', inferred: true };
244
+ }
245
+
246
+ // 5. tasks.md has at least one [x] and lastStep absent
247
+ const tasksPath = path.join(changeDir, 'tasks.md');
248
+ if (!lastStep && fs.existsSync(tasksPath)) {
249
+ try {
250
+ const tasksContent = fs.readFileSync(tasksPath, 'utf8');
251
+ if (/^- \[x\]/m.test(tasksContent)) {
252
+ return { currentState: 'apply-in-progress', inferred: true };
253
+ }
254
+ } catch (_) {
255
+ // Unexpected read error (e.g. permission denied): swallow and fall through
256
+ // to the proposal.md check so inference still produces a useful result.
257
+ }
258
+ }
259
+
260
+ // 6. only proposal.md exists (no design, tasks, specs)
261
+ if (fs.existsSync(path.join(changeDir, 'proposal.md'))) {
262
+ return { currentState: 'proposed', inferred: true };
263
+ }
264
+
265
+ return { currentState: 'unknown', inferred: true };
266
+ }
267
+
140
268
  // --- Subcomandos ---
141
269
 
142
270
  function cmdValidateName(argv) {
@@ -179,6 +307,38 @@ function cmdNewChange(argv, projectRoot) {
179
307
  console.log(`Cambio '${name}' creado en refacil-sdd/changes/${name}/`);
180
308
  }
181
309
 
310
+ function cmdSyncSpec(argv, projectRoot) {
311
+ const args = parseArgs(argv);
312
+ const rawName = args._positional[0];
313
+
314
+ autoMigrateOpenspec(projectRoot);
315
+ const resolved = resolveExistingChangeName(projectRoot, rawName);
316
+ if (!resolved.ok) {
317
+ console.error(resolved.reason);
318
+ process.exit(1);
319
+ }
320
+ const name = resolved.name;
321
+ const fromArchive = args['from-archive'] === true;
322
+
323
+ if (!fromArchive) {
324
+ const sourceDir = path.join(projectRoot, 'refacil-sdd', 'changes', name);
325
+ if (!fs.existsSync(sourceDir)) {
326
+ console.error(`No existe el cambio '${name}' en refacil-sdd/changes/${name}/`);
327
+ process.exit(1);
328
+ }
329
+ }
330
+
331
+ try {
332
+ const { syncSpecToCatalog } = require('../spec-sync');
333
+ const result = syncSpecToCatalog(projectRoot, name, { fromArchive });
334
+ const rel = path.relative(projectRoot, result.specPath).replace(/\\/g, '/');
335
+ console.log(` Spec sincronizado: ${rel} (${result.criteriaCount} criterios, idioma: ${result.language})`);
336
+ } catch (err) {
337
+ console.error(` Error sincronizando spec: ${err.message}`);
338
+ process.exit(1);
339
+ }
340
+ }
341
+
182
342
  function cmdArchive(argv, projectRoot) {
183
343
  const args = parseArgs(argv);
184
344
  const rawName = args._positional[0];
@@ -203,6 +363,20 @@ function cmdArchive(argv, projectRoot) {
203
363
  process.exit(1);
204
364
  }
205
365
 
366
+ if (!isBugFixChangeName(name)) {
367
+ try {
368
+ const { syncSpecToCatalog } = require('../spec-sync');
369
+ const result = syncSpecToCatalog(projectRoot, name);
370
+ const rel = path.relative(projectRoot, result.specPath).replace(/\\/g, '/');
371
+ console.log(` Spec sincronizado: ${rel} (idioma: ${result.language}, ${result.criteriaCount} criterios)`);
372
+ } catch (err) {
373
+ console.error(` Error sincronizando spec antes de archivar: ${err.message}`);
374
+ process.exit(1);
375
+ }
376
+ } else {
377
+ console.log(' Bug fix (fix-*): omitiendo sincronización de spec (use refacil:archive para documentar en refacil-sdd/specs/)');
378
+ }
379
+
206
380
  const date = new Date().toISOString().slice(0, 10);
207
381
  const archiveDir = path.join(projectRoot, 'refacil-sdd', 'changes', 'archive');
208
382
  const destDir = path.join(archiveDir, `${date}-${name}`);
@@ -212,10 +386,6 @@ function cmdArchive(argv, projectRoot) {
212
386
  process.exit(1);
213
387
  }
214
388
 
215
- // Delete memory.yaml before archiving (CA-18)
216
- const memoryFile = path.join(sourceDir, 'memory.yaml');
217
- if (fs.existsSync(memoryFile)) fs.unlinkSync(memoryFile);
218
-
219
389
  fs.mkdirSync(archiveDir, { recursive: true });
220
390
  fs.renameSync(sourceDir, destDir);
221
391
 
@@ -232,7 +402,7 @@ function cmdSetMemory(argv, projectRoot) {
232
402
  const rawName = args._positional[0];
233
403
 
234
404
  if (!rawName) {
235
- console.error('Uso: refacil-sdd-ai sdd set-memory <nombre-cambio> [--last-step <value>] [--stack-detected <value>] [--touched-files <csv>] [--commands-run <value>] [--criteria-run <csv>]');
405
+ console.error('Uso: refacil-sdd-ai sdd set-memory <nombre-cambio> [--last-step <value>] [--stack-detected <value>] [--touched-files <csv>] [--commands-run <value>] [--criteria-run <csv>] [--state <estado>] [--actor <nombre>]');
236
406
  process.exit(1);
237
407
  }
238
408
 
@@ -252,10 +422,30 @@ function cmdSetMemory(argv, projectRoot) {
252
422
  process.exit(1);
253
423
  }
254
424
 
425
+ // Validate --state before touching any file (CR-01: exit 1 without modifying file)
426
+ if (args['state'] !== undefined) {
427
+ const stateValue = args['state'];
428
+ // CR-02: empty string or bare flag (parsed as boolean true) → explicit empty-state error
429
+ if (stateValue === '' || stateValue === true) {
430
+ console.error('set-memory: el estado no puede estar vacío');
431
+ process.exit(1);
432
+ }
433
+ if (!VALID_STATES.includes(stateValue)) {
434
+ console.error(`set-memory: estado inválido '${stateValue}'. Estados válidos: ${VALID_STATES.join(', ')}`);
435
+ process.exit(1);
436
+ }
437
+ }
438
+
439
+ // Validate --actor: pipe character would corrupt the stateHistory pipe format
440
+ if (args['actor'] !== undefined && String(args['actor']).includes('|')) {
441
+ console.error("set-memory: --actor no puede contener el carácter '|'");
442
+ process.exit(1);
443
+ }
444
+
255
445
  // Require at least one field flag
256
- const knownFlags = ['last-step', 'stack-detected', 'touched-files', 'commands-run', 'criteria-run'];
446
+ const knownFlags = ['last-step', 'stack-detected', 'touched-files', 'commands-run', 'criteria-run', 'state', 'actor'];
257
447
  if (!knownFlags.some((f) => args[f] !== undefined)) {
258
- console.error('set-memory: debe especificar al menos un campo (--last-step, --stack-detected, --touched-files, --commands-run, --criteria-run)');
448
+ console.error('set-memory: debe especificar al menos un campo (--last-step, --stack-detected, --touched-files, --commands-run, --criteria-run, --state, --actor)');
259
449
  process.exit(1);
260
450
  }
261
451
 
@@ -282,6 +472,18 @@ function cmdSetMemory(argv, projectRoot) {
282
472
  existing['criteriaRun'] = args['criteria-run'].split(',').map((s) => s.trim()).filter(Boolean);
283
473
  }
284
474
 
475
+ // State tracking (T-01)
476
+ if (args['state'] !== undefined) {
477
+ const actor = args['actor'] !== undefined ? String(args['actor']) : 'cli';
478
+ existing['currentState'] = args['state'];
479
+ const iso = new Date().toISOString();
480
+ const entry = `${args['state']}|${iso}|${actor}`;
481
+ if (!Array.isArray(existing['stateHistory'])) {
482
+ existing['stateHistory'] = [];
483
+ }
484
+ existing['stateHistory'].push(entry);
485
+ }
486
+
285
487
  fs.writeFileSync(memoryPath, serializeMemoryYaml(existing), 'utf8');
286
488
  console.log(`memory.yaml actualizado para '${name}'`);
287
489
  }
@@ -389,6 +591,7 @@ function cmdClearReviewFails(argv, projectRoot) {
389
591
  function cmdList(argv, projectRoot) {
390
592
  const args = parseArgs(argv);
391
593
  const wantJson = args.json === true;
594
+ const includeArchived = args['include-archived'] === true;
392
595
 
393
596
  autoMigrateOpenspec(projectRoot);
394
597
 
@@ -410,17 +613,55 @@ function cmdList(argv, projectRoot) {
410
613
  return { name: e.name, reviewPassed };
411
614
  });
412
615
 
616
+ // Build archived entries when --include-archived is requested
617
+ const archivedResult = [];
618
+ if (includeArchived) {
619
+ const archiveDir = path.join(changesDir, 'archive');
620
+ if (fs.existsSync(archiveDir)) {
621
+ const datePrefix = /^\d{4}-\d{2}-\d{2}-/;
622
+ try {
623
+ const archiveEntries = fs.readdirSync(archiveDir, { withFileTypes: true })
624
+ .filter((e) => e.isDirectory() && datePrefix.test(e.name));
625
+ for (const e of archiveEntries) {
626
+ const dateMatch = e.name.match(/^(\d{4}-\d{2}-\d{2})-/);
627
+ const archivedDate = dateMatch ? dateMatch[1] : null;
628
+ const cleanName = e.name.replace(datePrefix, '');
629
+ archivedResult.push({ name: cleanName, reviewPassed: null, archived: true, archivedDate });
630
+ }
631
+ // Sort descending by date prefix (most recent first)
632
+ archivedResult.sort((a, b) => {
633
+ const da = a.archivedDate || '';
634
+ const db = b.archivedDate || '';
635
+ return db.localeCompare(da);
636
+ });
637
+ } catch (_) {}
638
+ }
639
+ // If archive dir doesn't exist, gracefully produce empty list (exit 0)
640
+ }
641
+
413
642
  if (wantJson) {
414
- process.stdout.write(JSON.stringify(result) + '\n');
643
+ const combined = result.concat(archivedResult);
644
+ process.stdout.write(JSON.stringify(combined) + '\n');
415
645
  } else {
416
- if (result.length === 0) {
646
+ if (result.length === 0 && archivedResult.length === 0) {
417
647
  console.log('Sin cambios activos.');
418
648
  return;
419
649
  }
420
- console.log('Cambios activos en refacil-sdd/changes/:');
421
- for (const item of result) {
422
- const badge = item.reviewPassed ? '[reviewed]' : '[pending-review]';
423
- console.log(` ${item.name} ${badge}`);
650
+ if (result.length > 0) {
651
+ console.log('Cambios activos en refacil-sdd/changes/:');
652
+ for (const item of result) {
653
+ const badge = item.reviewPassed ? '[reviewed]' : '[pending-review]';
654
+ console.log(` ${item.name} ${badge}`);
655
+ }
656
+ } else if (!includeArchived) {
657
+ console.log('Sin cambios activos.');
658
+ }
659
+ if (includeArchived && archivedResult.length > 0) {
660
+ if (result.length > 0) console.log('');
661
+ console.log('Cambios archivados en refacil-sdd/changes/archive/:');
662
+ for (const item of archivedResult) {
663
+ console.log(` ${item.name} [archived] (${item.archivedDate || '?'})`);
664
+ }
424
665
  }
425
666
  }
426
667
  }
@@ -454,16 +695,7 @@ function cmdStatus(argv, projectRoot) {
454
695
  const hasDesign = fs.existsSync(path.join(changeDir, 'design.md'));
455
696
  const hasTasks = fs.existsSync(path.join(changeDir, 'tasks.md'));
456
697
 
457
- // specs: specs.md existe OR specs/ dir con al menos un .md
458
- let hasSpecs = false;
459
- const specsMd = path.join(changeDir, 'specs.md');
460
- const specsDir = path.join(changeDir, 'specs');
461
- if (fs.existsSync(specsMd)) {
462
- hasSpecs = true;
463
- } else if (fs.existsSync(specsDir) && fs.statSync(specsDir).isDirectory()) {
464
- const mdFiles = fs.readdirSync(specsDir).filter((f) => f.endsWith('.md'));
465
- hasSpecs = mdFiles.length > 0;
466
- }
698
+ const hasSpecs = collectSpecSourceFiles(changeDir).length > 0;
467
699
 
468
700
  const artifacts = {
469
701
  proposal: hasProposal,
@@ -485,16 +717,46 @@ function cmdStatus(argv, projectRoot) {
485
717
  const reviewPassed = fs.existsSync(path.join(changeDir, '.review-passed'));
486
718
 
487
719
  const ready = {
488
- forApply: artifacts.proposal && artifacts.tasks,
720
+ forApply: artifacts.proposal && artifacts.design && artifacts.tasks && artifacts.specs,
489
721
  forArchive: reviewPassed && taskStats.total > 0 && taskStats.pending === 0,
490
722
  };
491
723
 
724
+ // Read memory for currentState / lastStep / touchedFiles
725
+ const memoryPath = path.join(changeDir, 'memory.yaml');
726
+ let memory = {};
727
+ if (fs.existsSync(memoryPath)) {
728
+ try {
729
+ memory = parseYaml(fs.readFileSync(memoryPath, 'utf8')) || {};
730
+ } catch (_) {
731
+ memory = {};
732
+ }
733
+ }
734
+
735
+ // Resolve currentState: explicit from memory or inferred
736
+ let currentState = null;
737
+ let stateInferred = false;
738
+ if (memory.currentState) {
739
+ currentState = memory.currentState;
740
+ stateInferred = false;
741
+ } else {
742
+ const inferred = inferCurrentState(changeDir, memory);
743
+ currentState = inferred.currentState;
744
+ stateInferred = inferred.inferred;
745
+ }
746
+
747
+ const lastStep = memory.lastStep || null;
748
+ const touchedFiles = Array.isArray(memory.touchedFiles) ? memory.touchedFiles : [];
749
+
492
750
  const status = {
493
751
  name,
494
752
  artifacts,
495
753
  tasks: taskStats,
496
754
  reviewPassed,
497
755
  ready,
756
+ currentState,
757
+ stateInferred,
758
+ lastStep,
759
+ touchedFiles,
498
760
  };
499
761
 
500
762
  if (wantJson) {
@@ -514,6 +776,11 @@ function cmdStatus(argv, projectRoot) {
514
776
  console.log('');
515
777
  console.log(` Review aprobado: ${reviewPassed ? 'Si' : 'No'}`);
516
778
  console.log('');
779
+ console.log(' Estado:');
780
+ console.log(` currentState: ${currentState}${stateInferred ? ' (inferido)' : ''}`);
781
+ if (lastStep) console.log(` lastStep: ${lastStep}`);
782
+ if (touchedFiles.length > 0) console.log(` touchedFiles: ${touchedFiles.join(', ')}`);
783
+ console.log('');
517
784
  console.log(' Listo para:');
518
785
  console.log(` apply: ${ready.forApply ? 'Si' : 'No'}`);
519
786
  console.log(` archive: ${ready.forArchive ? 'Si' : 'No'}`);
@@ -653,11 +920,12 @@ function cmdWriteConfig(argv, projectRoot) {
653
920
  const rawBaseBranch = args['base-branch'];
654
921
  const rawProtectedBranches = args['protected-branches'];
655
922
  const rawArtifactLanguage = args['artifact-language'];
923
+ const rawCodegraph = args['codegraph'];
656
924
 
657
925
  // CR-03: no flags provided
658
- if (rawBaseBranch === undefined && rawProtectedBranches === undefined && rawArtifactLanguage === undefined) {
659
- console.error('Uso: refacil-sdd-ai sdd write-config [--global] [--base-branch <branch>] [--protected-branches <csv>] [--artifact-language <language>]');
660
- console.error('Debe especificar al menos --base-branch, --protected-branches o --artifact-language.');
926
+ if (rawBaseBranch === undefined && rawProtectedBranches === undefined && rawArtifactLanguage === undefined && rawCodegraph === undefined) {
927
+ console.error('Uso: refacil-sdd-ai sdd write-config [--global] [--base-branch <branch>] [--protected-branches <csv>] [--artifact-language <language>] [--codegraph <mode>]');
928
+ console.error('Debe especificar al menos --base-branch, --protected-branches, --artifact-language o --codegraph.');
661
929
  process.exit(1);
662
930
  }
663
931
 
@@ -689,6 +957,18 @@ function cmdWriteConfig(argv, projectRoot) {
689
957
  }
690
958
  }
691
959
 
960
+ // --codegraph: must be a valid CODEGRAPH_MODES value
961
+ if (rawCodegraph !== undefined) {
962
+ if (typeof rawCodegraph !== 'string' || rawCodegraph.trim() === '') {
963
+ console.error('Error: --codegraph no puede estar vacío.');
964
+ process.exit(1);
965
+ }
966
+ if (!CODEGRAPH_MODES.includes(rawCodegraph.trim())) {
967
+ console.error(`Error: --codegraph "${rawCodegraph.trim()}" no es un modo válido. Valores válidos: ${CODEGRAPH_MODES.join(', ')}.`);
968
+ process.exit(1);
969
+ }
970
+ }
971
+
692
972
  const targetPath = isGlobal
693
973
  ? path.join(os.homedir(), '.refacil-sdd-ai', 'config.yaml')
694
974
  : path.join(projectRoot, 'refacil-sdd', 'config.yaml');
@@ -707,6 +987,9 @@ function cmdWriteConfig(argv, projectRoot) {
707
987
  if (rawArtifactLanguage !== undefined) {
708
988
  merged.artifactLanguage = rawArtifactLanguage.trim();
709
989
  }
990
+ if (rawCodegraph !== undefined) {
991
+ merged.codegraphMode = rawCodegraph.trim();
992
+ }
710
993
 
711
994
  // CA-03: no-op when all provided keys already match existing config (semantic comparison)
712
995
  const isNoOp = Object.keys(existing).length > 0 &&
@@ -714,7 +997,8 @@ function cmdWriteConfig(argv, projectRoot) {
714
997
  (protectedBranchesList === undefined ||
715
998
  (Array.isArray(existing.protectedBranches) &&
716
999
  JSON.stringify(existing.protectedBranches.slice().sort()) === JSON.stringify(protectedBranchesList.slice().sort()))) &&
717
- (rawArtifactLanguage === undefined || existing.artifactLanguage === rawArtifactLanguage.trim());
1000
+ (rawArtifactLanguage === undefined || existing.artifactLanguage === rawArtifactLanguage.trim()) &&
1001
+ (rawCodegraph === undefined || existing.codegraphMode === rawCodegraph.trim());
718
1002
  if (isNoOp) {
719
1003
  console.log(`Sin cambios: ${targetPath} ya tiene los valores indicados.`);
720
1004
  process.exit(0);
@@ -730,14 +1014,249 @@ function cmdWriteConfig(argv, projectRoot) {
730
1014
  console.log(`Configuración escrita en ${targetPath} (nivel: ${level})`);
731
1015
  }
732
1016
 
1017
+ function cmdTestScope(argv, projectRoot) {
1018
+ const args = parseArgs(argv);
1019
+ const wantJson = args.json === true;
1020
+
1021
+ const filesRaw = args.files || '';
1022
+ const stackHint = args.stack || undefined;
1023
+ const baselineCmd = args.baseline || '';
1024
+ // Use the already-resolved projectRoot from handleSdd (via findProjectRoot()) so
1025
+ // the CLI works correctly when invoked from a subdirectory within the monorepo.
1026
+ const root = projectRoot || process.cwd();
1027
+
1028
+ // Parse CSV files — empty string or missing → empty array (CR-04: never fail on empty)
1029
+ const files = filesRaw
1030
+ ? filesRaw.split(',').map((s) => s.trim()).filter(Boolean)
1031
+ : [];
1032
+
1033
+ const { testScope } = require('../test-scope');
1034
+ const result = testScope({ files, stack: stackHint, baseline: baselineCmd, projectRoot: root });
1035
+
1036
+ if (wantJson) {
1037
+ process.stdout.write(JSON.stringify(result) + '\n');
1038
+ } else {
1039
+ console.log(`testCommand: ${result.testCommand}`);
1040
+ console.log(`files: ${result.files.join(', ') || '(none)'}`);
1041
+ console.log(`fallback: ${result.fallback}`);
1042
+ if (result.fallbackReason) console.log(`fallbackReason: ${result.fallbackReason}`);
1043
+ }
1044
+ // Always exit 0 (CR-04)
1045
+ process.exit(0);
1046
+ }
1047
+
1048
+ function cmdStats(argv, projectRoot) {
1049
+ const args = parseArgs(argv);
1050
+ const rawName = args._positional[0];
1051
+ const wantJson = args.json === true;
1052
+
1053
+ if (!rawName) {
1054
+ console.error('Uso: refacil-sdd-ai sdd stats <nombre-cambio> [--json]');
1055
+ process.exit(1);
1056
+ }
1057
+
1058
+ autoMigrateOpenspec(projectRoot);
1059
+ const resolved = resolveExistingChangeName(projectRoot, rawName);
1060
+ if (!resolved.ok) {
1061
+ console.error(resolved.reason);
1062
+ process.exit(1);
1063
+ }
1064
+ const name = resolved.name;
1065
+
1066
+ let changeDir = path.join(projectRoot, 'refacil-sdd', 'changes', name);
1067
+ let isArchived = false;
1068
+ let archivedDate = null;
1069
+
1070
+ if (!fs.existsSync(changeDir)) {
1071
+ // Fallback: try to find in archive
1072
+ const archivedPath = resolveArchivedChangeName(projectRoot, name);
1073
+ if (archivedPath) {
1074
+ changeDir = archivedPath;
1075
+ isArchived = true;
1076
+ // Extract YYYY-MM-DD from the directory basename
1077
+ const baseName = path.basename(archivedPath);
1078
+ const dateMatch = baseName.match(/^(\d{4}-\d{2}-\d{2})-/);
1079
+ archivedDate = dateMatch ? dateMatch[1] : null;
1080
+ } else {
1081
+ console.error(`No existe el cambio '${name}' en refacil-sdd/changes/${name}/ ni en el archivo.`);
1082
+ process.exit(1);
1083
+ }
1084
+ }
1085
+
1086
+ // --- Read memory.yaml ---
1087
+ const memoryPath = path.join(changeDir, 'memory.yaml');
1088
+ let memory = {};
1089
+ if (fs.existsSync(memoryPath)) {
1090
+ try {
1091
+ memory = parseYaml(fs.readFileSync(memoryPath, 'utf8')) || {};
1092
+ } catch (_) {
1093
+ memory = {};
1094
+ }
1095
+ }
1096
+ const testCommand = memory.testCommand || memory.commandsRun || null;
1097
+ const lastStep = memory.lastStep || null;
1098
+ const criteriaRun = Array.isArray(memory.criteriaRun) ? memory.criteriaRun : [];
1099
+
1100
+ // Resolve currentState for stats
1101
+ let statsCurrentState = null;
1102
+ let statsStateInferred = false;
1103
+ if (memory.currentState) {
1104
+ statsCurrentState = memory.currentState;
1105
+ statsStateInferred = false;
1106
+ } else {
1107
+ const inferred = inferCurrentState(changeDir, memory);
1108
+ statsCurrentState = inferred.currentState;
1109
+ statsStateInferred = inferred.inferred;
1110
+ }
1111
+
1112
+ // --- Read .review-passed ---
1113
+ const reviewPassedPath = path.join(changeDir, '.review-passed');
1114
+ let reviewDate = null;
1115
+ let reviewVerdict = null;
1116
+ let reviewFailCount = 0;
1117
+ if (fs.existsSync(reviewPassedPath)) {
1118
+ try {
1119
+ const rp = JSON.parse(fs.readFileSync(reviewPassedPath, 'utf8'));
1120
+ reviewDate = rp.date || null;
1121
+ reviewVerdict = rp.verdict || null;
1122
+ reviewFailCount = rp.failCount || 0;
1123
+ } catch (_) {}
1124
+ }
1125
+
1126
+ // --- Determine change start date from proposal.md mtime (fallback: summary.md) ---
1127
+ const proposalPath = path.join(changeDir, 'proposal.md');
1128
+ let startDate = null;
1129
+ if (fs.existsSync(proposalPath)) {
1130
+ try {
1131
+ const stat = fs.statSync(proposalPath);
1132
+ startDate = stat.mtime;
1133
+ } catch (_) {}
1134
+ }
1135
+ if (!startDate) {
1136
+ const summaryPath = path.join(changeDir, 'summary.md');
1137
+ if (fs.existsSync(summaryPath)) {
1138
+ try {
1139
+ const stat = fs.statSync(summaryPath);
1140
+ startDate = stat.mtime;
1141
+ } catch (_) {}
1142
+ }
1143
+ }
1144
+
1145
+ // --- Read compact.log filtered by change period ---
1146
+ let compactStats = { totalRewrites: 0, totalSaved: 0, totalAlreadyCompact: 0, totalAlreadyCompactPotential: 0, totalEvents: 0 };
1147
+ try {
1148
+ const compactTelemetry = require('../compact/telemetry');
1149
+ const allCompact = compactTelemetry.readLog ? compactTelemetry.readLog() : [];
1150
+ const filtered = startDate ? allCompact.filter((e) => e.ts && new Date(e.ts) >= startDate) : allCompact;
1151
+ let totalRewrites = 0, totalSaved = 0, totalAlreadyCompact = 0, totalAlreadyCompactPotential = 0;
1152
+ for (const e of filtered) {
1153
+ const evType = e.eventType || 'hook_rewrite';
1154
+ if (evType === 'already_compact') {
1155
+ totalAlreadyCompact++;
1156
+ totalAlreadyCompactPotential += e.savedTokensEst || 0;
1157
+ } else {
1158
+ totalRewrites++;
1159
+ totalSaved += e.savedTokensEst || 0;
1160
+ }
1161
+ }
1162
+ compactStats = { totalRewrites, totalSaved, totalAlreadyCompact, totalAlreadyCompactPotential, totalEvents: filtered.length };
1163
+ } catch (_) {}
1164
+
1165
+ // --- Read codegraph.log filtered by change period ---
1166
+ let codegraphStats = { totalEvents: 0, totalToolCalls: 0, totalTokensSaved: 0 };
1167
+ try {
1168
+ const cgTelemetry = require('../codegraph-telemetry');
1169
+ const allCg = cgTelemetry.readLog ? cgTelemetry.readLog() : [];
1170
+ const filtered = startDate ? allCg.filter((e) => e.ts && new Date(e.ts) >= startDate) : allCg;
1171
+ let totalEvents = 0, totalToolCalls = 0, totalTokensSaved = 0;
1172
+ for (const e of filtered) {
1173
+ totalEvents++;
1174
+ totalToolCalls += e.toolCallsCount || 0;
1175
+ totalTokensSaved += e.estimatedTokensSaved || 0;
1176
+ }
1177
+ codegraphStats = { totalEvents, totalToolCalls, totalTokensSaved };
1178
+ } catch (_) {}
1179
+
1180
+ // --- Build stats object ---
1181
+ const statsObj = {
1182
+ changeName: name,
1183
+ isArchived,
1184
+ archivedDate,
1185
+ startDate: startDate ? startDate.toISOString() : null,
1186
+ memory: {
1187
+ testCommand: testCommand || null,
1188
+ lastStep: lastStep || null,
1189
+ criteriaRun,
1190
+ currentState: statsCurrentState,
1191
+ stateInferred: statsStateInferred,
1192
+ },
1193
+ review: {
1194
+ passed: reviewDate !== null,
1195
+ verdict: reviewVerdict,
1196
+ date: reviewDate,
1197
+ failCount: reviewFailCount,
1198
+ },
1199
+ compact: {
1200
+ eventsInPeriod: compactStats.totalEvents,
1201
+ rewrites: compactStats.totalRewrites,
1202
+ estimatedTokensSavedByRewrites: compactStats.totalSaved,
1203
+ alreadyCompact: compactStats.totalAlreadyCompact,
1204
+ estimatedTokensSavedAlreadyCompact: compactStats.totalAlreadyCompactPotential,
1205
+ },
1206
+ codegraph: {
1207
+ eventsInPeriod: codegraphStats.totalEvents,
1208
+ totalToolCalls: codegraphStats.totalToolCalls,
1209
+ estimatedTokensSaved: codegraphStats.totalTokensSaved,
1210
+ },
1211
+ };
1212
+
1213
+ if (wantJson) {
1214
+ process.stdout.write(JSON.stringify(statsObj) + '\n');
1215
+ return;
1216
+ }
1217
+
1218
+ // --- Tabular output ---
1219
+ const pad = (s, n) => String(s).padEnd(n);
1220
+ const noDataLabel = isArchived ? '(archivado)' : '(sin datos)';
1221
+ console.log('');
1222
+ console.log(` Stats del cambio: ${name}${isArchived && archivedDate ? ` [archivado el ${archivedDate}]` : ""}`);
1223
+ if (startDate) console.log(` Inicio estimado: ${startDate.toISOString().slice(0, 10)}`);
1224
+ console.log('');
1225
+ console.log(' Memory');
1226
+ console.log(` ${pad('currentState', 20)} ${statsCurrentState || noDataLabel}${statsStateInferred ? ' (inferido)' : ''}`);
1227
+ console.log(` ${pad('lastStep', 20)} ${lastStep || noDataLabel}`);
1228
+ console.log(` ${pad('criteriaRun', 20)} ${criteriaRun.length > 0 ? criteriaRun.join(', ') : noDataLabel}`);
1229
+ console.log(` ${pad('testCommand', 20)} ${testCommand || noDataLabel}`);
1230
+ console.log('');
1231
+ console.log(' Review');
1232
+ console.log(` ${pad('passed', 20)} ${reviewDate !== null ? 'Si' : 'No'}`);
1233
+ if (reviewVerdict) console.log(` ${pad('verdict', 20)} ${reviewVerdict}`);
1234
+ if (reviewDate) console.log(` ${pad('date', 20)} ${reviewDate}`);
1235
+ if (reviewDate) console.log(` ${pad('failCount', 20)} ${reviewFailCount}`);
1236
+ console.log('');
1237
+ console.log(' Compact telemetry (periodo del cambio)');
1238
+ console.log(` ${pad('eventos', 20)} ${compactStats.totalEvents}`);
1239
+ console.log(` ${pad('rewrites', 20)} ${compactStats.totalRewrites}`);
1240
+ console.log(` ${pad('tokens ahorrados', 20)} ${compactStats.totalSaved}`);
1241
+ console.log('');
1242
+ console.log(' CodeGraph telemetry (periodo del cambio)');
1243
+ console.log(` ${pad('eventos', 20)} ${codegraphStats.totalEvents}`);
1244
+ console.log(` ${pad('tool calls', 20)} ${codegraphStats.totalToolCalls}`);
1245
+ console.log(` ${pad('tokens ahorrados', 20)} ${codegraphStats.totalTokensSaved}`);
1246
+ console.log('');
1247
+ }
1248
+
733
1249
  function sddHelp() {
734
1250
  console.log(`
735
1251
  refacil-sdd-ai sdd — Gestión de artefactos SDD-AI
736
1252
 
737
1253
  Subcomandos:
738
1254
  sdd new-change <nombre> Crea un nuevo cambio con los 4 artefactos scaffold
739
- sdd archive <nombre> Archiva un cambio completado a refacil-sdd/changes/archive/
740
- sdd list [--json] Lista cambios activos con estado de review
1255
+ sdd sync-spec <nombre> Sincroniza specs del change a refacil-sdd/specs/<nombre>/spec.md
1256
+ (mismo idioma que los artefactos; sin traducción)
1257
+ [--from-archive] Lee el change desde refacil-sdd/changes/archive/*-<nombre>/
1258
+ sdd archive <nombre> Archiva un cambio (sync-spec automático + mueve a archive/)
1259
+ sdd list [--json] [--include-archived] Lista cambios activos con estado de review
741
1260
  sdd status <nombre> [--json] Muestra estado de artefactos y tasks de un cambio
742
1261
  sdd mark-reviewed <nombre> Escribe .review-passed con veredicto y resumen
743
1262
  --verdict <v> Veredicto (ej: approved, approved-with-notes, rejected)
@@ -755,6 +1274,8 @@ function sddHelp() {
755
1274
  [--touched-files <csv>] Archivos modificados (separados por coma)
756
1275
  [--commands-run <value>] Comando de test ejecutado
757
1276
  [--criteria-run <csv>] Criterios CA/CR ejecutados (separados por coma)
1277
+ [--state <estado>] Estado del ciclo SDD-AI (proposed, approved, apply-in-progress, applied, tested, verified, reviewed, archived)
1278
+ [--actor <nombre>] Actor que origina el cambio de estado (default: cli)
758
1279
  sdd get-memory <nombre> Lee memory.yaml del cambio
759
1280
  [--json] Salida en JSON (por defecto: YAML raw)
760
1281
  sdd set-review-fails <nombre> Escribe .review-last-fails.json con archivos fallidos
@@ -769,11 +1290,20 @@ function sddHelp() {
769
1290
  [--base-branch <branch>] Rama base para nuevos cambios
770
1291
  [--protected-branches <csv>] Ramas protegidas (separadas por coma)
771
1292
  [--artifact-language <language>] Idioma para los artefactos SDD generados (english, spanish)
1293
+ sdd stats <nombre> [--json] Muestra estadísticas del cambio: memoria (incluyendo currentState),
1294
+ review, compact telemetry y CodeGraph en el periodo del cambio
1295
+ sdd test-scope Resolves scoped test files for the given source files
1296
+ --files <csv> Comma-separated source file paths to scope tests for
1297
+ [--stack <name>] Stack hint (node, python, go, rust, java, dotnet)
1298
+ [--baseline <cmd>] Fallback test command when no test files are found
1299
+ [--json] Output result as JSON (testCommand, files, fallback, fallbackReason)
1300
+ Always exits 0.
772
1301
 
773
1302
  Notas:
774
1303
  - Los nombres de cambio deben empezar con minúscula y usar solo [a-z0-9-]
775
1304
  - Si existe openspec/ y no existe refacil-sdd/, se migra automáticamente
776
- - sdd archive elimina memory.yaml automáticamente antes de mover el cambio
1305
+ - sdd archive ejecuta sync-spec antes de mover (preserva idioma de CA/CR del change)
1306
+ - sdd archive mueve el cambio completo (incluyendo memory.yaml si existe) al directorio archive/
777
1307
  `);
778
1308
  }
779
1309
 
@@ -790,6 +1320,9 @@ function handleSdd(sub, argv, projectRoot) {
790
1320
  case 'archive':
791
1321
  cmdArchive(args, root);
792
1322
  break;
1323
+ case 'sync-spec':
1324
+ cmdSyncSpec(args, root);
1325
+ break;
793
1326
  case 'list':
794
1327
  cmdList(args, root);
795
1328
  break;
@@ -823,10 +1356,30 @@ function handleSdd(sub, argv, projectRoot) {
823
1356
  case 'write-config':
824
1357
  cmdWriteConfig(args, root);
825
1358
  break;
1359
+ case 'stats':
1360
+ cmdStats(args, root);
1361
+ break;
1362
+ case 'test-scope':
1363
+ cmdTestScope(args, root);
1364
+ break;
826
1365
  default:
827
1366
  sddHelp();
828
1367
  process.exit(1);
829
1368
  }
830
1369
  }
831
1370
 
832
- module.exports = { handleSdd, parseArgs, autoMigrateOpenspec, validateChangeName, resolveExistingChangeName, findProjectRoot, cmdWriteConfig };
1371
+ module.exports = {
1372
+ handleSdd,
1373
+ parseArgs,
1374
+ autoMigrateOpenspec,
1375
+ validateChangeName,
1376
+ isBugFixChangeName,
1377
+ resolveExistingChangeName,
1378
+ resolveArchivedChangeName,
1379
+ findProjectRoot,
1380
+ cmdWriteConfig,
1381
+ cmdStats,
1382
+ cmdTestScope,
1383
+ VALID_STATES,
1384
+ inferCurrentState,
1385
+ };