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,210 @@
1
1
  'use strict';
2
2
 
3
3
  const compactTelemetry = require('../compact/telemetry');
4
+ const codegraphTelemetry = require('../codegraph-telemetry');
4
5
 
5
- function showCompactStats() {
6
- const s = compactTelemetry.stats();
7
- if (s.totalEvents === 0) {
8
- console.log('\n No hay eventos registrados todavia. Ejecuta comandos Bash para generar telemetria de compactacion.\n');
9
- return;
6
+ const USD_PER_MTOK = 3;
7
+
8
+ function formatUsd(tokens) {
9
+ return ((tokens / 1_000_000) * USD_PER_MTOK).toFixed(2);
10
+ }
11
+
12
+ function formatK(tokens) {
13
+ return (tokens / 1000).toFixed(1);
14
+ }
15
+
16
+ function parseCompactArgs(argv) {
17
+ const args = { _positional: [] };
18
+ for (let i = 0; i < argv.length; i++) {
19
+ const token = argv[i];
20
+ if (!token) continue;
21
+ if (!token.startsWith('--')) {
22
+ args._positional.push(token);
23
+ continue;
24
+ }
25
+ const key = token.slice(2);
26
+ const next = argv[i + 1];
27
+ if (next === undefined || next.startsWith('--')) {
28
+ args[key] = true;
29
+ } else {
30
+ args[key] = next;
31
+ i++;
32
+ }
10
33
  }
34
+ return args;
35
+ }
11
36
 
12
- const sortedRewrites = Object.entries(s.byRule)
13
- .filter(([, data]) => data.rewriteCount > 0)
14
- .sort((a, b) => b[1].rewriteSaved - a[1].rewriteSaved);
15
- const sortedAlreadyCompact = Object.entries(s.byRule)
16
- .filter(([, data]) => data.alreadyCompactCount > 0)
17
- .sort((a, b) => b[1].alreadyCompactPotential - a[1].alreadyCompactPotential);
18
-
19
- console.log(`\n compact-bash stats\n`);
20
- console.log(` Rewrites por hook: ${s.totalRewrites}`);
21
- console.log(` Comandos ya compactos detectados (skill/agente): ${s.totalAlreadyCompact}\n`);
22
-
23
- if (sortedRewrites.length > 0) {
24
- console.log(' Ahorro aplicado por hook (rewrite):');
25
- for (const [id, data] of sortedRewrites) {
26
- const kTokens = (data.rewriteSaved / 1000).toFixed(1);
37
+ function parseBool(value, defaultValue = false) {
38
+ if (value === undefined || value === null) return defaultValue;
39
+ if (value === true) return true;
40
+ const s = String(value).toLowerCase();
41
+ if (s === 'true' || s === '1' || s === 'yes') return true;
42
+ if (s === 'false' || s === '0' || s === 'no') return false;
43
+ return defaultValue;
44
+ }
45
+
46
+ function showCodegraphSection(cg) {
47
+ console.log(`\n CodeGraph — estimated savings by sub-agent\n`);
48
+ const sortedSkills = Object.entries(cg.bySkill)
49
+ .filter(([, data]) => data.withGraph.events > 0)
50
+ .sort((a, b) => b[1].withGraph.tokensSaved - a[1].withGraph.tokensSaved);
51
+
52
+ if (sortedSkills.length > 0) {
53
+ console.log(' With CodeGraph:');
54
+ for (const [skillId, data] of sortedSkills) {
55
+ const kTokens = formatK(data.withGraph.tokensSaved);
27
56
  console.log(
28
- ` ${id.padEnd(18)} ${String(data.rewriteCount).padStart(6)} rewrites ~${kTokens.padStart(7)}k tokens`,
57
+ ` ${skillId.padEnd(18)} ${String(data.withGraph.events).padStart(5)} sessions ` +
58
+ `${String(data.withGraph.toolCalls).padStart(5)} tool calls ~${kTokens.padStart(7)}k tokens saved`,
29
59
  );
30
60
  }
31
- const totalHookK = (s.totalSaved / 1000).toFixed(1);
32
- const hookUsd = ((s.totalSaved / 1_000_000) * 3).toFixed(2);
61
+ const totalCgK = formatK(cg.totalTokensSaved);
62
+ const totalCgUsd = formatUsd(cg.totalTokensSaved);
33
63
  console.log(` ${'-'.repeat(62)}`);
34
64
  console.log(
35
- ` ${'Total hook'.padEnd(18)} ${String(s.totalRewrites).padStart(6)} rewrites ~${totalHookK.padStart(7)}k tokens (~$${hookUsd} USD)`,
65
+ ` ${'Total CodeGraph'.padEnd(18)} ${String(cg.totalEvents).padStart(5)} events ` +
66
+ `${String(cg.totalToolCalls).padStart(5)} tool calls ~${totalCgK.padStart(7)}k tokens (~$${totalCgUsd} USD)`,
36
67
  );
37
- console.log('');
38
68
  }
39
69
 
40
- if (sortedAlreadyCompact.length > 0) {
41
- console.log(' Ahorro potencial ya capturado por skill/agente (sin rewrite):');
42
- for (const [id, data] of sortedAlreadyCompact) {
43
- const kTokens = (data.alreadyCompactPotential / 1000).toFixed(1);
70
+ const withoutGraph = Object.entries(cg.bySkill).filter(
71
+ ([, data]) => data.withoutGraph.events > 0,
72
+ );
73
+ if (withoutGraph.length > 0) {
74
+ console.log('\n Sessions without graph (baseline):');
75
+ for (const [skillId, data] of withoutGraph) {
44
76
  console.log(
45
- ` ${id.padEnd(18)} ${String(data.alreadyCompactCount).padStart(6)} eventos ~${kTokens.padStart(7)}k tokens potenciales`,
77
+ ` ${skillId.padEnd(18)} ${String(data.withoutGraph.events).padStart(5)} sessions`,
46
78
  );
47
79
  }
48
- const totalAgentK = (s.totalAlreadyCompactPotential / 1000).toFixed(1);
49
- const agentUsd = ((s.totalAlreadyCompactPotential / 1_000_000) * 3).toFixed(2);
80
+ }
81
+
82
+ console.log(`\n Log: ${codegraphTelemetry.LOG_PATH}`);
83
+ }
84
+
85
+ function showCompactStats() {
86
+ const s = compactTelemetry.stats();
87
+ const cg = codegraphTelemetry.stats();
88
+ const hasCompact = s.totalEvents > 0;
89
+ const hasCg = cg.totalEvents > 0;
90
+
91
+ if (!hasCompact && !hasCg) {
92
+ console.log('\n No hay eventos registrados todavia.');
93
+ console.log(' - Bash con hook compact-bash → compact.log');
94
+ console.log(
95
+ ' - Tras sub-agentes: refacil-sdd-ai compact log-codegraph-event --skill <id> --has-graph true|false --tool-calls N --tokens N\n',
96
+ );
97
+ return;
98
+ }
99
+
100
+ if (hasCompact) {
101
+ const sortedRewrites = Object.entries(s.byRule)
102
+ .filter(([, data]) => data.rewriteCount > 0)
103
+ .sort((a, b) => b[1].rewriteSaved - a[1].rewriteSaved);
104
+ const sortedAlreadyCompact = Object.entries(s.byRule)
105
+ .filter(([, data]) => data.alreadyCompactCount > 0)
106
+ .sort((a, b) => b[1].alreadyCompactPotential - a[1].alreadyCompactPotential);
107
+
108
+ console.log(`\n compact-bash stats\n`);
109
+ console.log(` Rewrites por hook: ${s.totalRewrites}`);
110
+ console.log(` Comandos ya compactos detectados (skill/agente): ${s.totalAlreadyCompact}\n`);
111
+
112
+ if (sortedRewrites.length > 0) {
113
+ console.log(' Ahorro aplicado por hook (rewrite):');
114
+ for (const [id, data] of sortedRewrites) {
115
+ const kTokens = formatK(data.rewriteSaved);
116
+ console.log(
117
+ ` ${id.padEnd(18)} ${String(data.rewriteCount).padStart(6)} rewrites ~${kTokens.padStart(7)}k tokens`,
118
+ );
119
+ }
120
+ const totalHookK = formatK(s.totalSaved);
121
+ const hookUsd = formatUsd(s.totalSaved);
122
+ console.log(` ${'-'.repeat(62)}`);
123
+ console.log(
124
+ ` ${'Total hook'.padEnd(18)} ${String(s.totalRewrites).padStart(6)} rewrites ~${totalHookK.padStart(7)}k tokens (~$${hookUsd} USD)`,
125
+ );
126
+ console.log('');
127
+ }
128
+
129
+ if (sortedAlreadyCompact.length > 0) {
130
+ console.log(' Ahorro potencial ya capturado por skill/agente (sin rewrite):');
131
+ for (const [id, data] of sortedAlreadyCompact) {
132
+ const kTokens = formatK(data.alreadyCompactPotential);
133
+ console.log(
134
+ ` ${id.padEnd(18)} ${String(data.alreadyCompactCount).padStart(6)} eventos ~${kTokens.padStart(7)}k tokens potenciales`,
135
+ );
136
+ }
137
+ const totalAgentK = formatK(s.totalAlreadyCompactPotential);
138
+ const agentUsd = formatUsd(s.totalAlreadyCompactPotential);
139
+ console.log(` ${'-'.repeat(62)}`);
140
+ console.log(
141
+ ` ${'Total skill'.padEnd(18)} ${String(s.totalAlreadyCompact).padStart(6)} eventos ~${totalAgentK.padStart(7)}k tokens (~$${agentUsd} USD)`,
142
+ );
143
+ console.log('');
144
+ }
145
+
146
+ const compactK = formatK(s.totalObservedPotential);
147
+ const compactUsd = formatUsd(s.totalObservedPotential);
50
148
  console.log(` ${'-'.repeat(62)}`);
51
149
  console.log(
52
- ` ${'Total skill'.padEnd(18)} ${String(s.totalAlreadyCompact).padStart(6)} eventos ~${totalAgentK.padStart(7)}k tokens (~$${agentUsd} USD)`,
150
+ ` ${'Total compact'.padEnd(18)} ${String(s.totalEvents).padStart(6)} eventos ~${compactK.padStart(7)}k tokens (~$${compactUsd} USD, Sonnet input)`,
53
151
  );
54
- console.log('');
152
+ console.log(`\n Log: ${compactTelemetry.LOG_PATH}`);
153
+ if (compactTelemetry.isDisabled()) {
154
+ console.log(' Estado: DESHABILITADO (no se registran nuevos eventos)');
155
+ }
55
156
  }
56
157
 
57
- const totalK = (s.totalObservedPotential / 1000).toFixed(1);
58
- const totalUsd = ((s.totalObservedPotential / 1_000_000) * 3).toFixed(2);
59
- console.log(` ${'-'.repeat(62)}`);
60
- console.log(
61
- ` ${'Total observado'.padEnd(18)} ${String(s.totalEvents).padStart(6)} eventos ~${totalK.padStart(7)}k tokens (~$${totalUsd} USD, Sonnet input)`,
62
- );
63
- console.log(`\n Log: ${compactTelemetry.LOG_PATH}`);
64
- if (compactTelemetry.isDisabled()) {
65
- console.log(' Estado: DESHABILITADO (no se registran nuevos eventos)');
158
+ if (hasCg) {
159
+ showCodegraphSection(cg);
160
+ }
161
+
162
+ const grandTotal = s.totalObservedPotential + cg.totalTokensSaved;
163
+ if (hasCompact && hasCg) {
164
+ const grandK = formatK(grandTotal);
165
+ const grandUsd = formatUsd(grandTotal);
166
+ console.log(`\n ${'-'.repeat(62)}`);
167
+ console.log(
168
+ ` ${'Total combinado'.padEnd(18)} compact + CodeGraph ~${grandK.padStart(7)}k tokens (~$${grandUsd} USD, Sonnet input)`,
169
+ );
170
+ } else if (!hasCompact && hasCg && cg.totalTokensSaved > 0) {
171
+ const grandK = formatK(grandTotal);
172
+ const grandUsd = formatUsd(grandTotal);
173
+ console.log(`\n ${'-'.repeat(62)}`);
174
+ console.log(
175
+ ` ${'Total observado'.padEnd(18)} ${String(cg.totalEvents).padStart(6)} eventos ~${grandK.padStart(7)}k tokens (~$${grandUsd} USD, Sonnet input)`,
176
+ );
66
177
  }
178
+
67
179
  console.log('');
68
180
  }
69
181
 
70
- function handleCompact(sub) {
182
+ function cmdLogCodegraphEvent(argv) {
183
+ const args = parseCompactArgs(argv);
184
+ const skillId = args.skill || args._positional[0];
185
+ if (!skillId) {
186
+ console.error(
187
+ ' Uso: refacil-sdd-ai compact log-codegraph-event --skill <id> --has-graph true|false [--tool-calls N] [--tokens N]',
188
+ );
189
+ process.exitCode = 1;
190
+ return;
191
+ }
192
+
193
+ const hasGraph = parseBool(args['has-graph'], false);
194
+ const toolCalls = Math.max(0, parseInt(args['tool-calls'] || '0', 10) || 0);
195
+ const tokens = Math.max(0, parseInt(args.tokens || '0', 10) || 0);
196
+
197
+ codegraphTelemetry.logEvent(skillId, hasGraph, toolCalls, tokens);
198
+ }
199
+
200
+ function handleCompact(sub, argv = []) {
71
201
  switch (sub) {
72
202
  case 'stats':
73
203
  showCompactStats();
74
204
  break;
205
+ case 'log-codegraph-event':
206
+ cmdLogCodegraphEvent(argv);
207
+ break;
75
208
  case 'disable':
76
209
  compactTelemetry.disable();
77
210
  console.log(' compact-bash deshabilitado. Reactiva con: refacil-sdd-ai compact enable');
@@ -84,9 +217,15 @@ function handleCompact(sub) {
84
217
  compactTelemetry.clearLog();
85
218
  console.log(' compact.log limpiado.');
86
219
  break;
220
+ case 'codegraph-clear-log':
221
+ codegraphTelemetry.clearLog();
222
+ console.log(' codegraph.log limpiado.');
223
+ break;
87
224
  default:
88
- console.log('Uso: refacil-sdd-ai compact <stats|disable|enable|clear-log>');
225
+ console.log(
226
+ 'Uso: refacil-sdd-ai compact <stats|log-codegraph-event|disable|enable|clear-log|codegraph-clear-log>',
227
+ );
89
228
  }
90
229
  }
91
230
 
92
- module.exports = { handleCompact };
231
+ module.exports = { handleCompact, showCompactStats, cmdLogCodegraphEvent };
@@ -0,0 +1,352 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { findProjectRoot } = require('./sdd');
6
+ const { parseFile, extractArtifactLanguageMeta } = require('../spec-reader/md-parser');
7
+ const { artifactLanguageToTtsCode } = require('../spec-reader/lang');
8
+ const { writeSession, writeFolderSession } = require('../spec-reader/session');
9
+ const busSpawn = require('../bus/spawn');
10
+ const { openInBrowser } = require('../open-browser');
11
+ const {
12
+ resolveDefaultTtsLang,
13
+ isValidTtsLang,
14
+ detectTtsLangFromContent,
15
+ DEFAULT_TTS_LANG,
16
+ } = require('../spec-reader/lang');
17
+ const { loadBranchConfigWithSources } = require('../config');
18
+
19
+ const DEFAULT_LANG = DEFAULT_TTS_LANG;
20
+ const DEFAULT_VOICE = 'M3';
21
+ const DEFAULT_SPEED = 1;
22
+ const VALID_VOICES = new Set(['M1', 'M2', 'M3', 'M4', 'M5', 'F1', 'F2', 'F3', 'F4', 'F5']);
23
+
24
+ function parseReadSpecArgs(argv) {
25
+ const args = {};
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const token = argv[i];
28
+ if (!token || !token.startsWith('--')) continue;
29
+ const key = token.slice(2);
30
+ const next = argv[i + 1];
31
+ if (next === undefined || next.startsWith('--')) {
32
+ args[key] = true;
33
+ } else {
34
+ args[key] = next;
35
+ i++;
36
+ }
37
+ }
38
+ return args;
39
+ }
40
+
41
+ function printHelp() {
42
+ console.log(`
43
+ read-spec — Listen to a Markdown spec in the browser (on-device TTS)
44
+
45
+ Usage:
46
+ refacil-sdd-ai read-spec --file <path> [options]
47
+ refacil-sdd-ai read-spec --change <changeName> [--select <file.md>] [options]
48
+
49
+ Options:
50
+ --file <path> Markdown file (must be inside project root)
51
+ --change <changeName> Load all SDD artifacts for a change folder
52
+ --select <file.md> Pre-select a specific file in folder mode (default: proposal.md)
53
+ --lang <code> TTS language (default: SDD artifactLanguage → es/en)
54
+ --voice <id> Voice style M1–M5 or F1–F5 (default: ${DEFAULT_VOICE})
55
+ --speed <n> Speech speed 0.9–1.5 (default: ${DEFAULT_SPEED})
56
+ --help Show this help
57
+ `);
58
+ }
59
+
60
+ /**
61
+ * @param {string} fileArg
62
+ * @param {string} projectRoot
63
+ * @returns {string} absolute path
64
+ */
65
+ function resolveFileWithinProject(fileArg, projectRoot) {
66
+ const resolved = path.resolve(projectRoot, fileArg);
67
+ const rootNorm = path.resolve(projectRoot) + path.sep;
68
+ if (!resolved.startsWith(rootNorm) && resolved !== path.resolve(projectRoot)) {
69
+ throw new Error('Security: --file must resolve inside the project root');
70
+ }
71
+ return resolved;
72
+ }
73
+
74
+ /**
75
+ * Canonical ordering for SDD artifact files in a change folder.
76
+ * Files not in this list are appended alphabetically after.
77
+ */
78
+ const FOLDER_FILE_ORDER = ['proposal.md', 'design.md', 'tasks.md', 'specs.md'];
79
+
80
+ /**
81
+ * Sort .md filenames: canonical order first, then remaining alphabetically.
82
+ * @param {string[]} names
83
+ * @returns {string[]}
84
+ */
85
+ function sortFolderFiles(names) {
86
+ const canonical = FOLDER_FILE_ORDER.filter((n) => names.includes(n));
87
+ const rest = names
88
+ .filter((n) => !FOLDER_FILE_ORDER.includes(n))
89
+ .sort((a, b) => a.localeCompare(b));
90
+ return [...canonical, ...rest];
91
+ }
92
+
93
+ /**
94
+ * @param {string[]} argv
95
+ * @param {string} packageRoot
96
+ * @param {string} [projectRootOverride]
97
+ */
98
+ async function handleReadSpec(argv, packageRoot, projectRootOverride) {
99
+ const args = parseReadSpecArgs(argv || []);
100
+ if (args.help) {
101
+ printHelp();
102
+ process.exit(0);
103
+ }
104
+
105
+ // Mutual exclusivity: --change and --file cannot be used together
106
+ if (args.change && args.file) {
107
+ console.error(' Error: --change y --file son mutuamente excluyentes. Usa solo uno de los dos.');
108
+ process.exit(1);
109
+ }
110
+
111
+ // Neither --change nor --file provided
112
+ if (!args.change && !args.file) {
113
+ console.error(' Error: --file is required');
114
+ printHelp();
115
+ process.exit(1);
116
+ }
117
+
118
+ const projectRoot = projectRootOverride || findProjectRoot();
119
+
120
+ // -----------------------------------------------------------------------
121
+ // FOLDER MODE: --change <changeName>
122
+ // -----------------------------------------------------------------------
123
+ if (args.change) {
124
+ const changeName = String(args.change);
125
+ const changeFolder = path.join(projectRoot, 'refacil-sdd', 'changes', changeName);
126
+
127
+ const changesRoot = path.join(projectRoot, 'refacil-sdd', 'changes') + path.sep;
128
+ if (!changeFolder.startsWith(changesRoot)) {
129
+ console.error(' Error: --change must be a simple folder name (no path separators).');
130
+ process.exit(1);
131
+ }
132
+
133
+ if (!fs.existsSync(changeFolder)) {
134
+ console.error(` Error: no se encontró la carpeta de cambio '${changeName}' en refacil-sdd/changes/.`);
135
+ process.exit(1);
136
+ }
137
+
138
+ // Collect .md files in the change folder
139
+ let allEntries;
140
+ try {
141
+ allEntries = fs.readdirSync(changeFolder);
142
+ } catch (err) {
143
+ console.error(` Error: could not read change folder: ${err.message}`);
144
+ process.exit(1);
145
+ }
146
+ const mdFiles = allEntries.filter((n) => n.endsWith('.md'));
147
+
148
+ if (mdFiles.length === 0) {
149
+ console.error(` Error: la carpeta '${changeName}' no contiene archivos Markdown.`);
150
+ process.exit(1);
151
+ }
152
+
153
+ const sortedFiles = sortFolderFiles(mdFiles);
154
+
155
+ // Validate --select if provided
156
+ const selectArg = typeof args.select === 'string' ? args.select : 'proposal.md';
157
+ const resolvedSelect = sortedFiles.includes(selectArg) ? selectArg : null;
158
+ if (typeof args.select === 'string' && !resolvedSelect) {
159
+ console.error(` Error: el archivo '${args.select}' no existe en la carpeta de cambio '${changeName}'.`);
160
+ process.exit(1);
161
+ }
162
+ if (!resolvedSelect && typeof args.select !== 'string') {
163
+ console.warn(` Note: proposal.md not found in '${changeName}', opening '${sortedFiles[0]}' instead.`);
164
+ }
165
+ const selectedFile = resolvedSelect || sortedFiles[0];
166
+
167
+ const voice = typeof args.voice === 'string' ? args.voice.toUpperCase() : DEFAULT_VOICE;
168
+ const speedRaw = args.speed !== undefined ? parseFloat(args.speed) : DEFAULT_SPEED;
169
+
170
+ if (!VALID_VOICES.has(voice)) {
171
+ console.error(` Error: invalid --voice "${args.voice}". Use M1–M5 or F1–F5`);
172
+ process.exit(1);
173
+ }
174
+ if (Number.isNaN(speedRaw) || speedRaw < 0.9 || speedRaw > 1.5) {
175
+ console.error(' Error: --speed must be between 0.9 and 1.5');
176
+ process.exit(1);
177
+ }
178
+
179
+ const { artifactLanguage } = loadBranchConfigWithSources(projectRoot);
180
+ let resolvedLang = typeof args.lang === 'string' ? args.lang : null;
181
+
182
+ // Parse each file and collect sections
183
+ const files = [];
184
+ const allContents = [];
185
+ for (const name of sortedFiles) {
186
+ const absPath = path.join(changeFolder, name);
187
+ const relPath = path.relative(projectRoot, absPath).replace(/\\/g, '/');
188
+ let sections;
189
+ let fileContent;
190
+ try {
191
+ fileContent = fs.readFileSync(absPath, 'utf8');
192
+ sections = parseFile(absPath);
193
+ } catch (err) {
194
+ console.error(` Error parsing ${name}: ${err.message}`);
195
+ process.exit(1);
196
+ }
197
+ // Explicit meta comment takes priority (first file that has one wins)
198
+ if (!resolvedLang) {
199
+ const metaFromFile = extractArtifactLanguageMeta(fileContent);
200
+ if (metaFromFile) {
201
+ resolvedLang = artifactLanguageToTtsCode(metaFromFile);
202
+ }
203
+ }
204
+ allContents.push(fileContent);
205
+ files.push({ name, relPath, sections });
206
+ }
207
+
208
+ if (!resolvedLang) {
209
+ // Content-based detection: inspect heading patterns across all files
210
+ resolvedLang = detectTtsLangFromContent(allContents.join('\n'));
211
+ }
212
+
213
+ if (!resolvedLang) {
214
+ resolvedLang = resolveDefaultTtsLang(projectRoot);
215
+ }
216
+
217
+ if (!isValidTtsLang(resolvedLang)) {
218
+ console.error(` Error: invalid --lang "${resolvedLang}". Use a Supertonic code (e.g. es, en).`);
219
+ process.exit(1);
220
+ }
221
+
222
+ const relativeChangeFolder = path.relative(projectRoot, changeFolder).replace(/\\/g, '/');
223
+
224
+ const sessionId = writeFolderSession({
225
+ files,
226
+ selectedFile,
227
+ meta: {
228
+ lang: resolvedLang,
229
+ voice,
230
+ speed: speedRaw,
231
+ changeName,
232
+ artifactLanguage,
233
+ },
234
+ sourcePath: relativeChangeFolder,
235
+ createdAt: new Date().toISOString(),
236
+ });
237
+
238
+ let info;
239
+ try {
240
+ ({ info } = await busSpawn.ensureBroker(packageRoot));
241
+ } catch (err) {
242
+ console.error(` Error: could not start broker: ${err.message}`);
243
+ process.exit(1);
244
+ }
245
+
246
+ const url = `http://127.0.0.1:${info.port}/read-spec?id=${sessionId}`;
247
+ console.log(` read-spec session: ${sessionId}`);
248
+ console.log(` Open: ${url}`);
249
+
250
+ const opened = openInBrowser(url);
251
+ if (!opened) {
252
+ console.log(' (could not open browser automatically — open the URL above manually)');
253
+ }
254
+ return;
255
+ }
256
+
257
+ // -----------------------------------------------------------------------
258
+ // FILE MODE: --file <path>
259
+ // -----------------------------------------------------------------------
260
+ let filePath;
261
+ try {
262
+ filePath = resolveFileWithinProject(args.file, projectRoot);
263
+ } catch (err) {
264
+ console.error(` Error: ${err.message}`);
265
+ process.exit(1);
266
+ }
267
+
268
+ if (!fs.existsSync(filePath)) {
269
+ console.error(` Error: file not found: ${args.file}`);
270
+ process.exit(1);
271
+ }
272
+
273
+ if (path.extname(filePath).toLowerCase() !== '.md') {
274
+ console.error(' Error: --file must be a .md Markdown file');
275
+ process.exit(1);
276
+ }
277
+
278
+ const voice = typeof args.voice === 'string' ? args.voice.toUpperCase() : DEFAULT_VOICE;
279
+ const speedRaw = args.speed !== undefined ? parseFloat(args.speed) : DEFAULT_SPEED;
280
+
281
+ if (!VALID_VOICES.has(voice)) {
282
+ console.error(` Error: invalid --voice "${args.voice}". Use M1–M5 or F1–F5`);
283
+ process.exit(1);
284
+ }
285
+ if (Number.isNaN(speedRaw) || speedRaw < 0.9 || speedRaw > 1.5) {
286
+ console.error(' Error: --speed must be between 0.9 and 1.5');
287
+ process.exit(1);
288
+ }
289
+
290
+ let sections;
291
+ let fileContent;
292
+ try {
293
+ fileContent = fs.readFileSync(filePath, 'utf8');
294
+ sections = parseFile(filePath);
295
+ } catch (err) {
296
+ console.error(` Error parsing Markdown: ${err.message}`);
297
+ process.exit(1);
298
+ }
299
+
300
+ const relativeSource = path.relative(projectRoot, filePath).replace(/\\/g, '/');
301
+ const { artifactLanguage } = loadBranchConfigWithSources(projectRoot);
302
+ const metaFromFile = extractArtifactLanguageMeta(fileContent);
303
+ const resolvedLang = typeof args.lang === 'string'
304
+ ? args.lang
305
+ : (metaFromFile
306
+ ? artifactLanguageToTtsCode(metaFromFile)
307
+ : (detectTtsLangFromContent(fileContent) || resolveDefaultTtsLang(projectRoot)));
308
+ if (!isValidTtsLang(resolvedLang)) {
309
+ console.error(` Error: invalid --lang "${resolvedLang}". Use a Supertonic code (e.g. es, en).`);
310
+ process.exit(1);
311
+ }
312
+
313
+ const sessionId = writeSession({
314
+ sections,
315
+ meta: {
316
+ lang: resolvedLang,
317
+ voice,
318
+ speed: speedRaw,
319
+ artifactLanguage: metaFromFile || artifactLanguage,
320
+ },
321
+ sourcePath: relativeSource,
322
+ createdAt: new Date().toISOString(),
323
+ });
324
+
325
+ let info;
326
+ try {
327
+ ({ info } = await busSpawn.ensureBroker(packageRoot));
328
+ } catch (err) {
329
+ console.error(` Error: could not start broker: ${err.message}`);
330
+ process.exit(1);
331
+ }
332
+
333
+ const url = `http://127.0.0.1:${info.port}/read-spec?id=${sessionId}`;
334
+ console.log(` read-spec session: ${sessionId}`);
335
+ console.log(` Open: ${url}`);
336
+
337
+ const opened = openInBrowser(url);
338
+ if (!opened) {
339
+ console.log(' (could not open browser automatically — open the URL above manually)');
340
+ }
341
+ }
342
+
343
+ module.exports = {
344
+ handleReadSpec,
345
+ parseReadSpecArgs,
346
+ sortFolderFiles,
347
+ FOLDER_FILE_ORDER,
348
+ printHelp,
349
+ DEFAULT_LANG,
350
+ DEFAULT_VOICE,
351
+ DEFAULT_SPEED,
352
+ };