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,1385 @@
1
+ /**
2
+ * openspec/spec — Pure domain logic for OpenSpec operations.
3
+ *
4
+ * No pi dependency — can be tested standalone. Handles:
5
+ * - Spec file parsing and generation
6
+ * - Change directory management
7
+ * - Lifecycle stage computation
8
+ * - Spec scaffolding from proposals and design nodes
9
+ * - Archive operations
10
+ * - Per-change assessment artifact persistence and freshness checks
11
+ */
12
+
13
+ import { execFileSync } from "node:child_process";
14
+ import * as crypto from "node:crypto";
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+ import type {
18
+ ChangeInfo,
19
+ ChangeStage,
20
+ Requirement,
21
+ Scenario,
22
+ SpecFile,
23
+ SpecSection,
24
+ } from "./types.ts";
25
+
26
+ // ─── Constants ───────────────────────────────────────────────────────────────
27
+
28
+ const OPENSPEC_DIR = "openspec";
29
+ const CHANGES_DIR = "changes";
30
+ const ARCHIVE_DIR = "archive";
31
+ const BASELINE_DIR = "baseline";
32
+ const ASSESSMENT_FILE = "assessment.json";
33
+ const ASSESSMENT_SCHEMA_VERSION = 1;
34
+
35
+ export type AssessmentKind = "spec" | "cleave" | "diff";
36
+ export type AssessmentOutcome = "pass" | "reopen" | "ambiguous";
37
+
38
+ export interface AssessmentSnapshotFile {
39
+ path: string;
40
+ exists: boolean;
41
+ size: number;
42
+ sha256: string | null;
43
+ }
44
+
45
+ export interface AssessmentSnapshot {
46
+ gitHead: string | null;
47
+ fingerprint: string;
48
+ dirty: boolean;
49
+ scopedPaths: string[];
50
+ files: AssessmentSnapshotFile[];
51
+ }
52
+
53
+ export interface AssessmentReconciliationHints {
54
+ reopen: boolean;
55
+ changedFiles: string[];
56
+ constraints: string[];
57
+ recommendedAction: string | null;
58
+ }
59
+
60
+ export interface AssessmentRecord {
61
+ schemaVersion: 1;
62
+ changeName: string;
63
+ assessmentKind: AssessmentKind;
64
+ outcome: AssessmentOutcome;
65
+ timestamp: string;
66
+ summary?: string;
67
+ snapshot: AssessmentSnapshot;
68
+ reconciliation: AssessmentReconciliationHints;
69
+ }
70
+
71
+ export interface AssessmentFreshness {
72
+ current: boolean;
73
+ reasons: string[];
74
+ }
75
+
76
+ export type VerificationSubstate =
77
+ | "missing-assessment"
78
+ | "stale-assessment"
79
+ | "reopened-work"
80
+ | "missing-binding"
81
+ | "archive-ready"
82
+ | "awaiting-reconciliation";
83
+
84
+ export interface VerificationStatus {
85
+ coarseStage: ChangeStage;
86
+ substate: VerificationSubstate | null;
87
+ nextAction: string | null;
88
+ reason: string | null;
89
+ }
90
+
91
+ // ─── Validation ──────────────────────────────────────────────────────────────
92
+
93
+ /** Validate a change name — prevent path traversal */
94
+ export function validateChangeName(name: string): string | null {
95
+ if (!name) return "Change name cannot be empty";
96
+ if (name.length > 80) return "Change name too long (max 80 characters)";
97
+ if (name.includes("/") || name.includes("\\")) return "Change name cannot contain path separators";
98
+ if (name.includes("..")) return "Change name cannot contain '..'";
99
+ if (name.startsWith(".")) return "Change name cannot start with '.'";
100
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) return "Change name must be lowercase alphanumeric with hyphens/underscores";
101
+ return null;
102
+ }
103
+
104
+ /** Validate a spec domain path — allow forward slashes for nesting but prevent traversal */
105
+ export function validateDomain(domain: string): string | null {
106
+ if (!domain) return "Domain cannot be empty";
107
+ if (domain.length > 120) return "Domain too long (max 120 characters)";
108
+ if (domain.includes("\\")) return "Domain cannot contain backslashes";
109
+ if (domain.includes("..")) return "Domain cannot contain '..'";
110
+ if (domain.startsWith("/") || domain.startsWith(".")) return "Domain cannot start with '/' or '.'";
111
+ if (!/^[a-z0-9][a-z0-9_/-]*$/.test(domain)) return "Domain must be lowercase alphanumeric with hyphens, underscores, and forward slashes";
112
+ return null;
113
+ }
114
+
115
+ // ─── Change Discovery ────────────────────────────────────────────────────────
116
+
117
+ /**
118
+ * Get the openspec directory path for a repo, or null if it doesn't exist.
119
+ */
120
+ export function getOpenSpecDir(repoPath: string): string | null {
121
+ const dir = path.join(repoPath, OPENSPEC_DIR);
122
+ return fs.existsSync(dir) ? dir : null;
123
+ }
124
+
125
+ /**
126
+ * Ensure the openspec directory structure exists.
127
+ */
128
+ export function ensureOpenSpecDir(repoPath: string): string {
129
+ const dir = path.join(repoPath, OPENSPEC_DIR, CHANGES_DIR);
130
+ fs.mkdirSync(dir, { recursive: true });
131
+ return path.join(repoPath, OPENSPEC_DIR);
132
+ }
133
+
134
+ /**
135
+ * List all active (non-archived) changes with full status.
136
+ */
137
+ export function listChanges(repoPath: string): ChangeInfo[] {
138
+ const openspecDir = getOpenSpecDir(repoPath);
139
+ if (!openspecDir) return [];
140
+
141
+ const changesDir = path.join(openspecDir, CHANGES_DIR);
142
+ if (!fs.existsSync(changesDir)) return [];
143
+
144
+ const entries = fs.readdirSync(changesDir, { withFileTypes: true });
145
+ const changes: ChangeInfo[] = [];
146
+
147
+ for (const entry of entries) {
148
+ if (!entry.isDirectory() || entry.name === "archive") continue;
149
+ const changePath = path.join(changesDir, entry.name);
150
+ changes.push(getChangeInfo(entry.name, changePath));
151
+ }
152
+
153
+ return changes;
154
+ }
155
+
156
+
157
+ /**
158
+ * Get a specific change by name.
159
+ */
160
+ export function getChange(repoPath: string, name: string): ChangeInfo | null {
161
+ const nameError = validateChangeName(name);
162
+ if (nameError) return null;
163
+
164
+ const openspecDir = getOpenSpecDir(repoPath);
165
+ if (!openspecDir) return null;
166
+
167
+ const changePath = path.join(openspecDir, CHANGES_DIR, name);
168
+ if (!fs.existsSync(changePath)) return null;
169
+
170
+ return getChangeInfo(name, changePath);
171
+ }
172
+
173
+ /**
174
+ * Build full change info including lifecycle stage computation.
175
+ */
176
+ function getChangeInfo(name: string, changePath: string): ChangeInfo {
177
+ const hasProposal = fs.existsSync(path.join(changePath, "proposal.md"));
178
+ const hasDesign = fs.existsSync(path.join(changePath, "design.md"));
179
+ const hasTasks = fs.existsSync(path.join(changePath, "tasks.md"));
180
+ const specsDir = path.join(changePath, "specs");
181
+ const hasSpecs = fs.existsSync(specsDir);
182
+
183
+ let totalTasks = 0;
184
+ let doneTasks = 0;
185
+ if (hasTasks) {
186
+ const content = fs.readFileSync(path.join(changePath, "tasks.md"), "utf-8");
187
+ const checkboxes = content.match(/^\s*-\s+\[[ xX]\]/gm) || [];
188
+ totalTasks = checkboxes.length;
189
+ doneTasks = (content.match(/^\s*-\s+\[[xX]\]/gm) || []).length;
190
+ }
191
+
192
+ const specs = hasSpecs ? parseSpecsDir(specsDir) : [];
193
+ const stage = computeStage(hasProposal, hasSpecs, hasTasks, totalTasks, doneTasks);
194
+
195
+ return {
196
+ name,
197
+ path: changePath,
198
+ stage,
199
+ hasProposal,
200
+ hasDesign,
201
+ hasSpecs,
202
+ hasTasks,
203
+ totalTasks,
204
+ doneTasks,
205
+ specs,
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Compute the lifecycle stage from artifact presence and task progress.
211
+ */
212
+ export function computeStage(
213
+ hasProposal: boolean,
214
+ hasSpecs: boolean,
215
+ hasTasks: boolean,
216
+ totalTasks: number,
217
+ doneTasks: number,
218
+ ): ChangeStage {
219
+ if (!hasProposal && !hasTasks && !hasSpecs) return "proposed";
220
+ if (hasTasks && totalTasks > 0 && doneTasks >= totalTasks) return "verifying";
221
+ if (hasTasks && totalTasks > 0 && doneTasks > 0) return "implementing";
222
+ if (hasTasks) return "planned";
223
+ if (hasSpecs) return "specified";
224
+ return "proposed";
225
+ }
226
+
227
+ // ─── Assessment Artifact Helpers ────────────────────────────────────────────
228
+
229
+ function getChangePath(repoPath: string, changeName: string): string | null {
230
+ const nameError = validateChangeName(changeName);
231
+ if (nameError) return null;
232
+
233
+ const openspecDir = getOpenSpecDir(repoPath);
234
+ if (!openspecDir) return null;
235
+
236
+ const changePath = path.join(openspecDir, CHANGES_DIR, changeName);
237
+ return fs.existsSync(changePath) ? changePath : null;
238
+ }
239
+
240
+ export function getAssessmentArtifactPath(changePath: string): string {
241
+ return path.join(changePath, ASSESSMENT_FILE);
242
+ }
243
+
244
+ function isAssessmentKind(value: unknown): value is AssessmentKind {
245
+ return value === "spec" || value === "cleave" || value === "diff";
246
+ }
247
+
248
+ function isAssessmentOutcome(value: unknown): value is AssessmentOutcome {
249
+ return value === "pass" || value === "reopen" || value === "ambiguous";
250
+ }
251
+
252
+ function parseStringArray(value: unknown): string[] {
253
+ if (!Array.isArray(value)) return [];
254
+ return value.filter((entry): entry is string => typeof entry === "string").map((entry) => entry.trim()).filter(Boolean);
255
+ }
256
+
257
+ function normalizeSnapshot(input: unknown): AssessmentSnapshot | null {
258
+ if (!input || typeof input !== "object") return null;
259
+ const candidate = input as Record<string, unknown>;
260
+ if (typeof candidate.fingerprint !== "string" || !candidate.fingerprint) return null;
261
+
262
+ const filesRaw = Array.isArray(candidate.files) ? candidate.files : [];
263
+ const files: AssessmentSnapshotFile[] = filesRaw.flatMap((entry) => {
264
+ if (!entry || typeof entry !== "object") return [];
265
+ const file = entry as Record<string, unknown>;
266
+ if (typeof file.path !== "string") return [];
267
+ if (typeof file.exists !== "boolean") return [];
268
+ if (typeof file.size !== "number") return [];
269
+ if (file.sha256 !== null && typeof file.sha256 !== "string") return [];
270
+ return [{
271
+ path: file.path,
272
+ exists: file.exists,
273
+ size: file.size,
274
+ sha256: file.sha256,
275
+ }];
276
+ });
277
+
278
+ return {
279
+ gitHead: typeof candidate.gitHead === "string" ? candidate.gitHead : null,
280
+ fingerprint: candidate.fingerprint,
281
+ dirty: candidate.dirty === true,
282
+ scopedPaths: parseStringArray(candidate.scopedPaths),
283
+ files,
284
+ };
285
+ }
286
+
287
+ function normalizeReconciliation(input: unknown, outcome: AssessmentOutcome): AssessmentReconciliationHints {
288
+ if (!input || typeof input !== "object") {
289
+ return {
290
+ reopen: outcome === "reopen",
291
+ changedFiles: [],
292
+ constraints: [],
293
+ recommendedAction: outcome === "reopen" ? "Run openspec_manage reconcile_after_assess before archive." : null,
294
+ };
295
+ }
296
+
297
+ const candidate = input as Record<string, unknown>;
298
+ const changedFiles = parseStringArray(candidate.changedFiles);
299
+ const constraints = parseStringArray(candidate.constraints);
300
+ const recommendedAction = typeof candidate.recommendedAction === "string" && candidate.recommendedAction.trim()
301
+ ? candidate.recommendedAction.trim()
302
+ : outcome === "reopen"
303
+ ? "Run openspec_manage reconcile_after_assess before archive."
304
+ : null;
305
+
306
+ return {
307
+ reopen: typeof candidate.reopen === "boolean" ? candidate.reopen : outcome === "reopen",
308
+ changedFiles,
309
+ constraints,
310
+ recommendedAction,
311
+ };
312
+ }
313
+
314
+ function normalizeAssessmentRecord(input: unknown): AssessmentRecord | null {
315
+ if (!input || typeof input !== "object") return null;
316
+ const candidate = input as Record<string, unknown>;
317
+ if (candidate.schemaVersion !== ASSESSMENT_SCHEMA_VERSION) return null;
318
+ if (typeof candidate.changeName !== "string" || !candidate.changeName) return null;
319
+ if (!isAssessmentKind(candidate.assessmentKind)) return null;
320
+ if (!isAssessmentOutcome(candidate.outcome)) return null;
321
+ if (typeof candidate.timestamp !== "string" || !candidate.timestamp) return null;
322
+
323
+ const snapshot = normalizeSnapshot(candidate.snapshot);
324
+ if (!snapshot) return null;
325
+
326
+ return {
327
+ schemaVersion: ASSESSMENT_SCHEMA_VERSION,
328
+ changeName: candidate.changeName,
329
+ assessmentKind: candidate.assessmentKind,
330
+ outcome: candidate.outcome,
331
+ timestamp: candidate.timestamp,
332
+ ...(typeof candidate.summary === "string" && candidate.summary.trim() ? { summary: candidate.summary.trim() } : {}),
333
+ snapshot,
334
+ reconciliation: normalizeReconciliation(candidate.reconciliation, candidate.outcome),
335
+ };
336
+ }
337
+
338
+ function safeReadGit(repoPath: string, args: readonly string[]): string | null {
339
+ try {
340
+ return execFileSync("git", args, {
341
+ cwd: repoPath,
342
+ encoding: "utf-8",
343
+ stdio: ["ignore", "pipe", "ignore"],
344
+ }).trim() || null;
345
+ } catch {
346
+ return null;
347
+ }
348
+ }
349
+
350
+ function detectGitDirty(repoPath: string, scopedPaths: readonly string[]): boolean {
351
+ if (scopedPaths.length === 0) return false;
352
+ const output = safeReadGit(repoPath, ["status", "--short", "--", ...scopedPaths]);
353
+ return output !== null && output.length > 0;
354
+ }
355
+
356
+ function parseDesignFileScope(changePath: string): string[] {
357
+ const designPath = path.join(changePath, "design.md");
358
+ if (!fs.existsSync(designPath)) return [];
359
+
360
+ const content = fs.readFileSync(designPath, "utf-8");
361
+ const fileChangesSection = content.match(/##\s+File Changes\s*\n([\s\S]*?)(?=\n##\s|$)/i);
362
+ if (!fileChangesSection) return [];
363
+
364
+ const scoped = new Set<string>();
365
+ for (const line of fileChangesSection[1].split("\n")) {
366
+ const match = line.match(/-\s+`([^`]+)`/);
367
+ if (match && match[1].trim()) scoped.add(match[1].trim());
368
+ }
369
+ return Array.from(scoped).sort();
370
+ }
371
+
372
+ function listChangeArtifactPaths(changePath: string): string[] {
373
+ const collected: string[] = [];
374
+ const stack = [changePath];
375
+
376
+ while (stack.length > 0) {
377
+ const dir = stack.pop();
378
+ if (!dir) continue;
379
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
380
+ for (const entry of entries) {
381
+ const fullPath = path.join(dir, entry.name);
382
+ if (entry.isDirectory()) {
383
+ stack.push(fullPath);
384
+ continue;
385
+ }
386
+ collected.push(fullPath);
387
+ }
388
+ }
389
+
390
+ return collected.sort();
391
+ }
392
+
393
+ function buildSnapshotFile(repoPath: string, relativeFilePath: string): AssessmentSnapshotFile {
394
+ const absolutePath = path.join(repoPath, relativeFilePath);
395
+ if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) {
396
+ return {
397
+ path: relativeFilePath,
398
+ exists: false,
399
+ size: 0,
400
+ sha256: null,
401
+ };
402
+ }
403
+
404
+ const content = fs.readFileSync(absolutePath);
405
+ return {
406
+ path: relativeFilePath,
407
+ exists: true,
408
+ size: content.length,
409
+ sha256: crypto.createHash("sha256").update(content).digest("hex"),
410
+ };
411
+ }
412
+
413
+ export function computeAssessmentSnapshot(repoPath: string, changeName: string): AssessmentSnapshot | null {
414
+ const changePath = getChangePath(repoPath, changeName);
415
+ if (!changePath) return null;
416
+
417
+ const scopedPaths = parseDesignFileScope(changePath);
418
+ const artifactRelativePaths = listChangeArtifactPaths(changePath)
419
+ .map((filePath) => path.relative(repoPath, filePath))
420
+ .filter((filePath) => filePath !== path.join(OPENSPEC_DIR, CHANGES_DIR, changeName, ASSESSMENT_FILE));
421
+
422
+ const snapshotPaths = Array.from(new Set([
423
+ ...scopedPaths,
424
+ ...artifactRelativePaths,
425
+ ])).sort();
426
+ const files = snapshotPaths.map((filePath) => buildSnapshotFile(repoPath, filePath));
427
+ const gitHead = safeReadGit(repoPath, ["rev-parse", "HEAD"]);
428
+ const dirty = detectGitDirty(repoPath, snapshotPaths);
429
+
430
+ const fingerprintSeed = JSON.stringify({
431
+ changeName,
432
+ gitHead,
433
+ dirty,
434
+ files,
435
+ });
436
+
437
+ return {
438
+ gitHead,
439
+ fingerprint: crypto.createHash("sha256").update(fingerprintSeed).digest("hex"),
440
+ dirty,
441
+ scopedPaths,
442
+ files,
443
+ };
444
+ }
445
+
446
+ export function readAssessmentRecord(repoPath: string, changeName: string): AssessmentRecord | null {
447
+ const changePath = getChangePath(repoPath, changeName);
448
+ if (!changePath) return null;
449
+
450
+ const artifactPath = getAssessmentArtifactPath(changePath);
451
+ if (!fs.existsSync(artifactPath)) return null;
452
+
453
+ try {
454
+ const parsed = JSON.parse(fs.readFileSync(artifactPath, "utf-8")) as unknown;
455
+ const record = normalizeAssessmentRecord(parsed);
456
+ if (!record || record.changeName !== changeName) return null;
457
+ return record;
458
+ } catch {
459
+ return null;
460
+ }
461
+ }
462
+
463
+ export function writeAssessmentRecord(
464
+ repoPath: string,
465
+ changeName: string,
466
+ record: Omit<AssessmentRecord, "schemaVersion">,
467
+ ): string {
468
+ const changePath = getChangePath(repoPath, changeName);
469
+ if (!changePath) {
470
+ throw new Error(`OpenSpec change '${changeName}' not found`);
471
+ }
472
+ if (record.changeName !== changeName) {
473
+ throw new Error(`Assessment record change '${record.changeName}' does not match requested change '${changeName}'`);
474
+ }
475
+
476
+ const normalized: AssessmentRecord = {
477
+ schemaVersion: ASSESSMENT_SCHEMA_VERSION,
478
+ changeName,
479
+ assessmentKind: record.assessmentKind,
480
+ outcome: record.outcome,
481
+ timestamp: record.timestamp,
482
+ ...(record.summary ? { summary: record.summary } : {}),
483
+ snapshot: record.snapshot,
484
+ reconciliation: normalizeReconciliation(record.reconciliation, record.outcome),
485
+ };
486
+
487
+ const artifactPath = getAssessmentArtifactPath(changePath);
488
+ fs.writeFileSync(artifactPath, JSON.stringify(normalized, null, 2) + "\n", "utf-8");
489
+ return artifactPath;
490
+ }
491
+
492
+ export function evaluateAssessmentFreshness(
493
+ record: AssessmentRecord | null,
494
+ currentSnapshot: AssessmentSnapshot | null,
495
+ ): AssessmentFreshness {
496
+ if (!record) {
497
+ return { current: false, reasons: ["Missing assessment record"] };
498
+ }
499
+ if (!currentSnapshot) {
500
+ return { current: false, reasons: ["Current implementation snapshot unavailable"] };
501
+ }
502
+
503
+ const reasons: string[] = [];
504
+ if (record.snapshot.fingerprint !== currentSnapshot.fingerprint) {
505
+ reasons.push("Implementation snapshot fingerprint differs from the persisted assessment record");
506
+ }
507
+ if (record.snapshot.gitHead !== currentSnapshot.gitHead) {
508
+ reasons.push("Git HEAD differs from the persisted assessment record");
509
+ }
510
+ if (record.snapshot.dirty !== currentSnapshot.dirty) {
511
+ reasons.push("Working tree cleanliness differs from the persisted assessment record");
512
+ }
513
+ if (record.outcome !== "pass") {
514
+ reasons.push(`Assessment outcome is '${record.outcome}', not 'pass'`);
515
+ }
516
+ if (record.reconciliation.reopen) {
517
+ reasons.push("Assessment record indicates lifecycle reconciliation is still open");
518
+ }
519
+
520
+ return {
521
+ current: reasons.length === 0,
522
+ reasons,
523
+ };
524
+ }
525
+
526
+ export function getAssessmentStatus(repoPath: string, changeName: string): {
527
+ record: AssessmentRecord | null;
528
+ snapshot: AssessmentSnapshot | null;
529
+ freshness: AssessmentFreshness;
530
+ } {
531
+ const record = readAssessmentRecord(repoPath, changeName);
532
+ const snapshot = computeAssessmentSnapshot(repoPath, changeName);
533
+ return {
534
+ record,
535
+ snapshot,
536
+ freshness: evaluateAssessmentFreshness(record, snapshot),
537
+ };
538
+ }
539
+
540
+ export function resolveVerificationStatus(input: {
541
+ stage: ChangeStage;
542
+ record: AssessmentRecord | null;
543
+ freshness: AssessmentFreshness;
544
+ archiveBlocked?: boolean;
545
+ archiveBlockedReason?: string | null;
546
+ archiveBlockedIssueCodes?: readonly string[];
547
+ changeName: string;
548
+ }): VerificationStatus {
549
+ if (input.stage !== "verifying") {
550
+ return {
551
+ coarseStage: input.stage,
552
+ substate: null,
553
+ nextAction: null,
554
+ reason: null,
555
+ };
556
+ }
557
+
558
+ if (!input.record) {
559
+ return {
560
+ coarseStage: input.stage,
561
+ substate: "missing-assessment",
562
+ nextAction: `/assess spec ${input.changeName}`,
563
+ reason: "No persisted assessment record exists for this task-complete change.",
564
+ };
565
+ }
566
+
567
+ if (input.record.outcome === "reopen" || input.record.reconciliation.reopen) {
568
+ return {
569
+ coarseStage: input.stage,
570
+ substate: "reopened-work",
571
+ nextAction: `Complete follow-up work for ${input.changeName}, reconcile lifecycle artifacts, then re-run /assess spec ${input.changeName}`,
572
+ reason: "The latest persisted assessment reopened work.",
573
+ };
574
+ }
575
+
576
+ if (!input.freshness.current || input.record.outcome === "ambiguous") {
577
+ return {
578
+ coarseStage: input.stage,
579
+ substate: "stale-assessment",
580
+ nextAction: `Refresh /assess spec ${input.changeName} for the current implementation snapshot`,
581
+ reason: input.record.outcome === "ambiguous"
582
+ ? "The latest persisted assessment is ambiguous and must be refreshed before archive."
583
+ : input.freshness.reasons.join(" "),
584
+ };
585
+ }
586
+
587
+ if (input.archiveBlockedIssueCodes?.includes("missing_design_binding")) {
588
+ return {
589
+ coarseStage: input.stage,
590
+ substate: "missing-binding",
591
+ nextAction: input.archiveBlockedReason ?? `Bind ${input.changeName} to a design-tree node before archive`,
592
+ reason: input.archiveBlockedReason ?? "No valid design-tree binding can be established for this change.",
593
+ };
594
+ }
595
+
596
+ if (input.archiveBlocked) {
597
+ return {
598
+ coarseStage: input.stage,
599
+ substate: "awaiting-reconciliation",
600
+ nextAction: input.archiveBlockedReason ?? `Reconcile lifecycle artifacts for ${input.changeName} before archive`,
601
+ reason: input.archiveBlockedReason ?? "Lifecycle reconciliation is still blocking archive.",
602
+ };
603
+ }
604
+
605
+ return {
606
+ coarseStage: input.stage,
607
+ substate: "archive-ready",
608
+ nextAction: `/opsx:archive ${input.changeName}`,
609
+ reason: "Assessment passed for the current snapshot and archive gates are clear.",
610
+ };
611
+ }
612
+
613
+ // ─── Spec Parsing ────────────────────────────────────────────────────────────
614
+
615
+ /**
616
+ * Parse all spec files in a specs/ directory.
617
+ */
618
+ export function parseSpecsDir(specsDir: string): SpecFile[] {
619
+ if (!fs.existsSync(specsDir)) return [];
620
+
621
+ const files = findSpecFiles(specsDir);
622
+ return files.map((filePath) => {
623
+ const content = fs.readFileSync(filePath, "utf-8");
624
+ const domain = filePath
625
+ .replace(specsDir + "/", "")
626
+ .replace(/\/spec\.md$/, "")
627
+ .replace(/\.md$/, "");
628
+
629
+ return {
630
+ domain,
631
+ filePath,
632
+ sections: parseSpecContent(content),
633
+ };
634
+ });
635
+ }
636
+
637
+ /**
638
+ * Recursively find spec.md files.
639
+ */
640
+ function findSpecFiles(dir: string): string[] {
641
+ const results: string[] = [];
642
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
643
+
644
+ for (const entry of entries) {
645
+ const fullPath = path.join(dir, entry.name);
646
+ if (entry.isDirectory()) {
647
+ results.push(...findSpecFiles(fullPath));
648
+ } else if (entry.name.endsWith(".md")) {
649
+ results.push(fullPath);
650
+ }
651
+ }
652
+
653
+ return results.sort();
654
+ }
655
+
656
+ /**
657
+ * Parse a spec file's content into sections and requirements.
658
+ */
659
+ export function parseSpecContent(content: string): SpecSection[] {
660
+ const sections: SpecSection[] = [];
661
+
662
+ // Split on ## ADDED/MODIFIED/REMOVED headings
663
+ const sectionRe = /^##\s+(ADDED|MODIFIED|REMOVED)\s+Requirements?\s*$/gim;
664
+ const parts: Array<{ type: SpecSection["type"]; startIndex: number }> = [];
665
+
666
+ let match: RegExpExecArray | null;
667
+ while ((match = sectionRe.exec(content)) !== null) {
668
+ parts.push({
669
+ type: match[1].toLowerCase() as SpecSection["type"],
670
+ startIndex: match.index + match[0].length,
671
+ });
672
+ }
673
+
674
+ for (let i = 0; i < parts.length; i++) {
675
+ const start = parts[i].startIndex;
676
+ const end = i + 1 < parts.length ? parts[i + 1].startIndex - parts[i + 1].type.length - 20 : content.length;
677
+ const sectionContent = content.slice(start, end).trim();
678
+ const requirements = parseRequirements(sectionContent);
679
+
680
+ sections.push({
681
+ type: parts[i].type,
682
+ requirements,
683
+ });
684
+ }
685
+
686
+ return sections;
687
+ }
688
+
689
+ /**
690
+ * Parse requirements from a section's content.
691
+ */
692
+ function parseRequirements(content: string): Requirement[] {
693
+ const requirements: Requirement[] = [];
694
+ const reqRe = /^###\s+Requirement:\s*(.+)$/gm;
695
+ const reqPositions: Array<{ title: string; startIndex: number }> = [];
696
+
697
+ let match: RegExpExecArray | null;
698
+ while ((match = reqRe.exec(content)) !== null) {
699
+ reqPositions.push({
700
+ title: match[1].trim(),
701
+ startIndex: match.index + match[0].length,
702
+ });
703
+ }
704
+
705
+ for (let i = 0; i < reqPositions.length; i++) {
706
+ const start = reqPositions[i].startIndex;
707
+ const end = i + 1 < reqPositions.length
708
+ ? content.lastIndexOf("###", reqPositions[i + 1].startIndex)
709
+ : content.length;
710
+ const reqContent = content.slice(start, end).trim();
711
+
712
+ // Extract description (text before first #### Scenario)
713
+ const firstScenario = reqContent.indexOf("#### Scenario:");
714
+ const description = firstScenario >= 0
715
+ ? reqContent.slice(0, firstScenario).trim()
716
+ : reqContent.trim();
717
+
718
+ const scenarios = parseScenarios(reqContent);
719
+
720
+ requirements.push({
721
+ title: reqPositions[i].title,
722
+ description,
723
+ scenarios,
724
+ });
725
+ }
726
+
727
+ return requirements;
728
+ }
729
+
730
+ /**
731
+ * Parse Given/When/Then scenarios from requirement content.
732
+ */
733
+ export function parseScenarios(content: string): Scenario[] {
734
+ const scenarios: Scenario[] = [];
735
+ const scenarioRe = /####\s+Scenario:\s*(.+)/g;
736
+ const positions: Array<{ title: string; startIndex: number }> = [];
737
+
738
+ let match: RegExpExecArray | null;
739
+ while ((match = scenarioRe.exec(content)) !== null) {
740
+ positions.push({
741
+ title: match[1].trim(),
742
+ startIndex: match.index + match[0].length,
743
+ });
744
+ }
745
+
746
+ for (let i = 0; i < positions.length; i++) {
747
+ const start = positions[i].startIndex;
748
+ const end = i + 1 < positions.length
749
+ ? content.lastIndexOf("####", positions[i + 1].startIndex)
750
+ : content.length;
751
+ const block = content.slice(start, end).trim();
752
+
753
+ const given = extractClause(block, "Given");
754
+ const when = extractClause(block, "When");
755
+ const then = extractClause(block, "Then");
756
+ const andClauses = extractAndClauses(block);
757
+
758
+ if (given || when || then) {
759
+ scenarios.push({
760
+ title: positions[i].title,
761
+ given: given || "",
762
+ when: when || "",
763
+ then: then || "",
764
+ ...(andClauses.length > 0 && { and: andClauses }),
765
+ });
766
+ }
767
+ }
768
+
769
+ return scenarios;
770
+ }
771
+
772
+ /**
773
+ * Extract a Given/When/Then clause from a scenario block.
774
+ */
775
+ function extractClause(block: string, keyword: string): string | null {
776
+ // Match "Given ..." up to next keyword or end
777
+ const re = new RegExp(
778
+ `^${keyword}\\s+(.+?)(?=\\n(?:Given|When|Then|And)\\s|$)`,
779
+ "ms",
780
+ );
781
+ const match = block.match(re);
782
+ return match ? match[1].trim() : null;
783
+ }
784
+
785
+ /**
786
+ * Extract "And ..." clauses from a scenario block.
787
+ */
788
+ function extractAndClauses(block: string): string[] {
789
+ const clauses: string[] = [];
790
+ const re = /^And\s+(.+)$/gm;
791
+ let match: RegExpExecArray | null;
792
+ while ((match = re.exec(block)) !== null) {
793
+ clauses.push(match[1].trim());
794
+ }
795
+ return clauses;
796
+ }
797
+
798
+ // ─── Spec Generation ─────────────────────────────────────────────────────────
799
+
800
+ /**
801
+ * Generate a spec file from a proposal and optional design decisions.
802
+ *
803
+ * This creates the ADDED Requirements section with placeholder scenarios
804
+ * derived from the proposal's intent and any design decisions.
805
+ */
806
+ export function generateSpecFromProposal(opts: {
807
+ domain: string;
808
+ proposalContent: string;
809
+ decisions?: Array<{ title: string; rationale: string }>;
810
+ openQuestions?: string[];
811
+ }): string {
812
+ const lines: string[] = [
813
+ `# ${opts.domain} — Delta Spec`,
814
+ "",
815
+ "## ADDED Requirements",
816
+ "",
817
+ ];
818
+
819
+ // Extract intent from proposal
820
+ const intentMatch = opts.proposalContent.match(
821
+ /##\s+Intent\s*\n([\s\S]*?)(?=\n##\s|$)/i,
822
+ );
823
+ const intent = intentMatch ? intentMatch[1].trim() : "Implement the proposed change.";
824
+
825
+ // Generate a requirement from intent
826
+ lines.push(`### Requirement: ${opts.domain} core functionality`, "");
827
+ lines.push(intent, "");
828
+
829
+ lines.push(`#### Scenario: Happy path`, "");
830
+ lines.push("Given the system is in a default state");
831
+ lines.push(`When the ${opts.domain} feature is exercised`);
832
+ lines.push("Then the expected behavior is observed");
833
+ lines.push("");
834
+
835
+ // Generate requirements from decisions
836
+ if (opts.decisions && opts.decisions.length > 0) {
837
+ for (const d of opts.decisions) {
838
+ lines.push(`### Requirement: ${d.title}`, "");
839
+ lines.push(d.rationale, "");
840
+
841
+ lines.push(`#### Scenario: ${d.title} — default case`, "");
842
+ lines.push("Given the system uses the decided approach");
843
+ lines.push(`When ${d.title.toLowerCase()} is applied`);
844
+ lines.push("Then the system behaves according to the decision");
845
+ lines.push("");
846
+ }
847
+ }
848
+
849
+ // Convert open questions to placeholder requirements
850
+ if (opts.openQuestions && opts.openQuestions.length > 0) {
851
+ lines.push("## MODIFIED Requirements", "");
852
+ lines.push(
853
+ "<!-- Open questions from design exploration — refine these into concrete scenarios -->",
854
+ "",
855
+ );
856
+ for (const q of opts.openQuestions) {
857
+ lines.push(`### Requirement: ${q.replace(/\?$/, "")}`, "");
858
+ lines.push(`<!-- TODO: Refine from open question: "${q}" -->`, "");
859
+ lines.push(`#### Scenario: ${q.replace(/\?$/, "")} — resolved`, "");
860
+ lines.push("Given the question has been resolved");
861
+ lines.push("When the resolution is applied");
862
+ lines.push("Then the system reflects the answer");
863
+ lines.push("");
864
+ }
865
+ }
866
+
867
+ return lines.join("\n");
868
+ }
869
+
870
+ /**
871
+ * Generate a scenario block as markdown.
872
+ */
873
+ export function formatScenario(s: Scenario): string {
874
+ const lines = [
875
+ `#### Scenario: ${s.title}`,
876
+ `Given ${s.given}`,
877
+ `When ${s.when}`,
878
+ `Then ${s.then}`,
879
+ ];
880
+ if (s.and) {
881
+ for (const clause of s.and) {
882
+ lines.push(`And ${clause}`);
883
+ }
884
+ }
885
+ return lines.join("\n");
886
+ }
887
+
888
+ /**
889
+ * Generate a complete spec file from structured data.
890
+ */
891
+ export function generateSpecFile(domain: string, sections: SpecSection[]): string {
892
+ const lines = [`# ${domain} — Delta Spec`, ""];
893
+
894
+ for (const section of sections) {
895
+ const typeLabel = section.type.charAt(0).toUpperCase() + section.type.slice(1);
896
+ lines.push(`## ${typeLabel.toUpperCase()} Requirements`, "");
897
+
898
+ for (const req of section.requirements) {
899
+ lines.push(`### Requirement: ${req.title}`, "");
900
+ if (req.description) {
901
+ lines.push(req.description, "");
902
+ }
903
+ for (const s of req.scenarios) {
904
+ lines.push(formatScenario(s), "");
905
+ }
906
+ }
907
+ }
908
+
909
+ return lines.join("\n");
910
+ }
911
+
912
+ // ─── Change Operations ───────────────────────────────────────────────────────
913
+
914
+ /**
915
+ * Create a new OpenSpec change with a proposal.
916
+ */
917
+ export function createChange(
918
+ repoPath: string,
919
+ name: string,
920
+ title: string,
921
+ intent: string,
922
+ ): { changePath: string; files: string[] } {
923
+ const slug = name
924
+ .toLowerCase()
925
+ .replace(/[^a-z0-9]+/g, "-")
926
+ .replace(/^-|-$/g, "")
927
+ .slice(0, 60);
928
+
929
+ const openspecDir = ensureOpenSpecDir(repoPath);
930
+ const changePath = path.join(openspecDir, CHANGES_DIR, slug);
931
+
932
+ if (fs.existsSync(changePath)) {
933
+ const existing = fs.readdirSync(changePath).filter((f) => f.endsWith(".md"));
934
+ if (existing.length > 0) {
935
+ throw new Error(
936
+ `Change '${slug}' already exists with files: ${existing.join(", ")}. ` +
937
+ `Delete it first: rm -rf ${changePath}`,
938
+ );
939
+ }
940
+ }
941
+
942
+ fs.mkdirSync(changePath, { recursive: true });
943
+
944
+ const proposalLines = [
945
+ `# ${title}`,
946
+ "",
947
+ "## Intent",
948
+ "",
949
+ intent,
950
+ "",
951
+ "## Scope",
952
+ "",
953
+ "<!-- Define what is in scope and out of scope -->",
954
+ "",
955
+ "## Success Criteria",
956
+ "",
957
+ "<!-- How will we know this change is complete and correct? -->",
958
+ "",
959
+ ];
960
+
961
+ const proposalPath = path.join(changePath, "proposal.md");
962
+ fs.writeFileSync(proposalPath, proposalLines.join("\n"));
963
+
964
+ return { changePath, files: ["proposal.md"] };
965
+ }
966
+
967
+ /**
968
+ * Add specs to an existing change.
969
+ * Creates specs/<domain>.md with the provided content.
970
+ */
971
+ export function addSpec(
972
+ changePath: string,
973
+ domain: string,
974
+ content: string,
975
+ ): string {
976
+ // Validate domain to prevent path traversal
977
+ const domainError = validateDomain(domain);
978
+ if (domainError) throw new Error(domainError);
979
+
980
+ const specsDir = path.join(changePath, "specs");
981
+ fs.mkdirSync(specsDir, { recursive: true });
982
+
983
+ const specPath = path.join(specsDir, domain + ".md");
984
+
985
+ // Defense-in-depth: verify resolved path is within specs directory
986
+ const resolved = path.resolve(specPath);
987
+ const resolvedBase = path.resolve(specsDir);
988
+ if (!resolved.startsWith(resolvedBase + path.sep) && resolved !== resolvedBase) {
989
+ throw new Error(`Path traversal detected: domain '${domain}' resolves outside specs/`);
990
+ }
991
+
992
+ // Ensure parent dirs for nested domains
993
+ fs.mkdirSync(path.dirname(specPath), { recursive: true });
994
+ fs.writeFileSync(specPath, content);
995
+
996
+ return specPath;
997
+ }
998
+
999
+ /**
1000
+ * Archive a completed change.
1001
+ *
1002
+ * Moves specs to baseline/ and the change directory to archive/.
1003
+ * Returns the list of operations performed.
1004
+ */
1005
+ export function archiveChange(
1006
+ repoPath: string,
1007
+ changeName: string,
1008
+ ): { operations: string[]; archived: boolean } {
1009
+ const nameError = validateChangeName(changeName);
1010
+ if (nameError) return { operations: [nameError], archived: false };
1011
+
1012
+ const openspecDir = getOpenSpecDir(repoPath);
1013
+ if (!openspecDir) return { operations: ["No openspec/ directory found"], archived: false };
1014
+
1015
+ const changePath = path.join(openspecDir, CHANGES_DIR, changeName);
1016
+ if (!fs.existsSync(changePath)) {
1017
+ return { operations: [`Change '${changeName}' not found`], archived: false };
1018
+ }
1019
+
1020
+ const operations: string[] = [];
1021
+
1022
+ // 1. Merge specs to baseline
1023
+ const specsDir = path.join(changePath, "specs");
1024
+ if (fs.existsSync(specsDir)) {
1025
+ const baselineDir = path.join(openspecDir, BASELINE_DIR);
1026
+ fs.mkdirSync(baselineDir, { recursive: true });
1027
+
1028
+ const specFiles = findSpecFiles(specsDir);
1029
+ for (const specFile of specFiles) {
1030
+ const relativePath = specFile.replace(specsDir + "/", "");
1031
+ const baselinePath = path.join(baselineDir, relativePath);
1032
+ fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
1033
+
1034
+ if (fs.existsSync(baselinePath)) {
1035
+ // Merge: append ADDED sections to existing baseline
1036
+ const existingContent = fs.readFileSync(baselinePath, "utf-8");
1037
+ const deltaContent = fs.readFileSync(specFile, "utf-8");
1038
+ const merged = mergeSpecToBaseline(existingContent, deltaContent);
1039
+ fs.writeFileSync(baselinePath, merged);
1040
+ operations.push(`Merged ${relativePath} into baseline`);
1041
+ } else {
1042
+ // New baseline file — convert delta format to baseline format
1043
+ const deltaContent = fs.readFileSync(specFile, "utf-8");
1044
+ const baseline = deltaToBaseline(deltaContent);
1045
+ fs.writeFileSync(baselinePath, baseline);
1046
+ operations.push(`Created baseline/${relativePath}`);
1047
+ }
1048
+ }
1049
+ }
1050
+
1051
+ // 2. Move change to archive
1052
+ const archiveDir = path.join(openspecDir, ARCHIVE_DIR);
1053
+ fs.mkdirSync(archiveDir, { recursive: true });
1054
+
1055
+ const timestamp = new Date().toISOString().slice(0, 10);
1056
+ const archiveName = `${timestamp}-${changeName}`;
1057
+ const archivePath = path.join(archiveDir, archiveName);
1058
+
1059
+ fs.renameSync(changePath, archivePath);
1060
+ operations.push(`Archived change to ${ARCHIVE_DIR}/${archiveName}`);
1061
+
1062
+ return { operations, archived: true };
1063
+ }
1064
+
1065
+ /**
1066
+ * Merge delta spec ADDED requirements into an existing baseline spec.
1067
+ */
1068
+ function mergeSpecToBaseline(existing: string, delta: string): string {
1069
+ // Extract ADDED requirements from delta
1070
+ const addedMatch = delta.match(
1071
+ /##\s+ADDED\s+Requirements?\s*\n([\s\S]*?)(?=\n##\s+(?:ADDED|MODIFIED|REMOVED)|$)/i,
1072
+ );
1073
+ if (!addedMatch) return existing;
1074
+
1075
+ // Find the end of the existing content (before any trailing whitespace)
1076
+ const trimmed = existing.trimEnd();
1077
+
1078
+ // Append the added requirements as regular requirements (no ADDED label)
1079
+ const addedContent = addedMatch[1].trim();
1080
+ return trimmed + "\n\n" + addedContent + "\n";
1081
+ }
1082
+
1083
+ /**
1084
+ * Convert a delta spec to baseline format.
1085
+ * Strips ADDED/MODIFIED/REMOVED section headers, keeping just requirements.
1086
+ */
1087
+ function deltaToBaseline(delta: string): string {
1088
+ // Get the title
1089
+ const titleMatch = delta.match(/^#\s+(.+)/);
1090
+ const title = titleMatch ? titleMatch[1].replace(/\s*—\s*Delta Spec$/, "") : "Spec";
1091
+
1092
+ const lines = [`# ${title}`, ""];
1093
+
1094
+ // Extract all requirements regardless of section
1095
+ const reqRe = /###\s+Requirement:\s*(.+)/g;
1096
+ let match: RegExpExecArray | null;
1097
+ const positions: number[] = [];
1098
+
1099
+ while ((match = reqRe.exec(delta)) !== null) {
1100
+ positions.push(match.index);
1101
+ }
1102
+
1103
+ for (let i = 0; i < positions.length; i++) {
1104
+ const start = positions[i];
1105
+ const end = i + 1 < positions.length ? positions[i + 1] : delta.length;
1106
+ lines.push(delta.slice(start, end).trim(), "");
1107
+ }
1108
+
1109
+ return lines.join("\n");
1110
+ }
1111
+
1112
+ // ─── Canonical Lifecycle Resolver ────────────────────────────────────────────
1113
+
1114
+ /**
1115
+ * Normalized lifecycle summary for an OpenSpec change.
1116
+ *
1117
+ * This is the single source of truth for a change's lifecycle state.
1118
+ * All consumers (status surfaces, archive gates, dashboard, design-tree)
1119
+ * should derive their display from this shape rather than recomputing
1120
+ * stage/substate/readiness independently.
1121
+ // ─── Spec Summary ────────────────────────────────────────────────────────────
1122
+
1123
+ /**
1124
+ * Count total scenarios across all spec files in a change.
1125
+ */
1126
+ export function countScenarios(specs: SpecFile[]): number {
1127
+ let count = 0;
1128
+ for (const spec of specs) {
1129
+ for (const section of spec.sections) {
1130
+ for (const req of section.requirements) {
1131
+ count += req.scenarios.length;
1132
+ }
1133
+ }
1134
+ }
1135
+ return count;
1136
+ }
1137
+
1138
+ // ─── Canonical Lifecycle Resolver ────────────────────────────────────────────
1139
+
1140
+ /**
1141
+ * Normalized lifecycle summary for an OpenSpec change.
1142
+ *
1143
+ * This is the single source of truth for a change's lifecycle state.
1144
+ * All consumers (status surfaces, archive gates, dashboard, design-tree)
1145
+ * should derive their display from this shape rather than recomputing
1146
+ * stage/substate/readiness independently.
1147
+ */
1148
+ export interface LifecycleSummary {
1149
+ /** Coarse lifecycle stage. Preserves historical stage contract. */
1150
+ stage: ChangeStage;
1151
+
1152
+ /**
1153
+ * Fine-grained verification substate. Only non-null when stage === 'verifying'.
1154
+ * Adds precision without changing the coarse stage contract.
1155
+ */
1156
+ verificationSubstate: VerificationSubstate | null;
1157
+
1158
+ /** Whether the change is safe to archive. */
1159
+ archiveReady: boolean;
1160
+
1161
+ /** Whether a design-tree binding exists for this change. */
1162
+ bindingStatus: "bound" | "unbound" | "unknown";
1163
+
1164
+ /** Total number of tasks (0 if no tasks.md). */
1165
+ totalTasks: number;
1166
+
1167
+ /** Number of completed tasks. */
1168
+ doneTasks: number;
1169
+
1170
+ /**
1171
+ * Assessment freshness. Null when no assessment record has been written.
1172
+ */
1173
+ assessmentFreshness: AssessmentFreshness | null;
1174
+
1175
+ /** Suggested next action for the operator. */
1176
+ nextAction: string | null;
1177
+
1178
+ /** Human-readable reason explaining the current substate. Null when not in verifying stage. */
1179
+ reason: string | null;
1180
+ }
1181
+
1182
+ /**
1183
+ * Canonical lifecycle resolver.
1184
+ *
1185
+ * Accepts the raw artifact state and derived assessment/reconciliation data,
1186
+ * and returns one normalized LifecycleSummary through a single implementation
1187
+ * path. All callers must route through this function — no separate stage or
1188
+ * substate derivations.
1189
+ */
1190
+ export function resolveLifecycleSummary(input: {
1191
+ change: Pick<ChangeInfo, "name" | "stage" | "totalTasks" | "doneTasks">;
1192
+ record: AssessmentRecord | null;
1193
+ freshness: AssessmentFreshness | null;
1194
+ archiveBlocked: boolean;
1195
+ archiveBlockedReason: string | null;
1196
+ archiveBlockedIssueCodes: readonly string[];
1197
+ boundNodeIds?: readonly string[];
1198
+ }): LifecycleSummary {
1199
+ const { change, record, freshness, archiveBlocked, archiveBlockedReason, archiveBlockedIssueCodes, boundNodeIds } = input;
1200
+
1201
+ // Derive verification status via existing resolveVerificationStatus — preserving
1202
+ // the historical substate contract without duplicating its logic.
1203
+ const vs = resolveVerificationStatus({
1204
+ stage: change.stage,
1205
+ record,
1206
+ freshness: freshness ?? { current: false, reasons: ["No assessment record"] },
1207
+ archiveBlocked,
1208
+ archiveBlockedReason,
1209
+ archiveBlockedIssueCodes,
1210
+ changeName: change.name,
1211
+ });
1212
+
1213
+ const archiveReady = vs.substate === "archive-ready";
1214
+
1215
+ const bindingStatus: LifecycleSummary["bindingStatus"] = archiveBlockedIssueCodes.includes("missing_design_binding")
1216
+ ? "unbound"
1217
+ : (boundNodeIds && boundNodeIds.length > 0) ? "bound" : "unknown";
1218
+
1219
+ // When stage is not "verifying" (e.g. "implementing" after a reopen appended tasks),
1220
+ // resolveVerificationStatus returns null reason/nextAction. Derive a fallback so that
1221
+ // the archive gate still surfaces a meaningful message.
1222
+ let reason = vs.reason;
1223
+ let nextAction = vs.nextAction;
1224
+ if (!archiveReady && !reason) {
1225
+ if (!record) {
1226
+ reason = "No persisted assessment record exists for this task-complete change.";
1227
+ nextAction = `/assess spec ${change.name}`;
1228
+ } else if (record.outcome === "reopen" || record.reconciliation?.reopen) {
1229
+ reason = "The latest persisted assessment reopened work.";
1230
+ nextAction = `Complete follow-up work for ${change.name}, reconcile lifecycle artifacts, then re-run /assess spec ${change.name}`;
1231
+ } else if (record.outcome === "ambiguous") {
1232
+ reason = "The latest persisted assessment is ambiguous and must be refreshed before archive.";
1233
+ nextAction = `Refresh /assess spec ${change.name} for the current implementation snapshot`;
1234
+ } else if (freshness && !freshness.current) {
1235
+ reason = freshness.reasons.join(" ");
1236
+ nextAction = `Refresh /assess spec ${change.name} for the current implementation snapshot`;
1237
+ }
1238
+ }
1239
+
1240
+ return {
1241
+ stage: change.stage,
1242
+ verificationSubstate: vs.substate,
1243
+ archiveReady,
1244
+ bindingStatus,
1245
+ totalTasks: change.totalTasks,
1246
+ doneTasks: change.doneTasks,
1247
+ assessmentFreshness: freshness,
1248
+ nextAction: nextAction ?? null,
1249
+ reason: reason ?? null,
1250
+ };
1251
+ }
1252
+
1253
+ // ─── Spec Summary ────────────────────────────────────────────────────────────
1254
+
1255
+ /**
1256
+ * Summarize a change's specs as a human-readable string.
1257
+ */
1258
+ export function summarizeSpecs(specs: SpecFile[]): string {
1259
+ if (specs.length === 0) return "No specs";
1260
+
1261
+ const domains = specs.map((s) => s.domain);
1262
+ const totalReqs = specs.reduce(
1263
+ (sum, s) => sum + s.sections.reduce(
1264
+ (sSum, sec) => sSum + sec.requirements.length, 0,
1265
+ ), 0,
1266
+ );
1267
+ const totalScenarios = countScenarios(specs);
1268
+
1269
+ return `${domains.length} domain(s), ${totalReqs} requirement(s), ${totalScenarios} scenario(s)`;
1270
+ }
1271
+
1272
+ // ─── Design Change Scanning ──────────────────────────────────────────────────
1273
+
1274
+ /**
1275
+ * Metadata about a design-phase OpenSpec change (openspec/design/<nodeId>/).
1276
+ */
1277
+ export interface DesignChangeInfo {
1278
+ nodeId: string;
1279
+ path: string;
1280
+ hasProposal: boolean;
1281
+ hasSpec: boolean;
1282
+ hasTasks: boolean;
1283
+ hasAssessment: boolean;
1284
+ assessmentPass: boolean | null;
1285
+ capturedAt: string | null;
1286
+ tasksDone: number;
1287
+ tasksTotal: number;
1288
+ isArchived: boolean;
1289
+ archivedPath?: string;
1290
+ }
1291
+
1292
+ /**
1293
+ * Scan openspec/design/ (active) and openspec/design-archive/ (archived) for
1294
+ * design-phase change directories. Returns [] when neither directory exists.
1295
+ */
1296
+ export function listDesignChanges(repoRoot: string): DesignChangeInfo[] {
1297
+ const results: DesignChangeInfo[] = [];
1298
+
1299
+ function scanDir(dir: string, archived: boolean): void {
1300
+ if (!fs.existsSync(dir)) return;
1301
+ let entries: string[];
1302
+ try {
1303
+ entries = fs.readdirSync(dir);
1304
+ } catch {
1305
+ return;
1306
+ }
1307
+ for (const entry of entries) {
1308
+ const entryPath = path.join(dir, entry);
1309
+ try {
1310
+ const stat = fs.statSync(entryPath);
1311
+ if (!stat.isDirectory()) continue;
1312
+ } catch {
1313
+ continue;
1314
+ }
1315
+
1316
+ const proposalPath = path.join(entryPath, "proposal.md");
1317
+ const specPath = path.join(entryPath, "spec.md");
1318
+ const specsDir = path.join(entryPath, "specs");
1319
+ const tasksPath = path.join(entryPath, "tasks.md");
1320
+ const assessPath = path.join(entryPath, "assessment.json");
1321
+
1322
+ const hasProposal = fs.existsSync(proposalPath);
1323
+ // Accept both flat spec.md (task-spec convention) and the standard
1324
+ // specs/ subdirectory layout used by real OpenSpec changes.
1325
+ const hasSpec = fs.existsSync(specPath) ||
1326
+ (fs.existsSync(specsDir) && fs.statSync(specsDir).isDirectory() &&
1327
+ fs.readdirSync(specsDir).some((f) => f.endsWith(".md")));
1328
+ const hasTasks = fs.existsSync(tasksPath);
1329
+ const hasAssessment = fs.existsSync(assessPath);
1330
+
1331
+ // Parse tasks.md checkbox progress
1332
+ let tasksDone = 0;
1333
+ let tasksTotal = 0;
1334
+ if (hasTasks) {
1335
+ try {
1336
+ const raw = fs.readFileSync(tasksPath, "utf8");
1337
+ const all = raw.match(/^\s*-\s+\[[ xX]\]/gm) ?? [];
1338
+ const done = raw.match(/^\s*-\s+\[[xX]\]/gm) ?? [];
1339
+ tasksTotal = all.length;
1340
+ tasksDone = done.length;
1341
+ } catch { /* leave at 0 */ }
1342
+ }
1343
+
1344
+ // Parse assessment.json
1345
+ let assessmentPass: boolean | null = null;
1346
+ let capturedAt: string | null = null;
1347
+ if (hasAssessment) {
1348
+ try {
1349
+ const raw = JSON.parse(fs.readFileSync(assessPath, "utf8")) as {
1350
+ outcome?: string;
1351
+ timestamp?: string;
1352
+ };
1353
+ // Only assign a boolean when the outcome field is actually present;
1354
+ // a malformed / empty JSON object should yield null, not false.
1355
+ assessmentPass = "outcome" in raw ? raw.outcome === "pass" : null;
1356
+ capturedAt = raw.timestamp ?? null;
1357
+ } catch { /* leave null */ }
1358
+ }
1359
+
1360
+ const info: DesignChangeInfo = {
1361
+ nodeId: entry,
1362
+ path: entryPath,
1363
+ hasProposal,
1364
+ hasSpec,
1365
+ hasTasks,
1366
+ hasAssessment,
1367
+ assessmentPass,
1368
+ capturedAt,
1369
+ tasksDone,
1370
+ tasksTotal,
1371
+ isArchived: archived,
1372
+ // archivedPath is set only for archived entries and only when the
1373
+ // archive location differs from path (i.e. if the caller passed a
1374
+ // separate archive dir). Since entryPath IS the archive dir here,
1375
+ // we intentionally omit the field to avoid redundancy.
1376
+ };
1377
+ results.push(info);
1378
+ }
1379
+ }
1380
+
1381
+ scanDir(path.join(repoRoot, "openspec", "design"), false);
1382
+ scanDir(path.join(repoRoot, "openspec", "design-archive"), true);
1383
+
1384
+ return results;
1385
+ }