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,861 @@
1
+ /**
2
+ * cleave/workspace — Lightweight workspace management.
3
+ *
4
+ * Creates and manages workspace directories under ~/.pi/cleave/ containing:
5
+ * - state.json: serialized CleaveState
6
+ * - {n}-task.md: child task files
7
+ *
8
+ * Workspaces live outside the target repo to avoid polluting the working tree.
9
+ */
10
+
11
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+ import type { ChildPlan, CleaveState, SplitPlan } from "./types.ts";
15
+ import type { OpenSpecContext } from "./openspec.ts";
16
+ import { discoverGuardrails } from "./guardrails.ts";
17
+ import type { GuardrailCheck } from "./guardrails.ts";
18
+
19
+ // ─── Guardrail Section ─────────────────────────────────────────────────────
20
+
21
+ /**
22
+ * Build a guardrail section for child task files.
23
+ *
24
+ * Discovers project guardrails (typecheck, lint, etc.) and generates
25
+ * a markdown section instructing the child to run them before reporting success.
26
+ *
27
+ * @param cwd - Project root directory to discover guardrails from
28
+ * @returns Markdown section string, or empty string if no guardrails found
29
+ */
30
+ export function buildGuardrailSection(cwd: string): string {
31
+ const checks = discoverGuardrails(cwd);
32
+ if (checks.length === 0) return "";
33
+
34
+ const lines = [
35
+ "",
36
+ "## Project Guardrails",
37
+ "",
38
+ "Before reporting success, run these deterministic checks and fix any failures:",
39
+ "",
40
+ ];
41
+
42
+ for (let i = 0; i < checks.length; i++) {
43
+ lines.push(`${i + 1}. **${checks[i].name}**: \`${checks[i].cmd}\``);
44
+ }
45
+
46
+ lines.push("");
47
+ lines.push("Include command output in the Verification section. If any check fails, fix the errors before completing your task.");
48
+ lines.push("");
49
+
50
+ return lines.join("\n");
51
+ }
52
+
53
+ // ─── Skill Directives ──────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * A resolved skill directive — skill name and absolute path to its SKILL.md.
57
+ * Used to inject skill reading instructions into child task files.
58
+ */
59
+ export interface SkillDirective {
60
+ skill: string;
61
+ path: string;
62
+ }
63
+
64
+ /** Base directory for all cleave workspaces. */
65
+ const CLEAVE_HOME = join(homedir(), ".pi", "cleave");
66
+
67
+ /**
68
+ * Generate a unique workspace directory path from a directive.
69
+ *
70
+ * Creates a human-readable path: ~/.pi/cleave/add-jwt-auth/
71
+ * Appends numeric suffix if collision: ~/.pi/cleave/add-jwt-auth-2/
72
+ */
73
+ export function generateWorkspacePath(directive: string): string {
74
+ mkdirSync(CLEAVE_HOME, { recursive: true });
75
+
76
+ let slug = directive
77
+ .toLowerCase()
78
+ .replace(/[^\w\s-]/g, "")
79
+ .replace(/[\s_]+/g, "-")
80
+ .replace(/-+/g, "-")
81
+ .replace(/^-|-$/g, "");
82
+
83
+ if (slug.length > 40) slug = slug.slice(0, 40).replace(/-$/, "");
84
+ if (!slug) slug = "task";
85
+
86
+ let candidate = join(CLEAVE_HOME, slug);
87
+ if (!existsSync(candidate)) return candidate;
88
+
89
+ let counter = 2;
90
+ while (existsSync(join(CLEAVE_HOME, `${slug}-${counter}`))) counter++;
91
+ return join(CLEAVE_HOME, `${slug}-${counter}`);
92
+ }
93
+
94
+ /**
95
+ * Initialize a cleave workspace directory.
96
+ *
97
+ * Creates the workspace directory, state.json, and child task files.
98
+ * Workspace lives under ~/.pi/cleave/, not inside the target repo.
99
+ *
100
+ * @param resolvedSkills - Pre-resolved skill directives per child index.
101
+ * Generated by resolveSkillPaths() from skills.ts.
102
+ */
103
+ export function initWorkspace(
104
+ state: CleaveState,
105
+ plan: SplitPlan,
106
+ repoPath: string,
107
+ openspecContext?: OpenSpecContext | null,
108
+ resolvedSkills?: Map<number, SkillDirective[]>,
109
+ ): string {
110
+ const wsPath = generateWorkspacePath(state.directive);
111
+ mkdirSync(wsPath, { recursive: true });
112
+
113
+ state.workspacePath = wsPath;
114
+
115
+ // Write initial state
116
+ saveState(state);
117
+
118
+ // Pre-compute scenario assignments across all children (orphan detection)
119
+ const scenarioAssignments = matchScenariosToChildren(plan.children, openspecContext);
120
+
121
+ // Discover guardrails once, reuse for all children
122
+ const guardrailSection = buildGuardrailSection(repoPath);
123
+
124
+ // Generate child task files
125
+ for (let i = 0; i < plan.children.length; i++) {
126
+ const child = plan.children[i];
127
+ const childScenarios = scenarioAssignments.get(i) ?? [];
128
+ const childSkillDirectives = resolvedSkills?.get(i) ?? [];
129
+ const taskContent = generateTaskFile(i, child, plan.children, state.directive, openspecContext, childScenarios, childSkillDirectives, guardrailSection);
130
+ writeFileSync(join(wsPath, `${i}-task.md`), taskContent, "utf-8");
131
+ }
132
+
133
+ return wsPath;
134
+ }
135
+
136
+ /** Persist CleaveState to workspace/state.json */
137
+ export function saveState(state: CleaveState): void {
138
+ if (!state.workspacePath) throw new Error("Cannot save state: workspacePath not set");
139
+ const statePath = join(state.workspacePath, "state.json");
140
+ writeFileSync(statePath, JSON.stringify(state, null, 2), "utf-8");
141
+ }
142
+
143
+ /** Load CleaveState from workspace/state.json */
144
+ export function loadState(workspacePath: string): CleaveState {
145
+ const statePath = join(workspacePath, "state.json");
146
+ if (!existsSync(statePath)) {
147
+ throw new Error(`State file not found: ${statePath}`);
148
+ }
149
+ return JSON.parse(readFileSync(statePath, "utf-8"));
150
+ }
151
+
152
+ /**
153
+ * Scan ~/.pi/cleave/ for workspaces whose phase is still "dispatch" — i.e.
154
+ * runs that were interrupted before the harvest/merge phase completed.
155
+ *
156
+ * If `repoPath` is provided only runs matching that repo are returned.
157
+ * Results are sorted newest-first by `createdAt`.
158
+ */
159
+ export function findIncompleteRuns(repoPath?: string): CleaveState[] {
160
+ if (!existsSync(CLEAVE_HOME)) return [];
161
+ const entries = readdirSync(CLEAVE_HOME, { withFileTypes: true });
162
+ const results: CleaveState[] = [];
163
+ for (const entry of entries) {
164
+ if (!entry.isDirectory()) continue;
165
+ const statePath = join(CLEAVE_HOME, entry.name, "state.json");
166
+ if (!existsSync(statePath)) continue;
167
+ try {
168
+ const state: CleaveState = JSON.parse(readFileSync(statePath, "utf-8"));
169
+ if (state.phase !== "dispatch" && state.phase !== "harvest") continue;
170
+ if (repoPath && state.repoPath !== repoPath) continue;
171
+ results.push(state);
172
+ } catch {
173
+ // Corrupt state — skip
174
+ }
175
+ }
176
+ results.sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
177
+ return results;
178
+ }
179
+
180
+ /**
181
+ * Generate a child task markdown file.
182
+ *
183
+ * Slim template that the child agent reads to understand its mission.
184
+ * Includes optional Specialist Skills section (D2: directive injection)
185
+ * that instructs the child to read SKILL.md files for domain guidance.
186
+ */
187
+ export function generateTaskFile(
188
+ taskId: number,
189
+ child: ChildPlan,
190
+ allChildren: ChildPlan[],
191
+ rootDirective: string,
192
+ openspecContext?: OpenSpecContext | null,
193
+ assignedScenarios?: AssignedScenario[],
194
+ skillDirectives?: SkillDirective[],
195
+ guardrailSection?: string,
196
+ ): string {
197
+ const siblingRefs = allChildren
198
+ .filter((_, i) => i !== taskId)
199
+ .map((c, i) => `${i >= taskId ? i + 1 : i}:${c.label}`)
200
+ .join(", ");
201
+
202
+ const scopeList = child.scope.length > 0
203
+ ? child.scope.map((s) => `- \`${s}\``).join("\n")
204
+ : "- (entire scope defined by description)";
205
+
206
+ const depsNote = child.dependsOn.length > 0
207
+ ? `**Depends on:** ${child.dependsOn.join(", ")}`
208
+ : "**Depends on:** none (independent)";
209
+
210
+ // Build optional skill section
211
+ const skillSection = buildSkillSection(skillDirectives);
212
+
213
+ // Build optional OpenSpec design context section
214
+ const designSection = buildDesignSection(child, openspecContext, assignedScenarios);
215
+
216
+ return `---
217
+ task_id: ${taskId}
218
+ label: ${child.label}
219
+ siblings: [${siblingRefs}]
220
+ ---
221
+
222
+ # Task ${taskId}: ${child.label}
223
+
224
+ ## Root Directive
225
+
226
+ > ${rootDirective}
227
+
228
+ ## Mission
229
+
230
+ ${child.description}
231
+
232
+ ## Scope
233
+
234
+ ${scopeList}
235
+
236
+ ${depsNote}
237
+ ${skillSection}${designSection}${guardrailSection ?? ""}
238
+ ## Contract
239
+
240
+ 1. Only work on files within your scope
241
+ 2. Update the Result section below when done
242
+ 3. Commit your work with clear messages — do not push
243
+ 4. If the task is too complex, set status to NEEDS_DECOMPOSITION
244
+
245
+ ## Result
246
+
247
+ **Status:** PENDING
248
+
249
+ **Summary:**
250
+
251
+ **Artifacts:**
252
+
253
+ **Decisions Made:**
254
+
255
+ **Assumptions:**
256
+
257
+ **Interfaces Published:**
258
+
259
+ **Verification:**
260
+ - Command: \`\`
261
+ - Output:
262
+ - Edge cases:
263
+ `;
264
+ }
265
+
266
+ /**
267
+ * Build the Specialist Skills section for a child task file.
268
+ *
269
+ * Per design D2: "Child prompt gets 'Before starting, read these skill files: ...'
270
+ * with paths. Skills are 200+ lines — inlining would bloat prompts."
271
+ *
272
+ * Returns empty string if no skills to inject.
273
+ */
274
+ export function buildSkillSection(skillDirectives?: SkillDirective[]): string {
275
+ if (!skillDirectives || skillDirectives.length === 0) return "";
276
+
277
+ const lines = [
278
+ "",
279
+ "## Specialist Skills",
280
+ "",
281
+ "Before starting, read these skill files for domain-specific guidance:",
282
+ "",
283
+ ];
284
+
285
+ for (const sd of skillDirectives) {
286
+ lines.push(`- **${sd.skill}**: \`${sd.path}\``);
287
+ }
288
+
289
+ lines.push("");
290
+
291
+ return lines.join("\n");
292
+ }
293
+
294
+ // ─── Scenario Matching ──────────────────────────────────────────────────────
295
+
296
+ export interface AssignedScenario {
297
+ domain: string;
298
+ requirement: string;
299
+ scenarios: string[];
300
+ /** Whether this was auto-injected as an orphan */
301
+ crossCutting: boolean;
302
+ }
303
+
304
+ /**
305
+ * Match spec scenarios to children using 3-tier priority:
306
+ * 1. Annotation match — child's specDomains (from <!-- specs: ... -->) includes the scenario domain
307
+ * 2. Scope match — child's file scope includes files referenced in the scenario
308
+ * 3. Word-overlap fallback — shared words between child description and scenario text
309
+ *
310
+ * Any scenario matching zero children is auto-injected into the best candidate
311
+ * with a cross-cutting marker.
312
+ *
313
+ * Returns a Map from child index to its assigned scenarios.
314
+ */
315
+ export function matchScenariosToChildren(
316
+ children: ChildPlan[],
317
+ ctx?: OpenSpecContext | null,
318
+ ): Map<number, AssignedScenario[]> {
319
+ const result = new Map<number, AssignedScenario[]>();
320
+ for (let i = 0; i < children.length; i++) result.set(i, []);
321
+
322
+ if (!ctx || ctx.specScenarios.length === 0) return result;
323
+
324
+ for (const ss of ctx.specScenarios) {
325
+ const assigned = assignScenario(ss, children);
326
+
327
+ if (assigned.length > 0) {
328
+ // Matched via annotation, scope, or word overlap
329
+ for (const idx of assigned) {
330
+ result.get(idx)!.push({
331
+ domain: ss.domain,
332
+ requirement: ss.requirement,
333
+ scenarios: ss.scenarios,
334
+ crossCutting: false,
335
+ });
336
+ }
337
+ } else {
338
+ // Orphan — auto-inject into best candidate
339
+ const target = findOrphanTarget(ss, children);
340
+ result.get(target)!.push({
341
+ domain: ss.domain,
342
+ requirement: ss.requirement,
343
+ scenarios: ss.scenarios,
344
+ crossCutting: true,
345
+ });
346
+ }
347
+ }
348
+
349
+ return result;
350
+ }
351
+
352
+ /**
353
+ * Assign a scenario to children using 3-tier priority.
354
+ * Returns array of child indices (may be multiple for annotation matches).
355
+ */
356
+ function assignScenario(
357
+ ss: { domain: string; requirement: string; scenarios: string[] },
358
+ children: ChildPlan[],
359
+ ): number[] {
360
+ // Tier 1: Annotation match — child declared this spec domain
361
+ const annotationMatches = children
362
+ .map((c, i) => ({ idx: i, child: c }))
363
+ .filter(({ child }) =>
364
+ child.specDomains.some((d) => domainMatches(d, ss.domain)),
365
+ )
366
+ .map(({ idx }) => idx);
367
+
368
+ if (annotationMatches.length > 0) return annotationMatches;
369
+
370
+ // Tier 2: Scope match — scenario text references files in child's scope
371
+ const scenarioText = `${ss.requirement} ${ss.scenarios.join(" ")}`.toLowerCase();
372
+ const scopeMatches: number[] = [];
373
+ for (let i = 0; i < children.length; i++) {
374
+ const child = children[i];
375
+ if (child.scope.length === 0) continue;
376
+ const hasMatch = child.scope.some((s) => {
377
+ const scopeClean = s.replace(/\*+/g, "").replace(/\/$/, "").toLowerCase();
378
+ const scopeParts = scopeClean.split("/");
379
+ const filename = scopeParts[scopeParts.length - 1];
380
+ if (filename.length <= 3) return false;
381
+ // Require word-boundary match to avoid "utils.py" matching "utility"
382
+ const pattern = new RegExp(`\\b${filename.replace(/\./g, "\\.")}\\b`);
383
+ return pattern.test(scenarioText);
384
+ });
385
+ if (hasMatch) scopeMatches.push(i);
386
+ }
387
+
388
+ if (scopeMatches.length > 0) return scopeMatches;
389
+
390
+ // Tier 3: Word-overlap fallback
391
+ const specText = `${ss.domain} ${ss.requirement}`.toLowerCase();
392
+ const specWords = specText.split(/\s+/).filter((w) => w.length > 3);
393
+
394
+ let bestIdx = -1;
395
+ let bestScore = 0;
396
+ for (let i = 0; i < children.length; i++) {
397
+ const childText = `${children[i].label} ${children[i].description}`.toLowerCase();
398
+ const score = specWords.filter((w) => childText.includes(w)).length;
399
+ if (score > bestScore) {
400
+ bestScore = score;
401
+ bestIdx = i;
402
+ }
403
+ }
404
+
405
+ return bestIdx >= 0 && bestScore > 0 ? [bestIdx] : [];
406
+ }
407
+
408
+ /**
409
+ * Find the best injection target for an orphan scenario.
410
+ * Priority: scope match on When clause → word overlap → last child.
411
+ */
412
+ function findOrphanTarget(
413
+ ss: { domain: string; requirement: string; scenarios: string[] },
414
+ children: ChildPlan[],
415
+ ): number {
416
+ // Try to extract function/file references from When clauses
417
+ const whenText = ss.scenarios
418
+ .join("\n")
419
+ .split("\n")
420
+ .filter((l) => /^\s*when\s/i.test(l))
421
+ .join(" ")
422
+ .toLowerCase();
423
+
424
+ // Check which child's scope contains referenced files/functions
425
+ for (let i = 0; i < children.length; i++) {
426
+ const child = children[i];
427
+ if (child.scope.length === 0) continue;
428
+ const hasMatch = child.scope.some((s) => {
429
+ const filename = s.replace(/\*+/g, "").split("/").pop()?.toLowerCase() ?? "";
430
+ return filename.length > 3 && whenText.includes(filename);
431
+ });
432
+ if (hasMatch) return i;
433
+ }
434
+
435
+ // Word overlap fallback
436
+ const scenarioText = `${ss.domain} ${ss.requirement}`.toLowerCase();
437
+ const words = scenarioText.split(/\s+/).filter((w) => w.length > 3);
438
+
439
+ let bestIdx = children.length - 1; // default: last child
440
+ let bestScore = 0;
441
+ for (let i = 0; i < children.length; i++) {
442
+ const childText = `${children[i].label} ${children[i].description}`.toLowerCase();
443
+ const score = words.filter((w) => childText.includes(w)).length;
444
+ if (score > bestScore) {
445
+ bestScore = score;
446
+ bestIdx = i;
447
+ }
448
+ }
449
+
450
+ return bestIdx;
451
+ }
452
+
453
+ /**
454
+ * Check if an annotation domain matches a scenario domain.
455
+ * Matches exact or parent/child relationships using path segments:
456
+ * "relay" matches "relay/rbac" (parent of scenario domain)
457
+ * "relay/rbac" matches "relay" (child of scenario domain)
458
+ * "relay" does NOT match "relay-admin" (different segment)
459
+ */
460
+ function domainMatches(annotationDomain: string, scenarioDomain: string): boolean {
461
+ if (annotationDomain === scenarioDomain) return true;
462
+ // Annotation is parent: "relay" matches scenario "relay/rbac"
463
+ if (scenarioDomain.startsWith(annotationDomain + "/")) return true;
464
+ // Annotation is child: "relay/rbac" matches scenario "relay"
465
+ if (annotationDomain.startsWith(scenarioDomain + "/")) return true;
466
+ return false;
467
+ }
468
+
469
+ // ─── Design Section Builder ─────────────────────────────────────────────────
470
+
471
+ /**
472
+ * Build the optional "Design Context" section for a child task file.
473
+ *
474
+ * Uses pre-computed scenario assignments (from matchScenariosToChildren)
475
+ * instead of per-child heuristic matching.
476
+ */
477
+ function buildDesignSection(
478
+ child: ChildPlan,
479
+ ctx?: OpenSpecContext | null,
480
+ assignedScenarios?: AssignedScenario[],
481
+ ): string {
482
+ if (!ctx) return "";
483
+
484
+ const sections: string[] = [];
485
+
486
+ // Architecture decisions — all decisions apply to all children
487
+ if (ctx.decisions.length > 0) {
488
+ sections.push(
489
+ "### Architecture Decisions",
490
+ "",
491
+ "Follow these design decisions from the project's design.md:",
492
+ "",
493
+ ...ctx.decisions.map((d) => `- ${d}`),
494
+ );
495
+ }
496
+
497
+ // File changes relevant to this child
498
+ if (ctx.fileChanges.length > 0) {
499
+ const childLabelWords = child.label.replace(/-/g, " ").split(" ");
500
+ const childDescLower = child.description.toLowerCase();
501
+
502
+ const relevant = ctx.fileChanges.filter((fc) => {
503
+ const fpLower = fc.path.toLowerCase();
504
+ const pathParts = fpLower.split("/");
505
+ return (
506
+ childLabelWords.some((w) => w.length > 2 && pathParts.some((p) => p.includes(w))) ||
507
+ childDescLower.includes(fpLower) ||
508
+ child.scope.some((s) => fpLower.startsWith(s.replace(/\*+/g, "")))
509
+ );
510
+ });
511
+
512
+ if (relevant.length > 0) {
513
+ sections.push(
514
+ "### File Changes (from design.md)",
515
+ "",
516
+ "These specific file changes are planned for this task:",
517
+ "",
518
+ ...relevant.map((fc) => `- \`${fc.path}\` (${fc.action})`),
519
+ );
520
+ }
521
+ }
522
+
523
+ // Spec scenarios from pre-computed assignments
524
+ if (assignedScenarios && assignedScenarios.length > 0) {
525
+ const regular = assignedScenarios.filter((s) => !s.crossCutting);
526
+ const crossCutting = assignedScenarios.filter((s) => s.crossCutting);
527
+
528
+ if (regular.length > 0) {
529
+ sections.push(
530
+ "### Acceptance Criteria (from specs)",
531
+ "",
532
+ "Your implementation should satisfy these spec scenarios:",
533
+ "",
534
+ );
535
+ for (const ss of regular) {
536
+ sections.push(`**${ss.domain} → ${ss.requirement}**`);
537
+ for (const scenario of ss.scenarios) {
538
+ const scenarioLines = scenario.split("\n").map((l) => ` ${l}`);
539
+ sections.push(...scenarioLines);
540
+ }
541
+ sections.push("");
542
+ }
543
+ }
544
+
545
+ if (crossCutting.length > 0) {
546
+ sections.push(
547
+ "### ⚠️ CROSS-CUTTING Acceptance Criteria",
548
+ "",
549
+ "These scenarios were not directly assigned to any task group but affect your scope.",
550
+ "Ensure your implementation does not break them, and wire any enforcement logic they require:",
551
+ "",
552
+ );
553
+ for (const ss of crossCutting) {
554
+ sections.push(`**⚠️ ${ss.domain} → ${ss.requirement}**`);
555
+ for (const scenario of ss.scenarios) {
556
+ const scenarioLines = scenario.split("\n").map((l) => ` ${l}`);
557
+ sections.push(...scenarioLines);
558
+ }
559
+ sections.push("");
560
+ }
561
+ }
562
+ }
563
+
564
+ if (sections.length === 0) return "";
565
+ return "\n## Design Context\n\n" + sections.join("\n") + "\n\n";
566
+ }
567
+
568
+ export type DirtyPathClass = "related" | "unrelated" | "unknown" | "volatile";
569
+ export type DirtyPathConfidence = "high" | "medium" | "low";
570
+
571
+ export interface ClassifiedDirtyPath {
572
+ path: string;
573
+ classification: DirtyPathClass;
574
+ confidence: DirtyPathConfidence;
575
+ reason: string;
576
+ includedInCheckpoint: boolean;
577
+ }
578
+
579
+ export interface DirtyTreeClassification {
580
+ files: ClassifiedDirtyPath[];
581
+ related: ClassifiedDirtyPath[];
582
+ unrelated: ClassifiedDirtyPath[];
583
+ unknown: ClassifiedDirtyPath[];
584
+ volatile: ClassifiedDirtyPath[];
585
+ checkpointFiles: string[];
586
+ }
587
+
588
+ export interface CheckpointPlanPreview {
589
+ files: string[];
590
+ message: string | null;
591
+ requiresApproval: true;
592
+ excluded: ClassifiedDirtyPath[];
593
+ }
594
+
595
+ export interface DirtyTreeClassificationOptions {
596
+ changeName?: string | null;
597
+ openspecContext?: OpenSpecContext | null;
598
+ volatileAllowlist?: string[];
599
+ }
600
+
601
+ export const DEFAULT_VOLATILE_ALLOWLIST = [".pi/memory/facts.jsonl"];
602
+
603
+ /**
604
+ * Classify dirty-tree paths for preflight UX.
605
+ *
606
+ * Confidence is intentionally conservative:
607
+ * - volatile allowlist and OpenSpec lifecycle artifacts → high
608
+ * - design.md file scope matches → medium
609
+ * - everything else defaults to unrelated/unknown and is excluded from checkpoint
610
+ */
611
+ export function classifyDirtyPaths(
612
+ paths: string[],
613
+ options: DirtyTreeClassificationOptions = {},
614
+ ): DirtyTreeClassification {
615
+ const knownRelated = collectKnownRelatedPaths(options.openspecContext);
616
+ const volatileAllowlist = (options.volatileAllowlist ?? DEFAULT_VOLATILE_ALLOWLIST)
617
+ .map(normalizePath);
618
+
619
+ const files = dedupePaths(paths).map((rawPath) => {
620
+ const path = normalizePath(rawPath);
621
+
622
+ if (matchesAnyPath(path, volatileAllowlist)) {
623
+ return {
624
+ path,
625
+ classification: "volatile" as const,
626
+ confidence: "high" as const,
627
+ reason: "matches volatile allowlist",
628
+ includedInCheckpoint: false,
629
+ };
630
+ }
631
+
632
+ if (matchesKnownRelatedPath(path, knownRelated.exact, knownRelated.prefixes)) {
633
+ return {
634
+ path,
635
+ classification: "related" as const,
636
+ confidence: "high" as const,
637
+ reason: "matches active OpenSpec change artifacts or design file scope",
638
+ includedInCheckpoint: true,
639
+ };
640
+ }
641
+
642
+ const scopedMatch = matchDesignScopedPath(path, options.openspecContext);
643
+ if (scopedMatch.matched) {
644
+ return {
645
+ path,
646
+ classification: "related" as const,
647
+ confidence: scopedMatch.confidence,
648
+ reason: scopedMatch.reason,
649
+ includedInCheckpoint: scopedMatch.confidence !== "low",
650
+ };
651
+ }
652
+
653
+ if (options.openspecContext) {
654
+ const unrelatedReason = findUnrelatedReason(path, options.openspecContext);
655
+ if (unrelatedReason) {
656
+ return {
657
+ path,
658
+ classification: "unrelated" as const,
659
+ confidence: "medium" as const,
660
+ reason: unrelatedReason,
661
+ includedInCheckpoint: false,
662
+ };
663
+ }
664
+
665
+ return {
666
+ path,
667
+ classification: "unknown" as const,
668
+ confidence: "low" as const,
669
+ reason: "outside active change scope and not on volatile allowlist",
670
+ includedInCheckpoint: false,
671
+ };
672
+ }
673
+
674
+ return {
675
+ path,
676
+ classification: "unknown" as const,
677
+ confidence: "low" as const,
678
+ reason: "generic preflight fallback without OpenSpec context",
679
+ includedInCheckpoint: false,
680
+ };
681
+ });
682
+
683
+ return {
684
+ files,
685
+ related: files.filter((f) => f.classification === "related"),
686
+ unrelated: files.filter((f) => f.classification === "unrelated"),
687
+ unknown: files.filter((f) => f.classification === "unknown"),
688
+ volatile: files.filter((f) => f.classification === "volatile"),
689
+ checkpointFiles: files.filter((f) => f.includedInCheckpoint).map((f) => f.path),
690
+ };
691
+ }
692
+
693
+ /**
694
+ * Prepare an operator-approved checkpoint plan without mutating git state.
695
+ */
696
+ export function buildCheckpointPlan(
697
+ classification: DirtyTreeClassification,
698
+ options: DirtyTreeClassificationOptions = {},
699
+ ): CheckpointPlanPreview {
700
+ const files = classification.checkpointFiles;
701
+ return {
702
+ files,
703
+ message: files.length > 0 ? suggestCheckpointCommitMessage(files, options.changeName, options.openspecContext) : null,
704
+ requiresApproval: true,
705
+ excluded: classification.files.filter((f) => !f.includedInCheckpoint),
706
+ };
707
+ }
708
+
709
+ /**
710
+ * Suggest a conventional checkpoint commit message scoped to the active change.
711
+ */
712
+ export function suggestCheckpointCommitMessage(
713
+ relatedFiles: string[],
714
+ changeName?: string | null,
715
+ openspecContext?: OpenSpecContext | null,
716
+ ): string {
717
+ const scope = deriveCheckpointScope(relatedFiles, changeName, openspecContext);
718
+ const summary = deriveCheckpointSummary(relatedFiles, changeName, openspecContext);
719
+ return `chore(${scope}): checkpoint ${summary}`;
720
+ }
721
+
722
+ function collectKnownRelatedPaths(ctx?: OpenSpecContext | null): { exact: Set<string>; prefixes: string[] } {
723
+ const exact = new Set<string>();
724
+ const prefixes: string[] = [];
725
+ if (!ctx) return { exact, prefixes };
726
+
727
+ for (const fileChange of ctx.fileChanges) {
728
+ exact.add(normalizePath(fileChange.path));
729
+ }
730
+
731
+ const normalizedChangePath = normalizePath(ctx.changePath);
732
+ const openspecIdx = normalizedChangePath.indexOf("openspec/changes/");
733
+ if (openspecIdx >= 0) {
734
+ prefixes.push(normalizedChangePath.slice(openspecIdx));
735
+ }
736
+
737
+ const changeSlug = openspecIdx >= 0
738
+ ? normalizedChangePath.slice(openspecIdx + "openspec/changes/".length).split("/")[0]
739
+ : normalizedChangePath.split("/").pop() ?? "";
740
+ if (changeSlug) {
741
+ prefixes.push(`openspec/changes/${changeSlug}`);
742
+ }
743
+
744
+ return { exact, prefixes: dedupePaths(prefixes) };
745
+ }
746
+
747
+ function matchDesignScopedPath(
748
+ path: string,
749
+ ctx?: OpenSpecContext | null,
750
+ ): { matched: boolean; confidence: DirtyPathConfidence; reason: string } {
751
+ if (!ctx) return { matched: false, confidence: "low", reason: "" };
752
+
753
+ for (const fileChange of ctx.fileChanges) {
754
+ const target = normalizePath(fileChange.path);
755
+ if (path === target) {
756
+ return {
757
+ matched: true,
758
+ confidence: "high",
759
+ reason: "exact match for design.md file scope entry",
760
+ };
761
+ }
762
+
763
+ const prefix = stripGlob(target);
764
+ if (prefix.length > 0 && (path === prefix || path.startsWith(prefix + "/"))) {
765
+ return {
766
+ matched: true,
767
+ confidence: prefix === target ? "medium" : "low",
768
+ reason: prefix === target
769
+ ? "matches design.md file scope path"
770
+ : "falls under a broad design.md file scope prefix",
771
+ };
772
+ }
773
+ }
774
+
775
+ return { matched: false, confidence: "low", reason: "" };
776
+ }
777
+
778
+ function findUnrelatedReason(path: string, ctx: OpenSpecContext): string | null {
779
+ const knownRelated = collectKnownRelatedPaths(ctx);
780
+ if (path.startsWith("openspec/changes/") && !matchesKnownRelatedPath(path, knownRelated.exact, knownRelated.prefixes)) {
781
+ return "belongs to a different OpenSpec change than the active checkpoint scope";
782
+ }
783
+ return null;
784
+ }
785
+
786
+ function deriveCheckpointScope(
787
+ relatedFiles: string[],
788
+ changeName?: string | null,
789
+ openspecContext?: OpenSpecContext | null,
790
+ ): string {
791
+ const rawScope = changeName
792
+ ?? normalizePath(openspecContext?.changePath ?? "").split("/").pop()
793
+ ?? relatedFiles[0]?.split("/")[0]
794
+ ?? "cleave";
795
+ const cleaned = rawScope
796
+ .toLowerCase()
797
+ .replace(/^cleave-/, "")
798
+ .replace(/^feature-/, "")
799
+ .replace(/[^\x00-\x7F]/g, "")
800
+ .replace(/[^a-z0-9/-]+/g, "-")
801
+ .replace(/\//g, "-")
802
+ .replace(/-+/g, "-")
803
+ .replace(/^-|-$/g, "");
804
+ return cleaned || "cleave";
805
+ }
806
+
807
+ function deriveCheckpointSummary(
808
+ relatedFiles: string[],
809
+ changeName?: string | null,
810
+ openspecContext?: OpenSpecContext | null,
811
+ ): string {
812
+ const source = changeName
813
+ ?? normalizePath(openspecContext?.changePath ?? "").split("/").pop()
814
+ ?? relatedFiles[0]?.split("/").pop()
815
+ ?? "work";
816
+ return source
817
+ .toLowerCase()
818
+ .replace(/^cleave-/, "")
819
+ .replace(/\.[a-z0-9]+$/i, "")
820
+ .replace(/[^a-z0-9]+/g, " ")
821
+ .trim() || "work";
822
+ }
823
+
824
+ function matchesKnownRelatedPath(path: string, exact: Set<string>, prefixes: string[]): boolean {
825
+ if (exact.has(path)) return true;
826
+ return prefixes.some((prefix) => path === prefix || path.startsWith(prefix + "/"));
827
+ }
828
+
829
+ function matchesAnyPath(path: string, candidates: string[]): boolean {
830
+ return candidates.some((candidate) => path === candidate || path.startsWith(candidate + "/"));
831
+ }
832
+
833
+ function stripGlob(path: string): string {
834
+ return path
835
+ .replace(/\*\*?/g, "")
836
+ .replace(/\/+/g, "/")
837
+ .replace(/\/$/, "");
838
+ }
839
+
840
+ function normalizePath(path: string): string {
841
+ return path.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+/g, "/").replace(/\/$/, "");
842
+ }
843
+
844
+ function dedupePaths(paths: string[]): string[] {
845
+ return [...new Set(paths.map(normalizePath).filter(Boolean))];
846
+ }
847
+
848
+ /**
849
+ * Read all task files from a workspace and return their contents.
850
+ */
851
+ export function readTaskFiles(workspacePath: string): Map<number, string> {
852
+ const tasks = new Map<number, string>();
853
+ let i = 0;
854
+ while (true) {
855
+ const taskPath = join(workspacePath, `${i}-task.md`);
856
+ if (!existsSync(taskPath)) break;
857
+ tasks.set(i, readFileSync(taskPath, "utf-8"));
858
+ i++;
859
+ }
860
+ return tasks;
861
+ }