@leonarto/spec-embryo 0.1.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/README.md +156 -0
- package/package.json +48 -0
- package/src/backends/base.ts +18 -0
- package/src/backends/deterministic.ts +105 -0
- package/src/backends/index.ts +26 -0
- package/src/backends/prompt.ts +169 -0
- package/src/backends/subprocess.ts +198 -0
- package/src/cli.ts +111 -0
- package/src/commands/agents.ts +16 -0
- package/src/commands/current.ts +95 -0
- package/src/commands/doctor.ts +12 -0
- package/src/commands/handoff.ts +64 -0
- package/src/commands/init.ts +101 -0
- package/src/commands/reshape.ts +20 -0
- package/src/commands/resume.ts +19 -0
- package/src/commands/spec.ts +108 -0
- package/src/commands/status.ts +98 -0
- package/src/commands/task.ts +190 -0
- package/src/commands/ui.ts +35 -0
- package/src/domain.ts +357 -0
- package/src/engine.ts +290 -0
- package/src/frontmatter.ts +83 -0
- package/src/index.ts +75 -0
- package/src/paths.ts +32 -0
- package/src/repository.ts +807 -0
- package/src/services/adoption.ts +169 -0
- package/src/services/agents.ts +191 -0
- package/src/services/dashboard.ts +776 -0
- package/src/services/details.ts +453 -0
- package/src/services/doctor.ts +452 -0
- package/src/services/layout.ts +420 -0
- package/src/services/spec-answer-evaluation.ts +103 -0
- package/src/services/spec-import.ts +217 -0
- package/src/services/spec-questions.ts +343 -0
- package/src/services/ui.ts +34 -0
- package/src/storage.ts +57 -0
- package/src/templates.ts +270 -0
- package/tsconfig.json +17 -0
- package/web/package.json +24 -0
- package/web/src/app.css +83 -0
- package/web/src/app.d.ts +6 -0
- package/web/src/app.html +11 -0
- package/web/src/lib/components/AnalysisFilters.svelte +293 -0
- package/web/src/lib/components/DocumentBody.svelte +100 -0
- package/web/src/lib/components/MultiSelectDropdown.svelte +280 -0
- package/web/src/lib/components/SelectDropdown.svelte +265 -0
- package/web/src/lib/server/project-root.ts +34 -0
- package/web/src/lib/task-board.ts +20 -0
- package/web/src/routes/+layout.server.ts +57 -0
- package/web/src/routes/+layout.svelte +421 -0
- package/web/src/routes/+layout.ts +1 -0
- package/web/src/routes/+page.svelte +530 -0
- package/web/src/routes/specs/+page.svelte +416 -0
- package/web/src/routes/specs/[specId]/+page.server.ts +81 -0
- package/web/src/routes/specs/[specId]/+page.svelte +675 -0
- package/web/src/routes/tasks/+page.svelte +341 -0
- package/web/src/routes/tasks/[taskId]/+page.server.ts +12 -0
- package/web/src/routes/tasks/[taskId]/+page.svelte +431 -0
- package/web/src/routes/timeline/+page.svelte +1093 -0
- package/web/svelte.config.js +10 -0
- package/web/tsconfig.json +9 -0
- package/web/vite.config.ts +11 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { extname, join, relative } from "node:path";
|
|
3
|
+
import { buildManagedPaths } from "./layout.ts";
|
|
4
|
+
|
|
5
|
+
const SUPPORTED_EXTENSIONS = new Set([".md", ".mdx", ".txt", ".rst", ".yaml", ".yml", ".toml", ".json"]);
|
|
6
|
+
|
|
7
|
+
export interface SpecImportSourceFile {
|
|
8
|
+
absolutePath: string;
|
|
9
|
+
relativePath: string;
|
|
10
|
+
extension: string;
|
|
11
|
+
chars: number;
|
|
12
|
+
headings: string[];
|
|
13
|
+
excerpt: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SpecImportBundle {
|
|
17
|
+
sourceRoot: string;
|
|
18
|
+
fileCount: number;
|
|
19
|
+
files: SpecImportSourceFile[];
|
|
20
|
+
userInstructions?: string;
|
|
21
|
+
targetStructure: {
|
|
22
|
+
specsDir: string;
|
|
23
|
+
tasksDir: string;
|
|
24
|
+
specFrontmatterFields: string[];
|
|
25
|
+
taskFrontmatterFields: string[];
|
|
26
|
+
};
|
|
27
|
+
migrationRules: string[];
|
|
28
|
+
recommendedPrompt: string;
|
|
29
|
+
expectedOutputFormat: {
|
|
30
|
+
requiredFiles: string[];
|
|
31
|
+
migrationChecklist: string[];
|
|
32
|
+
outputSections: string[];
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeWhitespace(value: string): string {
|
|
37
|
+
return value.replace(/\r\n/g, "\n").trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function truncate(value: string, limit: number): string {
|
|
41
|
+
if (value.length <= limit) {
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return `${value.slice(0, Math.max(0, limit - 14))}\n...[truncated]`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function extractHeadings(content: string): string[] {
|
|
49
|
+
return normalizeWhitespace(content)
|
|
50
|
+
.split("\n")
|
|
51
|
+
.filter((line) => /^#{1,6}\s+/.test(line))
|
|
52
|
+
.slice(0, 12)
|
|
53
|
+
.map((line) => line.replace(/^#{1,6}\s+/, "").trim())
|
|
54
|
+
.filter(Boolean);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function walkSupportedFiles(rootDir: string, currentDir = rootDir): Promise<string[]> {
|
|
58
|
+
const entries = await readdir(currentDir, { withFileTypes: true });
|
|
59
|
+
const results: string[] = [];
|
|
60
|
+
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (entry.name === ".git" || entry.name === "node_modules") {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const fullPath = join(currentDir, entry.name);
|
|
67
|
+
|
|
68
|
+
if (entry.isDirectory()) {
|
|
69
|
+
results.push(...(await walkSupportedFiles(rootDir, fullPath)));
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (entry.isFile() && entry.name !== "AGENTS.md" && SUPPORTED_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
|
|
74
|
+
results.push(fullPath);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return results.sort();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function buildSpecImportBundle(
|
|
82
|
+
rootDir: string,
|
|
83
|
+
sourceDir: string,
|
|
84
|
+
options?: {
|
|
85
|
+
instructions?: string;
|
|
86
|
+
maxCharsPerFile?: number;
|
|
87
|
+
targetMemoryDir?: string;
|
|
88
|
+
},
|
|
89
|
+
): Promise<SpecImportBundle> {
|
|
90
|
+
const maxCharsPerFile = options?.maxCharsPerFile ?? 1800;
|
|
91
|
+
const managed = buildManagedPaths(options?.targetMemoryDir ?? "docs/spm");
|
|
92
|
+
const absoluteSourceDir = sourceDir.startsWith("/") ? sourceDir : join(rootDir, sourceDir);
|
|
93
|
+
const files = await walkSupportedFiles(absoluteSourceDir);
|
|
94
|
+
|
|
95
|
+
const fileEntries = await Promise.all(
|
|
96
|
+
files.map(async (filePath) => {
|
|
97
|
+
const content = await readFile(filePath, "utf8");
|
|
98
|
+
return {
|
|
99
|
+
absolutePath: filePath,
|
|
100
|
+
relativePath: relative(rootDir, filePath),
|
|
101
|
+
extension: extname(filePath).toLowerCase(),
|
|
102
|
+
chars: content.length,
|
|
103
|
+
headings: extractHeadings(content),
|
|
104
|
+
excerpt: truncate(normalizeWhitespace(content), maxCharsPerFile),
|
|
105
|
+
};
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const sourceSummary = fileEntries
|
|
110
|
+
.slice(0, 10)
|
|
111
|
+
.map((file) => `- ${file.relativePath} (${file.chars} chars${file.headings.length > 0 ? `; headings: ${file.headings.join(" | ")}` : ""})`)
|
|
112
|
+
.join("\n");
|
|
113
|
+
|
|
114
|
+
const instructionSuffix = options?.instructions?.trim()
|
|
115
|
+
? `\nAdditional migration instructions from the user:\n${options.instructions.trim()}`
|
|
116
|
+
: "";
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
sourceRoot: absoluteSourceDir,
|
|
120
|
+
fileCount: fileEntries.length,
|
|
121
|
+
files: fileEntries,
|
|
122
|
+
userInstructions: options?.instructions?.trim() || undefined,
|
|
123
|
+
targetStructure: {
|
|
124
|
+
specsDir: `${managed.specsDir}/`,
|
|
125
|
+
tasksDir: `${managed.tasksDir}/`,
|
|
126
|
+
specFrontmatterFields: ["doc_kind", "id", "title", "status", "summary", "task_ids", "tags", "owner", "updated_at"],
|
|
127
|
+
taskFrontmatterFields: ["doc_kind", "id", "title", "status", "summary", "spec_ids", "depends_on", "blocked_by", "priority", "owner_role", "tags", "updated_at"],
|
|
128
|
+
},
|
|
129
|
+
migrationRules: [
|
|
130
|
+
"Create Markdown files with TOML frontmatter so Spec Embryo can parse them deterministically.",
|
|
131
|
+
"Use `doc_kind = \"spec\"` for specs and `doc_kind = \"task\"` for tasks.",
|
|
132
|
+
"Keep facts faithful to the source docs. Do not invent requirements, dependencies, or statuses unless the user explicitly asked for interpretation.",
|
|
133
|
+
"When dependencies are unclear, prefer leaving `depends_on = []` and note ambiguity in the body instead of guessing.",
|
|
134
|
+
"Prefer one coherent spec per product slice and create linked tasks for executable work.",
|
|
135
|
+
"Preserve important goals, constraints, decisions, and non-goals in Markdown body sections.",
|
|
136
|
+
"Use stable IDs like `SPEC-001` and `TASK-001`.",
|
|
137
|
+
`If the source folder mixes strategy and execution notes, separate them into \`${managed.specsDir}/\` and \`${managed.tasksDir}/\` rather than flattening everything into one file.`,
|
|
138
|
+
],
|
|
139
|
+
recommendedPrompt: [
|
|
140
|
+
"You are migrating an existing project spec/docs folder into Spec Embryo's deterministic format.",
|
|
141
|
+
"Use the scanned source file inventory and excerpts as the source of truth.",
|
|
142
|
+
`Produce repo-native Markdown files under \`${managed.specsDir}/\` and \`${managed.tasksDir}/\` using TOML frontmatter.`,
|
|
143
|
+
"The result must be deterministic-friendly: explicit IDs, linked tasks, and no missing required frontmatter fields.",
|
|
144
|
+
"Do not invent facts. When something is ambiguous, preserve the ambiguity in the Markdown body or add a concise note instead of fabricating structure.",
|
|
145
|
+
"Prefer stable spec intent in spec files and actionable execution slices in task files.",
|
|
146
|
+
"",
|
|
147
|
+
"Source inventory:",
|
|
148
|
+
sourceSummary || "- No supported files found.",
|
|
149
|
+
instructionSuffix,
|
|
150
|
+
]
|
|
151
|
+
.filter(Boolean)
|
|
152
|
+
.join("\n"),
|
|
153
|
+
expectedOutputFormat: {
|
|
154
|
+
requiredFiles: [`${managed.specsDir}/*.md`, `${managed.tasksDir}/*.md`],
|
|
155
|
+
migrationChecklist: [
|
|
156
|
+
"Every generated spec file has the required TOML frontmatter fields.",
|
|
157
|
+
"Every generated task file links back to one or more spec IDs.",
|
|
158
|
+
"No task dependency is invented unless explicit in the source.",
|
|
159
|
+
"Body sections preserve meaningful goals, scope, decisions, and notes from the source docs.",
|
|
160
|
+
],
|
|
161
|
+
outputSections: ["migration-summary", "files-created-or-updated", "ambiguities", "follow-up-gaps"],
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function renderSpecImportBundle(bundle: SpecImportBundle): string {
|
|
167
|
+
const lines: string[] = [];
|
|
168
|
+
|
|
169
|
+
lines.push("Spec import prompt bundle");
|
|
170
|
+
lines.push("");
|
|
171
|
+
lines.push(`Source root: ${bundle.sourceRoot}`);
|
|
172
|
+
lines.push(`Supported files scanned: ${bundle.fileCount}`);
|
|
173
|
+
lines.push(`Target specs dir: ${bundle.targetStructure.specsDir}`);
|
|
174
|
+
lines.push(`Target tasks dir: ${bundle.targetStructure.tasksDir}`);
|
|
175
|
+
lines.push("");
|
|
176
|
+
lines.push("Target spec frontmatter fields:");
|
|
177
|
+
lines.push(`- ${bundle.targetStructure.specFrontmatterFields.join(", ")}`);
|
|
178
|
+
lines.push("Target task frontmatter fields:");
|
|
179
|
+
lines.push(`- ${bundle.targetStructure.taskFrontmatterFields.join(", ")}`);
|
|
180
|
+
lines.push("");
|
|
181
|
+
lines.push("Migration rules:");
|
|
182
|
+
for (const rule of bundle.migrationRules) {
|
|
183
|
+
lines.push(`- ${rule}`);
|
|
184
|
+
}
|
|
185
|
+
lines.push("");
|
|
186
|
+
lines.push("Scanned source files:");
|
|
187
|
+
if (bundle.files.length === 0) {
|
|
188
|
+
lines.push("- none");
|
|
189
|
+
} else {
|
|
190
|
+
for (const file of bundle.files) {
|
|
191
|
+
const headingText = file.headings.length > 0 ? ` | headings: ${file.headings.join(" | ")}` : "";
|
|
192
|
+
lines.push(`- ${file.relativePath} (${file.chars} chars${headingText})`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
lines.push("");
|
|
196
|
+
lines.push("Recommended prompt:");
|
|
197
|
+
lines.push(bundle.recommendedPrompt);
|
|
198
|
+
lines.push("");
|
|
199
|
+
lines.push("Expected output format:");
|
|
200
|
+
lines.push(`- required files: ${bundle.expectedOutputFormat.requiredFiles.join(", ")}`);
|
|
201
|
+
lines.push(`- output sections: ${bundle.expectedOutputFormat.outputSections.join(", ")}`);
|
|
202
|
+
|
|
203
|
+
if (bundle.userInstructions) {
|
|
204
|
+
lines.push("");
|
|
205
|
+
lines.push(`User instructions: ${bundle.userInstructions}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
lines.push("");
|
|
209
|
+
lines.push("File excerpts:");
|
|
210
|
+
for (const file of bundle.files) {
|
|
211
|
+
lines.push("");
|
|
212
|
+
lines.push(`## ${file.relativePath}`);
|
|
213
|
+
lines.push(file.excerpt || "[empty file]");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return lines.join("\n");
|
|
217
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
export interface MarkdownSection {
|
|
2
|
+
heading: string;
|
|
3
|
+
depth: number;
|
|
4
|
+
lines: string[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface SplitMarkdownSectionsResult {
|
|
8
|
+
preamble: string[];
|
|
9
|
+
sections: MarkdownSection[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SpecOpenQuestion {
|
|
13
|
+
id: string;
|
|
14
|
+
question: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ResolvedDecisionEntry {
|
|
18
|
+
id: string;
|
|
19
|
+
question?: string;
|
|
20
|
+
answer: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ParsedSpecQuestionSections {
|
|
24
|
+
openQuestions: SpecOpenQuestion[];
|
|
25
|
+
resolvedDecisions: ResolvedDecisionEntry[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SpecAnswerInput {
|
|
29
|
+
question: string;
|
|
30
|
+
answer: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AppliedSpecAnswers {
|
|
34
|
+
body: string;
|
|
35
|
+
answeredQuestions: string[];
|
|
36
|
+
remainingQuestions: SpecOpenQuestion[];
|
|
37
|
+
resolvedDecisions: ResolvedDecisionEntry[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeHeading(value: string): string {
|
|
41
|
+
return value
|
|
42
|
+
.trim()
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.replace(/[^\p{L}\p{N}\s]+/gu, " ")
|
|
45
|
+
.replace(/\s+/g, " ");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeQuestion(value: string): string {
|
|
49
|
+
return value.trim().replace(/\s+/g, " ");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function splitSectionItems(lines: string[]): string[][] {
|
|
53
|
+
const items: string[][] = [];
|
|
54
|
+
let current: string[] = [];
|
|
55
|
+
let currentMode: "bullet" | "paragraph" | undefined;
|
|
56
|
+
|
|
57
|
+
function flush() {
|
|
58
|
+
if (current.length === 0) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
items.push(current.map((line) => line.trimEnd()));
|
|
63
|
+
current = [];
|
|
64
|
+
currentMode = undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const line of lines) {
|
|
68
|
+
const trimmed = line.trim();
|
|
69
|
+
|
|
70
|
+
if (trimmed.length === 0) {
|
|
71
|
+
flush();
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const bulletMatch = trimmed.match(/^[-*]\s+(.*)$/);
|
|
76
|
+
if (bulletMatch) {
|
|
77
|
+
flush();
|
|
78
|
+
currentMode = "bullet";
|
|
79
|
+
current.push(bulletMatch[1]!.trim());
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!currentMode) {
|
|
84
|
+
currentMode = "paragraph";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
current.push(currentMode === "bullet" ? trimmed : trimmed);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
flush();
|
|
91
|
+
return items;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseQuestionBlock(lines: string[], index: number): SpecOpenQuestion | undefined {
|
|
95
|
+
const question = normalizeQuestion(lines.join(" "));
|
|
96
|
+
if (!question) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
id: `open-question-${index + 1}`,
|
|
102
|
+
question,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseResolvedDecisionBlock(lines: string[], index: number): ResolvedDecisionEntry | undefined {
|
|
107
|
+
const normalizedLines = lines.map((line) => line.trim()).filter(Boolean);
|
|
108
|
+
if (normalizedLines.length === 0) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let question: string | undefined;
|
|
113
|
+
const answerLines: string[] = [];
|
|
114
|
+
let collectingAnswer = false;
|
|
115
|
+
|
|
116
|
+
for (const line of normalizedLines) {
|
|
117
|
+
const questionMatch = line.match(/^Question:\s*(.*)$/i);
|
|
118
|
+
if (questionMatch) {
|
|
119
|
+
question = normalizeQuestion(questionMatch[1] ?? "");
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const decisionMatch = line.match(/^Decision:\s*(.*)$/i);
|
|
124
|
+
if (decisionMatch) {
|
|
125
|
+
answerLines.push(decisionMatch[1]!.trim());
|
|
126
|
+
collectingAnswer = true;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const answerMatch = line.match(/^Answer:\s*(.*)$/i);
|
|
131
|
+
if (answerMatch) {
|
|
132
|
+
const initialAnswer = answerMatch[1]!.trim();
|
|
133
|
+
if (initialAnswer) {
|
|
134
|
+
answerLines.push(initialAnswer);
|
|
135
|
+
}
|
|
136
|
+
collectingAnswer = true;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (collectingAnswer) {
|
|
141
|
+
answerLines.push(line);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
answerLines.push(line);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const answer = answerLines.join("\n").trim();
|
|
149
|
+
if (!answer) {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
id: `resolved-decision-${index + 1}`,
|
|
155
|
+
question,
|
|
156
|
+
answer,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function renderOpenQuestions(items: SpecOpenQuestion[]): string[] {
|
|
161
|
+
return items.map((item) => `- ${item.question}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function renderResolvedDecisions(items: ResolvedDecisionEntry[]): string[] {
|
|
165
|
+
const lines: string[] = [];
|
|
166
|
+
|
|
167
|
+
for (const [index, item] of items.entries()) {
|
|
168
|
+
if (index > 0) {
|
|
169
|
+
lines.push("");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (item.question) {
|
|
173
|
+
lines.push(`- Question: ${item.question}`);
|
|
174
|
+
} else {
|
|
175
|
+
lines.push("- Decision:");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const answerLines = item.answer
|
|
179
|
+
.split("\n")
|
|
180
|
+
.map((line) => line.trim())
|
|
181
|
+
.filter((line) => line.length > 0);
|
|
182
|
+
|
|
183
|
+
if (answerLines.length === 0) {
|
|
184
|
+
lines.push(" Answer:");
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
lines.push(" Answer:");
|
|
189
|
+
for (const line of answerLines) {
|
|
190
|
+
lines.push(` ${line}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return lines;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function renderMarkdownSections(result: SplitMarkdownSectionsResult): string {
|
|
198
|
+
const chunks: string[] = [];
|
|
199
|
+
const trimmedPreamble = result.preamble.join("\n").trim();
|
|
200
|
+
|
|
201
|
+
if (trimmedPreamble) {
|
|
202
|
+
chunks.push(trimmedPreamble);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
for (const section of result.sections) {
|
|
206
|
+
const content = section.lines.join("\n").trimEnd();
|
|
207
|
+
const sectionLines = [`${"#".repeat(section.depth)} ${section.heading}`];
|
|
208
|
+
if (content.length > 0) {
|
|
209
|
+
sectionLines.push("", content);
|
|
210
|
+
}
|
|
211
|
+
chunks.push(sectionLines.join("\n"));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return `${chunks.join("\n\n").trim()}\n`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function splitMarkdownSections(body: string): SplitMarkdownSectionsResult {
|
|
218
|
+
const lines = body.replace(/\r\n/g, "\n").split("\n");
|
|
219
|
+
const preamble: string[] = [];
|
|
220
|
+
const sections: MarkdownSection[] = [];
|
|
221
|
+
let currentSection: MarkdownSection | undefined;
|
|
222
|
+
|
|
223
|
+
for (const line of lines) {
|
|
224
|
+
const match = line.trim().match(/^(#{1,6})\s+(.*)$/);
|
|
225
|
+
if (match) {
|
|
226
|
+
currentSection = {
|
|
227
|
+
heading: match[2]!.trim(),
|
|
228
|
+
depth: match[1]!.length,
|
|
229
|
+
lines: [],
|
|
230
|
+
};
|
|
231
|
+
sections.push(currentSection);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (currentSection) {
|
|
236
|
+
currentSection.lines.push(line);
|
|
237
|
+
} else {
|
|
238
|
+
preamble.push(line);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
preamble,
|
|
244
|
+
sections,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function parseSpecQuestionSections(body: string): ParsedSpecQuestionSections {
|
|
249
|
+
const { sections } = splitMarkdownSections(body);
|
|
250
|
+
const openQuestionsSection = sections.find((section) => normalizeHeading(section.heading) === "open questions");
|
|
251
|
+
const resolvedDecisionsSection = sections.find((section) => normalizeHeading(section.heading) === "resolved decisions");
|
|
252
|
+
|
|
253
|
+
const openQuestions = openQuestionsSection
|
|
254
|
+
? splitSectionItems(openQuestionsSection.lines)
|
|
255
|
+
.map((lines, index) => parseQuestionBlock(lines, index))
|
|
256
|
+
.filter((item): item is SpecOpenQuestion => Boolean(item))
|
|
257
|
+
: [];
|
|
258
|
+
|
|
259
|
+
const resolvedDecisions = resolvedDecisionsSection
|
|
260
|
+
? splitSectionItems(resolvedDecisionsSection.lines)
|
|
261
|
+
.map((lines, index) => parseResolvedDecisionBlock(lines, index))
|
|
262
|
+
.filter((item): item is ResolvedDecisionEntry => Boolean(item))
|
|
263
|
+
: [];
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
openQuestions,
|
|
267
|
+
resolvedDecisions,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function applySpecAnswers(body: string, answers: SpecAnswerInput[]): AppliedSpecAnswers {
|
|
272
|
+
const filteredAnswers = answers
|
|
273
|
+
.map((answer) => ({
|
|
274
|
+
question: normalizeQuestion(answer.question),
|
|
275
|
+
answer: answer.answer.trim(),
|
|
276
|
+
}))
|
|
277
|
+
.filter((answer) => answer.question.length > 0 && answer.answer.length > 0);
|
|
278
|
+
|
|
279
|
+
if (filteredAnswers.length === 0) {
|
|
280
|
+
throw new Error("At least one answered question is required.");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const split = splitMarkdownSections(body);
|
|
284
|
+
const openQuestionsIndex = split.sections.findIndex((section) => normalizeHeading(section.heading) === "open questions");
|
|
285
|
+
if (openQuestionsIndex === -1) {
|
|
286
|
+
throw new Error("This spec does not have an Open Questions section.");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const resolvedDecisionsIndex = split.sections.findIndex((section) => normalizeHeading(section.heading) === "resolved decisions");
|
|
290
|
+
const existing = parseSpecQuestionSections(body);
|
|
291
|
+
const answerByQuestion = new Map(filteredAnswers.map((answer) => [answer.question, answer.answer]));
|
|
292
|
+
const openQuestionSet = new Set(existing.openQuestions.map((entry) => entry.question));
|
|
293
|
+
|
|
294
|
+
for (const question of answerByQuestion.keys()) {
|
|
295
|
+
if (!openQuestionSet.has(question)) {
|
|
296
|
+
throw new Error(`Open question not found in spec: ${question}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const answeredQuestions = existing.openQuestions
|
|
301
|
+
.map((entry) => entry.question)
|
|
302
|
+
.filter((question) => answerByQuestion.has(question));
|
|
303
|
+
const remainingQuestions = existing.openQuestions.filter((entry) => !answerByQuestion.has(entry.question));
|
|
304
|
+
const nextResolvedDecisions = [
|
|
305
|
+
...existing.resolvedDecisions,
|
|
306
|
+
...answeredQuestions.map((question, index) => ({
|
|
307
|
+
id: `resolved-decision-new-${index + 1}`,
|
|
308
|
+
question,
|
|
309
|
+
answer: answerByQuestion.get(question) ?? "",
|
|
310
|
+
})),
|
|
311
|
+
];
|
|
312
|
+
|
|
313
|
+
if (remainingQuestions.length > 0) {
|
|
314
|
+
split.sections[openQuestionsIndex] = {
|
|
315
|
+
...split.sections[openQuestionsIndex]!,
|
|
316
|
+
lines: renderOpenQuestions(remainingQuestions),
|
|
317
|
+
};
|
|
318
|
+
} else {
|
|
319
|
+
split.sections.splice(openQuestionsIndex, 1);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const resolvedSection: MarkdownSection = {
|
|
323
|
+
heading: "Resolved Decisions",
|
|
324
|
+
depth: 2,
|
|
325
|
+
lines: renderResolvedDecisions(nextResolvedDecisions),
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
if (resolvedDecisionsIndex >= 0) {
|
|
329
|
+
const adjustedIndex = remainingQuestions.length === 0 && resolvedDecisionsIndex > openQuestionsIndex ? resolvedDecisionsIndex - 1 : resolvedDecisionsIndex;
|
|
330
|
+
split.sections[adjustedIndex] = resolvedSection;
|
|
331
|
+
} else if (openQuestionsIndex >= 0 && remainingQuestions.length > 0) {
|
|
332
|
+
split.sections.splice(openQuestionsIndex, 0, resolvedSection);
|
|
333
|
+
} else {
|
|
334
|
+
split.sections.push(resolvedSection);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
body: renderMarkdownSections(split),
|
|
339
|
+
answeredQuestions,
|
|
340
|
+
remainingQuestions,
|
|
341
|
+
resolvedDecisions: nextResolvedDecisions,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import type { ParsedArgs } from "../cli.ts";
|
|
3
|
+
import { flagValue } from "../cli.ts";
|
|
4
|
+
import { pathExists } from "../storage.ts";
|
|
5
|
+
|
|
6
|
+
export interface UiLaunchConfig {
|
|
7
|
+
webDir: string;
|
|
8
|
+
host: string;
|
|
9
|
+
port: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const PACKAGED_WEB_DIR = fileURLToPath(new URL("../../web", import.meta.url));
|
|
13
|
+
|
|
14
|
+
export async function resolveUiLaunchConfig(rootDir: string, parsed: ParsedArgs): Promise<UiLaunchConfig> {
|
|
15
|
+
const webDir = PACKAGED_WEB_DIR;
|
|
16
|
+
|
|
17
|
+
if (!(await pathExists(webDir))) {
|
|
18
|
+
throw new Error("Packaged web app not found. Reinstall Spec Embryo so the UI assets are available.");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const host = flagValue(parsed, "host") ?? "127.0.0.1";
|
|
22
|
+
const portRaw = flagValue(parsed, "port");
|
|
23
|
+
const port = portRaw ? Number.parseInt(portRaw, 10) : 4173;
|
|
24
|
+
|
|
25
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
26
|
+
throw new Error("`ui --port` must be a positive integer.");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
webDir,
|
|
31
|
+
host,
|
|
32
|
+
port,
|
|
33
|
+
};
|
|
34
|
+
}
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readdir, mkdir, readFile, writeFile, access } from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { dirname, extname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export async function pathExists(filePath: string): Promise<boolean> {
|
|
6
|
+
try {
|
|
7
|
+
await access(filePath, constants.F_OK);
|
|
8
|
+
return true;
|
|
9
|
+
} catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function ensureDir(dirPath: string): Promise<void> {
|
|
15
|
+
await mkdir(dirPath, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readUtf8(filePath: string): Promise<string> {
|
|
19
|
+
return readFile(filePath, "utf8");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function writeUtf8(filePath: string, content: string): Promise<void> {
|
|
23
|
+
await ensureDir(dirname(filePath));
|
|
24
|
+
await writeFile(filePath, content, "utf8");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function writeIfMissing(filePath: string, content: string): Promise<boolean> {
|
|
28
|
+
if (await pathExists(filePath)) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
await writeUtf8(filePath, content);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function listMarkdownFiles(dirPath: string): Promise<string[]> {
|
|
37
|
+
if (!(await pathExists(dirPath))) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
42
|
+
const results: string[] = [];
|
|
43
|
+
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
const fullPath = join(dirPath, entry.name);
|
|
46
|
+
if (entry.isDirectory()) {
|
|
47
|
+
results.push(...(await listMarkdownFiles(fullPath)));
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (entry.isFile() && extname(entry.name) === ".md" && entry.name !== "AGENTS.md") {
|
|
52
|
+
results.push(fullPath);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return results.sort();
|
|
57
|
+
}
|