omegon 0.6.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/.gitattributes +3 -0
- package/AGENTS.md +16 -0
- package/LICENSE +15 -0
- package/README.md +289 -0
- package/bin/pi.mjs +30 -0
- package/extensions/00-secrets/index.ts +1126 -0
- package/extensions/01-auth/auth.ts +401 -0
- package/extensions/01-auth/index.ts +289 -0
- package/extensions/auto-compact.ts +42 -0
- package/extensions/bootstrap/deps.ts +291 -0
- package/extensions/bootstrap/index.ts +811 -0
- package/extensions/chronos/chronos.sh +487 -0
- package/extensions/chronos/index.ts +148 -0
- package/extensions/cleave/assessment.ts +754 -0
- package/extensions/cleave/bridge.ts +31 -0
- package/extensions/cleave/conflicts.ts +250 -0
- package/extensions/cleave/dispatcher.ts +808 -0
- package/extensions/cleave/guardrails.ts +426 -0
- package/extensions/cleave/index.ts +3121 -0
- package/extensions/cleave/lifecycle-emitter.ts +20 -0
- package/extensions/cleave/openspec.ts +811 -0
- package/extensions/cleave/planner.ts +260 -0
- package/extensions/cleave/review.ts +579 -0
- package/extensions/cleave/skills.ts +355 -0
- package/extensions/cleave/types.ts +261 -0
- package/extensions/cleave/workspace.ts +861 -0
- package/extensions/cleave/worktree.ts +243 -0
- package/extensions/core-renderers.ts +253 -0
- package/extensions/dashboard/context-gauge.ts +58 -0
- package/extensions/dashboard/file-watch.ts +14 -0
- package/extensions/dashboard/footer.ts +1145 -0
- package/extensions/dashboard/git.ts +185 -0
- package/extensions/dashboard/index.ts +478 -0
- package/extensions/dashboard/memory-audit.ts +34 -0
- package/extensions/dashboard/overlay-data.ts +705 -0
- package/extensions/dashboard/overlay.ts +365 -0
- package/extensions/dashboard/render-utils.ts +54 -0
- package/extensions/dashboard/types.ts +191 -0
- package/extensions/dashboard/uri-helper.ts +45 -0
- package/extensions/debug.ts +69 -0
- package/extensions/defaults.ts +282 -0
- package/extensions/design-tree/dashboard-state.ts +161 -0
- package/extensions/design-tree/design-card.ts +362 -0
- package/extensions/design-tree/index.ts +2130 -0
- package/extensions/design-tree/lifecycle-emitter.ts +41 -0
- package/extensions/design-tree/tree.ts +1607 -0
- package/extensions/design-tree/types.ts +163 -0
- package/extensions/distill.ts +127 -0
- package/extensions/effort/index.ts +395 -0
- package/extensions/effort/tiers.ts +146 -0
- package/extensions/effort/types.ts +105 -0
- package/extensions/lib/git-state.ts +227 -0
- package/extensions/lib/local-models.ts +157 -0
- package/extensions/lib/model-preferences.ts +51 -0
- package/extensions/lib/model-routing.ts +720 -0
- package/extensions/lib/operator-fallback.ts +205 -0
- package/extensions/lib/operator-profile.ts +360 -0
- package/extensions/lib/slash-command-bridge.ts +253 -0
- package/extensions/lib/typebox-helpers.ts +16 -0
- package/extensions/local-inference/index.ts +727 -0
- package/extensions/mcp-bridge/README.md +220 -0
- package/extensions/mcp-bridge/index.ts +951 -0
- package/extensions/mcp-bridge/lib.ts +365 -0
- package/extensions/mcp-bridge/mcp.json +3 -0
- package/extensions/mcp-bridge/package.json +11 -0
- package/extensions/model-budget.ts +752 -0
- package/extensions/offline-driver.ts +403 -0
- package/extensions/openspec/archive-gate.ts +164 -0
- package/extensions/openspec/branch-cleanup.ts +64 -0
- package/extensions/openspec/dashboard-state.ts +50 -0
- package/extensions/openspec/index.ts +1917 -0
- package/extensions/openspec/lifecycle-emitter.ts +65 -0
- package/extensions/openspec/lifecycle-files.ts +70 -0
- package/extensions/openspec/lifecycle.ts +50 -0
- package/extensions/openspec/reconcile.ts +187 -0
- package/extensions/openspec/spec.ts +1385 -0
- package/extensions/openspec/types.ts +98 -0
- package/extensions/project-memory/DESIGN-global-mind.md +198 -0
- package/extensions/project-memory/README.md +202 -0
- package/extensions/project-memory/api-types.ts +382 -0
- package/extensions/project-memory/compaction-policy.ts +29 -0
- package/extensions/project-memory/core.ts +164 -0
- package/extensions/project-memory/embeddings.ts +230 -0
- package/extensions/project-memory/extraction-v2.ts +861 -0
- package/extensions/project-memory/factstore.ts +2177 -0
- package/extensions/project-memory/index.ts +3459 -0
- package/extensions/project-memory/injection-metrics.ts +91 -0
- package/extensions/project-memory/jsonl-io.ts +12 -0
- package/extensions/project-memory/lifecycle.ts +331 -0
- package/extensions/project-memory/migration.ts +293 -0
- package/extensions/project-memory/package.json +9 -0
- package/extensions/project-memory/sci-renderers.ts +7 -0
- package/extensions/project-memory/template.ts +103 -0
- package/extensions/project-memory/triggers.ts +52 -0
- package/extensions/project-memory/types.ts +102 -0
- package/extensions/render/composition/fonts/Inter-Bold.ttf +0 -0
- package/extensions/render/composition/fonts/Inter-Regular.ttf +0 -0
- package/extensions/render/composition/fonts/Tomorrow-Bold.ttf +0 -0
- package/extensions/render/composition/fonts/Tomorrow-Regular.ttf +0 -0
- package/extensions/render/composition/package-lock.json +534 -0
- package/extensions/render/composition/package.json +22 -0
- package/extensions/render/composition/render.mjs +246 -0
- package/extensions/render/composition/test-comp.tsx +87 -0
- package/extensions/render/composition/types.ts +24 -0
- package/extensions/render/excalidraw/UPSTREAM.md +81 -0
- package/extensions/render/excalidraw/elements.ts +764 -0
- package/extensions/render/excalidraw/index.ts +66 -0
- package/extensions/render/excalidraw/types.ts +223 -0
- package/extensions/render/excalidraw-renderer/pyproject.toml +8 -0
- package/extensions/render/excalidraw-renderer/render_excalidraw.py +182 -0
- package/extensions/render/excalidraw-renderer/render_template.html +59 -0
- package/extensions/render/index.ts +830 -0
- package/extensions/render/native-diagrams/index.ts +57 -0
- package/extensions/render/native-diagrams/motifs.ts +542 -0
- package/extensions/render/native-diagrams/raster.ts +8 -0
- package/extensions/render/native-diagrams/scene.ts +75 -0
- package/extensions/render/native-diagrams/spec.ts +204 -0
- package/extensions/render/native-diagrams/svg.ts +116 -0
- package/extensions/sci-ui.ts +304 -0
- package/extensions/session-log.ts +174 -0
- package/extensions/shared-state.ts +146 -0
- package/extensions/spinner-verbs.ts +91 -0
- package/extensions/style.ts +281 -0
- package/extensions/terminal-title.ts +191 -0
- package/extensions/tool-profile/index.ts +291 -0
- package/extensions/tool-profile/profiles.ts +290 -0
- package/extensions/types.d.ts +9 -0
- package/extensions/vault/index.ts +185 -0
- package/extensions/version-check.ts +90 -0
- package/extensions/view/index.ts +859 -0
- package/extensions/view/uri-resolver.ts +148 -0
- package/extensions/web-search/index.ts +182 -0
- package/extensions/web-search/providers.ts +121 -0
- package/extensions/web-ui/index.ts +110 -0
- package/extensions/web-ui/server.ts +265 -0
- package/extensions/web-ui/state.ts +462 -0
- package/extensions/web-ui/static/index.html +145 -0
- package/extensions/web-ui/types.ts +284 -0
- package/package.json +76 -0
- package/prompts/init.md +75 -0
- package/prompts/new-repo.md +54 -0
- package/prompts/oci-login.md +56 -0
- package/prompts/status.md +50 -0
- package/settings.json +4 -0
- package/skills/cleave/SKILL.md +218 -0
- package/skills/git/SKILL.md +209 -0
- package/skills/git/_reference/ci-validation.md +204 -0
- package/skills/oci/SKILL.md +338 -0
- package/skills/openspec/SKILL.md +346 -0
- package/skills/pi-extensions/SKILL.md +191 -0
- package/skills/pi-tui/SKILL.md +517 -0
- package/skills/python/SKILL.md +189 -0
- package/skills/rust/SKILL.md +268 -0
- package/skills/security/SKILL.md +206 -0
- package/skills/style/SKILL.md +264 -0
- package/skills/typescript/SKILL.md +225 -0
- package/skills/vault/SKILL.md +102 -0
- package/themes/alpharius-legacy.json +85 -0
- package/themes/alpharius.conf +59 -0
- package/themes/alpharius.json +88 -0
|
@@ -0,0 +1,1607 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design-tree/tree — Pure domain logic for design tree operations.
|
|
3
|
+
*
|
|
4
|
+
* No pi dependency — can be tested standalone. All functions operate
|
|
5
|
+
* on the filesystem and return plain data structures.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import type {
|
|
11
|
+
AcceptanceCriteria,
|
|
12
|
+
AcceptanceCriteriaConstraint,
|
|
13
|
+
AcceptanceCriteriaFalsifiability,
|
|
14
|
+
AcceptanceCriteriaScenario,
|
|
15
|
+
DesignNode,
|
|
16
|
+
DesignTree,
|
|
17
|
+
DesignDecision,
|
|
18
|
+
DocumentSections,
|
|
19
|
+
FileScope,
|
|
20
|
+
IssueType,
|
|
21
|
+
NodeStatus,
|
|
22
|
+
Priority,
|
|
23
|
+
ResearchEntry,
|
|
24
|
+
} from "./types.ts";
|
|
25
|
+
import { VALID_ISSUE_TYPES, VALID_STATUSES, SECTION_HEADINGS } from "./types.ts";
|
|
26
|
+
|
|
27
|
+
// ─── Frontmatter Parsing ─────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export function parseFrontmatter(content: string): Record<string, unknown> | null {
|
|
30
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
31
|
+
if (!match) return null;
|
|
32
|
+
|
|
33
|
+
const yaml = match[1];
|
|
34
|
+
const result: Record<string, unknown> = {};
|
|
35
|
+
|
|
36
|
+
let currentKey: string | null = null;
|
|
37
|
+
let currentArray: string[] | null = null;
|
|
38
|
+
|
|
39
|
+
for (const line of yaml.split("\n")) {
|
|
40
|
+
// Array item: " - something"
|
|
41
|
+
const arrayMatch = line.match(/^\s+-\s+(.+)/);
|
|
42
|
+
if (arrayMatch && currentKey) {
|
|
43
|
+
if (!currentArray) currentArray = [];
|
|
44
|
+
currentArray.push(arrayMatch[1].trim().replace(/^["']|["']$/g, ""));
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Flush previous array when we hit a non-array line
|
|
49
|
+
if (currentKey && currentArray !== null) {
|
|
50
|
+
result[currentKey] = currentArray;
|
|
51
|
+
currentArray = null;
|
|
52
|
+
currentKey = null;
|
|
53
|
+
}
|
|
54
|
+
if (currentKey && currentArray === null) {
|
|
55
|
+
result[currentKey] = [];
|
|
56
|
+
currentKey = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Key-value pair
|
|
60
|
+
const kvMatch = line.match(/^(\w[\w_]*):\s*(.*)/);
|
|
61
|
+
if (kvMatch) {
|
|
62
|
+
const key = kvMatch[1];
|
|
63
|
+
const value = kvMatch[2].trim();
|
|
64
|
+
|
|
65
|
+
if (value === "" || value === "[]") {
|
|
66
|
+
if (value === "[]") {
|
|
67
|
+
result[key] = [];
|
|
68
|
+
} else {
|
|
69
|
+
currentKey = key;
|
|
70
|
+
currentArray = null;
|
|
71
|
+
}
|
|
72
|
+
} else if (value.startsWith("[") && value.endsWith("]")) {
|
|
73
|
+
result[key] = value
|
|
74
|
+
.slice(1, -1)
|
|
75
|
+
.split(",")
|
|
76
|
+
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
} else {
|
|
79
|
+
// Strip inline YAML comments (# ...) unless value is quoted
|
|
80
|
+
const stripped = /^["']/.test(value)
|
|
81
|
+
? value.replace(/^["'](.*)["']$/, "$1")
|
|
82
|
+
: value.replace(/\s+#.*$/, "").trim();
|
|
83
|
+
result[key] = stripped;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (currentKey && currentArray !== null) {
|
|
89
|
+
result[currentKey] = currentArray;
|
|
90
|
+
} else if (currentKey) {
|
|
91
|
+
result[currentKey] = [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Quote a YAML value if it contains special characters */
|
|
98
|
+
export function yamlQuote(value: string): string {
|
|
99
|
+
if (/[:#\[\]{}&*!|>'"%@`/]/.test(value) || value.startsWith("- ")) {
|
|
100
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
101
|
+
}
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function generateFrontmatter(node: Omit<DesignNode, "filePath" | "lastModified">): string {
|
|
106
|
+
let fm = "---\n";
|
|
107
|
+
fm += `id: ${node.id}\n`;
|
|
108
|
+
fm += `title: ${yamlQuote(node.title)}\n`;
|
|
109
|
+
fm += `status: ${node.status}\n`;
|
|
110
|
+
if (node.parent) fm += `parent: ${node.parent}\n`;
|
|
111
|
+
if (node.dependencies.length > 0) {
|
|
112
|
+
fm += `dependencies: [${node.dependencies.join(", ")}]\n`;
|
|
113
|
+
}
|
|
114
|
+
if (node.related.length > 0) {
|
|
115
|
+
fm += `related: [${node.related.join(", ")}]\n`;
|
|
116
|
+
}
|
|
117
|
+
if (node.tags.length > 0) {
|
|
118
|
+
fm += `tags: [${node.tags.join(", ")}]\n`;
|
|
119
|
+
}
|
|
120
|
+
if (node.open_questions.length > 0) {
|
|
121
|
+
fm += "open_questions:\n";
|
|
122
|
+
for (const q of node.open_questions) {
|
|
123
|
+
fm += ` - ${yamlQuote(q)}\n`;
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
fm += "open_questions: []\n";
|
|
127
|
+
}
|
|
128
|
+
if (node.branch) {
|
|
129
|
+
fm += `branch: ${yamlQuote(node.branch)}\n`;
|
|
130
|
+
}
|
|
131
|
+
if (node.branches && node.branches.length > 0) {
|
|
132
|
+
fm += `branches: [${node.branches.map((b) => yamlQuote(b)).join(", ")}]\n`;
|
|
133
|
+
}
|
|
134
|
+
if (node.openspec_change) {
|
|
135
|
+
fm += `openspec_change: ${node.openspec_change}\n`;
|
|
136
|
+
}
|
|
137
|
+
if (node.issue_type) {
|
|
138
|
+
fm += `issue_type: ${node.issue_type}\n`;
|
|
139
|
+
}
|
|
140
|
+
if (node.priority !== undefined) {
|
|
141
|
+
fm += `priority: ${node.priority}\n`;
|
|
142
|
+
}
|
|
143
|
+
fm += "---\n";
|
|
144
|
+
return fm;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── Body Section Parsing ────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Parse the document body (after frontmatter) into structured sections.
|
|
151
|
+
*
|
|
152
|
+
* Recognized sections:
|
|
153
|
+
* ## Overview → overview text
|
|
154
|
+
* ## Research → ### subheadings with content
|
|
155
|
+
* ## Decisions → ### Decision: Title blocks with status/rationale
|
|
156
|
+
* ## Open Questions → bullet list of questions
|
|
157
|
+
* ## Implementation Notes → file scope, constraints, raw content
|
|
158
|
+
*
|
|
159
|
+
* Unrecognized ## headings are captured as extraSections.
|
|
160
|
+
*/
|
|
161
|
+
export function parseSections(body: string): DocumentSections {
|
|
162
|
+
const sections: DocumentSections = {
|
|
163
|
+
overview: "",
|
|
164
|
+
research: [],
|
|
165
|
+
decisions: [],
|
|
166
|
+
openQuestions: [],
|
|
167
|
+
implementationNotes: { fileScope: [], constraints: [], rawContent: "" },
|
|
168
|
+
acceptanceCriteria: { scenarios: [], falsifiability: [], constraints: [] },
|
|
169
|
+
extraSections: [],
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Split body on ## headings, keeping the heading text
|
|
173
|
+
const parts = body.split(/^(## .+)$/m);
|
|
174
|
+
|
|
175
|
+
// First part (before any ##) could be content after the title
|
|
176
|
+
let preamble = parts[0].trim();
|
|
177
|
+
// Strip the # title line if present
|
|
178
|
+
preamble = preamble.replace(/^# .+\n?/, "").trim();
|
|
179
|
+
if (preamble && !parts.some((p) => p.startsWith("## Overview"))) {
|
|
180
|
+
sections.overview = preamble;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Process heading+content pairs
|
|
184
|
+
for (let i = 1; i < parts.length; i += 2) {
|
|
185
|
+
const heading = parts[i].trim();
|
|
186
|
+
const content = (parts[i + 1] || "").trim();
|
|
187
|
+
|
|
188
|
+
if (heading === SECTION_HEADINGS.overview) {
|
|
189
|
+
sections.overview = content;
|
|
190
|
+
} else if (heading === SECTION_HEADINGS.research) {
|
|
191
|
+
sections.research = parseResearchSection(content);
|
|
192
|
+
} else if (heading === SECTION_HEADINGS.decisions) {
|
|
193
|
+
sections.decisions = parseDecisionsSection(content);
|
|
194
|
+
} else if (heading === SECTION_HEADINGS.openQuestions) {
|
|
195
|
+
sections.openQuestions = parseOpenQuestionsSection(content);
|
|
196
|
+
} else if (heading === SECTION_HEADINGS.implementationNotes) {
|
|
197
|
+
sections.implementationNotes = parseImplementationNotesSection(content);
|
|
198
|
+
} else if (heading === SECTION_HEADINGS.acceptanceCriteria) {
|
|
199
|
+
sections.acceptanceCriteria = parseAcceptanceCriteriaSection(content);
|
|
200
|
+
} else {
|
|
201
|
+
sections.extraSections.push({
|
|
202
|
+
heading: heading.replace(/^## /, ""),
|
|
203
|
+
content,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return sections;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function parseResearchSection(content: string): ResearchEntry[] {
|
|
212
|
+
const entries: ResearchEntry[] = [];
|
|
213
|
+
const parts = content.split(/^(### .+)$/m);
|
|
214
|
+
|
|
215
|
+
// Content before first ### is a general research note
|
|
216
|
+
const preamble = parts[0].trim();
|
|
217
|
+
if (preamble) {
|
|
218
|
+
entries.push({ heading: "General", content: preamble });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
for (let i = 1; i < parts.length; i += 2) {
|
|
222
|
+
const heading = parts[i].replace(/^### /, "").trim();
|
|
223
|
+
const body = (parts[i + 1] || "").trim();
|
|
224
|
+
entries.push({ heading, content: body });
|
|
225
|
+
}
|
|
226
|
+
return entries;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function parseDecisionsSection(content: string): DesignDecision[] {
|
|
230
|
+
const decisions: DesignDecision[] = [];
|
|
231
|
+
// Split on ### Decision: headings
|
|
232
|
+
const parts = content.split(/^(### Decision: .+)$/m);
|
|
233
|
+
|
|
234
|
+
for (let i = 1; i < parts.length; i += 2) {
|
|
235
|
+
const title = parts[i].replace(/^### Decision:\s*/, "").trim();
|
|
236
|
+
const body = (parts[i + 1] || "").trim();
|
|
237
|
+
|
|
238
|
+
// Extract status
|
|
239
|
+
const statusMatch = body.match(/\*\*Status:\*\*\s*(\w+)/);
|
|
240
|
+
let status: DesignDecision["status"] = "exploring";
|
|
241
|
+
if (statusMatch) {
|
|
242
|
+
const s = statusMatch[1].toLowerCase();
|
|
243
|
+
if (s === "decided" || s === "rejected" || s === "exploring") {
|
|
244
|
+
status = s;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Extract rationale
|
|
249
|
+
const rationaleMatch = body.match(/\*\*Rationale:\*\*\s*([\s\S]*?)(?=\n\*\*|\n###|$)/);
|
|
250
|
+
const rationale = rationaleMatch ? rationaleMatch[1].trim() : body;
|
|
251
|
+
|
|
252
|
+
decisions.push({ title, status, rationale });
|
|
253
|
+
}
|
|
254
|
+
return decisions;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function parseOpenQuestionsSection(content: string): string[] {
|
|
258
|
+
const questions: string[] = [];
|
|
259
|
+
for (const line of content.split("\n")) {
|
|
260
|
+
// Match: - Question text or * Question text or 1. Question text
|
|
261
|
+
const m = line.match(/^\s*[-*]\s+(.+)/) || line.match(/^\s*\d+\.\s+(.+)/);
|
|
262
|
+
if (m) {
|
|
263
|
+
questions.push(m[1].trim());
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return questions;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function parseImplementationNotesSection(
|
|
270
|
+
content: string,
|
|
271
|
+
): DocumentSections["implementationNotes"] {
|
|
272
|
+
const result: DocumentSections["implementationNotes"] = {
|
|
273
|
+
fileScope: [],
|
|
274
|
+
constraints: [],
|
|
275
|
+
rawContent: content,
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// Parse ### File Scope sub-section
|
|
279
|
+
const fileScopeMatch = content.match(
|
|
280
|
+
/### File Scope\s*\n([\s\S]*?)(?=\n###|\n## |$)/,
|
|
281
|
+
);
|
|
282
|
+
if (fileScopeMatch) {
|
|
283
|
+
for (const line of fileScopeMatch[1].split("\n")) {
|
|
284
|
+
// Match: - `path/to/file` (action) — description or - `path/to/file` — description
|
|
285
|
+
const m = line.match(/^\s*[-*]\s+`([^`]+)`\s*(?:\((\w+)\)\s*)?(?:—|-)\s*(.+)/);
|
|
286
|
+
if (m) {
|
|
287
|
+
const action = parseFileAction(m[2]);
|
|
288
|
+
result.fileScope.push({ path: m[1], description: m[3].trim(), ...(action && { action }) });
|
|
289
|
+
} else {
|
|
290
|
+
// Match: - path/to/file — description (no backticks)
|
|
291
|
+
const m2 = line.match(/^\s*[-*]\s+(\S+)\s+(?:—|-)\s+(.+)/);
|
|
292
|
+
if (m2 && m2[1].includes("/")) {
|
|
293
|
+
result.fileScope.push({ path: m2[1], description: m2[2].trim() });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Parse ### Constraints sub-section
|
|
300
|
+
const constraintsMatch = content.match(
|
|
301
|
+
/### Constraints\s*\n([\s\S]*?)(?=\n###|\n## |$)/,
|
|
302
|
+
);
|
|
303
|
+
if (constraintsMatch) {
|
|
304
|
+
for (const line of constraintsMatch[1].split("\n")) {
|
|
305
|
+
const m = line.match(/^\s*[-*]\s+(.+)/);
|
|
306
|
+
if (m) {
|
|
307
|
+
result.constraints.push(m[1].trim());
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return result;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Parse a file action string from markdown */
|
|
316
|
+
function parseFileAction(raw: string | undefined): FileScope["action"] | undefined {
|
|
317
|
+
if (!raw) return undefined;
|
|
318
|
+
const s = raw.toLowerCase();
|
|
319
|
+
if (s === "new" || s === "created" || s === "create") return "new";
|
|
320
|
+
if (s === "modified" || s === "updated" || s === "modify") return "modified";
|
|
321
|
+
if (s === "deleted" || s === "removed" || s === "delete") return "deleted";
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ─── Acceptance Criteria Parsing ──────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Parse the ## Acceptance Criteria section into structured scenarios,
|
|
329
|
+
* falsifiability conditions, and checkbox constraints.
|
|
330
|
+
*
|
|
331
|
+
* ### Scenarios — bold Given/When/Then blocks:
|
|
332
|
+
* **Given** some context
|
|
333
|
+
* **When** something happens
|
|
334
|
+
* **Then** expected outcome
|
|
335
|
+
*
|
|
336
|
+
* ### Falsifiability — bullet list with "This decision is wrong if:" prefix:
|
|
337
|
+
* - This decision is wrong if: some condition
|
|
338
|
+
* - some condition (bare, without prefix)
|
|
339
|
+
*
|
|
340
|
+
* ### Constraints — GFM checkboxes:
|
|
341
|
+
* - [ ] unchecked constraint
|
|
342
|
+
* - [x] checked constraint
|
|
343
|
+
*/
|
|
344
|
+
function parseAcceptanceCriteriaSection(content: string): AcceptanceCriteria {
|
|
345
|
+
const result: AcceptanceCriteria = {
|
|
346
|
+
scenarios: [],
|
|
347
|
+
falsifiability: [],
|
|
348
|
+
constraints: [],
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
// Split on ### sub-headings
|
|
352
|
+
const parts = content.split(/^(### .+)$/m);
|
|
353
|
+
|
|
354
|
+
for (let i = 1; i < parts.length; i += 2) {
|
|
355
|
+
const subHeading = parts[i].replace(/^### /, "").trim();
|
|
356
|
+
const body = (parts[i + 1] || "").trim();
|
|
357
|
+
|
|
358
|
+
if (subHeading === "Scenarios") {
|
|
359
|
+
result.scenarios = parseScenariosBlock(body);
|
|
360
|
+
} else if (subHeading === "Falsifiability") {
|
|
361
|
+
result.falsifiability = parseFalsifiabilityBlock(body);
|
|
362
|
+
} else if (subHeading === "Constraints") {
|
|
363
|
+
result.constraints = parseCheckboxConstraints(body);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return result;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Parse bold Given/When/Then scenario blocks. Each scenario may optionally
|
|
372
|
+
* have a title on a line before the Given keyword, or be titled by sequence.
|
|
373
|
+
*
|
|
374
|
+
* Accepts both single-line (**Given** text) and multi-line formats.
|
|
375
|
+
*/
|
|
376
|
+
function parseScenariosBlock(content: string): AcceptanceCriteriaScenario[] {
|
|
377
|
+
const scenarios: AcceptanceCriteriaScenario[] = [];
|
|
378
|
+
const blocks: Array<{ title: string; content: string }> = [];
|
|
379
|
+
|
|
380
|
+
// Try explicit #### or "Scenario:" headings first
|
|
381
|
+
const headingMatches = [...content.matchAll(/^(?:####\s+(.+)|Scenario:\s*(.+))$/gm)];
|
|
382
|
+
if (headingMatches.length > 0) {
|
|
383
|
+
for (let i = 0; i < headingMatches.length; i++) {
|
|
384
|
+
const m = headingMatches[i];
|
|
385
|
+
const title = (m[1] || m[2] || "").trim();
|
|
386
|
+
const start = (m.index ?? 0) + m[0].length;
|
|
387
|
+
const end = i + 1 < headingMatches.length ? (headingMatches[i + 1].index ?? content.length) : content.length;
|
|
388
|
+
blocks.push({ title, content: content.slice(start, end).trim() });
|
|
389
|
+
}
|
|
390
|
+
} else {
|
|
391
|
+
// Split on "**Given**" lines (non-zero-width match to avoid infinite loop)
|
|
392
|
+
const parts = content.split(/^(?=\*\*Given\*\*)/m);
|
|
393
|
+
if (parts.length <= 1) {
|
|
394
|
+
// No split happened — whole content is one block
|
|
395
|
+
blocks.push({ title: "", content: content.trim() });
|
|
396
|
+
} else {
|
|
397
|
+
for (const part of parts) {
|
|
398
|
+
const trimmed = part.trim();
|
|
399
|
+
// Skip preamble segments that don't start with **Given** (e.g. intro text)
|
|
400
|
+
if (trimmed && /^\*\*Given\*\*/.test(trimmed)) {
|
|
401
|
+
blocks.push({ title: "", content: trimmed });
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
for (let idx = 0; idx < blocks.length; idx++) {
|
|
408
|
+
const { title, content: block } = blocks[idx];
|
|
409
|
+
const givenMatch = block.match(/\*\*Given\*\*\s*(.+)/);
|
|
410
|
+
const whenMatch = block.match(/\*\*When\*\*\s*(.+)/);
|
|
411
|
+
const thenMatch = block.match(/\*\*Then\*\*\s*(.+)/);
|
|
412
|
+
|
|
413
|
+
if (givenMatch || whenMatch || thenMatch) {
|
|
414
|
+
scenarios.push({
|
|
415
|
+
title: title || `Scenario ${idx + 1}`,
|
|
416
|
+
given: givenMatch ? givenMatch[1].trim() : "",
|
|
417
|
+
when: whenMatch ? whenMatch[1].trim() : "",
|
|
418
|
+
then: thenMatch ? thenMatch[1].trim() : "",
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return scenarios;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Parse falsifiability bullet list.
|
|
428
|
+
* Strips the "This decision is wrong if:" prefix when present.
|
|
429
|
+
*/
|
|
430
|
+
function parseFalsifiabilityBlock(content: string): AcceptanceCriteriaFalsifiability[] {
|
|
431
|
+
const results: AcceptanceCriteriaFalsifiability[] = [];
|
|
432
|
+
const PREFIX = /^this decision is wrong if:\s*/i;
|
|
433
|
+
|
|
434
|
+
for (const line of content.split("\n")) {
|
|
435
|
+
const m = line.match(/^\s*[-*]\s+(.+)/);
|
|
436
|
+
if (m) {
|
|
437
|
+
const raw = m[1].trim();
|
|
438
|
+
const condition = raw.replace(PREFIX, "").trim();
|
|
439
|
+
results.push({ condition });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return results;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Parse GFM checkbox list into AcceptanceCriteriaConstraint items.
|
|
447
|
+
*/
|
|
448
|
+
function parseCheckboxConstraints(content: string): AcceptanceCriteriaConstraint[] {
|
|
449
|
+
const results: AcceptanceCriteriaConstraint[] = [];
|
|
450
|
+
for (const line of content.split("\n")) {
|
|
451
|
+
const m = line.match(/^\s*-\s+\[([ xX])\]\s+(.+)/);
|
|
452
|
+
if (m) {
|
|
453
|
+
results.push({
|
|
454
|
+
checked: m[1].toLowerCase() === "x",
|
|
455
|
+
text: m[2].trim(),
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return results;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ─── Body Generation ─────────────────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Generate a complete document body from structured sections.
|
|
466
|
+
*/
|
|
467
|
+
export function generateBody(title: string, sections: DocumentSections): string {
|
|
468
|
+
const parts: string[] = [`# ${title}`, ""];
|
|
469
|
+
|
|
470
|
+
// Overview
|
|
471
|
+
parts.push(SECTION_HEADINGS.overview, "");
|
|
472
|
+
parts.push(sections.overview || "*To be explored.*");
|
|
473
|
+
parts.push("");
|
|
474
|
+
|
|
475
|
+
// Research (only if non-empty)
|
|
476
|
+
if (sections.research.length > 0) {
|
|
477
|
+
parts.push(SECTION_HEADINGS.research, "");
|
|
478
|
+
for (const entry of sections.research) {
|
|
479
|
+
if (entry.heading !== "General") {
|
|
480
|
+
parts.push(`### ${entry.heading}`, "");
|
|
481
|
+
}
|
|
482
|
+
parts.push(entry.content, "");
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Decisions (only if non-empty)
|
|
487
|
+
if (sections.decisions.length > 0) {
|
|
488
|
+
parts.push(SECTION_HEADINGS.decisions, "");
|
|
489
|
+
for (const d of sections.decisions) {
|
|
490
|
+
parts.push(`### Decision: ${d.title}`, "");
|
|
491
|
+
parts.push(`**Status:** ${d.status}`);
|
|
492
|
+
parts.push(`**Rationale:** ${d.rationale}`, "");
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Open Questions
|
|
497
|
+
parts.push(SECTION_HEADINGS.openQuestions, "");
|
|
498
|
+
if (sections.openQuestions.length > 0) {
|
|
499
|
+
for (const q of sections.openQuestions) {
|
|
500
|
+
parts.push(`- ${q}`);
|
|
501
|
+
}
|
|
502
|
+
} else {
|
|
503
|
+
parts.push("*No open questions.*");
|
|
504
|
+
}
|
|
505
|
+
parts.push("");
|
|
506
|
+
|
|
507
|
+
// Implementation Notes (only if has content)
|
|
508
|
+
if (
|
|
509
|
+
sections.implementationNotes.fileScope.length > 0 ||
|
|
510
|
+
sections.implementationNotes.constraints.length > 0
|
|
511
|
+
) {
|
|
512
|
+
parts.push(SECTION_HEADINGS.implementationNotes, "");
|
|
513
|
+
if (sections.implementationNotes.fileScope.length > 0) {
|
|
514
|
+
parts.push("### File Scope", "");
|
|
515
|
+
for (const f of sections.implementationNotes.fileScope) {
|
|
516
|
+
const actionTag = f.action ? ` (${f.action})` : "";
|
|
517
|
+
parts.push(`- \`${f.path}\`${actionTag} — ${f.description}`);
|
|
518
|
+
}
|
|
519
|
+
parts.push("");
|
|
520
|
+
}
|
|
521
|
+
if (sections.implementationNotes.constraints.length > 0) {
|
|
522
|
+
parts.push("### Constraints", "");
|
|
523
|
+
for (const c of sections.implementationNotes.constraints) {
|
|
524
|
+
parts.push(`- ${c}`);
|
|
525
|
+
}
|
|
526
|
+
parts.push("");
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Acceptance Criteria (only if has content)
|
|
531
|
+
const ac = sections.acceptanceCriteria;
|
|
532
|
+
if (ac.scenarios.length > 0 || ac.falsifiability.length > 0 || ac.constraints.length > 0) {
|
|
533
|
+
parts.push(SECTION_HEADINGS.acceptanceCriteria, "");
|
|
534
|
+
|
|
535
|
+
if (ac.scenarios.length > 0) {
|
|
536
|
+
parts.push("### Scenarios", "");
|
|
537
|
+
for (const s of ac.scenarios) {
|
|
538
|
+
if (s.title && !s.title.match(/^Scenario \d+$/)) {
|
|
539
|
+
parts.push(`#### ${s.title}`, "");
|
|
540
|
+
}
|
|
541
|
+
if (s.given) parts.push(`**Given** ${s.given}`);
|
|
542
|
+
if (s.when) parts.push(`**When** ${s.when}`);
|
|
543
|
+
if (s.then) parts.push(`**Then** ${s.then}`);
|
|
544
|
+
parts.push("");
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (ac.falsifiability.length > 0) {
|
|
549
|
+
parts.push("### Falsifiability", "");
|
|
550
|
+
for (const f of ac.falsifiability) {
|
|
551
|
+
parts.push(`- This decision is wrong if: ${f.condition}`);
|
|
552
|
+
}
|
|
553
|
+
parts.push("");
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (ac.constraints.length > 0) {
|
|
557
|
+
parts.push("### Constraints", "");
|
|
558
|
+
for (const c of ac.constraints) {
|
|
559
|
+
parts.push(`- [${c.checked ? "x" : " "}] ${c.text}`);
|
|
560
|
+
}
|
|
561
|
+
parts.push("");
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Extra sections
|
|
566
|
+
for (const extra of sections.extraSections) {
|
|
567
|
+
parts.push(`## ${extra.heading}`, "");
|
|
568
|
+
parts.push(extra.content, "");
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return parts.join("\n");
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ─── Tree Scanning ───────────────────────────────────────────────────────────
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Scan a docs/ directory for design documents and build a DesignTree.
|
|
578
|
+
*/
|
|
579
|
+
export function scanDesignDocs(docsDir: string): DesignTree {
|
|
580
|
+
const tree: DesignTree = { nodes: new Map(), docsDir };
|
|
581
|
+
if (!fs.existsSync(docsDir)) return tree;
|
|
582
|
+
|
|
583
|
+
// Scan both docs/ and docs/design/ for design documents
|
|
584
|
+
let files = fs.readdirSync(docsDir).filter((f) => f.endsWith(".md"));
|
|
585
|
+
const designSubdir = path.join(docsDir, "design");
|
|
586
|
+
if (fs.existsSync(designSubdir)) {
|
|
587
|
+
const archiveFiles = fs.readdirSync(designSubdir)
|
|
588
|
+
.filter((f) => f.endsWith(".md"))
|
|
589
|
+
.map((f) => path.join("design", f));
|
|
590
|
+
files = files.concat(archiveFiles);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
for (const file of files) {
|
|
594
|
+
const filePath = path.join(docsDir, file);
|
|
595
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
596
|
+
const fm = parseFrontmatter(content);
|
|
597
|
+
|
|
598
|
+
if (fm && fm.id) {
|
|
599
|
+
const rawStatus = fm.status as string;
|
|
600
|
+
const status: NodeStatus = VALID_STATUSES.includes(rawStatus as NodeStatus)
|
|
601
|
+
? (rawStatus as NodeStatus)
|
|
602
|
+
: "exploring";
|
|
603
|
+
|
|
604
|
+
// Parse body sections to sync open_questions from body
|
|
605
|
+
const body = extractBody(content);
|
|
606
|
+
const sections = parseSections(body);
|
|
607
|
+
const bodyQuestions = sections.openQuestions;
|
|
608
|
+
|
|
609
|
+
// Body is source of truth for open questions; merge with frontmatter
|
|
610
|
+
// Prefer body questions, but keep frontmatter-only questions too
|
|
611
|
+
const fmQuestions = (fm.open_questions as string[]) || [];
|
|
612
|
+
const mergedQuestions = mergeQuestions(bodyQuestions, fmQuestions);
|
|
613
|
+
|
|
614
|
+
// Validate optional branch override — discard and warn if invalid
|
|
615
|
+
const rawBranch = fm.branch as string | undefined;
|
|
616
|
+
let validatedBranch: string | undefined;
|
|
617
|
+
if (rawBranch !== undefined) {
|
|
618
|
+
validatedBranch = sanitizeBranchName(rawBranch) ?? undefined;
|
|
619
|
+
if (validatedBranch === undefined) {
|
|
620
|
+
console.warn(
|
|
621
|
+
`[design-tree] Node '${fm.id}': invalid 'branch' value '${rawBranch}' — ` +
|
|
622
|
+
`contains disallowed characters. Field ignored; fix the frontmatter.`,
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const rawIssueType = fm.issue_type as string | undefined;
|
|
628
|
+
const issue_type: IssueType | undefined =
|
|
629
|
+
rawIssueType && VALID_ISSUE_TYPES.includes(rawIssueType as IssueType)
|
|
630
|
+
? (rawIssueType as IssueType)
|
|
631
|
+
: undefined;
|
|
632
|
+
|
|
633
|
+
// parseInt handles both numeric strings ("3") and quoted strings — explicit radix avoids octal ambiguity
|
|
634
|
+
const rawPriority = fm.priority !== undefined ? parseInt(String(fm.priority), 10) : undefined;
|
|
635
|
+
const priority: Priority | undefined =
|
|
636
|
+
rawPriority !== undefined && rawPriority >= 1 && rawPriority <= 5
|
|
637
|
+
? (rawPriority as Priority)
|
|
638
|
+
: undefined;
|
|
639
|
+
|
|
640
|
+
const node: DesignNode = {
|
|
641
|
+
id: fm.id as string,
|
|
642
|
+
title: (fm.title as string) || file.replace(".md", ""),
|
|
643
|
+
status,
|
|
644
|
+
parent: fm.parent as string | undefined,
|
|
645
|
+
dependencies: (fm.dependencies as string[]) || [],
|
|
646
|
+
related: (fm.related as string[]) || [],
|
|
647
|
+
tags: (fm.tags as string[]) || [],
|
|
648
|
+
open_questions: mergedQuestions,
|
|
649
|
+
branch: validatedBranch,
|
|
650
|
+
branches: (fm.branches as string[]) || [],
|
|
651
|
+
openspec_change: fm.openspec_change as string | undefined,
|
|
652
|
+
issue_type,
|
|
653
|
+
priority,
|
|
654
|
+
filePath,
|
|
655
|
+
lastModified: fs.statSync(filePath).mtimeMs,
|
|
656
|
+
};
|
|
657
|
+
tree.nodes.set(node.id, node);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
return tree;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** Merge body questions (source of truth) with frontmatter questions (legacy) */
|
|
665
|
+
function mergeQuestions(bodyQuestions: string[], fmQuestions: string[]): string[] {
|
|
666
|
+
if (bodyQuestions.length > 0) return bodyQuestions;
|
|
667
|
+
return fmQuestions; // fallback to frontmatter if body section empty/missing
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Extract body content after frontmatter */
|
|
671
|
+
export function extractBody(content: string): string {
|
|
672
|
+
const match = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)/);
|
|
673
|
+
return match ? match[1].trim() : content.trim();
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ─── Tree Queries ────────────────────────────────────────────────────────────
|
|
677
|
+
|
|
678
|
+
export function getChildren(tree: DesignTree, parentId: string): DesignNode[] {
|
|
679
|
+
return Array.from(tree.nodes.values()).filter((n) => n.parent === parentId);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export function getRoots(tree: DesignTree): DesignNode[] {
|
|
683
|
+
return Array.from(tree.nodes.values()).filter((n) => !n.parent);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
export function getAllOpenQuestions(
|
|
687
|
+
tree: DesignTree,
|
|
688
|
+
): Array<{ node: DesignNode; question: string }> {
|
|
689
|
+
const questions: Array<{ node: DesignNode; question: string }> = [];
|
|
690
|
+
for (const node of tree.nodes.values()) {
|
|
691
|
+
for (const q of node.open_questions) {
|
|
692
|
+
questions.push({ node, question: q });
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return questions;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** Get document body, optionally truncated */
|
|
699
|
+
export function getDocBody(filePath: string, maxChars: number = 4000): string {
|
|
700
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
701
|
+
const body = extractBody(content);
|
|
702
|
+
if (body.length <= maxChars) return body;
|
|
703
|
+
return body.slice(0, maxChars) + "\n\n[...truncated]";
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** Get fully parsed sections from a node's document */
|
|
707
|
+
export function getNodeSections(node: DesignNode): DocumentSections {
|
|
708
|
+
const content = fs.readFileSync(node.filePath, "utf-8");
|
|
709
|
+
const body = extractBody(content);
|
|
710
|
+
return parseSections(body);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Lightweight acceptance-criteria counter for the `list` hot path.
|
|
715
|
+
* Avoids full section parse by scanning for the AC section and counting
|
|
716
|
+
* structural markers (Given blocks, bullet items, checkboxes) with regex.
|
|
717
|
+
* Returns null when no Acceptance Criteria section exists.
|
|
718
|
+
*/
|
|
719
|
+
export function countAcceptanceCriteria(
|
|
720
|
+
node: DesignNode,
|
|
721
|
+
): { scenarios: number; falsifiability: number; constraints: number } | null {
|
|
722
|
+
let content: string;
|
|
723
|
+
try {
|
|
724
|
+
content = fs.readFileSync(node.filePath, "utf-8");
|
|
725
|
+
} catch {
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Find the ## Acceptance Criteria section via string search (regex lookahead
|
|
730
|
+
// for end-of-string is unreliable across JS engines with multiline mode).
|
|
731
|
+
const ACH = "\n## Acceptance Criteria\n";
|
|
732
|
+
const acStart = content.indexOf(ACH);
|
|
733
|
+
if (acStart === -1) return null;
|
|
734
|
+
const acBodyStart = acStart + ACH.length;
|
|
735
|
+
const nextH2 = content.indexOf("\n## ", acBodyStart);
|
|
736
|
+
const acBody = nextH2 >= 0 ? content.slice(acBodyStart, nextH2) : content.slice(acBodyStart);
|
|
737
|
+
|
|
738
|
+
// Count **Given** occurrences for scenarios
|
|
739
|
+
const scenarioCount = (acBody.match(/^\*\*Given\*\*/gm) ?? []).length;
|
|
740
|
+
|
|
741
|
+
// Find the ### Falsifiability sub-section and count list items
|
|
742
|
+
const falsifiabilityCount = (() => {
|
|
743
|
+
const h = "\n### Falsifiability\n";
|
|
744
|
+
const start = acBody.indexOf(h);
|
|
745
|
+
if (start === -1) return 0;
|
|
746
|
+
const bodyStart = start + h.length;
|
|
747
|
+
const nextH3 = acBody.indexOf("\n### ", bodyStart);
|
|
748
|
+
const sub = nextH3 >= 0 ? acBody.slice(bodyStart, nextH3) : acBody.slice(bodyStart);
|
|
749
|
+
return (sub.match(/^\s*-\s+\S/gm) ?? []).length;
|
|
750
|
+
})();
|
|
751
|
+
|
|
752
|
+
// Find the ### Constraints sub-section and count checkboxes
|
|
753
|
+
const constraintCount = (() => {
|
|
754
|
+
const h = "\n### Constraints\n";
|
|
755
|
+
const start = acBody.indexOf(h);
|
|
756
|
+
if (start === -1) return 0;
|
|
757
|
+
const bodyStart = start + h.length;
|
|
758
|
+
const nextH3 = acBody.indexOf("\n### ", bodyStart);
|
|
759
|
+
const sub = nextH3 >= 0 ? acBody.slice(bodyStart, nextH3) : acBody.slice(bodyStart);
|
|
760
|
+
return (sub.match(/^\s*-\s+\[[ xX]\]/gm) ?? []).length;
|
|
761
|
+
})();
|
|
762
|
+
|
|
763
|
+
const total = scenarioCount + falsifiabilityCount + constraintCount;
|
|
764
|
+
if (total === 0) return null;
|
|
765
|
+
|
|
766
|
+
return {
|
|
767
|
+
scenarios: scenarioCount,
|
|
768
|
+
falsifiability: falsifiabilityCount,
|
|
769
|
+
constraints: constraintCount,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ─── Tree Mutations ──────────────────────────────────────────────────────────
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Create a new design node document.
|
|
777
|
+
* Returns the created node.
|
|
778
|
+
*/
|
|
779
|
+
export function createNode(
|
|
780
|
+
docsDir: string,
|
|
781
|
+
opts: {
|
|
782
|
+
id: string;
|
|
783
|
+
title: string;
|
|
784
|
+
parent?: string;
|
|
785
|
+
status?: NodeStatus;
|
|
786
|
+
tags?: string[];
|
|
787
|
+
overview?: string;
|
|
788
|
+
issue_type?: IssueType;
|
|
789
|
+
priority?: Priority;
|
|
790
|
+
spawnedFrom?: { parentTitle: string; parentFile: string; question: string };
|
|
791
|
+
},
|
|
792
|
+
): DesignNode {
|
|
793
|
+
const idError = validateNodeId(opts.id);
|
|
794
|
+
if (idError) throw new Error(`Invalid node ID '${opts.id}': ${idError}`);
|
|
795
|
+
|
|
796
|
+
if (!fs.existsSync(docsDir)) {
|
|
797
|
+
fs.mkdirSync(docsDir, { recursive: true });
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const node: Omit<DesignNode, "filePath" | "lastModified"> = {
|
|
801
|
+
id: opts.id,
|
|
802
|
+
title: opts.title,
|
|
803
|
+
status: opts.status || "seed",
|
|
804
|
+
parent: opts.parent,
|
|
805
|
+
dependencies: [],
|
|
806
|
+
related: [],
|
|
807
|
+
tags: opts.tags || [],
|
|
808
|
+
open_questions: [],
|
|
809
|
+
branch: undefined,
|
|
810
|
+
branches: [],
|
|
811
|
+
issue_type: opts.issue_type,
|
|
812
|
+
priority: opts.priority,
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
const sections: DocumentSections = {
|
|
816
|
+
overview: opts.overview || "*To be explored.*",
|
|
817
|
+
research: [],
|
|
818
|
+
decisions: [],
|
|
819
|
+
openQuestions: [],
|
|
820
|
+
implementationNotes: { fileScope: [], constraints: [], rawContent: "" },
|
|
821
|
+
acceptanceCriteria: { scenarios: [], falsifiability: [], constraints: [] },
|
|
822
|
+
extraSections: [],
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
// If spawned from a parent question, add context
|
|
826
|
+
if (opts.spawnedFrom) {
|
|
827
|
+
sections.overview =
|
|
828
|
+
`> Parent: [${opts.spawnedFrom.parentTitle}](${opts.spawnedFrom.parentFile})\n` +
|
|
829
|
+
`> Spawned from: "${opts.spawnedFrom.question}"\n\n` +
|
|
830
|
+
(opts.overview || "*To be explored.*");
|
|
831
|
+
sections.openQuestions = [`${opts.spawnedFrom.question}`];
|
|
832
|
+
node.open_questions = [...sections.openQuestions];
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const fm = generateFrontmatter(node);
|
|
836
|
+
const body = generateBody(opts.title, sections);
|
|
837
|
+
const filePath = path.join(docsDir, `${opts.id}.md`);
|
|
838
|
+
|
|
839
|
+
fs.writeFileSync(filePath, fm + "\n" + body);
|
|
840
|
+
|
|
841
|
+
return {
|
|
842
|
+
...node,
|
|
843
|
+
filePath,
|
|
844
|
+
lastModified: Date.now(),
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Set a node's status. Writes to disk.
|
|
850
|
+
* Returns the updated node.
|
|
851
|
+
*/
|
|
852
|
+
export function setNodeStatus(node: DesignNode, newStatus: NodeStatus): DesignNode {
|
|
853
|
+
let content = fs.readFileSync(node.filePath, "utf-8");
|
|
854
|
+
content = content.replace(
|
|
855
|
+
/^(---\n[\s\S]*?\nstatus:\s*)\S+/m,
|
|
856
|
+
`$1${newStatus}`,
|
|
857
|
+
);
|
|
858
|
+
fs.writeFileSync(node.filePath, content);
|
|
859
|
+
return { ...node, status: newStatus };
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Add an open question to a node. Updates both the body ## Open Questions
|
|
864
|
+
* section and the frontmatter.
|
|
865
|
+
*/
|
|
866
|
+
export function addOpenQuestion(node: DesignNode, question: string): DesignNode {
|
|
867
|
+
const content = fs.readFileSync(node.filePath, "utf-8");
|
|
868
|
+
const body = extractBody(content);
|
|
869
|
+
const sections = parseSections(body);
|
|
870
|
+
|
|
871
|
+
sections.openQuestions.push(question);
|
|
872
|
+
const updatedNode = {
|
|
873
|
+
...node,
|
|
874
|
+
open_questions: [...sections.openQuestions],
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
writeNodeDocument(updatedNode, sections);
|
|
878
|
+
return updatedNode;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Remove an open question from a node by index or text match.
|
|
883
|
+
*/
|
|
884
|
+
export function removeOpenQuestion(
|
|
885
|
+
node: DesignNode,
|
|
886
|
+
questionOrIndex: string | number,
|
|
887
|
+
): DesignNode {
|
|
888
|
+
const content = fs.readFileSync(node.filePath, "utf-8");
|
|
889
|
+
const body = extractBody(content);
|
|
890
|
+
const sections = parseSections(body);
|
|
891
|
+
|
|
892
|
+
// Seed body sections from node's in-memory merged state when body has no Open Questions
|
|
893
|
+
// section yet (e.g. frontmatter-only nodes). Keeps body + frontmatter in sync.
|
|
894
|
+
if (sections.openQuestions.length === 0 && node.open_questions.length > 0) {
|
|
895
|
+
sections.openQuestions = [...node.open_questions];
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
if (typeof questionOrIndex === "number") {
|
|
899
|
+
if (questionOrIndex >= 0 && questionOrIndex < sections.openQuestions.length) {
|
|
900
|
+
sections.openQuestions.splice(questionOrIndex, 1);
|
|
901
|
+
}
|
|
902
|
+
} else {
|
|
903
|
+
sections.openQuestions = sections.openQuestions.filter(
|
|
904
|
+
(q) => q !== questionOrIndex,
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const updatedNode = {
|
|
909
|
+
...node,
|
|
910
|
+
open_questions: [...sections.openQuestions],
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
writeNodeDocument(updatedNode, sections);
|
|
914
|
+
return updatedNode;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Add a research entry to a node.
|
|
919
|
+
*/
|
|
920
|
+
export function addResearch(
|
|
921
|
+
node: DesignNode,
|
|
922
|
+
heading: string,
|
|
923
|
+
content: string,
|
|
924
|
+
): void {
|
|
925
|
+
const fileContent = fs.readFileSync(node.filePath, "utf-8");
|
|
926
|
+
const body = extractBody(fileContent);
|
|
927
|
+
const sections = parseSections(body);
|
|
928
|
+
|
|
929
|
+
sections.research.push({ heading, content });
|
|
930
|
+
writeNodeDocument(node, sections);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Add a decision to a node.
|
|
935
|
+
*/
|
|
936
|
+
export function addDecision(
|
|
937
|
+
node: DesignNode,
|
|
938
|
+
decision: DesignDecision,
|
|
939
|
+
): void {
|
|
940
|
+
const content = fs.readFileSync(node.filePath, "utf-8");
|
|
941
|
+
const body = extractBody(content);
|
|
942
|
+
const sections = parseSections(body);
|
|
943
|
+
|
|
944
|
+
sections.decisions.push(decision);
|
|
945
|
+
writeNodeDocument(node, sections);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Add a dependency to a node.
|
|
950
|
+
*/
|
|
951
|
+
export function addDependency(node: DesignNode, depId: string): DesignNode {
|
|
952
|
+
if (node.dependencies.includes(depId)) return node;
|
|
953
|
+
const updatedNode = {
|
|
954
|
+
...node,
|
|
955
|
+
dependencies: [...node.dependencies, depId],
|
|
956
|
+
};
|
|
957
|
+
const content = fs.readFileSync(node.filePath, "utf-8");
|
|
958
|
+
const body = extractBody(content);
|
|
959
|
+
const sections = parseSections(body);
|
|
960
|
+
writeNodeDocument(updatedNode, sections);
|
|
961
|
+
return updatedNode;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Add a related node reference.
|
|
966
|
+
* If `reciprocal` is provided, also adds the reverse link on the target node.
|
|
967
|
+
*/
|
|
968
|
+
export function addRelated(node: DesignNode, relatedId: string, reciprocal?: DesignNode): DesignNode {
|
|
969
|
+
if (node.related.includes(relatedId)) return node;
|
|
970
|
+
const updatedNode = {
|
|
971
|
+
...node,
|
|
972
|
+
related: [...node.related, relatedId],
|
|
973
|
+
};
|
|
974
|
+
const content = fs.readFileSync(node.filePath, "utf-8");
|
|
975
|
+
const body = extractBody(content);
|
|
976
|
+
const sections = parseSections(body);
|
|
977
|
+
writeNodeDocument(updatedNode, sections);
|
|
978
|
+
|
|
979
|
+
// Add reverse link if reciprocal node provided and not already linked
|
|
980
|
+
if (reciprocal && !reciprocal.related.includes(node.id)) {
|
|
981
|
+
const recipUpdated = {
|
|
982
|
+
...reciprocal,
|
|
983
|
+
related: [...reciprocal.related, node.id],
|
|
984
|
+
};
|
|
985
|
+
const recipContent = fs.readFileSync(reciprocal.filePath, "utf-8");
|
|
986
|
+
const recipBody = extractBody(recipContent);
|
|
987
|
+
const recipSections = parseSections(recipBody);
|
|
988
|
+
writeNodeDocument(recipUpdated, recipSections);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
return updatedNode;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Update a node's overview text.
|
|
996
|
+
*/
|
|
997
|
+
export function updateOverview(node: DesignNode, overview: string): void {
|
|
998
|
+
const content = fs.readFileSync(node.filePath, "utf-8");
|
|
999
|
+
const body = extractBody(content);
|
|
1000
|
+
const sections = parseSections(body);
|
|
1001
|
+
sections.overview = overview;
|
|
1002
|
+
writeNodeDocument(node, sections);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Add implementation notes (file scope and/or constraints).
|
|
1007
|
+
*/
|
|
1008
|
+
export function addImplementationNotes(
|
|
1009
|
+
node: DesignNode,
|
|
1010
|
+
opts: { fileScope?: FileScope[]; constraints?: string[] },
|
|
1011
|
+
): void {
|
|
1012
|
+
const content = fs.readFileSync(node.filePath, "utf-8");
|
|
1013
|
+
const body = extractBody(content);
|
|
1014
|
+
const sections = parseSections(body);
|
|
1015
|
+
|
|
1016
|
+
if (opts.fileScope) {
|
|
1017
|
+
sections.implementationNotes.fileScope.push(...opts.fileScope);
|
|
1018
|
+
}
|
|
1019
|
+
if (opts.constraints) {
|
|
1020
|
+
sections.implementationNotes.constraints.push(...opts.constraints);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
writeNodeDocument(node, sections);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// ─── Document Write-Back ─────────────────────────────────────────────────────
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Write a node's full document to disk (frontmatter + body).
|
|
1030
|
+
* Syncs open_questions between sections and frontmatter.
|
|
1031
|
+
*/
|
|
1032
|
+
export function writeNodeDocument(node: DesignNode, sections: DocumentSections): void {
|
|
1033
|
+
// Sync open questions from sections to node
|
|
1034
|
+
const syncedNode = {
|
|
1035
|
+
...node,
|
|
1036
|
+
open_questions: sections.openQuestions,
|
|
1037
|
+
};
|
|
1038
|
+
|
|
1039
|
+
const fm = generateFrontmatter(syncedNode);
|
|
1040
|
+
const body = generateBody(syncedNode.title, sections);
|
|
1041
|
+
fs.writeFileSync(node.filePath, fm + "\n" + body);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// ─── Branch ──────────────────────────────────────────────────────────────────
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* Branch a child node from a parent's open question.
|
|
1048
|
+
*
|
|
1049
|
+
* Creates the child doc and optionally removes the question from the parent.
|
|
1050
|
+
*/
|
|
1051
|
+
export function branchFromQuestion(
|
|
1052
|
+
tree: DesignTree,
|
|
1053
|
+
parentId: string,
|
|
1054
|
+
question: string,
|
|
1055
|
+
childId: string,
|
|
1056
|
+
childTitle: string,
|
|
1057
|
+
removeFromParent: boolean = true,
|
|
1058
|
+
): DesignNode | null {
|
|
1059
|
+
const parent = tree.nodes.get(parentId);
|
|
1060
|
+
if (!parent) return null;
|
|
1061
|
+
if (!parent.open_questions.includes(question)) return null;
|
|
1062
|
+
|
|
1063
|
+
const childIdError = validateNodeId(childId);
|
|
1064
|
+
if (childIdError) return null;
|
|
1065
|
+
|
|
1066
|
+
const child = createNode(tree.docsDir, {
|
|
1067
|
+
id: childId,
|
|
1068
|
+
title: childTitle,
|
|
1069
|
+
parent: parentId,
|
|
1070
|
+
spawnedFrom: {
|
|
1071
|
+
parentTitle: parent.title,
|
|
1072
|
+
parentFile: path.basename(parent.filePath),
|
|
1073
|
+
question,
|
|
1074
|
+
},
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
if (removeFromParent) {
|
|
1078
|
+
removeOpenQuestion(parent, question);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
return child;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// ─── Validation & Helpers ────────────────────────────────────────────────────
|
|
1085
|
+
|
|
1086
|
+
/** Strict pattern for node IDs — no path traversal, no dots, no slashes */
|
|
1087
|
+
const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]*$/;
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Validate a git branch name — reject shell metacharacters and invalid git ref chars.
|
|
1091
|
+
* Minimum length 2 (single-char names rejected by the allowlist regex).
|
|
1092
|
+
* Returns the name if valid, null if rejected.
|
|
1093
|
+
*/
|
|
1094
|
+
export function sanitizeBranchName(name: string): string | null {
|
|
1095
|
+
if (!name || name.length > 200) return null;
|
|
1096
|
+
// Only allow: alphanumeric, hyphens, underscores, dots, forward slashes
|
|
1097
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._\-/]*[a-zA-Z0-9]$/.test(name)) return null;
|
|
1098
|
+
// Reject: consecutive dots, slash-dot, dot-slash, double slash, @{, backslash, space, ~, ^, :, ?, *, [
|
|
1099
|
+
if (/\.{2}|\/\.|\.\/|\/\/|@\{|\\|\s|[~^:?*[\]]/.test(name)) return null;
|
|
1100
|
+
// Reject .lock suffix on any component
|
|
1101
|
+
if (/\.lock(\/|$)/.test(name)) return null;
|
|
1102
|
+
return name;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Validate a node ID. Rejects path traversal attempts, dots, slashes,
|
|
1107
|
+
* uppercase, spaces, and empty strings.
|
|
1108
|
+
* Returns null if valid, or an error message string if invalid.
|
|
1109
|
+
*/
|
|
1110
|
+
export function validateNodeId(id: string): string | null {
|
|
1111
|
+
if (!id) return "Node ID cannot be empty";
|
|
1112
|
+
if (id.length > 80) return "Node ID too long (max 80 characters)";
|
|
1113
|
+
if (id.includes("/") || id.includes("\\")) return "Node ID cannot contain path separators";
|
|
1114
|
+
if (id.includes("..")) return "Node ID cannot contain '..'";
|
|
1115
|
+
if (id.startsWith(".")) return "Node ID cannot start with '.'";
|
|
1116
|
+
if (!VALID_ID_RE.test(id)) return "Node ID must match /^[a-z0-9][a-z0-9_-]*$/ (lowercase alphanumeric, hyphens, underscores)";
|
|
1117
|
+
return null;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/** Convert a title or question to a URL-safe slug */
|
|
1121
|
+
export function toSlug(text: string, maxLen: number = 40): string {
|
|
1122
|
+
return text
|
|
1123
|
+
.toLowerCase()
|
|
1124
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
1125
|
+
.replace(/^-|-$/g, "")
|
|
1126
|
+
.slice(0, maxLen);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// ─── Branch Association ──────────────────────────────────────────────────────
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* Match a git branch name to a design node using segment-aware matching.
|
|
1133
|
+
*
|
|
1134
|
+
* Algorithm:
|
|
1135
|
+
* 1. Split branch on "/" to get path segments (e.g. "feature/auth-strategy" → ["feature", "auth-strategy"])
|
|
1136
|
+
* 2. For each implementing node, check if any segment starts with the node ID
|
|
1137
|
+
* when both are split on hyphens (segment-aware prefix match)
|
|
1138
|
+
* 3. Longest matching node ID wins (prevents "auth" matching when "auth-strategy" exists)
|
|
1139
|
+
*
|
|
1140
|
+
* Only matches nodes with status "implementing" — association stops once a node
|
|
1141
|
+
* transitions to "implemented".
|
|
1142
|
+
*
|
|
1143
|
+
* @returns The matched DesignNode, or null if no match.
|
|
1144
|
+
*/
|
|
1145
|
+
export function matchBranchToNode(tree: DesignTree, branchName: string): DesignNode | null {
|
|
1146
|
+
if (!branchName || branchName === "main" || branchName === "detached") return null;
|
|
1147
|
+
|
|
1148
|
+
// Split branch on "/" to get path segments
|
|
1149
|
+
const branchSegments = branchName.split("/");
|
|
1150
|
+
|
|
1151
|
+
let bestMatch: DesignNode | null = null;
|
|
1152
|
+
let bestMatchLength = 0;
|
|
1153
|
+
|
|
1154
|
+
for (const node of tree.nodes.values()) {
|
|
1155
|
+
if (node.status !== "implementing") continue;
|
|
1156
|
+
|
|
1157
|
+
const nodeIdParts = node.id.split("-");
|
|
1158
|
+
|
|
1159
|
+
for (const segment of branchSegments) {
|
|
1160
|
+
const segmentParts = segment.split("-");
|
|
1161
|
+
|
|
1162
|
+
// Check if node ID parts are a prefix of this segment's parts
|
|
1163
|
+
if (nodeIdParts.length <= segmentParts.length &&
|
|
1164
|
+
nodeIdParts.every((part, i) => part === segmentParts[i])) {
|
|
1165
|
+
if (node.id.length > bestMatchLength) {
|
|
1166
|
+
bestMatch = node;
|
|
1167
|
+
bestMatchLength = node.id.length;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
return bestMatch;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Append a branch name to a node's branches list and write to disk.
|
|
1178
|
+
* Skips if the branch is already listed.
|
|
1179
|
+
*/
|
|
1180
|
+
export function appendBranch(node: DesignNode, branchName: string): DesignNode {
|
|
1181
|
+
if (node.branches.includes(branchName)) return node;
|
|
1182
|
+
|
|
1183
|
+
const updatedNode = {
|
|
1184
|
+
...node,
|
|
1185
|
+
branches: [...node.branches, branchName],
|
|
1186
|
+
};
|
|
1187
|
+
|
|
1188
|
+
const content = fs.readFileSync(node.filePath, "utf-8");
|
|
1189
|
+
const body = extractBody(content);
|
|
1190
|
+
const sections = parseSections(body);
|
|
1191
|
+
writeNodeDocument(updatedNode, sections);
|
|
1192
|
+
|
|
1193
|
+
return updatedNode;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* Read the current git branch from .git/HEAD.
|
|
1198
|
+
* Returns null if not in a git repo, "detached" for detached HEAD.
|
|
1199
|
+
*/
|
|
1200
|
+
export function readGitBranch(cwd: string): string | null {
|
|
1201
|
+
try {
|
|
1202
|
+
const gitPath = path.join(cwd, ".git");
|
|
1203
|
+
if (!fs.existsSync(gitPath)) return null;
|
|
1204
|
+
|
|
1205
|
+
let headPath: string;
|
|
1206
|
+
const stat = fs.statSync(gitPath);
|
|
1207
|
+
if (stat.isFile()) {
|
|
1208
|
+
// Worktree: .git is a file pointing to the real git dir
|
|
1209
|
+
const content = fs.readFileSync(gitPath, "utf-8").trim();
|
|
1210
|
+
if (!content.startsWith("gitdir: ")) return null;
|
|
1211
|
+
const gitDir = content.slice(8);
|
|
1212
|
+
headPath = path.resolve(cwd, gitDir, "HEAD");
|
|
1213
|
+
} else {
|
|
1214
|
+
headPath = path.join(gitPath, "HEAD");
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
if (!fs.existsSync(headPath)) return null;
|
|
1218
|
+
const headContent = fs.readFileSync(headPath, "utf-8").trim();
|
|
1219
|
+
return headContent.startsWith("ref: refs/heads/") ? headContent.slice(16) : "detached";
|
|
1220
|
+
} catch {
|
|
1221
|
+
return null;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// ─── OpenSpec Bridge ─────────────────────────────────────────────────────────
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* Scaffold an OpenSpec change directory from a decided design node.
|
|
1229
|
+
*
|
|
1230
|
+
* Creates:
|
|
1231
|
+
* openspec/changes/<node-id>/
|
|
1232
|
+
* proposal.md — from the node's overview and title
|
|
1233
|
+
* design.md — from the node's decisions and research
|
|
1234
|
+
* tasks.md — from child nodes or decisions (task groups)
|
|
1235
|
+
*
|
|
1236
|
+
* The generated tasks.md is compatible with cleave's openspec parser,
|
|
1237
|
+
* completing the pipeline: design → specify → parallelize → verify.
|
|
1238
|
+
*/
|
|
1239
|
+
export function scaffoldOpenSpecChange(
|
|
1240
|
+
cwd: string,
|
|
1241
|
+
tree: DesignTree,
|
|
1242
|
+
node: DesignNode,
|
|
1243
|
+
): { message: string; changePath: string; files: string[] } {
|
|
1244
|
+
const sections = getNodeSections(node);
|
|
1245
|
+
const changePath = path.join(cwd, "openspec", "changes", node.id);
|
|
1246
|
+
const files: string[] = [];
|
|
1247
|
+
|
|
1248
|
+
// Check for existing change directory — refuse to overwrite
|
|
1249
|
+
if (fs.existsSync(changePath)) {
|
|
1250
|
+
const existing = fs.readdirSync(changePath).filter((f) => f.endsWith(".md"));
|
|
1251
|
+
if (existing.length > 0) {
|
|
1252
|
+
return {
|
|
1253
|
+
message:
|
|
1254
|
+
`OpenSpec change directory already exists at ${changePath}\n` +
|
|
1255
|
+
`Existing files: ${existing.join(", ")}\n\n` +
|
|
1256
|
+
`To regenerate, delete the directory first:\n rm -rf ${changePath}`,
|
|
1257
|
+
changePath,
|
|
1258
|
+
files: [],
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
fs.mkdirSync(changePath, { recursive: true });
|
|
1264
|
+
|
|
1265
|
+
// ── proposal.md ──────────────────────────────────────────────
|
|
1266
|
+
const proposalLines = [
|
|
1267
|
+
`# ${node.title}`,
|
|
1268
|
+
"",
|
|
1269
|
+
"## Intent",
|
|
1270
|
+
"",
|
|
1271
|
+
sections.overview || "*Implement the design as specified.*",
|
|
1272
|
+
"",
|
|
1273
|
+
];
|
|
1274
|
+
|
|
1275
|
+
if (node.dependencies.length > 0) {
|
|
1276
|
+
proposalLines.push("## Dependencies", "");
|
|
1277
|
+
for (const depId of node.dependencies) {
|
|
1278
|
+
const dep = tree.nodes.get(depId);
|
|
1279
|
+
proposalLines.push(`- ${dep ? dep.title : depId} (${dep?.status || "unknown"})`);
|
|
1280
|
+
}
|
|
1281
|
+
proposalLines.push("");
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
const proposalPath = path.join(changePath, "proposal.md");
|
|
1285
|
+
fs.writeFileSync(proposalPath, proposalLines.join("\n"));
|
|
1286
|
+
files.push("proposal.md");
|
|
1287
|
+
|
|
1288
|
+
// ── design.md ────────────────────────────────────────────────
|
|
1289
|
+
const designLines = [
|
|
1290
|
+
`# ${node.title} — Design`,
|
|
1291
|
+
"",
|
|
1292
|
+
];
|
|
1293
|
+
|
|
1294
|
+
if (sections.decisions.length > 0) {
|
|
1295
|
+
designLines.push("## Architecture Decisions", "");
|
|
1296
|
+
for (const d of sections.decisions) {
|
|
1297
|
+
designLines.push(`### Decision: ${d.title}`, "");
|
|
1298
|
+
designLines.push(`**Status:** ${d.status}`);
|
|
1299
|
+
if (d.rationale) designLines.push(`**Rationale:** ${d.rationale}`);
|
|
1300
|
+
designLines.push("");
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
if (sections.research.length > 0) {
|
|
1305
|
+
designLines.push("## Research Context", "");
|
|
1306
|
+
for (const r of sections.research) {
|
|
1307
|
+
designLines.push(`### ${r.heading}`, "");
|
|
1308
|
+
designLines.push(r.content, "");
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
if (sections.implementationNotes.fileScope.length > 0) {
|
|
1313
|
+
designLines.push("## File Changes", "");
|
|
1314
|
+
for (const f of sections.implementationNotes.fileScope) {
|
|
1315
|
+
const action = f.action || "new";
|
|
1316
|
+
designLines.push(`- \`${f.path}\` (${action}) — ${f.description}`);
|
|
1317
|
+
}
|
|
1318
|
+
designLines.push("");
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
if (sections.implementationNotes.constraints.length > 0) {
|
|
1322
|
+
designLines.push("## Constraints", "");
|
|
1323
|
+
for (const c of sections.implementationNotes.constraints) {
|
|
1324
|
+
designLines.push(`- ${c}`);
|
|
1325
|
+
}
|
|
1326
|
+
designLines.push("");
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
const designPath = path.join(changePath, "design.md");
|
|
1330
|
+
fs.writeFileSync(designPath, designLines.join("\n"));
|
|
1331
|
+
files.push("design.md");
|
|
1332
|
+
|
|
1333
|
+
// ── tasks.md ─────────────────────────────────────────────────
|
|
1334
|
+
const children = getChildren(tree, node.id);
|
|
1335
|
+
const taskLines = [`# ${node.title} — Tasks`, ""];
|
|
1336
|
+
|
|
1337
|
+
if (children.length > 0) {
|
|
1338
|
+
// ── Child-node-driven groups ───────────────────────────────
|
|
1339
|
+
let groupNum = 1;
|
|
1340
|
+
for (const child of children) {
|
|
1341
|
+
taskLines.push(`## ${groupNum}. ${child.title}`, "");
|
|
1342
|
+
const childSections = getNodeSections(child);
|
|
1343
|
+
|
|
1344
|
+
if (childSections.openQuestions.length > 0) {
|
|
1345
|
+
let taskNum = 1;
|
|
1346
|
+
for (const q of childSections.openQuestions) {
|
|
1347
|
+
taskLines.push(`- [ ] ${groupNum}.${taskNum} ${q}`);
|
|
1348
|
+
taskNum++;
|
|
1349
|
+
}
|
|
1350
|
+
} else if (childSections.decisions.length > 0) {
|
|
1351
|
+
let taskNum = 1;
|
|
1352
|
+
for (const d of childSections.decisions) {
|
|
1353
|
+
taskLines.push(`- [ ] ${groupNum}.${taskNum} Implement: ${d.title}`);
|
|
1354
|
+
taskNum++;
|
|
1355
|
+
}
|
|
1356
|
+
} else {
|
|
1357
|
+
taskLines.push(`- [ ] ${groupNum}.1 Implement ${child.title}`);
|
|
1358
|
+
}
|
|
1359
|
+
taskLines.push("");
|
|
1360
|
+
groupNum++;
|
|
1361
|
+
}
|
|
1362
|
+
} else if (sections.implementationNotes.fileScope.length > 0) {
|
|
1363
|
+
// ── File-scope-driven groups (preferred over bare decisions) ─
|
|
1364
|
+
// Each file in impl_notes becomes a task group. Constraints that
|
|
1365
|
+
// mention the file's basename are attached to that group; any
|
|
1366
|
+
// remaining constraints land in a final "Cross-cutting" group.
|
|
1367
|
+
const constraints = sections.implementationNotes.constraints;
|
|
1368
|
+
const usedConstraints = new Set<number>();
|
|
1369
|
+
|
|
1370
|
+
let groupNum = 1;
|
|
1371
|
+
for (const f of sections.implementationNotes.fileScope) {
|
|
1372
|
+
const baseName = f.path.split("/").pop() ?? f.path;
|
|
1373
|
+
const actionTag = f.action ? ` (${f.action})` : "";
|
|
1374
|
+
taskLines.push(`## ${groupNum}. ${f.path}${actionTag}`, "");
|
|
1375
|
+
taskLines.push(`- [ ] ${groupNum}.1 ${f.description}`);
|
|
1376
|
+
|
|
1377
|
+
// Attach constraints that reference this file by basename or path
|
|
1378
|
+
let taskNum = 2;
|
|
1379
|
+
for (let i = 0; i < constraints.length; i++) {
|
|
1380
|
+
if (
|
|
1381
|
+
!usedConstraints.has(i) &&
|
|
1382
|
+
(constraints[i].toLowerCase().includes(baseName.toLowerCase()) ||
|
|
1383
|
+
constraints[i].toLowerCase().includes(f.path.toLowerCase()))
|
|
1384
|
+
) {
|
|
1385
|
+
taskLines.push(`- [ ] ${groupNum}.${taskNum} ${constraints[i]}`);
|
|
1386
|
+
usedConstraints.add(i);
|
|
1387
|
+
taskNum++;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Attach research entries whose heading references this file
|
|
1392
|
+
for (const r of sections.research) {
|
|
1393
|
+
if (
|
|
1394
|
+
r.heading.toLowerCase().includes(baseName.toLowerCase()) ||
|
|
1395
|
+
r.heading.toLowerCase().includes(f.path.toLowerCase())
|
|
1396
|
+
) {
|
|
1397
|
+
const firstLine = r.content.split("\n").find((l) => l.trim()) ?? "";
|
|
1398
|
+
if (firstLine) {
|
|
1399
|
+
taskLines.push(`- [ ] ${groupNum}.${taskNum} ${firstLine.replace(/^[-*]\s*/, "")}`);
|
|
1400
|
+
taskNum++;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
taskLines.push("");
|
|
1406
|
+
groupNum++;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// Emit any constraints not matched to a specific file
|
|
1410
|
+
const orphanConstraints = constraints.filter((_, i) => !usedConstraints.has(i));
|
|
1411
|
+
if (orphanConstraints.length > 0) {
|
|
1412
|
+
taskLines.push(`## ${groupNum}. Cross-cutting constraints`, "");
|
|
1413
|
+
let taskNum = 1;
|
|
1414
|
+
for (const c of orphanConstraints) {
|
|
1415
|
+
taskLines.push(`- [ ] ${groupNum}.${taskNum} ${c}`);
|
|
1416
|
+
taskNum++;
|
|
1417
|
+
}
|
|
1418
|
+
taskLines.push("");
|
|
1419
|
+
}
|
|
1420
|
+
} else if (sections.decisions.length > 0) {
|
|
1421
|
+
// ── Decision-driven groups (fallback when no file scope) ──────
|
|
1422
|
+
let groupNum = 1;
|
|
1423
|
+
for (const d of sections.decisions) {
|
|
1424
|
+
taskLines.push(`## ${groupNum}. ${d.title}`, "");
|
|
1425
|
+
taskLines.push(`- [ ] ${groupNum}.1 Implement ${d.title}`);
|
|
1426
|
+
taskLines.push("");
|
|
1427
|
+
groupNum++;
|
|
1428
|
+
}
|
|
1429
|
+
} else {
|
|
1430
|
+
taskLines.push(`## 1. ${node.title}`, "");
|
|
1431
|
+
taskLines.push(`- [ ] 1.1 Implement ${node.title}`);
|
|
1432
|
+
taskLines.push("");
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
const tasksContent = taskLines.join("\n");
|
|
1436
|
+
const tasksPath = path.join(changePath, "tasks.md");
|
|
1437
|
+
fs.writeFileSync(tasksPath, tasksContent);
|
|
1438
|
+
files.push("tasks.md");
|
|
1439
|
+
|
|
1440
|
+
// Surface the generated tasks.md content so the agent is forced to read
|
|
1441
|
+
// and refine it before proceeding — do not skip this review.
|
|
1442
|
+
const message =
|
|
1443
|
+
`Scaffolded OpenSpec change at ${changePath}\n\n` +
|
|
1444
|
+
`Files created:\n${files.map((f) => ` - ${f}`).join("\n")}\n\n` +
|
|
1445
|
+
`⚠️ REVIEW REQUIRED — tasks.md draft (read before proceeding):\n` +
|
|
1446
|
+
`${"─".repeat(60)}\n` +
|
|
1447
|
+
`${tasksContent}\n` +
|
|
1448
|
+
`${"─".repeat(60)}\n\n` +
|
|
1449
|
+
`The tasks above are a scaffold, not a final plan. Before running /cleave:\n` +
|
|
1450
|
+
` 1. Verify every file in impl_notes has at least one concrete subtask\n` +
|
|
1451
|
+
` 2. Check that each constraint appears in at least one task\n` +
|
|
1452
|
+
` 3. Expand any one-liner tasks that need numbered subtasks\n` +
|
|
1453
|
+
` 4. Add spec domain annotations if specs exist (<!-- specs: domain/name -->)\n\n` +
|
|
1454
|
+
`When satisfied:\n` +
|
|
1455
|
+
` - Run \`/cleave\` to parallelize execution via git worktrees\n` +
|
|
1456
|
+
` - After implementation, run \`/assess spec ${node.id}\` to verify against specs`;
|
|
1457
|
+
|
|
1458
|
+
return { message, changePath, files };
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// ─── Design-phase OpenSpec Scaffolding ──────────────────────────────────────
|
|
1462
|
+
|
|
1463
|
+
/**
|
|
1464
|
+
* Scaffold a design-phase OpenSpec change at openspec/design/<node-id>/.
|
|
1465
|
+
* Called on set_status(exploring) transition — idempotent (returns early if
|
|
1466
|
+
* the directory already has files).
|
|
1467
|
+
*
|
|
1468
|
+
* Generated files:
|
|
1469
|
+
* proposal.md — one-liner intent + link to design doc
|
|
1470
|
+
* spec.md — template with Scenarios / Falsifiability / Constraints subsections
|
|
1471
|
+
* tasks.md — Open Questions mirrored as unchecked tasks
|
|
1472
|
+
*/
|
|
1473
|
+
export function scaffoldDesignOpenSpecChange(
|
|
1474
|
+
cwd: string,
|
|
1475
|
+
node: DesignNode,
|
|
1476
|
+
): { message: string; changePath: string; created: boolean } {
|
|
1477
|
+
const changePath = path.join(cwd, "openspec", "design", node.id);
|
|
1478
|
+
|
|
1479
|
+
// Idempotent: if directory already has markdown files, skip
|
|
1480
|
+
if (fs.existsSync(changePath)) {
|
|
1481
|
+
const existing = fs.readdirSync(changePath).filter((f) => f.endsWith(".md"));
|
|
1482
|
+
if (existing.length > 0) {
|
|
1483
|
+
return {
|
|
1484
|
+
message: `Design OpenSpec change already exists at openspec/design/${node.id}/ — skipping scaffold.`,
|
|
1485
|
+
changePath,
|
|
1486
|
+
created: false,
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
fs.mkdirSync(changePath, { recursive: true });
|
|
1492
|
+
|
|
1493
|
+
// C4: guard against missing file (e.g. freshly-created node not yet flushed)
|
|
1494
|
+
let sections: DocumentSections;
|
|
1495
|
+
try {
|
|
1496
|
+
sections = getNodeSections(node);
|
|
1497
|
+
} catch {
|
|
1498
|
+
// Fall back to empty sections so scaffold can still proceed
|
|
1499
|
+
sections = {
|
|
1500
|
+
overview: "",
|
|
1501
|
+
research: [],
|
|
1502
|
+
decisions: [],
|
|
1503
|
+
openQuestions: node.open_questions ?? [],
|
|
1504
|
+
implementationNotes: { fileScope: [], constraints: [], rawContent: "" },
|
|
1505
|
+
acceptanceCriteria: { scenarios: [], falsifiability: [], constraints: [] },
|
|
1506
|
+
extraSections: [],
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
const docRelPath = `docs/${node.id}.md`;
|
|
1510
|
+
|
|
1511
|
+
// ── proposal.md ──────────────────────────────────────────────
|
|
1512
|
+
const intentLine = sections.overview
|
|
1513
|
+
? sections.overview.split("\n").find((l) => l.trim()) ?? sections.overview
|
|
1514
|
+
: `Explore and decide the design of: ${node.title}`;
|
|
1515
|
+
|
|
1516
|
+
const proposal = [
|
|
1517
|
+
`# ${node.title}`,
|
|
1518
|
+
"",
|
|
1519
|
+
"## Intent",
|
|
1520
|
+
"",
|
|
1521
|
+
intentLine,
|
|
1522
|
+
"",
|
|
1523
|
+
`See [${node.title} design doc](../../../${docRelPath}) for full context.`,
|
|
1524
|
+
"",
|
|
1525
|
+
].join("\n");
|
|
1526
|
+
|
|
1527
|
+
fs.writeFileSync(path.join(changePath, "proposal.md"), proposal);
|
|
1528
|
+
|
|
1529
|
+
// ── spec.md ───────────────────────────────────────────────────
|
|
1530
|
+
const spec = [
|
|
1531
|
+
`# ${node.title} — Design Spec`,
|
|
1532
|
+
"",
|
|
1533
|
+
"> This spec defines acceptance criteria for the design phase.",
|
|
1534
|
+
"> Add Given/When/Then scenarios that must be true before marking this node 'decided'.",
|
|
1535
|
+
"",
|
|
1536
|
+
"## Scenarios",
|
|
1537
|
+
"",
|
|
1538
|
+
"### Scenario 1 (replace with a real scenario)",
|
|
1539
|
+
"",
|
|
1540
|
+
"Given this node is in the exploring state",
|
|
1541
|
+
"When the design questions are answered and a decision is recorded",
|
|
1542
|
+
"Then the node can be transitioned to decided",
|
|
1543
|
+
"",
|
|
1544
|
+
"## Falsifiability",
|
|
1545
|
+
"",
|
|
1546
|
+
"<!-- What would disprove this design? List concrete failure conditions. -->",
|
|
1547
|
+
"",
|
|
1548
|
+
"## Constraints",
|
|
1549
|
+
"",
|
|
1550
|
+
"<!-- Non-negotiable constraints this design must satisfy. -->",
|
|
1551
|
+
"",
|
|
1552
|
+
].join("\n");
|
|
1553
|
+
|
|
1554
|
+
fs.writeFileSync(path.join(changePath, "spec.md"), spec);
|
|
1555
|
+
|
|
1556
|
+
// ── tasks.md ──────────────────────────────────────────────────
|
|
1557
|
+
const tasks = buildDesignTasksContent(node, sections);
|
|
1558
|
+
fs.writeFileSync(path.join(changePath, "tasks.md"), tasks);
|
|
1559
|
+
|
|
1560
|
+
return {
|
|
1561
|
+
message: `Scaffolded design OpenSpec change at openspec/design/${node.id}/ (proposal.md, spec.md, tasks.md).`,
|
|
1562
|
+
changePath,
|
|
1563
|
+
created: true,
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
/**
|
|
1568
|
+
* Build tasks.md content from a node's Open Questions.
|
|
1569
|
+
* Used both during initial scaffold and for mirroring on question mutations.
|
|
1570
|
+
*/
|
|
1571
|
+
export function buildDesignTasksContent(node: DesignNode, sections: DocumentSections): string {
|
|
1572
|
+
const lines = [`# ${node.title} — Design Tasks`, ""];
|
|
1573
|
+
|
|
1574
|
+
if (sections.openQuestions.length === 0) {
|
|
1575
|
+
lines.push("## 1. Design exploration", "");
|
|
1576
|
+
lines.push(`- [ ] 1.1 Explore and decide: ${node.title}`);
|
|
1577
|
+
lines.push("");
|
|
1578
|
+
} else {
|
|
1579
|
+
lines.push("## 1. Open Questions", "");
|
|
1580
|
+
let i = 1;
|
|
1581
|
+
for (const q of sections.openQuestions) {
|
|
1582
|
+
lines.push(`- [ ] 1.${i} ${q}`);
|
|
1583
|
+
i++;
|
|
1584
|
+
}
|
|
1585
|
+
lines.push("");
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
return lines.join("\n");
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
/**
|
|
1592
|
+
* Mirror the node's Open Questions to tasks.md in the design OpenSpec change
|
|
1593
|
+
* directory (openspec/design/<node-id>/tasks.md), if that directory exists.
|
|
1594
|
+
* Idempotent — overwrites tasks.md on every call.
|
|
1595
|
+
*/
|
|
1596
|
+
export function mirrorOpenQuestionsToDesignSpec(cwd: string, node: DesignNode): void {
|
|
1597
|
+
const tasksPath = path.join(cwd, "openspec", "design", node.id, "tasks.md");
|
|
1598
|
+
if (!fs.existsSync(tasksPath)) return;
|
|
1599
|
+
|
|
1600
|
+
// W1: use node.open_questions directly — avoids redundant disk read and
|
|
1601
|
+
// potential race if the file write from add/removeOpenQuestion hasn't flushed.
|
|
1602
|
+
const syntheticSections: Pick<DocumentSections, "openQuestions"> = {
|
|
1603
|
+
openQuestions: node.open_questions ?? [],
|
|
1604
|
+
};
|
|
1605
|
+
const content = buildDesignTasksContent(node, syntheticSections as DocumentSections);
|
|
1606
|
+
fs.writeFileSync(tasksPath, content);
|
|
1607
|
+
}
|