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,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
|
+
}
|