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.
- package/NOTICE.md +46 -0
- package/README.md +209 -42
- package/agents/auditor.md +46 -0
- package/agents/debugger.md +41 -1
- package/agents/implementer.md +76 -10
- package/agents/investigator.md +36 -0
- package/agents/proposer.md +46 -2
- package/agents/tester.md +45 -8
- package/agents/validator.md +67 -13
- package/bin/cli.js +428 -83
- package/bin/postinstall.js +20 -0
- package/lib/bus/broker.js +121 -3
- package/lib/bus/spawn.js +189 -121
- package/lib/check-review.js +102 -0
- package/lib/codegraph-telemetry.js +135 -0
- package/lib/codegraph.js +273 -0
- package/lib/commands/autopilot.js +120 -0
- package/lib/commands/bus.js +29 -36
- package/lib/commands/compact.js +185 -46
- package/lib/commands/read-spec.js +352 -0
- package/lib/commands/sdd.js +429 -44
- package/lib/compact-guidance.js +122 -77
- package/lib/config.js +136 -0
- package/lib/global-paths.js +56 -20
- package/lib/hooks.js +32 -4
- package/lib/ide-detection.js +1 -1
- package/lib/ignore-files.js +5 -1
- package/lib/installer.js +202 -19
- package/lib/kapso.js +241 -0
- package/lib/methodology-migration-pending.js +13 -0
- package/lib/open-browser.js +32 -0
- package/lib/opencode-migrate.js +148 -0
- package/lib/opencode-plugin/index.js +84 -104
- package/lib/opencode-plugin/rules.js +236 -0
- package/lib/project-root.js +154 -0
- package/lib/repo-ide-sync.js +5 -0
- package/lib/spec-reader/lang.js +72 -0
- package/lib/spec-reader/md-parser.js +299 -0
- package/lib/spec-reader/session.js +139 -0
- package/lib/spec-reader/ui/app.js +685 -0
- package/lib/spec-reader/ui/index.html +59 -0
- package/lib/spec-reader/ui/mixed-lang.js +200 -0
- package/lib/spec-reader/ui/model-cache.js +117 -0
- package/lib/spec-reader/ui/style.css +294 -0
- package/lib/spec-reader/ui/supertonic-helper.js +565 -0
- package/lib/spec-sync.js +258 -0
- package/lib/test-scope.js +713 -0
- package/lib/testing-policy-sync.js +14 -2
- package/package.json +6 -3
- package/skills/apply/SKILL.md +39 -64
- package/skills/archive/SKILL.md +74 -48
- package/skills/ask/SKILL.md +43 -8
- package/skills/autopilot/SKILL.md +476 -0
- package/skills/bug/SKILL.md +52 -53
- package/skills/explore/SKILL.md +48 -1
- package/skills/guide/SKILL.md +31 -13
- package/skills/inbox/SKILL.md +9 -0
- package/skills/join/SKILL.md +1 -1
- package/skills/prereqs/BUS-CROSS-REPO.md +33 -16
- package/skills/prereqs/METHODOLOGY-CONTRACT.md +96 -17
- package/skills/prereqs/SKILL.md +1 -1
- package/skills/propose/SKILL.md +74 -19
- package/skills/read-spec/SKILL.md +76 -0
- package/skills/reply/SKILL.md +42 -9
- package/skills/review/SKILL.md +63 -25
- package/skills/review/checklist.md +2 -2
- package/skills/say/SKILL.md +40 -4
- package/skills/setup/SKILL.md +59 -5
- package/skills/setup/troubleshooting.md +11 -3
- package/skills/stats/SKILL.md +157 -0
- package/skills/test/SKILL.md +35 -10
- package/skills/up-code/SKILL.md +20 -13
- package/skills/update/SKILL.md +32 -1
- package/skills/verify/SKILL.md +78 -41
- package/templates/compact-guidance.md +10 -0
- 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
|
+
};
|
package/lib/repo-ide-sync.js
CHANGED
|
@@ -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
|
+
};
|