refacil-sdd-ai 5.2.2 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/NOTICE.md +46 -0
  2. package/README.md +209 -42
  3. package/agents/auditor.md +46 -0
  4. package/agents/debugger.md +41 -1
  5. package/agents/implementer.md +76 -10
  6. package/agents/investigator.md +36 -0
  7. package/agents/proposer.md +46 -2
  8. package/agents/tester.md +45 -8
  9. package/agents/validator.md +67 -13
  10. package/bin/cli.js +428 -83
  11. package/bin/postinstall.js +20 -0
  12. package/lib/bus/broker.js +121 -3
  13. package/lib/bus/spawn.js +189 -121
  14. package/lib/check-review.js +102 -0
  15. package/lib/codegraph-telemetry.js +135 -0
  16. package/lib/codegraph.js +273 -0
  17. package/lib/commands/autopilot.js +120 -0
  18. package/lib/commands/bus.js +29 -36
  19. package/lib/commands/compact.js +185 -46
  20. package/lib/commands/read-spec.js +352 -0
  21. package/lib/commands/sdd.js +429 -44
  22. package/lib/compact-guidance.js +122 -77
  23. package/lib/config.js +136 -0
  24. package/lib/global-paths.js +56 -20
  25. package/lib/hooks.js +32 -4
  26. package/lib/ide-detection.js +1 -1
  27. package/lib/ignore-files.js +5 -1
  28. package/lib/installer.js +202 -19
  29. package/lib/kapso.js +241 -0
  30. package/lib/methodology-migration-pending.js +13 -0
  31. package/lib/open-browser.js +32 -0
  32. package/lib/opencode-migrate.js +148 -0
  33. package/lib/opencode-plugin/index.js +84 -104
  34. package/lib/opencode-plugin/rules.js +236 -0
  35. package/lib/project-root.js +154 -0
  36. package/lib/repo-ide-sync.js +5 -0
  37. package/lib/spec-reader/lang.js +72 -0
  38. package/lib/spec-reader/md-parser.js +299 -0
  39. package/lib/spec-reader/session.js +139 -0
  40. package/lib/spec-reader/ui/app.js +685 -0
  41. package/lib/spec-reader/ui/index.html +59 -0
  42. package/lib/spec-reader/ui/mixed-lang.js +200 -0
  43. package/lib/spec-reader/ui/model-cache.js +117 -0
  44. package/lib/spec-reader/ui/style.css +294 -0
  45. package/lib/spec-reader/ui/supertonic-helper.js +565 -0
  46. package/lib/spec-sync.js +258 -0
  47. package/lib/test-scope.js +713 -0
  48. package/lib/testing-policy-sync.js +14 -2
  49. package/package.json +6 -3
  50. package/skills/apply/SKILL.md +39 -64
  51. package/skills/archive/SKILL.md +74 -48
  52. package/skills/ask/SKILL.md +43 -8
  53. package/skills/autopilot/SKILL.md +476 -0
  54. package/skills/bug/SKILL.md +52 -53
  55. package/skills/explore/SKILL.md +48 -1
  56. package/skills/guide/SKILL.md +31 -13
  57. package/skills/inbox/SKILL.md +9 -0
  58. package/skills/join/SKILL.md +1 -1
  59. package/skills/prereqs/BUS-CROSS-REPO.md +33 -16
  60. package/skills/prereqs/METHODOLOGY-CONTRACT.md +96 -17
  61. package/skills/prereqs/SKILL.md +1 -1
  62. package/skills/propose/SKILL.md +74 -19
  63. package/skills/read-spec/SKILL.md +76 -0
  64. package/skills/reply/SKILL.md +42 -9
  65. package/skills/review/SKILL.md +63 -25
  66. package/skills/review/checklist.md +2 -2
  67. package/skills/say/SKILL.md +40 -4
  68. package/skills/setup/SKILL.md +59 -5
  69. package/skills/setup/troubleshooting.md +11 -3
  70. package/skills/stats/SKILL.md +157 -0
  71. package/skills/test/SKILL.md +35 -10
  72. package/skills/up-code/SKILL.md +20 -13
  73. package/skills/update/SKILL.md +32 -1
  74. package/skills/verify/SKILL.md +78 -41
  75. package/templates/compact-guidance.md +10 -0
  76. package/templates/methodology-guide.md +5 -0
@@ -0,0 +1,154 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * Walk up from startDir and return the topmost directory that contains a .git entry.
8
+ * @param {string} [startDir]
9
+ * @returns {string|null}
10
+ */
11
+ function findGitRoot(startDir = process.cwd()) {
12
+ let dir = path.resolve(startDir);
13
+ const { root } = path.parse(dir);
14
+ let gitRoot = null;
15
+
16
+ while (dir !== root) {
17
+ if (fs.existsSync(path.join(dir, '.git'))) gitRoot = dir;
18
+ const parent = path.dirname(dir);
19
+ if (parent === dir) break;
20
+ dir = parent;
21
+ }
22
+
23
+ return gitRoot;
24
+ }
25
+
26
+ /**
27
+ * Walk up from startDir and return the topmost directory that contains refacil-sdd/.
28
+ * @param {string} [startDir]
29
+ * @returns {string|null}
30
+ */
31
+ function findRefacilSddRoot(startDir = process.cwd()) {
32
+ let dir = path.resolve(startDir);
33
+ const { root } = path.parse(dir);
34
+ let sddRoot = null;
35
+
36
+ while (dir !== root) {
37
+ if (fs.existsSync(path.join(dir, 'refacil-sdd'))) sddRoot = dir;
38
+ const parent = path.dirname(dir);
39
+ if (parent === dir) break;
40
+ dir = parent;
41
+ }
42
+
43
+ return sddRoot;
44
+ }
45
+
46
+ /**
47
+ * @param {string} dir
48
+ * @returns {boolean}
49
+ */
50
+ function isRefacilSddAiPackageDir(dir) {
51
+ try {
52
+ const pkgPath = path.join(dir, 'package.json');
53
+ if (!fs.existsSync(pkgPath)) return false;
54
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
55
+ return pkg && pkg.name === 'refacil-sdd-ai';
56
+ } catch (_) {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * If dir is the methodology npm package inside a monorepo, return the git repo root above it.
63
+ * @param {string} dir
64
+ * @returns {string}
65
+ */
66
+ function elevateFromEmbeddedPackage(dir) {
67
+ const resolved = path.resolve(dir);
68
+ if (!isRefacilSddAiPackageDir(resolved)) return resolved;
69
+
70
+ const parent = path.dirname(resolved);
71
+ if (fs.existsSync(path.join(parent, '.git'))) return parent;
72
+
73
+ const gitRoot = findGitRoot(parent);
74
+ return gitRoot || resolved;
75
+ }
76
+
77
+ /**
78
+ * Repo root for SDD commands: prefer .git (full tree), then refacil-sdd/, else cwd.
79
+ * @param {string} [startDir]
80
+ * @returns {string}
81
+ */
82
+ function findProjectRoot(startDir = process.cwd()) {
83
+ const gitRoot = findGitRoot(startDir);
84
+ if (gitRoot) return gitRoot;
85
+
86
+ const sddRoot = findRefacilSddRoot(startDir);
87
+ if (sddRoot) return sddRoot;
88
+
89
+ return path.resolve(startDir);
90
+ }
91
+
92
+ /**
93
+ * Read optional JSON from stdin (Cursor/Claude hooks).
94
+ * @returns {object|null}
95
+ */
96
+ function readHookStdinJson() {
97
+ try {
98
+ if (process.stdin.isTTY) return null;
99
+ const stdin = fs.readFileSync(0, 'utf8');
100
+ if (!stdin.trim()) return null;
101
+ return JSON.parse(stdin);
102
+ } catch (_) {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Root of the workspace/repo for hook-driven commands (check-update, CodeGraph).
109
+ *
110
+ * Resolution order (first match wins):
111
+ * 1. CURSOR_PROJECT_DIR — Cursor hooks (sessionStart, workspaceOpen, preToolUse, …)
112
+ * 2. CLAUDE_PROJECT_DIR — Claude Code hooks (SessionStart, PreToolUse, …); Codex uses the same alias
113
+ * 3. workspace_roots[0] from hook JSON stdin — Cursor workspaceOpen when env is absent
114
+ * 4. Ascend from process.cwd(): topmost .git, then topmost refacil-sdd/
115
+ * 5. If result is the embedded refacil-sdd-ai npm package inside a monorepo → parent .git root
116
+ *
117
+ * OpenCode: session.created passes event.projectRoot as child `cwd` and mirrors it into (1)/(2)
118
+ * in runCheckUpdateCli — same effective root without Cursor-specific APIs.
119
+ *
120
+ * @param {{ hookInput?: object|null, skipStdin?: boolean }} [options]
121
+ * @returns {string}
122
+ */
123
+ function resolveWorkspaceRoot(options = {}) {
124
+ const hookInput = options.hookInput !== undefined
125
+ ? options.hookInput
126
+ : (options.skipStdin ? null : readHookStdinJson());
127
+
128
+ const envDir = process.env.CURSOR_PROJECT_DIR || process.env.CLAUDE_PROJECT_DIR;
129
+ if (envDir) {
130
+ const resolved = path.resolve(envDir);
131
+ if (fs.existsSync(resolved)) return resolved;
132
+ }
133
+
134
+ const workspaceRoot = hookInput && Array.isArray(hookInput.workspace_roots)
135
+ ? hookInput.workspace_roots[0]
136
+ : null;
137
+ if (workspaceRoot) {
138
+ const resolved = path.resolve(workspaceRoot);
139
+ if (fs.existsSync(resolved)) return resolved;
140
+ }
141
+
142
+ const fromTraversal = findProjectRoot(process.cwd());
143
+ return elevateFromEmbeddedPackage(fromTraversal);
144
+ }
145
+
146
+ module.exports = {
147
+ findGitRoot,
148
+ findRefacilSddRoot,
149
+ findProjectRoot,
150
+ isRefacilSddAiPackageDir,
151
+ elevateFromEmbeddedPackage,
152
+ readHookStdinJson,
153
+ resolveWorkspaceRoot,
154
+ };
@@ -7,6 +7,7 @@ const {
7
7
  globalClaudeDir,
8
8
  globalCursorDir,
9
9
  globalOpenCodeDir,
10
+ legacyOpenCodeDirs,
10
11
  globalCodexDir,
11
12
  readSelectedIDEs,
12
13
  writeSelectedIDEs,
@@ -36,9 +37,13 @@ function resolveSelectedIDEsForRepo(projectRoot, homeDir = os.homedir()) {
36
37
  detectedIds.includes('cursor') ||
37
38
  fs.existsSync(path.join(globalCursorDir(homeDir), 'skills')) ||
38
39
  fs.existsSync(path.join(projectRoot, '.cursor'));
40
+ const hasLegacyOpenCodeSkills = legacyOpenCodeDirs(homeDir).some((dir) =>
41
+ fs.existsSync(path.join(dir, 'skills')),
42
+ );
39
43
  const hasOpenCodeDir =
40
44
  detectedIds.includes('opencode') ||
41
45
  fs.existsSync(path.join(globalOpenCodeDir(homeDir), 'skills')) ||
46
+ hasLegacyOpenCodeSkills ||
42
47
  fs.existsSync(path.join(projectRoot, '.opencode'));
43
48
  const hasCodexFallback =
44
49
  detectedIds.includes('codex') || fs.existsSync(path.join(globalCodexDir(homeDir), 'skills'));
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ const { loadBranchConfigWithSources } = require('../config');
4
+
5
+ /** Supertonic language codes supported for TTS. */
6
+ const TTS_LANG_CODES = new Set([
7
+ 'en', 'ko', 'ja', 'ar', 'bg', 'cs', 'da', 'de', 'el', 'es', 'et', 'fi', 'fr',
8
+ 'hi', 'hr', 'hu', 'id', 'it', 'lt', 'lv', 'nl', 'pl', 'pt', 'ro', 'ru', 'sk',
9
+ 'sl', 'sv', 'tr', 'uk', 'vi',
10
+ ]);
11
+
12
+ const ARTIFACT_TO_TTS = {
13
+ spanish: 'es',
14
+ english: 'en',
15
+ };
16
+
17
+ const DEFAULT_TTS_LANG = 'es';
18
+
19
+ /**
20
+ * Map SDD artifactLanguage (english | spanish) to Supertonic code.
21
+ * @param {string} artifactLanguage
22
+ * @returns {string}
23
+ */
24
+ function artifactLanguageToTtsCode(artifactLanguage) {
25
+ return ARTIFACT_TO_TTS[artifactLanguage] || DEFAULT_TTS_LANG;
26
+ }
27
+
28
+ /**
29
+ * Default TTS language from project/global SDD config.
30
+ * @param {string} projectRoot
31
+ * @returns {string}
32
+ */
33
+ function resolveDefaultTtsLang(projectRoot) {
34
+ const { artifactLanguage } = loadBranchConfigWithSources(projectRoot);
35
+ return artifactLanguageToTtsCode(artifactLanguage);
36
+ }
37
+
38
+ /**
39
+ * @param {string} code
40
+ * @returns {boolean}
41
+ */
42
+ function isValidTtsLang(code) {
43
+ return TTS_LANG_CODES.has(code);
44
+ }
45
+
46
+ // Heading patterns that unambiguously identify the artifact language from content.
47
+ const ENGLISH_CONTENT_RE = /^##\s+(?:Objective|Purpose|Scope|Requirements?|Acceptance Criteria|Rejection Criteria)\b/im;
48
+ const SPANISH_CONTENT_RE = /^##\s+(?:Objetivo|Propósito|Alcance|Requisitos?|Criterios? de Aceptación|Criterios? de Rechazo)\b/im;
49
+
50
+ /**
51
+ * Detect TTS language from markdown content when no explicit meta comment is present.
52
+ * Returns 'en', 'es', or null if the content is ambiguous.
53
+ * @param {string} content
54
+ * @returns {string|null}
55
+ */
56
+ function detectTtsLangFromContent(content) {
57
+ const hasEnglish = ENGLISH_CONTENT_RE.test(content);
58
+ const hasSpanish = SPANISH_CONTENT_RE.test(content);
59
+ if (hasEnglish && !hasSpanish) return 'en';
60
+ if (hasSpanish && !hasEnglish) return 'es';
61
+ return null;
62
+ }
63
+
64
+ module.exports = {
65
+ TTS_LANG_CODES,
66
+ ARTIFACT_TO_TTS,
67
+ DEFAULT_TTS_LANG,
68
+ artifactLanguageToTtsCode,
69
+ resolveDefaultTtsLang,
70
+ isValidTtsLang,
71
+ detectTtsLangFromContent,
72
+ };
@@ -0,0 +1,299 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const MAX_FILE_BYTES = 512 * 1024;
7
+
8
+ const SPEAKABLE_REPLACEMENTS = {
9
+ '→': ' arrow ',
10
+ '←': ' back ',
11
+ '⇒': ' then ',
12
+ '✅': '',
13
+ '❌': '',
14
+ '⚠️': '',
15
+ 'ℹ️': '',
16
+ };
17
+
18
+ /**
19
+ * @param {string} raw
20
+ * @returns {string}
21
+ */
22
+ function stripFencedCodeBlocks(raw) {
23
+ return raw.replace(/```(\w*)[\s\S]*?```/g, (_match, lang) => {
24
+ if (lang && lang.trim()) {
25
+ // Named language: actual code — replace with spoken label, never read source
26
+ return ` code block: ${lang.trim()} `;
27
+ }
28
+ // No language specifier: plain-text diagram or prose — extract body and read it
29
+ const firstNewline = _match.indexOf('\n');
30
+ const lastFence = _match.lastIndexOf('```');
31
+ if (firstNewline < 0 || firstNewline >= lastFence) return '';
32
+ return _match.slice(firstNewline + 1, lastFence).trim();
33
+ });
34
+ }
35
+
36
+ /**
37
+ * Replace markdown table blocks with a spoken label.
38
+ * Extracts column headers from the first row: "tabla: ColA, ColB."
39
+ * The separator row (|----|) is used to confirm a real table.
40
+ * @param {string} text
41
+ * @returns {string}
42
+ */
43
+ function stripMarkdownTables(text) {
44
+ const lines = text.split('\n');
45
+ const result = [];
46
+ let i = 0;
47
+ while (i < lines.length) {
48
+ const line = lines[i];
49
+ if (/^\|.+\|/.test(line)) {
50
+ const nextLine = lines[i + 1] || '';
51
+ if (/^\|[-:| ]+\|/.test(nextLine)) {
52
+ const headers = line.split('|').map((h) => h.trim()).filter(Boolean);
53
+ const label = headers.length > 0
54
+ ? `tabla: ${headers.join(', ')}.`
55
+ : 'tabla.';
56
+ result.push(label);
57
+ i += 2; // skip header + separator
58
+ // Read each data row as a comma-separated list
59
+ while (i < lines.length && /^\|/.test(lines[i])) {
60
+ const cells = lines[i].split('|').map((c) => c.trim()).filter(Boolean);
61
+ if (cells.length > 0) result.push(`${cells.join(', ')}.`);
62
+ i++;
63
+ }
64
+ continue;
65
+ }
66
+ }
67
+ result.push(line);
68
+ i++;
69
+ }
70
+ return result.join('\n');
71
+ }
72
+
73
+ /**
74
+ * @param {string} text
75
+ * @returns {string}
76
+ */
77
+ function stripInlineMarkdown(text) {
78
+ let out = text;
79
+ out = out.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1');
80
+ out = out.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
81
+ out = out.replace(/`([^`]+)`/g, '$1');
82
+ out = out.replace(/\*\*([^*]+)\*\*/g, '$1');
83
+ out = out.replace(/\*([^*]+)\*/g, '$1');
84
+ out = out.replace(/__([^_]+)__/g, '$1');
85
+ out = out.replace(/_([^_]+)_/g, '$1');
86
+ out = out.replace(/^#{1,6}\s+/gm, '');
87
+ out = out.replace(/^>\s?/gm, '');
88
+ out = out.replace(/^[-*+]\s+/gm, '');
89
+ out = out.replace(/^\d+\.\s+/gm, '');
90
+ out = out.replace(/~~([^~]+)~~/g, '$1');
91
+ // Replace HTML tag references with the tag name so TTS can pronounce them
92
+ out = out.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)(?:\s[^>]*)?\/?>/g, (_, name) => ` ${name} `);
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * @param {string} text
98
+ * @returns {string}
99
+ */
100
+ function normalizeSpeakable(text) {
101
+ const emojiPattern = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/gu;
102
+ const lines = text.split('\n').map((line) => {
103
+ let out = line;
104
+ out = out.replace(emojiPattern, '');
105
+ for (const [sym, repl] of Object.entries(SPEAKABLE_REPLACEMENTS)) {
106
+ out = out.split(sym).join(repl);
107
+ }
108
+ out = out.replace(/\s+/g, ' ').trim();
109
+ // CA-07: add period at end of paragraph lines without terminal punctuation.
110
+ // Exclude lines ending with :,; — colons introduce lists, semicolons/commas
111
+ // already have their own prosodic meaning in TTS.
112
+ if (out && !/[.!?:,;]$/.test(out)) {
113
+ out = `${out}.`;
114
+ }
115
+ return out;
116
+ }).filter(Boolean);
117
+ return lines.join('\n');
118
+ }
119
+
120
+ /**
121
+ * @param {string} content
122
+ * @returns {string}
123
+ */
124
+ function preprocessMarkdown(content) {
125
+ let out = content
126
+ .replace(/<!--[\s\S]*?-->/g, '')
127
+ .replace(/^\s*---\s*$/gm, '');
128
+ const isSpanish = /artifactLanguage=spanish/i.test(content) || /^##\s+Propósito\s*$/im.test(content);
129
+ if (isSpanish) {
130
+ out = out
131
+ .replace(/^####\s+Scenario:/gim, '#### Escenario:')
132
+ .replace(/^###\s+Requirement:/gim, '### Requisito:');
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /**
138
+ * @param {string} content
139
+ * @returns {string|null}
140
+ */
141
+ function extractArtifactLanguageMeta(content) {
142
+ const m = content.match(/<!--\s*refacil-sdd:\s*artifactLanguage=(\w+)\s*-->/i);
143
+ return m ? m[1].toLowerCase() : null;
144
+ }
145
+
146
+ /**
147
+ * CA-08: Given the raw body and its already-normalized speakable text, replace the
148
+ * terminal period of each list item (except the last) with a comma, and ensure the
149
+ * last list item ends with a period. Non-list lines are left untouched.
150
+ *
151
+ * Strategy: scan the raw body to identify which *line indices* (after stripping) came
152
+ * from list items, then apply comma/period punctuation to those speakable lines.
153
+ *
154
+ * @param {string} rawBody — original body before stripping
155
+ * @param {string} speakable — already-normalized speakable text (output of normalizeSpeakable)
156
+ * @returns {string}
157
+ */
158
+ function applyListPunctuation(rawBody, speakable) {
159
+ // Identify list-item line positions in rawBody
160
+ const rawLines = rawBody.split('\n');
161
+ const isListItem = rawLines.map((l) => /^[-*+]\s+/.test(l) || /^\d+\.\s+/.test(l));
162
+
163
+ // Collect consecutive list-item runs; mark which speakable-line index corresponds
164
+ // We re-derive speakable lines from the speakable text (already joined by \n, filtered)
165
+ const speakLines = speakable.split('\n');
166
+ if (speakLines.length === 0) return speakable;
167
+
168
+ // Map each raw line to its speakable equivalent by filtering empty lines as done in
169
+ // normalizeSpeakable (filter(Boolean)). We walk both arrays together.
170
+ let speakIdx = 0;
171
+ /** @type {boolean[]} — parallel to speakLines: true if this speakable line is a list item */
172
+ const speakIsListItem = new Array(speakLines.length).fill(false);
173
+
174
+ for (let r = 0; r < rawLines.length && speakIdx < speakLines.length; r++) {
175
+ const rawTrimmed = rawLines[r].trim();
176
+ if (!rawTrimmed) continue; // blank lines are filtered by normalizeSpeakable
177
+ speakIsListItem[speakIdx] = isListItem[r];
178
+ speakIdx += 1;
179
+ }
180
+
181
+ // Find all consecutive list-item runs in speakLines and apply punctuation
182
+ let i = 0;
183
+ const result = [...speakLines];
184
+ while (i < result.length) {
185
+ if (!speakIsListItem[i]) {
186
+ i += 1;
187
+ continue;
188
+ }
189
+ // Find end of this run
190
+ let runEnd = i;
191
+ while (runEnd + 1 < result.length && speakIsListItem[runEnd + 1]) {
192
+ runEnd += 1;
193
+ }
194
+ if (runEnd > i) {
195
+ // More than one item: replace period with comma for all except last
196
+ for (let j = i; j < runEnd; j++) {
197
+ result[j] = result[j].replace(/\.$/, ',');
198
+ }
199
+ // Ensure last item ends with period (normalizeSpeakable already adds it, but be safe)
200
+ if (!/[.!?]$/.test(result[runEnd])) {
201
+ result[runEnd] = `${result[runEnd]}.`;
202
+ }
203
+ }
204
+ // Single-item lists already have a period from normalizeSpeakable — nothing to do
205
+ i = runEnd + 1;
206
+ }
207
+
208
+ return result.join('\n');
209
+ }
210
+
211
+ /**
212
+ * Split markdown into sections by ATX headings (## and deeper; # starts new section too).
213
+ * @param {string} content
214
+ * @returns {{ title: string, text: string, rawMarkdown: string }[]}
215
+ */
216
+ function parseMarkdown(content) {
217
+ const normalized = preprocessMarkdown(content).replace(/\r\n/g, '\n');
218
+ const lines = normalized.split('\n');
219
+ const sections = [];
220
+ let currentTitle = '';
221
+ let currentLines = [];
222
+
223
+ const flush = () => {
224
+ const body = currentLines.join('\n').trim();
225
+ const rawMarkdown = body;
226
+ const afterCode = stripFencedCodeBlocks(body);
227
+ const afterTables = stripMarkdownTables(afterCode);
228
+ const inline = stripInlineMarkdown(afterTables);
229
+ const speakable = normalizeSpeakable(inline);
230
+ // TTS flattens \n to space (supertonic-helper line 107), so each line must end
231
+ // with sentence-ending punctuation to create natural pauses. Commas produce only
232
+ // a brief pause that runs long list items together; periods create a full stop.
233
+ // applyListPunctuation (CA-08 comma conversion) is intentionally skipped here.
234
+ const text = speakable;
235
+ if (!text) {
236
+ currentLines = [];
237
+ return;
238
+ }
239
+ sections.push({ title: currentTitle.trim(), text, rawMarkdown });
240
+ currentLines = [];
241
+ };
242
+
243
+ for (const line of lines) {
244
+ const heading = line.match(/^(#{1,6})\s+(.+)$/);
245
+ if (heading) {
246
+ const level = heading[1].length;
247
+ // #### and deeper: stay inside the current requirement (avoid 40+ micro-sections)
248
+ if (level >= 4) {
249
+ const sub = normalizeSpeakable(stripInlineMarkdown(heading[2]));
250
+ if (sub) currentLines.push(sub);
251
+ continue;
252
+ }
253
+ const hasBody = currentLines.some((l) => l.trim());
254
+ if (level === 1) {
255
+ if (hasBody) flush();
256
+ currentTitle = normalizeSpeakable(stripInlineMarkdown(heading[2]));
257
+ currentLines = [];
258
+ continue;
259
+ }
260
+ if (hasBody) flush();
261
+ else if (currentTitle) currentLines = [];
262
+ currentTitle = normalizeSpeakable(stripInlineMarkdown(heading[2]));
263
+ continue;
264
+ }
265
+ if (line.trim()) currentLines.push(line);
266
+ }
267
+
268
+ flush();
269
+
270
+ if (sections.length === 0) {
271
+ return [{ title: '', text: '' }];
272
+ }
273
+
274
+ return sections;
275
+ }
276
+
277
+ /**
278
+ * @param {string} filePath
279
+ * @returns {{ title: string, text: string }[]}
280
+ */
281
+ function parseFile(filePath) {
282
+ const stat = fs.statSync(filePath);
283
+ if (stat.size > MAX_FILE_BYTES) {
284
+ throw new Error(`File exceeds maximum size (${MAX_FILE_BYTES} bytes)`);
285
+ }
286
+ const content = fs.readFileSync(filePath, 'utf8');
287
+ if (!content.trim()) {
288
+ return [{ title: '', text: '' }];
289
+ }
290
+ return parseMarkdown(content);
291
+ }
292
+
293
+ module.exports = {
294
+ parseMarkdown,
295
+ parseFile,
296
+ preprocessMarkdown,
297
+ extractArtifactLanguageMeta,
298
+ MAX_FILE_BYTES,
299
+ };
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const crypto = require('crypto');
7
+
8
+ const HOME_DIR = path.join(os.homedir(), '.refacil-sdd-ai');
9
+ const SESSIONS_DIR = path.join(HOME_DIR, 'read-spec-sessions');
10
+ const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
11
+ const SESSION_ID_RE = /^[a-f0-9-]{36}$/i;
12
+
13
+ function ensureSessionsDir() {
14
+ fs.mkdirSync(SESSIONS_DIR, { recursive: true });
15
+ }
16
+
17
+ /**
18
+ * @param {string} sessionId
19
+ * @returns {boolean}
20
+ */
21
+ function isValidSessionId(sessionId) {
22
+ return typeof sessionId === 'string' && SESSION_ID_RE.test(sessionId);
23
+ }
24
+
25
+ function sessionFilePath(sessionId) {
26
+ return path.join(SESSIONS_DIR, `${sessionId}.json`);
27
+ }
28
+
29
+ /**
30
+ * Remove session files older than SESSION_TTL_MS.
31
+ */
32
+ function cleanupStaleSessions() {
33
+ ensureSessionsDir();
34
+ const now = Date.now();
35
+ let entries;
36
+ try {
37
+ entries = fs.readdirSync(SESSIONS_DIR);
38
+ } catch (_) {
39
+ return;
40
+ }
41
+ for (const name of entries) {
42
+ if (!name.endsWith('.json')) continue;
43
+ const full = path.join(SESSIONS_DIR, name);
44
+ try {
45
+ const stat = fs.statSync(full);
46
+ if (now - stat.mtimeMs > SESSION_TTL_MS) {
47
+ fs.unlinkSync(full);
48
+ }
49
+ } catch (_) {
50
+ // ignore per-file errors
51
+ }
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Write a file-mode session (single file).
57
+ *
58
+ * @param {{ sections: object[], meta: object, sourcePath: string, createdAt?: string }} payload
59
+ * @returns {string} sessionId
60
+ */
61
+ function writeSession(payload) {
62
+ ensureSessionsDir();
63
+ cleanupStaleSessions();
64
+ const sessionId = crypto.randomUUID();
65
+ const filePath = sessionFilePath(sessionId);
66
+ const data = {
67
+ mode: 'file',
68
+ sections: payload.sections,
69
+ meta: payload.meta,
70
+ sourcePath: payload.sourcePath,
71
+ createdAt: payload.createdAt || new Date().toISOString(),
72
+ };
73
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
74
+ return sessionId;
75
+ }
76
+
77
+ /**
78
+ * Write a folder-mode session (multiple files with sidebar).
79
+ *
80
+ * Session format:
81
+ * {
82
+ * mode: "folder",
83
+ * files: [{ name: string, relPath: string, sections: object[] }],
84
+ * selectedFile: string,
85
+ * meta: { lang, voice, speed, changeName },
86
+ * sourcePath: string,
87
+ * createdAt: string
88
+ * }
89
+ *
90
+ * @param {{ files: Array<{ name: string, relPath: string, sections: object[] }>, selectedFile: string, meta: object, sourcePath: string, createdAt?: string }} payload
91
+ * @returns {string} sessionId
92
+ */
93
+ function writeFolderSession(payload) {
94
+ ensureSessionsDir();
95
+ cleanupStaleSessions();
96
+ const sessionId = crypto.randomUUID();
97
+ const filePath = sessionFilePath(sessionId);
98
+ // Expose the sections of the selected file at the top level for backward compat
99
+ // with the /api/read-spec handler which reads sessionData.sections.
100
+ const selectedFile = payload.files.find((f) => f.name === payload.selectedFile) || payload.files[0];
101
+ const data = {
102
+ mode: 'folder',
103
+ files: payload.files,
104
+ selectedFile: payload.selectedFile,
105
+ // sections of the initially selected file (for compat with existing HTTP handler)
106
+ sections: selectedFile ? selectedFile.sections : [],
107
+ meta: payload.meta,
108
+ sourcePath: payload.sourcePath,
109
+ createdAt: payload.createdAt || new Date().toISOString(),
110
+ };
111
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
112
+ return sessionId;
113
+ }
114
+
115
+ /**
116
+ * @param {string} sessionId
117
+ * @returns {object | null}
118
+ */
119
+ function readSession(sessionId) {
120
+ if (!isValidSessionId(sessionId)) return null;
121
+ const filePath = sessionFilePath(sessionId);
122
+ try {
123
+ const raw = fs.readFileSync(filePath, 'utf8');
124
+ return JSON.parse(raw);
125
+ } catch (_) {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ module.exports = {
131
+ HOME_DIR,
132
+ SESSIONS_DIR,
133
+ SESSION_TTL_MS,
134
+ isValidSessionId,
135
+ writeSession,
136
+ writeFolderSession,
137
+ readSession,
138
+ cleanupStaleSessions,
139
+ };