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.
Files changed (160) hide show
  1. package/.gitattributes +3 -0
  2. package/AGENTS.md +16 -0
  3. package/LICENSE +15 -0
  4. package/README.md +289 -0
  5. package/bin/pi.mjs +30 -0
  6. package/extensions/00-secrets/index.ts +1126 -0
  7. package/extensions/01-auth/auth.ts +401 -0
  8. package/extensions/01-auth/index.ts +289 -0
  9. package/extensions/auto-compact.ts +42 -0
  10. package/extensions/bootstrap/deps.ts +291 -0
  11. package/extensions/bootstrap/index.ts +811 -0
  12. package/extensions/chronos/chronos.sh +487 -0
  13. package/extensions/chronos/index.ts +148 -0
  14. package/extensions/cleave/assessment.ts +754 -0
  15. package/extensions/cleave/bridge.ts +31 -0
  16. package/extensions/cleave/conflicts.ts +250 -0
  17. package/extensions/cleave/dispatcher.ts +808 -0
  18. package/extensions/cleave/guardrails.ts +426 -0
  19. package/extensions/cleave/index.ts +3121 -0
  20. package/extensions/cleave/lifecycle-emitter.ts +20 -0
  21. package/extensions/cleave/openspec.ts +811 -0
  22. package/extensions/cleave/planner.ts +260 -0
  23. package/extensions/cleave/review.ts +579 -0
  24. package/extensions/cleave/skills.ts +355 -0
  25. package/extensions/cleave/types.ts +261 -0
  26. package/extensions/cleave/workspace.ts +861 -0
  27. package/extensions/cleave/worktree.ts +243 -0
  28. package/extensions/core-renderers.ts +253 -0
  29. package/extensions/dashboard/context-gauge.ts +58 -0
  30. package/extensions/dashboard/file-watch.ts +14 -0
  31. package/extensions/dashboard/footer.ts +1145 -0
  32. package/extensions/dashboard/git.ts +185 -0
  33. package/extensions/dashboard/index.ts +478 -0
  34. package/extensions/dashboard/memory-audit.ts +34 -0
  35. package/extensions/dashboard/overlay-data.ts +705 -0
  36. package/extensions/dashboard/overlay.ts +365 -0
  37. package/extensions/dashboard/render-utils.ts +54 -0
  38. package/extensions/dashboard/types.ts +191 -0
  39. package/extensions/dashboard/uri-helper.ts +45 -0
  40. package/extensions/debug.ts +69 -0
  41. package/extensions/defaults.ts +282 -0
  42. package/extensions/design-tree/dashboard-state.ts +161 -0
  43. package/extensions/design-tree/design-card.ts +362 -0
  44. package/extensions/design-tree/index.ts +2130 -0
  45. package/extensions/design-tree/lifecycle-emitter.ts +41 -0
  46. package/extensions/design-tree/tree.ts +1607 -0
  47. package/extensions/design-tree/types.ts +163 -0
  48. package/extensions/distill.ts +127 -0
  49. package/extensions/effort/index.ts +395 -0
  50. package/extensions/effort/tiers.ts +146 -0
  51. package/extensions/effort/types.ts +105 -0
  52. package/extensions/lib/git-state.ts +227 -0
  53. package/extensions/lib/local-models.ts +157 -0
  54. package/extensions/lib/model-preferences.ts +51 -0
  55. package/extensions/lib/model-routing.ts +720 -0
  56. package/extensions/lib/operator-fallback.ts +205 -0
  57. package/extensions/lib/operator-profile.ts +360 -0
  58. package/extensions/lib/slash-command-bridge.ts +253 -0
  59. package/extensions/lib/typebox-helpers.ts +16 -0
  60. package/extensions/local-inference/index.ts +727 -0
  61. package/extensions/mcp-bridge/README.md +220 -0
  62. package/extensions/mcp-bridge/index.ts +951 -0
  63. package/extensions/mcp-bridge/lib.ts +365 -0
  64. package/extensions/mcp-bridge/mcp.json +3 -0
  65. package/extensions/mcp-bridge/package.json +11 -0
  66. package/extensions/model-budget.ts +752 -0
  67. package/extensions/offline-driver.ts +403 -0
  68. package/extensions/openspec/archive-gate.ts +164 -0
  69. package/extensions/openspec/branch-cleanup.ts +64 -0
  70. package/extensions/openspec/dashboard-state.ts +50 -0
  71. package/extensions/openspec/index.ts +1917 -0
  72. package/extensions/openspec/lifecycle-emitter.ts +65 -0
  73. package/extensions/openspec/lifecycle-files.ts +70 -0
  74. package/extensions/openspec/lifecycle.ts +50 -0
  75. package/extensions/openspec/reconcile.ts +187 -0
  76. package/extensions/openspec/spec.ts +1385 -0
  77. package/extensions/openspec/types.ts +98 -0
  78. package/extensions/project-memory/DESIGN-global-mind.md +198 -0
  79. package/extensions/project-memory/README.md +202 -0
  80. package/extensions/project-memory/api-types.ts +382 -0
  81. package/extensions/project-memory/compaction-policy.ts +29 -0
  82. package/extensions/project-memory/core.ts +164 -0
  83. package/extensions/project-memory/embeddings.ts +230 -0
  84. package/extensions/project-memory/extraction-v2.ts +861 -0
  85. package/extensions/project-memory/factstore.ts +2177 -0
  86. package/extensions/project-memory/index.ts +3459 -0
  87. package/extensions/project-memory/injection-metrics.ts +91 -0
  88. package/extensions/project-memory/jsonl-io.ts +12 -0
  89. package/extensions/project-memory/lifecycle.ts +331 -0
  90. package/extensions/project-memory/migration.ts +293 -0
  91. package/extensions/project-memory/package.json +9 -0
  92. package/extensions/project-memory/sci-renderers.ts +7 -0
  93. package/extensions/project-memory/template.ts +103 -0
  94. package/extensions/project-memory/triggers.ts +52 -0
  95. package/extensions/project-memory/types.ts +102 -0
  96. package/extensions/render/composition/fonts/Inter-Bold.ttf +0 -0
  97. package/extensions/render/composition/fonts/Inter-Regular.ttf +0 -0
  98. package/extensions/render/composition/fonts/Tomorrow-Bold.ttf +0 -0
  99. package/extensions/render/composition/fonts/Tomorrow-Regular.ttf +0 -0
  100. package/extensions/render/composition/package-lock.json +534 -0
  101. package/extensions/render/composition/package.json +22 -0
  102. package/extensions/render/composition/render.mjs +246 -0
  103. package/extensions/render/composition/test-comp.tsx +87 -0
  104. package/extensions/render/composition/types.ts +24 -0
  105. package/extensions/render/excalidraw/UPSTREAM.md +81 -0
  106. package/extensions/render/excalidraw/elements.ts +764 -0
  107. package/extensions/render/excalidraw/index.ts +66 -0
  108. package/extensions/render/excalidraw/types.ts +223 -0
  109. package/extensions/render/excalidraw-renderer/pyproject.toml +8 -0
  110. package/extensions/render/excalidraw-renderer/render_excalidraw.py +182 -0
  111. package/extensions/render/excalidraw-renderer/render_template.html +59 -0
  112. package/extensions/render/index.ts +830 -0
  113. package/extensions/render/native-diagrams/index.ts +57 -0
  114. package/extensions/render/native-diagrams/motifs.ts +542 -0
  115. package/extensions/render/native-diagrams/raster.ts +8 -0
  116. package/extensions/render/native-diagrams/scene.ts +75 -0
  117. package/extensions/render/native-diagrams/spec.ts +204 -0
  118. package/extensions/render/native-diagrams/svg.ts +116 -0
  119. package/extensions/sci-ui.ts +304 -0
  120. package/extensions/session-log.ts +174 -0
  121. package/extensions/shared-state.ts +146 -0
  122. package/extensions/spinner-verbs.ts +91 -0
  123. package/extensions/style.ts +281 -0
  124. package/extensions/terminal-title.ts +191 -0
  125. package/extensions/tool-profile/index.ts +291 -0
  126. package/extensions/tool-profile/profiles.ts +290 -0
  127. package/extensions/types.d.ts +9 -0
  128. package/extensions/vault/index.ts +185 -0
  129. package/extensions/version-check.ts +90 -0
  130. package/extensions/view/index.ts +859 -0
  131. package/extensions/view/uri-resolver.ts +148 -0
  132. package/extensions/web-search/index.ts +182 -0
  133. package/extensions/web-search/providers.ts +121 -0
  134. package/extensions/web-ui/index.ts +110 -0
  135. package/extensions/web-ui/server.ts +265 -0
  136. package/extensions/web-ui/state.ts +462 -0
  137. package/extensions/web-ui/static/index.html +145 -0
  138. package/extensions/web-ui/types.ts +284 -0
  139. package/package.json +76 -0
  140. package/prompts/init.md +75 -0
  141. package/prompts/new-repo.md +54 -0
  142. package/prompts/oci-login.md +56 -0
  143. package/prompts/status.md +50 -0
  144. package/settings.json +4 -0
  145. package/skills/cleave/SKILL.md +218 -0
  146. package/skills/git/SKILL.md +209 -0
  147. package/skills/git/_reference/ci-validation.md +204 -0
  148. package/skills/oci/SKILL.md +338 -0
  149. package/skills/openspec/SKILL.md +346 -0
  150. package/skills/pi-extensions/SKILL.md +191 -0
  151. package/skills/pi-tui/SKILL.md +517 -0
  152. package/skills/python/SKILL.md +189 -0
  153. package/skills/rust/SKILL.md +268 -0
  154. package/skills/security/SKILL.md +206 -0
  155. package/skills/style/SKILL.md +264 -0
  156. package/skills/typescript/SKILL.md +225 -0
  157. package/skills/vault/SKILL.md +102 -0
  158. package/themes/alpharius-legacy.json +85 -0
  159. package/themes/alpharius.conf +59 -0
  160. package/themes/alpharius.json +88 -0
@@ -0,0 +1,811 @@
1
+ /**
2
+ * cleave/openspec — OpenSpec tasks.md parser.
3
+ *
4
+ * Parses OpenSpec's tasks.md format into ChildPlan[] for cleave execution.
5
+ * OpenSpec tasks.md uses numbered, grouped tasks with checkboxes:
6
+ *
7
+ * ## 1. Theme Infrastructure
8
+ * - [ ] 1.1 Create ThemeContext with light/dark state
9
+ * - [ ] 1.2 Add CSS custom properties for colors
10
+ *
11
+ * ## 2. UI Components
12
+ * - [ ] 2.1 Create ThemeToggle component
13
+ * - [ ] 2.2 Add toggle to settings page
14
+ *
15
+ * Each top-level group (## N. Title) becomes a ChildPlan.
16
+ * Subtasks within a group become the scope/description.
17
+ * Group ordering defines dependencies (later groups may depend on earlier).
18
+ */
19
+
20
+ import { existsSync, readFileSync, readdirSync, writeFileSync, statSync } from "node:fs";
21
+ import { join, basename } from "node:path";
22
+ import type { ChildPlan, SplitPlan } from "./types.ts";
23
+
24
+ // ─── Types ──────────────────────────────────────────────────────────────────
25
+
26
+ export interface OpenSpecChange {
27
+ /** Change directory name (e.g., "add-dark-mode") */
28
+ name: string;
29
+ /** Full path to the change directory */
30
+ path: string;
31
+ /** Whether tasks.md exists */
32
+ hasTasks: boolean;
33
+ /** Whether proposal.md exists */
34
+ hasProposal: boolean;
35
+ /** Whether design.md exists */
36
+ hasDesign: boolean;
37
+ }
38
+
39
+ export interface TaskGroup {
40
+ /** Group number (1-based) */
41
+ number: number;
42
+ /** Group title (e.g., "Theme Infrastructure") */
43
+ title: string;
44
+ /** Individual tasks within the group */
45
+ tasks: Array<{
46
+ id: string; // e.g., "1.1"
47
+ text: string; // e.g., "Create ThemeContext with light/dark state"
48
+ done: boolean; // checkbox state
49
+ }>;
50
+ /** Spec domains declared via <!-- specs: domain/name, ... --> annotation */
51
+ specDomains: string[];
52
+ /** Skill names declared via <!-- skills: skill1, skill2 --> annotation */
53
+ skills: string[];
54
+ }
55
+
56
+ /**
57
+ * Rich context extracted from an OpenSpec change, beyond just tasks.
58
+ * Carries design decisions, file scope, and spec scenarios for
59
+ * child enrichment and post-merge verification.
60
+ */
61
+ export interface OpenSpecContext {
62
+ /** The change directory path */
63
+ changePath: string;
64
+ /** Full design.md content (null if absent) */
65
+ designContent: string | null;
66
+ /** Architecture decisions extracted from design.md */
67
+ decisions: string[];
68
+ /** Explicit file changes from design.md "File Changes" section */
69
+ fileChanges: Array<{ path: string; action: "new" | "modified" | "deleted" | "unknown" }>;
70
+ /** Delta spec scenarios for post-merge verification */
71
+ specScenarios: Array<{ domain: string; requirement: string; scenarios: string[] }>;
72
+ /** OpenAPI/AsyncAPI contract content (null if absent) */
73
+ apiContract: string | null;
74
+ }
75
+
76
+ // ─── Detection ──────────────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Detect whether an OpenSpec workspace exists in the given repo.
80
+ * Returns the path to openspec/ if found, null otherwise.
81
+ */
82
+ export function detectOpenSpec(repoPath: string): string | null {
83
+ const openspecDir = join(repoPath, "openspec");
84
+ return existsSync(openspecDir) ? openspecDir : null;
85
+ }
86
+
87
+ /**
88
+ * List active (non-archived) OpenSpec changes.
89
+ */
90
+ export function listChanges(openspecDir: string): OpenSpecChange[] {
91
+ const changesDir = join(openspecDir, "changes");
92
+ if (!existsSync(changesDir)) return [];
93
+
94
+ const entries = readdirSync(changesDir, { withFileTypes: true });
95
+ const changes: OpenSpecChange[] = [];
96
+
97
+ for (const entry of entries) {
98
+ if (!entry.isDirectory() || entry.name === "archive") continue;
99
+
100
+ const changePath = join(changesDir, entry.name);
101
+ changes.push({
102
+ name: entry.name,
103
+ path: changePath,
104
+ hasTasks: existsSync(join(changePath, "tasks.md")),
105
+ hasProposal: existsSync(join(changePath, "proposal.md")),
106
+ hasDesign: existsSync(join(changePath, "design.md")),
107
+ });
108
+ }
109
+
110
+ return changes;
111
+ }
112
+
113
+ /**
114
+ * Find changes that have tasks.md ready for execution.
115
+ */
116
+ export function findExecutableChanges(openspecDir: string): OpenSpecChange[] {
117
+ return listChanges(openspecDir).filter((c) => c.hasTasks);
118
+ }
119
+
120
+ // ─── Parsing ────────────────────────────────────────────────────────────────
121
+
122
+ /**
123
+ * Parse an OpenSpec tasks.md into task groups.
124
+ *
125
+ * Supports formats:
126
+ * ## 1. Group Title
127
+ * - [ ] 1.1 Task description
128
+ * - [x] 1.2 Completed task
129
+ *
130
+ * Also handles unnumbered groups:
131
+ * ## Group Title
132
+ * - [ ] Task description
133
+ */
134
+ export function parseTasksFile(content: string): TaskGroup[] {
135
+ const groups: TaskGroup[] = [];
136
+ let currentGroup: TaskGroup | null = null;
137
+
138
+ const lines = content.split("\n");
139
+
140
+ for (const line of lines) {
141
+ // Match group header: ## 1. Title or ## Title
142
+ const groupMatch = line.match(/^##\s+(?:(\d+)\.\s+)?(.+)$/);
143
+ if (groupMatch) {
144
+ if (currentGroup) groups.push(currentGroup);
145
+ currentGroup = {
146
+ number: groupMatch[1] ? parseInt(groupMatch[1], 10) : groups.length + 1,
147
+ title: groupMatch[2].trim(),
148
+ tasks: [],
149
+ specDomains: [],
150
+ skills: [],
151
+ };
152
+ continue;
153
+ }
154
+
155
+ // Match spec-domain annotation: <!-- specs: domain/name, domain2/name2 -->
156
+ const specMatch = line.match(/^\s*<!--\s*specs:\s*(.+?)\s*-->\s*$/);
157
+ if (specMatch && currentGroup && currentGroup.tasks.length === 0) {
158
+ currentGroup.specDomains = specMatch[1]
159
+ .split(",")
160
+ .map((s) => s.trim())
161
+ .filter((s) => s.length > 0);
162
+ continue;
163
+ }
164
+
165
+ // Match skills annotation: <!-- skills: python, k8s-operations -->
166
+ const skillsMatch = line.match(/^\s*<!--\s*skills:\s*(.+?)\s*-->\s*$/);
167
+ if (skillsMatch && currentGroup && currentGroup.tasks.length === 0) {
168
+ currentGroup.skills = skillsMatch[1]
169
+ .split(",")
170
+ .map((s) => s.trim())
171
+ .filter((s) => s.length > 0);
172
+ continue;
173
+ }
174
+
175
+ // Match task item: - [ ] 1.1 Description or - [x] 1.2 Description
176
+ const taskMatch = line.match(/^\s*-\s+\[([ xX])\]\s+(?:(\d+(?:\.\d+)?)\s+)?(.+)$/);
177
+ if (taskMatch && currentGroup) {
178
+ currentGroup.tasks.push({
179
+ id: taskMatch[2] || `${currentGroup.number}.${currentGroup.tasks.length + 1}`,
180
+ text: taskMatch[3].trim(),
181
+ done: taskMatch[1] !== " ",
182
+ });
183
+ continue;
184
+ }
185
+
186
+ // Match unnumbered bullet task under a group: - Task text (no checkbox)
187
+ const bulletMatch = line.match(/^\s*-\s+(?!\[)(.+)$/);
188
+ if (bulletMatch && currentGroup) {
189
+ currentGroup.tasks.push({
190
+ id: `${currentGroup.number}.${currentGroup.tasks.length + 1}`,
191
+ text: bulletMatch[1].trim(),
192
+ done: false,
193
+ });
194
+ }
195
+ }
196
+
197
+ if (currentGroup) groups.push(currentGroup);
198
+ return groups;
199
+ }
200
+
201
+ // ─── Conversion ─────────────────────────────────────────────────────────────
202
+
203
+ /**
204
+ * Convert OpenSpec task groups to cleave ChildPlan[].
205
+ *
206
+ * Each group becomes a child. Dependencies are inferred from:
207
+ * - Explicit markers in title: "after X", "requires X", "depends on X"
208
+ * - Task text references to earlier group titles
209
+ *
210
+ * Groups where ALL tasks are already done are filtered out.
211
+ *
212
+ * Returns null if fewer than 2 executable groups (not worth cleaving).
213
+ */
214
+ export function taskGroupsToChildPlans(groups: TaskGroup[]): ChildPlan[] | null {
215
+ // Filter out groups where all tasks are done
216
+ const activeGroups = groups.filter((g) =>
217
+ g.tasks.length === 0 || g.tasks.some((t) => !t.done),
218
+ );
219
+
220
+ if (activeGroups.length < 2) return null;
221
+
222
+ // Cap at 4 children (cleave limit)
223
+ const effectiveGroups = activeGroups.length > 4 ? mergeSmallGroups(activeGroups, 4) : activeGroups;
224
+
225
+ const plans: ChildPlan[] = effectiveGroups.map((group) => {
226
+ const label = group.title
227
+ .toLowerCase()
228
+ .replace(/[^\w\s-]/g, "")
229
+ .replace(/[\s_]+/g, "-")
230
+ .replace(/-+/g, "-")
231
+ .replace(/^-|-$/g, "")
232
+ .slice(0, 40);
233
+
234
+ const taskDescriptions = group.tasks
235
+ .filter((t) => !t.done) // Skip already-completed tasks
236
+ .map((t) => `- ${t.text}`);
237
+
238
+ const description = taskDescriptions.length > 0
239
+ ? `${group.title}:\n${taskDescriptions.join("\n")}`
240
+ : group.title;
241
+
242
+ // Infer scope from task text: look for file paths and patterns
243
+ const scope = inferScope(group.tasks.map((t) => t.text));
244
+
245
+ return {
246
+ label,
247
+ description,
248
+ scope,
249
+ dependsOn: [] as string[],
250
+ specDomains: [...(group.specDomains ?? [])],
251
+ skills: [...(group.skills ?? [])],
252
+ };
253
+ });
254
+
255
+ // Infer dependencies from explicit markers and title references
256
+ inferDependencies(plans);
257
+
258
+ return plans;
259
+ }
260
+
261
+ /**
262
+ * Infer inter-group dependencies from explicit markers in descriptions.
263
+ *
264
+ * Looks for patterns like:
265
+ * - "after <label>" or "after <title words>"
266
+ * - "requires <label>"
267
+ * - "depends on <label>"
268
+ * - Task text referencing an earlier group's title
269
+ */
270
+ function inferDependencies(plans: ChildPlan[]): void {
271
+ const labelSet = new Set(plans.map((p) => p.label));
272
+
273
+ for (let i = 0; i < plans.length; i++) {
274
+ const text = plans[i].description.toLowerCase();
275
+
276
+ for (let j = 0; j < plans.length; j++) {
277
+ if (i === j) continue;
278
+ const otherLabel = plans[j].label;
279
+ // Convert label back to words for fuzzy matching: "database-layer" → "database layer"
280
+ const otherWords = otherLabel.replace(/-/g, " ");
281
+
282
+ // Explicit markers: "after X", "requires X", "depends on X"
283
+ const markers = [
284
+ `after ${otherLabel}`, `after ${otherWords}`,
285
+ `requires ${otherLabel}`, `requires ${otherWords}`,
286
+ `depends on ${otherLabel}`, `depends on ${otherWords}`,
287
+ ];
288
+
289
+ if (markers.some((m) => text.includes(m))) {
290
+ if (labelSet.has(otherLabel) && !plans[i].dependsOn.includes(otherLabel)) {
291
+ plans[i].dependsOn.push(otherLabel);
292
+ }
293
+ }
294
+ }
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Full pipeline: read an OpenSpec change and convert to SplitPlan.
300
+ *
301
+ * Returns null if the change doesn't have tasks or has fewer than 2 groups.
302
+ */
303
+ export function openspecChangeToSplitPlan(changePath: string): SplitPlan | null {
304
+ const tasksPath = join(changePath, "tasks.md");
305
+ if (!existsSync(tasksPath)) return null;
306
+
307
+ const content = readFileSync(tasksPath, "utf-8");
308
+ const groups = parseTasksFile(content);
309
+ const children = taskGroupsToChildPlans(groups);
310
+ if (!children) return null;
311
+
312
+ // Read proposal for rationale if available
313
+ let rationale = `From OpenSpec change: ${basename(changePath)}`;
314
+ const proposalPath = join(changePath, "proposal.md");
315
+ if (existsSync(proposalPath)) {
316
+ const proposal = readFileSync(proposalPath, "utf-8");
317
+ // Extract intent section
318
+ const intentMatch = proposal.match(/##\s+Intent\s*\n([\s\S]*?)(?=\n##|$)/);
319
+ if (intentMatch) {
320
+ rationale = intentMatch[1].trim().slice(0, 200);
321
+ }
322
+ }
323
+
324
+ return { children, rationale };
325
+ }
326
+
327
+ // ─── Design Context ─────────────────────────────────────────────────────────
328
+
329
+ /**
330
+ * Parse the "File Changes" section from design.md.
331
+ *
332
+ * Supports formats:
333
+ * - `src/contexts/ThemeContext.tsx` (new)
334
+ * - `src/styles/globals.css` (modified)
335
+ * - src/old/file.ts (deleted)
336
+ * - `path/to/file.ts` (no action → unknown)
337
+ */
338
+ export function parseDesignFileChanges(
339
+ designContent: string,
340
+ ): Array<{ path: string; action: "new" | "modified" | "deleted" | "unknown" }> {
341
+ const results: Array<{ path: string; action: "new" | "modified" | "deleted" | "unknown" }> = [];
342
+
343
+ // Find the File Changes section
344
+ const sectionMatch = designContent.match(
345
+ /##\s+File\s+Changes?\s*\n([\s\S]*?)(?=\n##\s|\n#\s|$)/i,
346
+ );
347
+ if (!sectionMatch) return results;
348
+
349
+ const section = sectionMatch[1];
350
+ // Match lines like: - `path/to/file` (action) or - path/to/file (action)
351
+ const lineRe = /^[\s-]*[`"']?([a-zA-Z0-9_./-]+\.[a-zA-Z0-9]+)[`"']?\s*(?:\((\w+)\))?/gm;
352
+ let m: RegExpExecArray | null;
353
+ while ((m = lineRe.exec(section)) !== null) {
354
+ const filePath = m[1];
355
+ const rawAction = (m[2] || "").toLowerCase();
356
+ let action: "new" | "modified" | "deleted" | "unknown" = "unknown";
357
+ if (rawAction === "new" || rawAction === "created" || rawAction === "create") action = "new";
358
+ else if (rawAction === "modified" || rawAction === "updated" || rawAction === "modify") action = "modified";
359
+ else if (rawAction === "deleted" || rawAction === "removed" || rawAction === "delete") action = "deleted";
360
+ results.push({ path: filePath, action });
361
+ }
362
+
363
+ return results;
364
+ }
365
+
366
+ /**
367
+ * Extract architecture decisions from design.md.
368
+ *
369
+ * Looks for "### Decision:" headers and captures the title + rationale.
370
+ */
371
+ export function parseDesignDecisions(designContent: string): string[] {
372
+ const decisions: string[] = [];
373
+ const re = /###\s+Decision:\s*(.+?)(?:\n[\s\S]*?(?=\n###|\n##|$))/g;
374
+ let m: RegExpExecArray | null;
375
+ while ((m = re.exec(designContent)) !== null) {
376
+ // Capture the decision title and first line of rationale
377
+ const title = m[0];
378
+ const lines = title.split("\n").filter((l) => l.trim());
379
+ const summary = lines.length > 1
380
+ ? `${lines[0].replace(/^###\s+Decision:\s*/, "").trim()}: ${lines.slice(1).find((l) => !l.startsWith("#"))?.trim() || ""}`
381
+ : lines[0].replace(/^###\s+Decision:\s*/, "").trim();
382
+ decisions.push(summary);
383
+ }
384
+ return decisions;
385
+ }
386
+
387
+ // ─── Spec Scenarios ─────────────────────────────────────────────────────────
388
+
389
+ /**
390
+ * Read delta spec files from a change and extract scenarios for verification.
391
+ *
392
+ * Parses Given/When/Then scenarios from ADDED and MODIFIED requirements
393
+ * in the change's specs/ directory.
394
+ */
395
+ export function readSpecScenarios(
396
+ changePath: string,
397
+ ): Array<{ domain: string; requirement: string; scenarios: string[] }> {
398
+ const specsDir = join(changePath, "specs");
399
+ if (!existsSync(specsDir)) return [];
400
+
401
+ const results: Array<{ domain: string; requirement: string; scenarios: string[] }> = [];
402
+
403
+ // Recursively find spec.md files
404
+ const specFiles = findSpecFiles(specsDir);
405
+
406
+ for (const specFile of specFiles) {
407
+ const content = readFileSync(specFile, "utf-8");
408
+ const domain = specFile
409
+ .replace(specsDir + "/", "")
410
+ .replace(/\/spec\.md$/, "")
411
+ .replace(/\.md$/, "");
412
+
413
+ // Only extract from ADDED and MODIFIED sections (these need verification)
414
+ const relevantSections = content.match(
415
+ /##\s+(?:ADDED|MODIFIED)\s+Requirements?\s*\n([\s\S]*?)(?=\n##\s+(?:ADDED|MODIFIED|REMOVED)|$)/gi,
416
+ );
417
+ if (!relevantSections) continue;
418
+
419
+ for (const section of relevantSections) {
420
+ // Find requirements with scenarios
421
+ const reqRe = /###\s+Requirement:\s*(.+)/g;
422
+ let reqMatch: RegExpExecArray | null;
423
+ while ((reqMatch = reqRe.exec(section)) !== null) {
424
+ const reqName = reqMatch[1].trim();
425
+ // Find scenarios after this requirement until next requirement or section end
426
+ const afterReq = section.slice(reqMatch.index + reqMatch[0].length);
427
+ const nextReq = afterReq.search(/\n###\s+Requirement:/);
428
+ const scenarioBlock = nextReq >= 0 ? afterReq.slice(0, nextReq) : afterReq;
429
+
430
+ const scenarios: string[] = [];
431
+ const scenarioRe = /####\s+Scenario:\s*(.+?)(?:\n[\s\S]*?)(?=\n####|\n###|$)/g;
432
+ let scenMatch: RegExpExecArray | null;
433
+ while ((scenMatch = scenarioRe.exec(scenarioBlock)) !== null) {
434
+ // Extract the full scenario including Given/When/Then
435
+ const scenarioText = scenMatch[0]
436
+ .replace(/^####\s+Scenario:\s*/, "")
437
+ .trim();
438
+ scenarios.push(scenarioText);
439
+ }
440
+
441
+ if (scenarios.length > 0) {
442
+ results.push({ domain, requirement: reqName, scenarios });
443
+ }
444
+ }
445
+ }
446
+ }
447
+
448
+ return results;
449
+ }
450
+
451
+ /** Recursively find spec.md files under a directory. */
452
+ function findSpecFiles(dir: string): string[] {
453
+ const files: string[] = [];
454
+ if (!existsSync(dir)) return files;
455
+
456
+ const entries = readdirSync(dir, { withFileTypes: true });
457
+ for (const entry of entries) {
458
+ const fullPath = join(dir, entry.name);
459
+ if (entry.isDirectory()) {
460
+ files.push(...findSpecFiles(fullPath));
461
+ } else if (entry.name.endsWith(".md")) {
462
+ files.push(fullPath);
463
+ }
464
+ }
465
+ return files;
466
+ }
467
+
468
+ // ─── Full Context ───────────────────────────────────────────────────────────
469
+
470
+ /**
471
+ * Build full OpenSpec context from a change directory.
472
+ *
473
+ * Reads design.md (decisions, file changes), delta specs (scenarios),
474
+ * and returns a structured context object that cleave uses to:
475
+ * - Enrich child task files with design context
476
+ * - Supply exact file scope from design file changes
477
+ * - Verify implementation against spec scenarios post-merge
478
+ */
479
+ export function buildOpenSpecContext(changePath: string): OpenSpecContext {
480
+ const ctx: OpenSpecContext = {
481
+ changePath,
482
+ designContent: null,
483
+ decisions: [],
484
+ fileChanges: [],
485
+ specScenarios: [],
486
+ apiContract: null,
487
+ };
488
+
489
+ // Design
490
+ const designPath = join(changePath, "design.md");
491
+ if (existsSync(designPath)) {
492
+ ctx.designContent = readFileSync(designPath, "utf-8");
493
+ ctx.decisions = parseDesignDecisions(ctx.designContent);
494
+ ctx.fileChanges = parseDesignFileChanges(ctx.designContent);
495
+ }
496
+
497
+ // Specs
498
+ ctx.specScenarios = readSpecScenarios(changePath);
499
+
500
+ // API contract (OpenAPI / AsyncAPI) — check common extensions
501
+ for (const name of ["api.yaml", "api.yml", "api.json"]) {
502
+ const apiPath = join(changePath, name);
503
+ if (existsSync(apiPath)) {
504
+ ctx.apiContract = readFileSync(apiPath, "utf-8");
505
+ break;
506
+ }
507
+ }
508
+
509
+ return ctx;
510
+ }
511
+
512
+ /**
513
+ * Full pipeline: read an OpenSpec change and convert to SplitPlan + context.
514
+ *
515
+ * Returns null if the change doesn't have tasks or has fewer than 2 groups.
516
+ */
517
+ export function openspecChangeToSplitPlanWithContext(
518
+ changePath: string,
519
+ ): { plan: SplitPlan; context: OpenSpecContext } | null {
520
+ const plan = openspecChangeToSplitPlan(changePath);
521
+ if (!plan) return null;
522
+
523
+ const context = buildOpenSpecContext(changePath);
524
+
525
+ // Supplement scope from design.md file changes when available
526
+ if (context.fileChanges.length > 0) {
527
+ supplementScopeFromDesign(plan.children, context.fileChanges);
528
+ }
529
+
530
+ return { plan, context };
531
+ }
532
+
533
+ /**
534
+ * Supplement child scope with explicit file changes from design.md.
535
+ *
536
+ * For each child, if design.md lists files that match the child's description
537
+ * or existing scope patterns, add them. This replaces heuristic guessing
538
+ * with author-declared intent.
539
+ */
540
+ function supplementScopeFromDesign(
541
+ children: ChildPlan[],
542
+ fileChanges: Array<{ path: string; action: string }>,
543
+ ): void {
544
+ // If there's only one group of files, distribute to the closest-matching child
545
+ // If files are clearly separated by directory, match by path prefix
546
+
547
+ const filePaths = fileChanges
548
+ .filter((f) => f.action !== "deleted")
549
+ .map((f) => f.path);
550
+
551
+ if (filePaths.length === 0) return;
552
+
553
+ for (const child of children) {
554
+ const descLower = child.description.toLowerCase();
555
+ const labelWords = child.label.replace(/-/g, " ").split(" ");
556
+
557
+ const matched: string[] = [];
558
+ for (const fp of filePaths) {
559
+ const fpLower = fp.toLowerCase();
560
+ // Match if: file path contains a label word, or child description mentions the file
561
+ const pathParts = fpLower.split("/");
562
+ const isMatch =
563
+ labelWords.some((w) => w.length > 2 && pathParts.some((p) => p.includes(w))) ||
564
+ descLower.includes(fpLower) ||
565
+ child.scope.some((s) => {
566
+ const pattern = s.replace(/\*\*/g, "").replace(/\*/g, "");
567
+ return fpLower.startsWith(pattern) || pattern.startsWith(fpLower.split("/").slice(0, -1).join("/"));
568
+ });
569
+
570
+ if (isMatch) matched.push(fp);
571
+ }
572
+
573
+ // Add matched files to scope (deduplicated)
574
+ const existingScope = new Set(child.scope);
575
+ for (const fp of matched) {
576
+ if (!existingScope.has(fp)) {
577
+ child.scope.push(fp);
578
+ }
579
+ }
580
+ }
581
+ }
582
+
583
+ // ─── Helpers ────────────────────────────────────────────────────────────────
584
+
585
+ /**
586
+ * Merge small groups to fit within maxGroups.
587
+ * Combines the smallest adjacent groups until we're at the limit.
588
+ */
589
+ function mergeSmallGroups(groups: TaskGroup[], maxGroups: number): TaskGroup[] {
590
+ const result = [...groups];
591
+
592
+ while (result.length > maxGroups) {
593
+ // Find the smallest group by task count
594
+ let smallestIdx = 0;
595
+ let smallestSize = Infinity;
596
+ for (let i = 0; i < result.length - 1; i++) {
597
+ const combined = result[i].tasks.length + result[i + 1].tasks.length;
598
+ if (combined < smallestSize) {
599
+ smallestSize = combined;
600
+ smallestIdx = i;
601
+ }
602
+ }
603
+
604
+ // Merge with next group
605
+ const merged: TaskGroup = {
606
+ number: result[smallestIdx].number,
607
+ title: `${result[smallestIdx].title} + ${result[smallestIdx + 1].title}`,
608
+ tasks: [...result[smallestIdx].tasks, ...result[smallestIdx + 1].tasks],
609
+ specDomains: [
610
+ ...(result[smallestIdx].specDomains ?? []),
611
+ ...(result[smallestIdx + 1].specDomains ?? []),
612
+ ],
613
+ skills: [
614
+ ...new Set([
615
+ ...(result[smallestIdx].skills ?? []),
616
+ ...(result[smallestIdx + 1].skills ?? []),
617
+ ]),
618
+ ],
619
+ };
620
+ result.splice(smallestIdx, 2, merged);
621
+ }
622
+
623
+ return result;
624
+ }
625
+
626
+ // ─── Task Write-Back ────────────────────────────────────────────────────────
627
+
628
+ /**
629
+ * After a successful cleave merge, mark completed child tasks as done
630
+ * in the original OpenSpec tasks.md.
631
+ *
632
+ * Maps completed child labels back to task groups and checks off their
633
+ * unchecked tasks. Returns the number of tasks marked done.
634
+ */
635
+ export function writeBackTaskCompletion(
636
+ changePath: string,
637
+ completedLabels: string[],
638
+ ): { updated: number; totalTasks: number; allDone: boolean; unmatchedLabels: string[] } {
639
+ const tasksPath = join(changePath, "tasks.md");
640
+ if (!existsSync(tasksPath)) {
641
+ return { updated: 0, totalTasks: 0, allDone: false, unmatchedLabels: [...completedLabels] };
642
+ }
643
+
644
+ const content = readFileSync(tasksPath, "utf-8");
645
+ const groups = parseTasksFile(content);
646
+
647
+ // Build a set of completed label slugs for matching
648
+ const completedSet = new Set(completedLabels.map((l) => l.toLowerCase()));
649
+
650
+ // Track which group numbers are completed
651
+ const completedGroupNumbers = new Set<number>();
652
+ const matchedLabels = new Set<string>();
653
+ for (const group of groups) {
654
+ const groupSlug = group.title
655
+ .toLowerCase()
656
+ .replace(/[^\w\s-]/g, "")
657
+ .replace(/[\s_]+/g, "-")
658
+ .replace(/-+/g, "-")
659
+ .replace(/^-|-$/g, "")
660
+ .slice(0, 40);
661
+
662
+ if (completedSet.has(groupSlug)) {
663
+ completedGroupNumbers.add(group.number);
664
+ matchedLabels.add(groupSlug);
665
+ }
666
+ }
667
+
668
+ const unmatchedLabels = completedLabels
669
+ .map((label) => label.toLowerCase())
670
+ .filter((label) => !matchedLabels.has(label));
671
+
672
+ if (completedGroupNumbers.size === 0) {
673
+ const totalTasks = groups.reduce((sum, g) => sum + g.tasks.length, 0);
674
+ return { updated: 0, totalTasks, allDone: false, unmatchedLabels };
675
+ }
676
+
677
+ // Rewrite tasks.md line by line, checking off tasks in completed groups
678
+ const lines = content.split("\n");
679
+ let currentGroupNumber = -1;
680
+ let updated = 0;
681
+
682
+ for (let i = 0; i < lines.length; i++) {
683
+ // Detect group header
684
+ const groupMatch = lines[i].match(/^##\s+(?:(\d+)\.\s+)?(.+)$/);
685
+ if (groupMatch) {
686
+ currentGroupNumber = groupMatch[1] ? parseInt(groupMatch[1], 10) : -1;
687
+ // If unnumbered, find by title match
688
+ if (currentGroupNumber === -1) {
689
+ const title = groupMatch[2].trim();
690
+ const g = groups.find((g) => g.title === title);
691
+ if (g) currentGroupNumber = g.number;
692
+ }
693
+ continue;
694
+ }
695
+
696
+ // Check off unchecked tasks in completed groups
697
+ if (completedGroupNumbers.has(currentGroupNumber)) {
698
+ const taskMatch = lines[i].match(/^(\s*-\s+)\[ \](\s+.*)$/);
699
+ if (taskMatch) {
700
+ lines[i] = `${taskMatch[1]}[x]${taskMatch[2]}`;
701
+ updated++;
702
+ }
703
+ }
704
+ }
705
+
706
+ if (updated > 0) {
707
+ writeFileSync(tasksPath, lines.join("\n"), "utf-8");
708
+ }
709
+
710
+ // Check if all tasks are now done
711
+ const totalTasks = groups.reduce((sum, g) => sum + g.tasks.length, 0);
712
+ const wasDone = groups.reduce((sum, g) => sum + g.tasks.filter((t) => t.done).length, 0);
713
+ const allDone = wasDone + updated >= totalTasks;
714
+
715
+ return { updated, totalTasks, allDone, unmatchedLabels };
716
+ }
717
+
718
+ // ─── Active Changes Status ──────────────────────────────────────────────────
719
+
720
+ export interface ChangeStatus {
721
+ name: string;
722
+ path: string;
723
+ totalTasks: number;
724
+ doneTasks: number;
725
+ hasProposal: boolean;
726
+ hasDesign: boolean;
727
+ hasSpecs: boolean;
728
+ /** Most recent mtime across change artifacts (ms since epoch) */
729
+ lastModifiedMs: number;
730
+ }
731
+
732
+ /**
733
+ * Summarize all active OpenSpec changes and their task completion status.
734
+ *
735
+ * Returns a list of changes with their task progress, suitable for
736
+ * session-start status display.
737
+ */
738
+ export function getActiveChangesStatus(repoPath: string): ChangeStatus[] {
739
+ const openspecDir = detectOpenSpec(repoPath);
740
+ if (!openspecDir) return [];
741
+
742
+ const changes = listChanges(openspecDir);
743
+ const result: ChangeStatus[] = [];
744
+
745
+ for (const change of changes) {
746
+ let totalTasks = 0;
747
+ let doneTasks = 0;
748
+
749
+ if (change.hasTasks) {
750
+ const content = readFileSync(join(change.path, "tasks.md"), "utf-8");
751
+ const groups = parseTasksFile(content);
752
+ for (const group of groups) {
753
+ totalTasks += group.tasks.length;
754
+ doneTasks += group.tasks.filter((t) => t.done).length;
755
+ }
756
+ }
757
+
758
+ const specsDir = join(change.path, "specs");
759
+
760
+ // Find most recent modification time across artifacts
761
+ let lastModifiedMs = 0;
762
+ for (const file of ["tasks.md", "proposal.md", "design.md"]) {
763
+ const fp = join(change.path, file);
764
+ if (existsSync(fp)) {
765
+ try {
766
+ const mtime = statSync(fp).mtimeMs;
767
+ if (mtime > lastModifiedMs) lastModifiedMs = mtime;
768
+ } catch { /* skip */ }
769
+ }
770
+ }
771
+
772
+ result.push({
773
+ name: change.name,
774
+ path: change.path,
775
+ totalTasks,
776
+ doneTasks,
777
+ hasProposal: change.hasProposal,
778
+ hasDesign: change.hasDesign,
779
+ hasSpecs: existsSync(specsDir),
780
+ lastModifiedMs,
781
+ });
782
+ }
783
+
784
+ return result;
785
+ }
786
+
787
+ // ─── Helpers ────────────────────────────────────────────────────────────────
788
+
789
+ /**
790
+ * Infer file scope patterns from task descriptions.
791
+ * Looks for quoted paths, file extensions, and common patterns.
792
+ */
793
+ function inferScope(taskTexts: string[]): string[] {
794
+ const scope = new Set<string>();
795
+ const combined = taskTexts.join("\n");
796
+
797
+ // Backtick-quoted paths: `src/auth/login.ts`
798
+ for (const m of combined.matchAll(/`([a-zA-Z0-9_./-]+\.[a-zA-Z0-9]+)`/g)) {
799
+ scope.add(m[1]);
800
+ }
801
+
802
+ // Directory references: src/auth/, components/
803
+ for (const m of combined.matchAll(/\b((?:src|lib|app|components|pages|api|tests?|spec)\/?[a-zA-Z0-9_/-]*)\b/g)) {
804
+ const dir = m[1].replace(/\/$/, "");
805
+ if (dir.includes("/")) {
806
+ scope.add(dir + "/**");
807
+ }
808
+ }
809
+
810
+ return [...scope].slice(0, 10); // Cap at 10 patterns
811
+ }