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