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,1917 @@
1
+ /**
2
+ * OpenSpec Extension
3
+ *
4
+ * The specification layer for spec-and-test-driven development.
5
+ * Manages the OpenSpec lifecycle:
6
+ *
7
+ * propose → spec → plan → implement → verify → archive
8
+ *
9
+ * Commands:
10
+ * /opsx:propose <name> <title> — Create a new change with proposal.md
11
+ * /opsx:spec <change> — Generate or edit specs for a change
12
+ * /opsx:ff <change> — Fast-forward: scaffold design.md + tasks.md from specs
13
+ * /opsx:status — Show all active changes with lifecycle stage
14
+ * /opsx:verify <change> — Check spec verification status
15
+ * /opsx:archive <change> — Archive completed change, merge specs to baseline
16
+ *
17
+ * Tools:
18
+ * openspec_manage — Agent-callable change lifecycle operations
19
+ */
20
+
21
+ import type { ExtensionAPI, ExtensionContext } from "@cwilson613/pi-coding-agent";
22
+ import { Type } from "@sinclair/typebox";
23
+ import { StringEnum } from "../lib/typebox-helpers.ts";
24
+ import { Text } from "@cwilson613/pi-tui";
25
+ import { sciCall, sciLoading, sciOk, sciErr, sciExpanded } from "../sci-ui.ts";
26
+ import { sciBanner } from "../sci-ui.ts";
27
+ import * as fs from "node:fs";
28
+ import * as path from "node:path";
29
+ import { getSharedBridge, buildSlashCommandResult, type BridgedSlashCommand, type SlashCommandExecutionContext } from "../lib/slash-command-bridge.ts";
30
+ import { shouldRefreshOpenSpecForPath } from "../dashboard/file-watch.ts";
31
+
32
+ import type { ChangeInfo } from "./types.ts";
33
+ import {
34
+ getOpenSpecDir,
35
+ listChanges,
36
+ getChange,
37
+ createChange,
38
+ addSpec,
39
+ archiveChange,
40
+ generateSpecFromProposal,
41
+ parseSpecContent,
42
+ countScenarios,
43
+ summarizeSpecs,
44
+ generateSpecFile,
45
+ computeAssessmentSnapshot,
46
+ readAssessmentRecord,
47
+ writeAssessmentRecord,
48
+ getAssessmentStatus,
49
+ type AssessmentKind,
50
+ type AssessmentOutcome,
51
+ type AssessmentRecord,
52
+ type LifecycleSummary,
53
+ } from "./spec.ts";
54
+ import { buildLifecycleSummary } from "./lifecycle.ts";
55
+ import { transitionDesignNodesOnArchive, resolveBoundDesignNodes } from "./archive-gate.ts";
56
+ import { deleteMergedBranches } from "./branch-cleanup.ts";
57
+ import { emitOpenSpecState } from "./dashboard-state.ts";
58
+ import {
59
+ applyPostAssessReconciliation,
60
+ evaluateLifecycleReconciliation,
61
+ formatReconciliationIssues,
62
+ } from "./reconcile.ts";
63
+ import { scanDesignDocs } from "../design-tree/tree.ts";
64
+ import { emitDesignTreeState } from "../design-tree/dashboard-state.ts";
65
+ import { emitArchiveCandidates, emitReconcileCandidates } from "./lifecycle-emitter.ts";
66
+ import { sharedState } from "../shared-state.ts";
67
+
68
+ interface AssessmentState {
69
+ record: AssessmentRecord | null;
70
+ status: "missing" | "current" | "stale";
71
+ reason: string;
72
+ }
73
+
74
+ // ─── Extension ───────────────────────────────────────────────────────────────
75
+
76
+ export default function openspecExtension(pi: ExtensionAPI): void {
77
+ let openspecWatcher: fs.FSWatcher | null = null;
78
+ let openspecRefreshTimer: NodeJS.Timeout | null = null;
79
+
80
+ function scheduleOpenSpecRefresh(cwd: string, filePath?: string): void {
81
+ if (filePath && !shouldRefreshOpenSpecForPath(filePath, cwd)) {
82
+ return;
83
+ }
84
+ if (openspecRefreshTimer) clearTimeout(openspecRefreshTimer);
85
+ openspecRefreshTimer = setTimeout(() => {
86
+ openspecRefreshTimer = null;
87
+ emitOpenSpecState(cwd, pi);
88
+ }, 75);
89
+ }
90
+
91
+ function startOpenSpecWatcher(cwd: string): void {
92
+ const dir = path.join(cwd, "openspec");
93
+ if (!fs.existsSync(dir)) return;
94
+ openspecWatcher?.close();
95
+ openspecWatcher = null;
96
+ try {
97
+ openspecWatcher = fs.watch(dir, { recursive: true }, (_eventType, filename) => {
98
+ const filePath = typeof filename === "string" && filename.length > 0
99
+ ? path.join(dir, filename)
100
+ : undefined;
101
+ scheduleOpenSpecRefresh(cwd, filePath);
102
+ });
103
+ } catch {
104
+ // Best effort only — unsupported platforms fall back to command/tool-driven emits.
105
+ }
106
+ }
107
+
108
+ // ─── Dashboard: emit on session start so dashboard has data immediately ───
109
+
110
+ pi.on("session_start", async (_event, ctx) => {
111
+ emitOpenSpecState(ctx.cwd, pi);
112
+ startOpenSpecWatcher(ctx.cwd);
113
+ });
114
+
115
+ // ─── Helpers ─────────────────────────────────────────────────────
116
+
117
+ function stageIcon(stage: ChangeInfo["stage"]): string {
118
+ switch (stage) {
119
+ case "proposed": return "◌";
120
+ case "specified": return "◐";
121
+ case "planned": return "▸";
122
+ case "implementing": return "⟳";
123
+ case "verifying": return "◉";
124
+ case "archived": return "✓";
125
+ }
126
+ }
127
+
128
+ function stageColor(stage: ChangeInfo["stage"]): string {
129
+ switch (stage) {
130
+ case "proposed": return "muted";
131
+ case "specified": return "accent";
132
+ case "planned": return "warning";
133
+ case "implementing": return "accent";
134
+ case "verifying": return "success";
135
+ case "archived": return "dim";
136
+ }
137
+ }
138
+
139
+ function formatChangeStatus(c: ChangeInfo): string {
140
+ const progress = c.totalTasks > 0
141
+ ? `${c.doneTasks}/${c.totalTasks} tasks`
142
+ : "no tasks";
143
+ const specSummary = c.specs.length > 0
144
+ ? ` · ${summarizeSpecs(c.specs)}`
145
+ : "";
146
+ return `${stageIcon(c.stage)} **${c.name}** (${c.stage}) — ${progress}${specSummary}`;
147
+ }
148
+
149
+ function nextStepHint(c: ChangeInfo): string {
150
+ switch (c.stage) {
151
+ case "proposed":
152
+ return `Next: \`/opsx:spec ${c.name}\` to add specifications`;
153
+ case "specified":
154
+ return `Next: \`/opsx:ff ${c.name}\` to generate design + tasks, then \`/cleave\``;
155
+ case "planned":
156
+ return `Next: \`/cleave\` to execute tasks in parallel`;
157
+ case "implementing":
158
+ return `Next: Continue implementation or \`/cleave\` remaining tasks`;
159
+ case "verifying":
160
+ return `Next: \`/assess spec ${c.name}\` then \`/opsx:archive ${c.name}\``;
161
+ case "archived":
162
+ return "Complete.";
163
+ }
164
+ }
165
+
166
+ function buildReconciliationNextSteps(
167
+ changeName: string,
168
+ assessmentKind: "spec" | "cleave",
169
+ outcome: "pass" | "reopen" | "ambiguous",
170
+ ): string[] {
171
+ switch (outcome) {
172
+ case "pass":
173
+ return [
174
+ `Run /opsx:archive ${changeName} if lifecycle artifacts are current`,
175
+ `Optionally run /opsx:verify ${changeName} for an operator-facing verification pass`,
176
+ ];
177
+ case "reopen":
178
+ return [
179
+ `Resume implementation for ${changeName} and reconcile any new follow-up task(s) in tasks.md`,
180
+ `Re-run /assess ${assessmentKind} ${changeName} after fixes`,
181
+ ];
182
+ case "ambiguous":
183
+ return [
184
+ `Review the assessment summary for ${changeName} and decide whether to reopen work or restate findings structurally`,
185
+ `If changes were made, re-run /assess ${assessmentKind} ${changeName} before archive`,
186
+ ];
187
+ default:
188
+ return [];
189
+ }
190
+ }
191
+
192
+ async function getAssessmentState(cwd: string, change: ChangeInfo): Promise<AssessmentState> {
193
+ const assessment = getAssessmentStatus(cwd, change.name);
194
+ if (!assessment.record) {
195
+ return {
196
+ record: null,
197
+ status: "missing",
198
+ reason: "No persisted assessment record found for this change.",
199
+ };
200
+ }
201
+ if (!assessment.freshness.current) {
202
+ return {
203
+ record: assessment.record,
204
+ status: "stale",
205
+ reason: "The persisted assessment does not match the current implementation snapshot.",
206
+ };
207
+ }
208
+ return {
209
+ record: assessment.record,
210
+ status: "current",
211
+ reason: "The persisted assessment matches the current implementation snapshot.",
212
+ };
213
+ }
214
+
215
+ function formatAssessmentSummary(record: AssessmentRecord): string[] {
216
+ return [
217
+ `Assessment kind: ${record.assessmentKind}`,
218
+ `Outcome: ${record.outcome}`,
219
+ `Timestamp: ${record.timestamp}`,
220
+ `Snapshot: git=${record.snapshot.gitHead ?? "detached"} fingerprint=${record.snapshot.fingerprint ? "present" : "missing"}`,
221
+ `Recommended action: ${record.reconciliation.recommendedAction ?? "none"}`,
222
+ ...(record.summary ? [`Summary: ${record.summary}`] : []),
223
+ ];
224
+ }
225
+
226
+ // getLifecycleSummary is the single shared resolver for all lifecycle surfaces.
227
+ // It is imported from lifecycle.ts so that tests can import and verify the same
228
+ // function is used by both status and get surfaces (not re-implemented locally).
229
+ const getLifecycleSummary = buildLifecycleSummary;
230
+
231
+ // ─── Tool: openspec_manage ───────────────────────────────────────
232
+
233
+ pi.registerTool({
234
+ name: "openspec_manage",
235
+ label: "Implementation",
236
+ description:
237
+ "Manage Implementation (OpenSpec) changes: create proposals, add specs, generate plans, check status, archive. " +
238
+ "The Implementation layer drives spec-driven development. For tracked changes, use design_tree_update(implement) from a decided node — this tool is for untracked/throwaway changes only.\n\n" +
239
+ "Actions:\n" +
240
+ "- status: List all active changes with lifecycle stage\n" +
241
+ "- get: Get details of a specific change\n" +
242
+ "- propose: Create a new change (name, title, intent required)\n" +
243
+ "- add_spec: Add a spec file to a change (change_name, domain, spec_content required)\n" +
244
+ "- generate_spec: Generate spec from proposal content (change_name, domain required)\n" +
245
+ "- fast_forward: Generate design.md + tasks.md from specs (change_name required)\n" +
246
+ "- archive: Archive a completed change (change_name required)",
247
+ promptSnippet:
248
+ "Manage OpenSpec lifecycle — propose changes, write specs, generate plans, verify, archive",
249
+ promptGuidelines: [
250
+ "⚠️ IMPORTANT: For tracked changes use design_tree_update(implement) from a decided node — /opsx:propose is for untracked/throwaway changes only.",
251
+ "The primary entry point for all tracked work is design_tree_update with action 'implement' on a decided design node, which scaffolds the full change directory automatically.",
252
+ "Before implementing any multi-file change, create an OpenSpec change with a proposal and specs.",
253
+ "Specs define what must be true BEFORE code is written — they are the source of truth for correctness.",
254
+ "Use 'propose' to start an untracked change, 'add_spec' or 'generate_spec' to define requirements with Given/When/Then scenarios.",
255
+ "Use 'fast_forward' to generate design.md and tasks.md from the specs, then `/cleave` to execute.",
256
+ "Treat lifecycle reconciliation as required: after implementation checkpoints, ensure tasks.md and bound design-tree state reflect reality before archive.",
257
+ "After `/assess spec` or `/assess cleave`, call `openspec_manage` with action `reconcile_after_assess` when review reopens work, changes file scope, or uncovers new constraints.",
258
+ "Archive should refuse obviously stale lifecycle state (for example incomplete tasks or no design-tree binding) until reconciliation is done.",
259
+ "After implementation, use `/assess spec` to verify specs are satisfied, then 'archive' to close the change.",
260
+ "The full lifecycle: propose → spec → fast_forward → /cleave → /assess spec → archive",
261
+ ],
262
+ parameters: Type.Object({
263
+ action: StringEnum([
264
+ "status", "get", "propose", "add_spec", "generate_spec",
265
+ "fast_forward", "archive", "reconcile_after_assess",
266
+ ] as const),
267
+ change_name: Type.Optional(Type.String({ description: "Change name/slug (for get, add_spec, generate_spec, fast_forward, archive, reconcile_after_assess)" })),
268
+ // propose params
269
+ name: Type.Optional(Type.String({ description: "Change name for propose (will be slugified)" })),
270
+ title: Type.Optional(Type.String({ description: "Change title (for propose)" })),
271
+ intent: Type.Optional(Type.String({ description: "Change intent/description (for propose)" })),
272
+ // add_spec params
273
+ domain: Type.Optional(Type.String({ description: "Spec domain name, e.g., 'auth' or 'auth/tokens' (for add_spec, generate_spec)" })),
274
+ spec_content: Type.Optional(Type.String({ description: "Raw spec markdown content (for add_spec)" })),
275
+ // generate_spec context
276
+ decisions: Type.Optional(Type.Array(
277
+ Type.Object({ title: Type.String(), rationale: Type.String() }),
278
+ { description: "Design decisions to include in generated spec (for generate_spec)" },
279
+ )),
280
+ open_questions: Type.Optional(Type.Array(Type.String(), { description: "Open questions to convert to placeholder requirements (for generate_spec)" })),
281
+ assessment_kind: Type.Optional(StringEnum(["spec", "cleave"] as const)),
282
+ outcome: Type.Optional(StringEnum(["pass", "reopen", "ambiguous"] as const)),
283
+ summary: Type.Optional(Type.String({ description: "Brief operator-facing summary of what assessment found" })),
284
+ changed_files: Type.Optional(Type.Array(Type.String(), { description: "Files touched during follow-up fixes after assessment" })),
285
+ constraints: Type.Optional(Type.Array(Type.String(), { description: "New implementation constraints discovered during assessment" })),
286
+ }),
287
+
288
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
289
+ const cwd = ctx.cwd;
290
+
291
+ switch (params.action) {
292
+ // ── status ────────────────────────────────────────────
293
+ case "status": {
294
+ const changes = listChanges(cwd);
295
+ if (changes.length === 0) {
296
+ return {
297
+ content: [{
298
+ type: "text",
299
+ text: "No active OpenSpec changes.\n\nUse openspec_manage with action 'propose' to start a new change, " +
300
+ "or `/opsx:propose <name> <title>` interactively.",
301
+ }],
302
+ details: { changes: [] },
303
+ };
304
+ }
305
+
306
+ const lines = changes.map((c) => {
307
+ const lifecycle = getLifecycleSummary(cwd, c);
308
+ const verificationLine = lifecycle.verificationSubstate
309
+ ? `\n Verification: ${lifecycle.verificationSubstate}`
310
+ : "";
311
+ const nextLine = lifecycle.nextAction
312
+ ? `\n Next: ${lifecycle.nextAction}`
313
+ : `\n ${nextStepHint(c)}`;
314
+ return `${formatChangeStatus(c)}${verificationLine}${nextLine}`;
315
+ });
316
+
317
+ return {
318
+ content: [{ type: "text", text: lines.join("\n\n") }],
319
+ details: {
320
+ changes: changes.map((c) => {
321
+ const lifecycle = getLifecycleSummary(cwd, c);
322
+ return {
323
+ name: c.name,
324
+ stage: lifecycle.stage,
325
+ verificationStage: lifecycle.stage,
326
+ verificationSubstate: lifecycle.verificationSubstate,
327
+ archiveReady: lifecycle.archiveReady,
328
+ bindingStatus: lifecycle.bindingStatus,
329
+ nextAction: lifecycle.nextAction,
330
+ totalTasks: lifecycle.totalTasks,
331
+ doneTasks: lifecycle.doneTasks,
332
+ specCount: countScenarios(c.specs),
333
+ };
334
+ }),
335
+ },
336
+ };
337
+ }
338
+
339
+ // ── get ──────────────────────────────────────────────
340
+ case "get": {
341
+ if (!params.change_name) {
342
+ return { content: [{ type: "text", text: "Error: change_name required" }], details: {}, isError: true };
343
+ }
344
+ const change = getChange(cwd, params.change_name);
345
+ if (!change) {
346
+ return { content: [{ type: "text", text: `Change '${params.change_name}' not found` }], details: {}, isError: true };
347
+ }
348
+
349
+ const lines = [
350
+ formatChangeStatus(change),
351
+ "",
352
+ `**Path:** ${change.path}`,
353
+ `**Artifacts:** ${[
354
+ change.hasProposal && "proposal.md",
355
+ change.hasDesign && "design.md",
356
+ change.hasTasks && "tasks.md",
357
+ change.hasSpecs && "specs/",
358
+ ].filter(Boolean).join(", ") || "none"}`,
359
+ ];
360
+
361
+ if (change.specs.length > 0) {
362
+ lines.push("", "**Specs:**");
363
+ for (const spec of change.specs) {
364
+ const reqs = spec.sections.flatMap((s) => s.requirements);
365
+ const scenarios = reqs.flatMap((r) => r.scenarios);
366
+ lines.push(` - ${spec.domain}: ${reqs.length} requirements, ${scenarios.length} scenarios`);
367
+ }
368
+ }
369
+
370
+ const assessmentRecord = readAssessmentRecord(cwd, change.name);
371
+ if (assessmentRecord) {
372
+ lines.push("", "**Assessment:**");
373
+ for (const line of formatAssessmentSummary(assessmentRecord)) {
374
+ lines.push(` - ${line}`);
375
+ }
376
+ }
377
+
378
+ const lifecycle = getLifecycleSummary(cwd, change);
379
+ if (lifecycle.verificationSubstate) {
380
+ lines.push("", `**Verification substate:** ${lifecycle.verificationSubstate}`);
381
+ }
382
+
383
+ lines.push("", lifecycle.nextAction ? `Next: ${lifecycle.nextAction}` : nextStepHint(change));
384
+
385
+ // Include proposal content if it exists
386
+ if (change.hasProposal) {
387
+ const proposalContent = fs.readFileSync(path.join(change.path, "proposal.md"), "utf-8");
388
+ lines.push("", "--- Proposal ---", "", proposalContent.slice(0, 4000));
389
+ }
390
+
391
+ return {
392
+ content: [{ type: "text", text: lines.join("\n") }],
393
+ details: { change: { name: change.name, stage: change.stage, specs: change.specs.length } },
394
+ };
395
+ }
396
+
397
+ // ── propose ──────────────────────────────────────────
398
+ case "propose": {
399
+ if (!params.name || !params.title || !params.intent) {
400
+ return { content: [{ type: "text", text: "Error: name, title, and intent required for propose" }], details: {}, isError: true };
401
+ }
402
+ try {
403
+ const result = createChange(cwd, params.name, params.title, params.intent);
404
+ emitOpenSpecState(cwd, pi);
405
+ return {
406
+ content: [{
407
+ type: "text",
408
+ text: `Created OpenSpec change at ${result.changePath}\n\n` +
409
+ `Files: ${result.files.join(", ")}\n\n` +
410
+ `Next: Add specs with \`openspec_manage\` action 'generate_spec' or 'add_spec', ` +
411
+ `or interactively with \`/opsx:spec ${path.basename(result.changePath)}\``,
412
+ }],
413
+ details: { changePath: result.changePath, files: result.files },
414
+ };
415
+ } catch (e) {
416
+ return { content: [{ type: "text", text: `Error: ${(e as Error).message}` }], details: {}, isError: true };
417
+ }
418
+ }
419
+
420
+ // ── add_spec ─────────────────────────────────────────
421
+ case "add_spec": {
422
+ if (!params.change_name || !params.domain || !params.spec_content) {
423
+ return { content: [{ type: "text", text: "Error: change_name, domain, and spec_content required" }], details: {}, isError: true };
424
+ }
425
+ const change = getChange(cwd, params.change_name);
426
+ if (!change) {
427
+ return { content: [{ type: "text", text: `Change '${params.change_name}' not found` }], details: {}, isError: true };
428
+ }
429
+
430
+ const specPath = addSpec(change.path, params.domain, params.spec_content);
431
+ const sections = parseSpecContent(params.spec_content);
432
+ const scenarioCount = sections.flatMap(
433
+ (s) => s.requirements.flatMap((r) => r.scenarios),
434
+ ).length;
435
+
436
+ emitOpenSpecState(cwd, pi);
437
+ return {
438
+ content: [{
439
+ type: "text",
440
+ text: `Added spec: ${specPath}\n\n` +
441
+ `Parsed: ${sections.length} section(s), ${scenarioCount} scenario(s)\n\n` +
442
+ `Next: Add more specs or use \`/opsx:ff ${params.change_name}\` to generate tasks.`,
443
+ }],
444
+ details: { specPath, sections: sections.length, scenarios: scenarioCount },
445
+ };
446
+ }
447
+
448
+ // ── generate_spec ────────────────────────────────────
449
+ case "generate_spec": {
450
+ if (!params.change_name || !params.domain) {
451
+ return { content: [{ type: "text", text: "Error: change_name and domain required" }], details: {}, isError: true };
452
+ }
453
+ const change = getChange(cwd, params.change_name);
454
+ if (!change) {
455
+ return { content: [{ type: "text", text: `Change '${params.change_name}' not found` }], details: {}, isError: true };
456
+ }
457
+
458
+ // Read proposal for context
459
+ let proposalContent = "";
460
+ if (change.hasProposal) {
461
+ proposalContent = fs.readFileSync(path.join(change.path, "proposal.md"), "utf-8");
462
+ }
463
+
464
+ const specContent = generateSpecFromProposal({
465
+ domain: params.domain,
466
+ proposalContent,
467
+ decisions: params.decisions,
468
+ openQuestions: params.open_questions,
469
+ });
470
+
471
+ const specPath = addSpec(change.path, params.domain, specContent);
472
+
473
+ emitOpenSpecState(cwd, pi);
474
+ return {
475
+ content: [{
476
+ type: "text",
477
+ text: `Generated spec: ${specPath}\n\n` +
478
+ `**This is a scaffold — refine the Given/When/Then scenarios before proceeding.**\n\n` +
479
+ `The generated scenarios are placeholders. Edit them to be specific and testable.\n\n` +
480
+ `Next: Review and refine specs, then \`/opsx:ff ${params.change_name}\` to generate tasks.`,
481
+ }],
482
+ details: { specPath, generated: true },
483
+ };
484
+ }
485
+
486
+ // ── fast_forward ─────────────────────────────────────
487
+ case "fast_forward": {
488
+ if (!params.change_name) {
489
+ return { content: [{ type: "text", text: "Error: change_name required" }], details: {}, isError: true };
490
+ }
491
+ const change = getChange(cwd, params.change_name);
492
+ if (!change) {
493
+ return { content: [{ type: "text", text: `Change '${params.change_name}' not found` }], details: {}, isError: true };
494
+ }
495
+ if (!change.hasSpecs && !change.hasProposal) {
496
+ return {
497
+ content: [{ type: "text", text: "Change has no specs or proposal. Add specs first with 'add_spec' or 'generate_spec'." }],
498
+ details: {},
499
+ isError: true,
500
+ };
501
+ }
502
+
503
+ const files: string[] = [];
504
+
505
+ // Generate design.md if not present
506
+ if (!change.hasDesign) {
507
+ const designLines = [`# ${change.name} — Design`, ""];
508
+
509
+ if (change.specs.length > 0) {
510
+ designLines.push("## Spec-Derived Architecture", "");
511
+ for (const spec of change.specs) {
512
+ designLines.push(`### ${spec.domain}`, "");
513
+ for (const section of spec.sections) {
514
+ if (section.type === "removed") continue;
515
+ for (const req of section.requirements) {
516
+ designLines.push(`- **${req.title}** (${section.type}) — ${req.scenarios.length} scenarios`);
517
+ }
518
+ }
519
+ designLines.push("");
520
+ }
521
+ }
522
+
523
+ // Read proposal for additional context
524
+ if (change.hasProposal) {
525
+ const proposal = fs.readFileSync(path.join(change.path, "proposal.md"), "utf-8");
526
+ const scopeMatch = proposal.match(/##\s+Scope\s*\n([\s\S]*?)(?=\n##\s|$)/i);
527
+ if (scopeMatch) {
528
+ designLines.push("## Scope", "", scopeMatch[1].trim(), "");
529
+ }
530
+ }
531
+
532
+ designLines.push("## File Changes", "");
533
+ designLines.push("<!-- Add file changes as you design the implementation -->", "");
534
+
535
+ fs.writeFileSync(path.join(change.path, "design.md"), designLines.join("\n"));
536
+ files.push("design.md");
537
+ }
538
+
539
+ // Generate tasks.md if not present
540
+ if (!change.hasTasks) {
541
+ const taskLines = [`# ${change.name} — Tasks`, ""];
542
+
543
+ if (change.specs.length > 0) {
544
+ // Generate task groups from spec domains/requirements
545
+ let groupNum = 1;
546
+ for (const spec of change.specs) {
547
+ for (const section of spec.sections) {
548
+ if (section.type === "removed") continue;
549
+ for (const req of section.requirements) {
550
+ taskLines.push(`## ${groupNum}. ${req.title}`, "");
551
+ // Each scenario becomes a task
552
+ let taskNum = 1;
553
+ for (const s of req.scenarios) {
554
+ taskLines.push(`- [ ] ${groupNum}.${taskNum} ${s.title}`);
555
+ taskNum++;
556
+ }
557
+ // Add a verification task
558
+ taskLines.push(`- [ ] ${groupNum}.${taskNum} Write tests for ${req.title}`);
559
+ taskLines.push("");
560
+ groupNum++;
561
+ }
562
+ }
563
+ }
564
+ } else {
565
+ taskLines.push("## 1. Implementation", "");
566
+ taskLines.push("- [ ] 1.1 Implement the proposed change", "");
567
+ }
568
+
569
+ fs.writeFileSync(path.join(change.path, "tasks.md"), taskLines.join("\n"));
570
+ files.push("tasks.md");
571
+ }
572
+
573
+ if (files.length === 0) {
574
+ return {
575
+ content: [{ type: "text", text: `design.md and tasks.md already exist for '${change.name}'. Delete them to regenerate.` }],
576
+ details: {},
577
+ };
578
+ }
579
+
580
+ emitOpenSpecState(cwd, pi);
581
+ return {
582
+ content: [{
583
+ type: "text",
584
+ text: `Fast-forwarded '${change.name}': generated ${files.join(", ")}\n\n` +
585
+ `Next: Review the generated files, then \`/cleave\` to execute tasks in parallel.`,
586
+ }],
587
+ details: { files },
588
+ };
589
+ }
590
+
591
+ // ── reconcile_after_assess ──────────────────────────
592
+ case "reconcile_after_assess": {
593
+ if (!params.change_name || !params.assessment_kind || !params.outcome) {
594
+ return {
595
+ content: [{ type: "text", text: "Error: change_name, assessment_kind, and outcome required" }],
596
+ details: {},
597
+ isError: true,
598
+ };
599
+ }
600
+
601
+ const change = getChange(cwd, params.change_name);
602
+ const result = applyPostAssessReconciliation(cwd, params.change_name, {
603
+ assessmentKind: params.assessment_kind,
604
+ outcome: params.outcome,
605
+ summary: params.summary,
606
+ changedFiles: params.changed_files,
607
+ constraints: params.constraints,
608
+ });
609
+ const snapshot = change ? computeAssessmentSnapshot(cwd, params.change_name) : null;
610
+ const assessmentPath = change && snapshot
611
+ ? writeAssessmentRecord(cwd, params.change_name, {
612
+ changeName: params.change_name,
613
+ assessmentKind: params.assessment_kind as AssessmentKind,
614
+ outcome: params.outcome as AssessmentOutcome,
615
+ timestamp: new Date().toISOString(),
616
+ summary: params.summary,
617
+ snapshot,
618
+ reconciliation: {
619
+ reopen: params.outcome === "reopen",
620
+ changedFiles: params.changed_files ?? [],
621
+ constraints: params.constraints ?? [],
622
+ recommendedAction: params.outcome === "pass" ? null : "Run openspec_manage reconcile_after_assess before archive.",
623
+ },
624
+ })
625
+ : null;
626
+
627
+ const reconcileCandidates = emitReconcileCandidates(params.change_name, params.summary, params.constraints);
628
+ if (reconcileCandidates.length > 0) {
629
+ (sharedState.lifecycleCandidateQueue ??= []).push({
630
+ source: "openspec",
631
+ context: `reconcile_after_assess for '${params.change_name}'`,
632
+ candidates: reconcileCandidates,
633
+ });
634
+ }
635
+
636
+ emitOpenSpecState(cwd, pi);
637
+ const tree = scanDesignDocs(path.join(cwd, "docs"));
638
+ emitDesignTreeState(pi, tree, null);
639
+
640
+ const lifecycleStatus = evaluateLifecycleReconciliation(cwd, params.change_name);
641
+ const nextSteps = buildReconciliationNextSteps(params.change_name, params.assessment_kind, params.outcome);
642
+ const lifecycleSignals = {
643
+ assessmentKind: params.assessment_kind,
644
+ outcome: params.outcome,
645
+ reopened: result.reopened,
646
+ archiveReady: params.outcome === "pass" && lifecycleStatus.issues.length === 0,
647
+ requiresOpenSpecReconciliation: result.updatedTaskState || result.outcome !== "pass",
648
+ requiresDesignTreeRefresh: result.updatedNodeIds.length > 0,
649
+ boundNodeIds: lifecycleStatus.boundNodeIds,
650
+ issues: lifecycleStatus.issues,
651
+ };
652
+ const observedEffects = {
653
+ filesChanged: [
654
+ ...(result.updatedTaskState ? [`openspec/changes/${params.change_name}/tasks.md`] : []),
655
+ ...result.updatedNodeIds.map((nodeId) => `docs/${nodeId}.md`),
656
+ ],
657
+ lifecycleTouched: [
658
+ "openspec",
659
+ ...(result.updatedNodeIds.length > 0 ? ["design-tree"] : []),
660
+ ],
661
+ sideEffectClass: "workspace-write",
662
+ };
663
+
664
+ const lines = [
665
+ `Post-assess reconciliation applied to '${params.change_name}'.`,
666
+ "",
667
+ `Assessment kind: ${params.assessment_kind}`,
668
+ `Outcome: ${result.outcome}`,
669
+ `Lifecycle reopened: ${result.reopened ? "yes" : "no"}`,
670
+ `Task state updated: ${result.updatedTaskState ? "yes" : "no"}`,
671
+ `Archive ready: ${lifecycleSignals.archiveReady ? "yes" : "no"}`,
672
+ ...(assessmentPath ? [`Assessment record: ${assessmentPath}`] : []),
673
+ ];
674
+ if (result.updatedNodeIds.length > 0) {
675
+ lines.push(`Updated design nodes: ${result.updatedNodeIds.join(", ")}`);
676
+ }
677
+ if (result.appendedFileScope.length > 0) {
678
+ lines.push(`Appended file-scope deltas: ${result.appendedFileScope.join(", ")}`);
679
+ }
680
+ if (result.appendedConstraints.length > 0) {
681
+ lines.push(`Appended constraints: ${result.appendedConstraints.join(" | ")}`);
682
+ }
683
+ if (lifecycleStatus.issues.length > 0) {
684
+ lines.push("", "Remaining lifecycle issues:", formatReconciliationIssues(lifecycleStatus.issues));
685
+ }
686
+ if (result.warning) {
687
+ lines.push("", `Warning: ${result.warning}`);
688
+ }
689
+ if (nextSteps.length > 0) {
690
+ lines.push("", "Next steps:", ...nextSteps.map((step) => `- ${step}`));
691
+ }
692
+
693
+ return {
694
+ content: [{ type: "text", text: lines.join("\n") }],
695
+ details: {
696
+ ...result,
697
+ assessmentPath,
698
+ lifecycleSignals,
699
+ observedEffects,
700
+ nextSteps,
701
+ reconcileCandidatesEmitted: reconcileCandidates.length,
702
+ },
703
+ };
704
+ }
705
+
706
+ // ── archive ──────────────────────────────────────────
707
+ case "archive": {
708
+ if (!params.change_name) {
709
+ return { content: [{ type: "text", text: "Error: change_name required" }], details: {}, isError: true };
710
+ }
711
+
712
+ const changeInfo = getChange(cwd, params.change_name);
713
+ if (!changeInfo) {
714
+ return {
715
+ content: [{ type: "text", text: `Change '${params.change_name}' not found` }],
716
+ details: {},
717
+ isError: true,
718
+ };
719
+ }
720
+ // Archive gate: use the canonical lifecycle resolver so that the readiness
721
+ // check here is identical to what the status/get surfaces report.
722
+ const lifecycle = getLifecycleSummary(cwd, changeInfo);
723
+ if (!lifecycle.archiveReady) {
724
+ const assessmentState = await getAssessmentState(cwd, changeInfo);
725
+ return {
726
+ content: [{
727
+ type: "text",
728
+ text: [
729
+ `Archive refused for '${params.change_name}': ${lifecycle.reason ?? lifecycle.nextAction ?? "lifecycle not ready for archive."}`,
730
+ ...(assessmentState.record ? ["", ...formatAssessmentSummary(assessmentState.record)] : []),
731
+ ].join("\n"),
732
+ }],
733
+ details: { lifecycle },
734
+ isError: true,
735
+ };
736
+ }
737
+
738
+ const result = archiveChange(cwd, params.change_name);
739
+ if (!result.archived) {
740
+ return {
741
+ content: [{ type: "text", text: result.operations.join("\n") }],
742
+ details: {},
743
+ isError: true,
744
+ };
745
+ }
746
+
747
+ if (changeInfo) {
748
+ const archiveCandidates = emitArchiveCandidates({ ...changeInfo, stage: "archived" });
749
+ if (archiveCandidates.length > 0) {
750
+ (sharedState.lifecycleCandidateQueue ??= []).push({
751
+ source: "openspec",
752
+ context: `archive for '${params.change_name}'`,
753
+ candidates: archiveCandidates,
754
+ });
755
+ result.operations.push(`Emitted ${archiveCandidates.length} lifecycle memory candidate(s)`);
756
+ }
757
+ }
758
+
759
+ // Archive gate: transition implementing → implemented in design tree
760
+ const transitioned = transitionDesignNodesOnArchive(cwd, params.change_name);
761
+ if (transitioned.length > 0) {
762
+ result.operations.push(
763
+ `Transitioned design node${transitioned.length > 1 ? "s" : ""} to implemented: ${transitioned.join(", ")}`,
764
+ );
765
+ }
766
+
767
+ // Auto-delete merged feature branches from transitioned design nodes
768
+ const allBranches = resolveBoundDesignNodes(cwd, params.change_name)
769
+ .flatMap((n) => n.branches ?? []);
770
+ if (allBranches.length > 0) {
771
+ const { deleted, skipped } = await deleteMergedBranches(pi, cwd, allBranches);
772
+ if (deleted.length > 0) {
773
+ result.operations.push(`Deleted merged branches: ${deleted.join(", ")}`);
774
+ }
775
+ if (skipped.length > 0) {
776
+ result.operations.push(`Skipped unmerged/protected branches: ${skipped.join(", ")}`);
777
+ }
778
+ }
779
+
780
+ emitOpenSpecState(cwd, pi);
781
+ return {
782
+ content: [{
783
+ type: "text",
784
+ text: `Archived '${params.change_name}':\n\n` +
785
+ result.operations.map((op) => ` - ${op}`).join("\n") +
786
+ "\n\nSpecs have been merged to baseline. Change is complete.",
787
+ }],
788
+ details: { operations: result.operations, transitionedNodes: transitioned },
789
+ };
790
+ }
791
+ }
792
+
793
+ return { content: [{ type: "text", text: "Unknown action" }], details: {} };
794
+ },
795
+
796
+ renderCall(args, theme) {
797
+ let summary = args.action as string;
798
+ switch (args.action) {
799
+ case "propose":
800
+ summary = args.name ? `propose:${args.name}` : "propose";
801
+ break;
802
+ case "add_spec":
803
+ summary = args.change_name
804
+ ? `add_spec:${args.change_name}${args.domain ? `/${args.domain}` : ""}`
805
+ : "add_spec";
806
+ break;
807
+ case "generate_spec":
808
+ summary = args.change_name
809
+ ? `generate_spec:${args.change_name}${args.domain ? `/${args.domain}` : ""}`
810
+ : "generate_spec";
811
+ break;
812
+ case "fast_forward":
813
+ case "get":
814
+ case "archive":
815
+ case "reconcile_after_assess":
816
+ summary = args.change_name ? `${args.action}:${args.change_name}` : args.action;
817
+ break;
818
+ case "status":
819
+ summary = "status";
820
+ break;
821
+ }
822
+ return sciCall("openspec_manage", summary, theme);
823
+ },
824
+
825
+ renderResult(result, { expanded, isPartial }, theme) {
826
+ if (isPartial) {
827
+ return sciLoading("openspec_manage", theme);
828
+ }
829
+
830
+ if ((result as any).isError) {
831
+ const first = result.content?.[0];
832
+ const msg = (first && "text" in first ? first.text : "Error").split("\n")[0];
833
+ return sciErr(msg, theme);
834
+ }
835
+
836
+ // Build action-specific summary for both collapsed and expanded
837
+ const details = (result.details || {}) as Record<string, any>;
838
+ let summary = "";
839
+ let expandedLines: string[] = [];
840
+
841
+ if (details.changePath) {
842
+ // propose
843
+ const name = typeof details.changePath === "string"
844
+ ? details.changePath.split("/").pop() ?? details.changePath
845
+ : "";
846
+ summary = `✓ proposed ${name}`;
847
+ expandedLines = [
848
+ theme.fg("accent", `Change: ${name}`),
849
+ theme.fg("dim", `Path: ${details.changePath}`),
850
+ ];
851
+ } else if (details.specPath !== undefined && details.sections !== undefined) {
852
+ // add_spec
853
+ const specName = typeof details.specPath === "string"
854
+ ? details.specPath.split("/").slice(-2).join("/")
855
+ : "spec";
856
+ const sections = Array.isArray(details.sections) ? details.sections : [];
857
+ summary = `✓ spec added ${specName}`;
858
+ expandedLines = [
859
+ theme.fg("accent", `Spec: ${specName}`),
860
+ ...sections.map((s: any) =>
861
+ ` ${theme.fg("muted", s.title ?? s)} ${s.requirements ? theme.fg("dim", `· ${s.requirements} req${s.requirements !== 1 ? "s" : ""}`) : ""}`,
862
+ ),
863
+ ];
864
+ } else if (details.specPath !== undefined && details.generated) {
865
+ // generate_spec
866
+ const specName = typeof details.specPath === "string"
867
+ ? details.specPath.split("/").slice(-2).join("/")
868
+ : "spec";
869
+ summary = `✓ spec generated ${specName}`;
870
+ expandedLines = [theme.fg("accent", `Generated: ${specName}`)];
871
+ } else if (details.files && !details.operations) {
872
+ // fast_forward
873
+ const files = Array.isArray(details.files) ? details.files : [];
874
+ summary = `✓ fast-forwarded (${files.join(", ")})`;
875
+ expandedLines = files.map((f: string) => ` ${theme.fg("success", "✓")} ${theme.fg("muted", f)}`);
876
+ } else if (details.operations) {
877
+ // archive
878
+ const firstContent = result.content?.[0];
879
+ const name = details.transitionedNodes !== undefined
880
+ ? ((firstContent && "text" in firstContent ? firstContent.text : "").match(/Archived '([^']+)'/)?.[1] ?? "change")
881
+ : "change";
882
+ const ops = Array.isArray(details.operations) ? details.operations : [];
883
+ summary = `✓ archived ${name}`;
884
+ expandedLines = ops.map((op: string) => ` ${theme.fg("muted", op)}`);
885
+ if (details.transitionedNodes) {
886
+ expandedLines.push(theme.fg("dim", ` Design nodes transitioned: ${details.transitionedNodes}`));
887
+ }
888
+ } else if (details.changes) {
889
+ // status
890
+ const changes = Array.isArray(details.changes) ? details.changes : [];
891
+ const count = changes.length;
892
+ summary = count === 0 ? "no active changes" : `${count} change${count !== 1 ? "s" : ""}`;
893
+ const STAGE_ICONS: Record<string, string> = {
894
+ proposed: "◌", specced: "◐", planned: "●", ready: "★", complete: "✓",
895
+ };
896
+ expandedLines = changes.map((c: any) => {
897
+ const icon = STAGE_ICONS[c.stage] ?? "·";
898
+ return ` ${theme.fg("accent", icon)} ${theme.fg("muted", c.name)} ${theme.fg("dim", `(${c.stage})`)}`;
899
+ });
900
+ } else if (details.change) {
901
+ // get
902
+ const c = details.change;
903
+ const name = c?.name ?? "";
904
+ const stage = c?.stage ?? "";
905
+ summary = `${name} (${stage})`;
906
+ const STAGE_ICONS: Record<string, string> = {
907
+ proposed: "◌", specced: "◐", planned: "●", ready: "★", complete: "✓",
908
+ };
909
+ const icon = STAGE_ICONS[stage] ?? "·";
910
+ expandedLines = [
911
+ `${theme.fg("accent", icon)} ${theme.fg("muted", name)} ${theme.fg("dim", stage)}`,
912
+ ];
913
+ if (c.specs && Array.isArray(c.specs)) {
914
+ expandedLines.push(theme.fg("dim", ` Specs: ${c.specs.length}`));
915
+ for (const s of c.specs.slice(0, 5)) {
916
+ expandedLines.push(` ${theme.fg("muted", typeof s === "string" ? s : s.domain ?? s.path ?? "")}`);
917
+ }
918
+ }
919
+ } else if (details.reconcileCandidatesEmitted !== undefined) {
920
+ // reconcile_after_assess
921
+ const changeName = details.changeName
922
+ ?? ((result.content?.[0] && "text" in result.content[0]
923
+ ? result.content[0].text : "").match(/reconciliation applied to '([^']+)'/)?.[1]
924
+ ?? "change");
925
+ const outcome = details.lifecycleSignals?.outcome ?? "";
926
+ summary = `✓ reconciled ${changeName}${outcome ? ` (${outcome})` : ""}`;
927
+ } else {
928
+ const first = result.content?.[0];
929
+ summary = (first && "text" in first ? first.text?.split("\n")[0] : null) || "done";
930
+ }
931
+
932
+ if (expanded && expandedLines.length > 0) {
933
+ return sciExpanded(expandedLines, summary, theme);
934
+ }
935
+
936
+ if (expanded) {
937
+ // Fallback: raw text
938
+ const first = result.content?.[0];
939
+ const full = (first && "text" in first ? first.text : null) || "Done";
940
+ const lines = full.split("\n");
941
+ return sciExpanded(lines, summary, theme);
942
+ }
943
+
944
+ return sciOk(summary, theme);
945
+ },
946
+ });
947
+
948
+ // ─── Bridged Commands ────────────────────────────────────────────────────
949
+
950
+ const bridge = getSharedBridge();
951
+
952
+ bridge.register(pi, {
953
+ name: "opsx:propose",
954
+ description: "Create a new untracked OpenSpec change: /opsx:propose <name> <title>. For tracked work, use design_tree_update(implement) from a decided node instead.",
955
+ bridge: {
956
+ agentCallable: true,
957
+ sideEffectClass: "workspace-write",
958
+ },
959
+ structuredExecutor: async (args: string, ctx: SlashCommandExecutionContext) => {
960
+ const trimmedArgs = (args || "").trim();
961
+
962
+ if (ctx.bridgeInvocation) {
963
+ // When called via bridge, args are JSON-encoded to preserve boundaries
964
+ let parsedArgs: string[];
965
+ try {
966
+ parsedArgs = JSON.parse(trimmedArgs);
967
+ } catch (e) {
968
+ return buildSlashCommandResult("opsx:propose", [], {
969
+ ok: false,
970
+ summary: "Bridge argument parsing error",
971
+ humanText: "Error: Invalid argument format from bridge",
972
+ effects: { sideEffectClass: "workspace-write" },
973
+ });
974
+ }
975
+
976
+ const [name, title, intent] = parsedArgs;
977
+
978
+ if (!name) {
979
+ return buildSlashCommandResult("opsx:propose", parsedArgs, {
980
+ ok: false,
981
+ summary: "Usage: /opsx:propose <name> <title> <intent>",
982
+ humanText: "Error: name required for propose",
983
+ effects: { sideEffectClass: "workspace-write" },
984
+ });
985
+ }
986
+
987
+ const finalTitle = title || name;
988
+ const finalIntent = intent || "";
989
+
990
+ try {
991
+ const result = createChange(ctx.cwd, name, finalTitle, finalIntent);
992
+ emitOpenSpecState(ctx.cwd, pi);
993
+
994
+ return buildSlashCommandResult("opsx:propose", [name, finalTitle, finalIntent], {
995
+ ok: true,
996
+ summary: `Created OpenSpec change: ${path.basename(result.changePath)}`,
997
+ humanText: `Created: ${result.changePath}\n\nNext: Add specs with \`/opsx:spec ${path.basename(result.changePath)}\` ` +
998
+ `or use \`openspec_manage\` with action \`generate_spec\``,
999
+ data: { changePath: result.changePath, files: result.files },
1000
+ effects: {
1001
+ sideEffectClass: "workspace-write",
1002
+ filesChanged: result.files.map(f => path.join(result.changePath, f)),
1003
+ lifecycleTouched: ["openspec"],
1004
+ },
1005
+ nextSteps: [
1006
+ { label: "Add specs", command: `/opsx:spec ${path.basename(result.changePath)}` },
1007
+ { label: "Generate specs", rationale: "Use openspec_manage with action generate_spec" },
1008
+ ],
1009
+ });
1010
+ } catch (e) {
1011
+ return buildSlashCommandResult("opsx:propose", [name, finalTitle, finalIntent], {
1012
+ ok: false,
1013
+ summary: `Error: ${(e as Error).message}`,
1014
+ humanText: `Error: ${(e as Error).message}`,
1015
+ effects: { sideEffectClass: "workspace-write" },
1016
+ });
1017
+ }
1018
+ } else {
1019
+ // Interactive path - parse name and title, then prompt for intent if needed
1020
+ const parts = trimmedArgs.split(/\s+/);
1021
+ const name = parts[0];
1022
+ const title = parts.slice(1).join(" ");
1023
+
1024
+ if (!name) {
1025
+ return buildSlashCommandResult("opsx:propose", [name, title].filter(Boolean), {
1026
+ ok: false,
1027
+ summary: "Usage: /opsx:propose <name> <title>",
1028
+ humanText: "Error: name required for propose",
1029
+ effects: { sideEffectClass: "workspace-write" },
1030
+ });
1031
+ }
1032
+
1033
+ const finalTitle = title || name;
1034
+ // Interactive prompting for intent will be handled in interactiveHandler
1035
+ // For now, use empty string and let handler prompt if needed
1036
+ const intent = "";
1037
+
1038
+ try {
1039
+ const result = createChange(ctx.cwd, name, finalTitle, intent);
1040
+ emitOpenSpecState(ctx.cwd, pi);
1041
+
1042
+ pi.sendMessage({
1043
+ customType: "openspec-created",
1044
+ content: `Created OpenSpec change \`${path.basename(result.changePath)}\`.\n\n` +
1045
+ `Next step: Define specs with \`/opsx:spec ${path.basename(result.changePath)}\` ` +
1046
+ `or use \`openspec_manage\` with action \`generate_spec\` to scaffold Given/When/Then scenarios.`,
1047
+ display: true,
1048
+ }, { triggerTurn: false });
1049
+
1050
+ return buildSlashCommandResult("opsx:propose", [name, finalTitle, intent], {
1051
+ ok: true,
1052
+ summary: `Created OpenSpec change: ${path.basename(result.changePath)}`,
1053
+ humanText: `Created: ${result.changePath}\n\nNext: Add specs with \`/opsx:spec ${path.basename(result.changePath)}\` ` +
1054
+ `or use \`openspec_manage\` with action \`generate_spec\``,
1055
+ data: { changePath: result.changePath, files: result.files },
1056
+ effects: {
1057
+ sideEffectClass: "workspace-write",
1058
+ filesChanged: result.files.map(f => path.join(result.changePath, f)),
1059
+ lifecycleTouched: ["openspec"],
1060
+ },
1061
+ nextSteps: [
1062
+ { label: "Add specs", command: `/opsx:spec ${path.basename(result.changePath)}` },
1063
+ { label: "Generate specs", rationale: "Use openspec_manage with action generate_spec" },
1064
+ ],
1065
+ });
1066
+ } catch (e) {
1067
+ return buildSlashCommandResult("opsx:propose", [name, finalTitle, intent], {
1068
+ ok: false,
1069
+ summary: `Error: ${(e as Error).message}`,
1070
+ humanText: `Error: ${(e as Error).message}`,
1071
+ effects: { sideEffectClass: "workspace-write" },
1072
+ });
1073
+ }
1074
+ }
1075
+ },
1076
+ interactiveHandler: async (result, args, ctx) => {
1077
+ if (!result.ok) {
1078
+ ctx.ui.notify(result.humanText, "error");
1079
+ return;
1080
+ }
1081
+
1082
+ // Check if we need to prompt for intent
1083
+ const trimmedArgs = (args || "").trim();
1084
+ const parts = trimmedArgs.split(/\s+/);
1085
+ const name = parts[0];
1086
+ const title = parts.slice(1).join(" ");
1087
+
1088
+ if (name && !title) {
1089
+ // Only name provided, prompt for title and intent
1090
+ const titleInput = await ctx.ui.input("Enter change title:");
1091
+ if (!titleInput) {
1092
+ ctx.ui.notify("Change creation cancelled", "warning");
1093
+ return;
1094
+ }
1095
+
1096
+ const intentInput = await ctx.ui.input("Enter change intent (what this change accomplishes):");
1097
+ if (!intentInput) {
1098
+ ctx.ui.notify("Change creation cancelled", "warning");
1099
+ return;
1100
+ }
1101
+
1102
+ try {
1103
+ const newResult = createChange(ctx.cwd, name, titleInput, intentInput);
1104
+ emitOpenSpecState(ctx.cwd, pi);
1105
+ ctx.ui.notify(`Created OpenSpec change: ${path.basename(newResult.changePath)}`, "info");
1106
+ } catch (e) {
1107
+ ctx.ui.notify(`Error: ${(e as Error).message}`, "error");
1108
+ }
1109
+ } else if (name && title) {
1110
+ // Change was already created by structuredExecutor with empty intent.
1111
+ // Prompt for intent and patch proposal.md — do NOT call createChange again.
1112
+ const intentInput = await ctx.ui.input("Enter change intent (what this change accomplishes):");
1113
+ const changeData = result.data as { changePath?: string } | undefined;
1114
+ if (intentInput && changeData?.changePath) {
1115
+ try {
1116
+ const proposalPath = path.join(changeData.changePath, "proposal.md");
1117
+ if (fs.existsSync(proposalPath)) {
1118
+ const current = fs.readFileSync(proposalPath, "utf-8");
1119
+ fs.writeFileSync(proposalPath, current.replace(/^## Intent\n[\s\S]*?(?=\n##|$)/m, `## Intent\n${intentInput}\n`));
1120
+ }
1121
+ emitOpenSpecState(ctx.cwd, pi);
1122
+ ctx.ui.notify(`Created OpenSpec change: ${path.basename(changeData.changePath)}`, "info");
1123
+ } catch (e) {
1124
+ ctx.ui.notify(`Error updating intent: ${(e as Error).message}`, "warning");
1125
+ ctx.ui.notify(result.humanText, "info");
1126
+ }
1127
+ } else {
1128
+ // Use the result we already have (with empty intent)
1129
+ ctx.ui.notify(result.humanText, "info");
1130
+ }
1131
+ } else {
1132
+ // All arguments provided or error case
1133
+ ctx.ui.notify(result.humanText, result.ok ? "info" : "error");
1134
+ }
1135
+ },
1136
+ } satisfies BridgedSlashCommand);
1137
+
1138
+ bridge.register(pi, {
1139
+ name: "opsx:spec",
1140
+ description: "Generate or add specs for a change: /opsx:spec <change>",
1141
+ bridge: {
1142
+ agentCallable: true,
1143
+ sideEffectClass: "workspace-write",
1144
+ },
1145
+ structuredExecutor: async (args: string, ctx: SlashCommandExecutionContext) => {
1146
+ let changeName: string;
1147
+ if (ctx.bridgeInvocation) {
1148
+ try {
1149
+ const parsedArgs = JSON.parse((args || "").trim());
1150
+ changeName = parsedArgs[0] || "";
1151
+ } catch (e) {
1152
+ changeName = (args || "").trim();
1153
+ }
1154
+ } else {
1155
+ changeName = (args || "").trim();
1156
+ }
1157
+
1158
+ if (!changeName) {
1159
+ return buildSlashCommandResult("opsx:spec", [], {
1160
+ ok: false,
1161
+ summary: "Usage: /opsx:spec <change-name>",
1162
+ humanText: "Error: change-name required",
1163
+ effects: { sideEffectClass: "workspace-write" },
1164
+ });
1165
+ }
1166
+
1167
+ const change = getChange(ctx.cwd, changeName);
1168
+ if (!change) {
1169
+ return buildSlashCommandResult("opsx:spec", [changeName], {
1170
+ ok: false,
1171
+ summary: `Change '${changeName}' not found`,
1172
+ humanText: `Change '${changeName}' not found`,
1173
+ effects: { sideEffectClass: "workspace-write" },
1174
+ });
1175
+ }
1176
+
1177
+ let proposalContent = "";
1178
+ if (change.hasProposal) {
1179
+ proposalContent = fs.readFileSync(path.join(change.path, "proposal.md"), "utf-8");
1180
+ }
1181
+
1182
+ // Actually generate specs instead of just requesting them
1183
+ try {
1184
+ if (!change.hasProposal) {
1185
+ return buildSlashCommandResult("opsx:spec", [changeName], {
1186
+ ok: false,
1187
+ summary: "No proposal found",
1188
+ humanText: `Change '${changeName}' has no proposal. Run /opsx:propose first.`,
1189
+ effects: { sideEffectClass: "workspace-write" },
1190
+ nextSteps: [
1191
+ { label: "Create proposal", command: `/opsx:propose ${changeName}` },
1192
+ ],
1193
+ });
1194
+ }
1195
+
1196
+ // Generate a default spec from the proposal
1197
+ const specContent = generateSpecFromProposal({
1198
+ domain: "core",
1199
+ proposalContent,
1200
+ });
1201
+
1202
+ // Ensure specs directory exists
1203
+ const specsDir = path.join(change.path, "specs");
1204
+ fs.mkdirSync(specsDir, { recursive: true });
1205
+
1206
+ // Write the generated spec
1207
+ const specFilePath = path.join(specsDir, "core.md");
1208
+ fs.writeFileSync(specFilePath, specContent);
1209
+
1210
+ emitOpenSpecState(ctx.cwd, pi);
1211
+
1212
+ const content = [
1213
+ `Generated spec file: specs/core.md`,
1214
+ "",
1215
+ change.hasProposal ? `Based on proposal content from proposal.md` : "Generated default spec structure.",
1216
+ "",
1217
+ "Edit the spec to add more specific Given/When/Then scenarios.",
1218
+ "Each scenario should be specific and testable.",
1219
+ ].join("\n");
1220
+
1221
+ if (!ctx.bridgeInvocation) {
1222
+ pi.sendMessage({
1223
+ customType: "openspec-spec-generated",
1224
+ content: `Generated spec for \`${changeName}\`:\n\n${content}`,
1225
+ display: true,
1226
+ }, { triggerTurn: false });
1227
+ }
1228
+
1229
+ return buildSlashCommandResult("opsx:spec", [changeName], {
1230
+ ok: true,
1231
+ summary: `Generated spec for '${changeName}'`,
1232
+ humanText: content,
1233
+ data: {
1234
+ changeName,
1235
+ specFilePath: path.relative(ctx.cwd, specFilePath),
1236
+ hasProposal: change.hasProposal,
1237
+ generatedContent: specContent.slice(0, 1000)
1238
+ },
1239
+ effects: {
1240
+ sideEffectClass: "workspace-write",
1241
+ filesChanged: [path.relative(ctx.cwd, specFilePath)],
1242
+ lifecycleTouched: ["openspec"],
1243
+ },
1244
+ nextSteps: [
1245
+ { label: "Review and edit spec", command: `edit ${path.relative(ctx.cwd, specFilePath)}` },
1246
+ { label: "Generate design and tasks", command: `/opsx:ff ${changeName}` },
1247
+ ],
1248
+ });
1249
+ } catch (e) {
1250
+ return buildSlashCommandResult("opsx:spec", [changeName], {
1251
+ ok: false,
1252
+ summary: `Error generating spec: ${(e as Error).message}`,
1253
+ humanText: `Error generating spec: ${(e as Error).message}`,
1254
+ effects: { sideEffectClass: "workspace-write" },
1255
+ });
1256
+ }
1257
+ },
1258
+ // No agentHandler needed - the structuredExecutor does the work
1259
+ } satisfies BridgedSlashCommand);
1260
+
1261
+ bridge.register(pi, {
1262
+ name: "opsx:ff",
1263
+ description: "Fast-forward: generate design + tasks from specs: /opsx:ff <change>",
1264
+ bridge: {
1265
+ agentCallable: true,
1266
+ sideEffectClass: "workspace-write",
1267
+ },
1268
+ structuredExecutor: async (args: string, ctx: SlashCommandExecutionContext) => {
1269
+ let changeName: string;
1270
+ if (ctx.bridgeInvocation) {
1271
+ try {
1272
+ const parsedArgs = JSON.parse((args || "").trim());
1273
+ changeName = parsedArgs[0] || "";
1274
+ } catch (e) {
1275
+ changeName = (args || "").trim();
1276
+ }
1277
+ } else {
1278
+ changeName = (args || "").trim();
1279
+ }
1280
+
1281
+ if (!changeName) {
1282
+ return buildSlashCommandResult("opsx:ff", [], {
1283
+ ok: false,
1284
+ summary: "Usage: /opsx:ff <change-name>",
1285
+ humanText: "Error: change-name required",
1286
+ effects: { sideEffectClass: "workspace-write" },
1287
+ });
1288
+ }
1289
+
1290
+ const change = getChange(ctx.cwd, changeName);
1291
+ if (!change) {
1292
+ return buildSlashCommandResult("opsx:ff", [changeName], {
1293
+ ok: false,
1294
+ summary: `Change '${changeName}' not found`,
1295
+ humanText: `Change '${changeName}' not found`,
1296
+ effects: { sideEffectClass: "workspace-write" },
1297
+ });
1298
+ }
1299
+
1300
+ if (!change.hasSpecs && !change.hasProposal) {
1301
+ return buildSlashCommandResult("opsx:ff", [changeName], {
1302
+ ok: false,
1303
+ summary: "Change has no specs or proposal",
1304
+ humanText: "Change has no specs or proposal. Run /opsx:spec first.",
1305
+ effects: { sideEffectClass: "workspace-write" },
1306
+ nextSteps: [
1307
+ { label: "Add specs", command: `/opsx:spec ${changeName}` },
1308
+ ],
1309
+ });
1310
+ }
1311
+
1312
+ const files: string[] = [];
1313
+
1314
+ // Generate design.md if not present
1315
+ if (!change.hasDesign) {
1316
+ const designLines = [`# ${change.name} — Design`, ""];
1317
+
1318
+ if (change.specs.length > 0) {
1319
+ designLines.push("## Spec-Derived Architecture", "");
1320
+ for (const spec of change.specs) {
1321
+ designLines.push(`### ${spec.domain}`, "");
1322
+ for (const section of spec.sections) {
1323
+ if (section.type === "removed") continue;
1324
+ for (const req of section.requirements) {
1325
+ designLines.push(`- **${req.title}** (${section.type}) — ${req.scenarios.length} scenarios`);
1326
+ }
1327
+ }
1328
+ designLines.push("");
1329
+ }
1330
+ }
1331
+
1332
+ // Read proposal for additional context
1333
+ if (change.hasProposal) {
1334
+ const proposal = fs.readFileSync(path.join(change.path, "proposal.md"), "utf-8");
1335
+ const scopeMatch = proposal.match(/##\s+Scope\s*\n([\s\S]*?)(?=\n##\s|$)/i);
1336
+ if (scopeMatch) {
1337
+ designLines.push("## Scope", "", scopeMatch[1].trim(), "");
1338
+ }
1339
+ }
1340
+
1341
+ designLines.push("## File Changes", "");
1342
+ designLines.push("<!-- Add file changes as you design the implementation -->", "");
1343
+
1344
+ fs.writeFileSync(path.join(change.path, "design.md"), designLines.join("\n"));
1345
+ files.push("design.md");
1346
+ }
1347
+
1348
+ // Generate tasks.md if not present
1349
+ if (!change.hasTasks) {
1350
+ const taskLines = [`# ${change.name} — Tasks`, ""];
1351
+
1352
+ if (change.specs.length > 0) {
1353
+ // Generate task groups from spec domains/requirements
1354
+ let groupNum = 1;
1355
+ for (const spec of change.specs) {
1356
+ for (const section of spec.sections) {
1357
+ if (section.type === "removed") continue;
1358
+ for (const req of section.requirements) {
1359
+ taskLines.push(`## ${groupNum}. ${req.title}`, "");
1360
+ // Each scenario becomes a task
1361
+ let taskNum = 1;
1362
+ for (const s of req.scenarios) {
1363
+ taskLines.push(`- [ ] ${groupNum}.${taskNum} ${s.title}`);
1364
+ taskNum++;
1365
+ }
1366
+ // Add a verification task
1367
+ taskLines.push(`- [ ] ${groupNum}.${taskNum} Write tests for ${req.title}`);
1368
+ taskLines.push("");
1369
+ groupNum++;
1370
+ }
1371
+ }
1372
+ }
1373
+ } else {
1374
+ taskLines.push("## 1. Implementation", "");
1375
+ taskLines.push("- [ ] 1.1 Implement the proposed change", "");
1376
+ }
1377
+
1378
+ fs.writeFileSync(path.join(change.path, "tasks.md"), taskLines.join("\n"));
1379
+ files.push("tasks.md");
1380
+ }
1381
+
1382
+ if (files.length === 0) {
1383
+ return buildSlashCommandResult("opsx:ff", [changeName], {
1384
+ ok: false,
1385
+ summary: "design.md and tasks.md already exist",
1386
+ humanText: `design.md and tasks.md already exist for '${changeName}'. Delete them to regenerate.`,
1387
+ effects: { sideEffectClass: "workspace-write" },
1388
+ });
1389
+ }
1390
+
1391
+ emitOpenSpecState(ctx.cwd, pi);
1392
+
1393
+ // Read the generated content to include in the response
1394
+ const generatedContent: { [filename: string]: string } = {};
1395
+ for (const filename of files) {
1396
+ const filePath = path.join(change.path, filename);
1397
+ generatedContent[filename] = fs.readFileSync(filePath, "utf-8");
1398
+ }
1399
+
1400
+ const content = [
1401
+ `Generated files for '${changeName}':`,
1402
+ "",
1403
+ ...files.map(f => `- ${f}`),
1404
+ "",
1405
+ "Files are ready for review and implementation.",
1406
+ "Next: Review the generated tasks and run `/cleave` to execute them.",
1407
+ ].join("\n");
1408
+
1409
+ if (!ctx.bridgeInvocation) {
1410
+ pi.sendMessage({
1411
+ customType: "openspec-ff-complete",
1412
+ content: `Generated design and tasks for \`${changeName}\`:\n\n${content}`,
1413
+ display: true,
1414
+ }, { triggerTurn: false });
1415
+ }
1416
+
1417
+ return buildSlashCommandResult("opsx:ff", [changeName], {
1418
+ ok: true,
1419
+ summary: `Fast-forwarded '${changeName}': generated ${files.join(", ")}`,
1420
+ humanText: content,
1421
+ data: { files, changeName, generatedContent },
1422
+ effects: {
1423
+ sideEffectClass: "workspace-write",
1424
+ filesChanged: files.map(f => path.join(change.path, f)),
1425
+ lifecycleTouched: ["openspec"],
1426
+ },
1427
+ nextSteps: [
1428
+ { label: "Review files", rationale: "Check generated design.md and tasks.md" },
1429
+ { label: "Execute tasks", command: "/cleave" },
1430
+ ],
1431
+ });
1432
+ },
1433
+ // No agentHandler needed - the structuredExecutor returns complete information
1434
+ } satisfies BridgedSlashCommand);
1435
+
1436
+ bridge.register(pi, {
1437
+ name: "opsx:status",
1438
+ description: "Show all active OpenSpec changes",
1439
+ bridge: {
1440
+ agentCallable: true,
1441
+ sideEffectClass: "read",
1442
+ },
1443
+ structuredExecutor: async (args: string, ctx: SlashCommandExecutionContext) => {
1444
+ const changes = listChanges(ctx.cwd);
1445
+ if (changes.length === 0) {
1446
+ return buildSlashCommandResult("opsx:status", [], {
1447
+ ok: true,
1448
+ summary: "No active OpenSpec changes",
1449
+ humanText: "No active OpenSpec changes. Use /opsx:propose to create one.",
1450
+ data: { changes: [] },
1451
+ effects: { sideEffectClass: "read" },
1452
+ nextSteps: [
1453
+ { label: "Create change", command: "/opsx:propose", rationale: "Start a new OpenSpec change" },
1454
+ ],
1455
+ });
1456
+ }
1457
+
1458
+ const lines = changes.map((c) => {
1459
+ const lifecycle = getLifecycleSummary(ctx.cwd, c);
1460
+ const verificationLine = lifecycle.verificationSubstate ? `\n Verification: ${lifecycle.verificationSubstate}` : "";
1461
+ const nextLine = lifecycle.nextAction ? `\n → ${lifecycle.nextAction}` : `\n → ${nextStepHint(c)}`;
1462
+ return `${formatChangeStatus(c)}${verificationLine}${nextLine}`;
1463
+ });
1464
+
1465
+ return buildSlashCommandResult("opsx:status", [], {
1466
+ ok: true,
1467
+ summary: "OpenSpec changes status",
1468
+ humanText: lines.join("\n\n"),
1469
+ data: {
1470
+ changes: changes.map((c) => {
1471
+ const lifecycle = getLifecycleSummary(ctx.cwd, c);
1472
+ return {
1473
+ name: c.name,
1474
+ stage: lifecycle.stage,
1475
+ verificationStage: lifecycle.stage,
1476
+ verificationSubstate: lifecycle.verificationSubstate,
1477
+ archiveReady: lifecycle.archiveReady,
1478
+ bindingStatus: lifecycle.bindingStatus,
1479
+ nextAction: lifecycle.nextAction,
1480
+ totalTasks: lifecycle.totalTasks,
1481
+ doneTasks: lifecycle.doneTasks,
1482
+ specCount: countScenarios(c.specs),
1483
+ };
1484
+ }),
1485
+ },
1486
+ effects: { sideEffectClass: "read" },
1487
+ });
1488
+ },
1489
+ } satisfies BridgedSlashCommand);
1490
+
1491
+ bridge.register(pi, {
1492
+ name: "opsx:verify",
1493
+ description: "Check verification status of a change: /opsx:verify <change>",
1494
+ bridge: {
1495
+ agentCallable: true,
1496
+ sideEffectClass: "read",
1497
+ },
1498
+ structuredExecutor: async (args: string, ctx: SlashCommandExecutionContext) => {
1499
+ let changeName: string;
1500
+ if (ctx.bridgeInvocation) {
1501
+ try {
1502
+ const parsedArgs = JSON.parse((args || "").trim());
1503
+ changeName = parsedArgs[0] || "";
1504
+ } catch (e) {
1505
+ changeName = (args || "").trim();
1506
+ }
1507
+ } else {
1508
+ changeName = (args || "").trim();
1509
+ }
1510
+
1511
+ if (!changeName) {
1512
+ return buildSlashCommandResult("opsx:verify", [], {
1513
+ ok: false,
1514
+ summary: "Usage: /opsx:verify <change-name>",
1515
+ humanText: "Usage: /opsx:verify <change-name>",
1516
+ effects: { sideEffectClass: "read" },
1517
+ });
1518
+ }
1519
+
1520
+ const change = getChange(ctx.cwd, changeName);
1521
+ if (!change) {
1522
+ return buildSlashCommandResult("opsx:verify", [changeName], {
1523
+ ok: false,
1524
+ summary: `Change '${changeName}' not found`,
1525
+ humanText: `Change '${changeName}' not found`,
1526
+ effects: { sideEffectClass: "read" },
1527
+ });
1528
+ }
1529
+
1530
+ if (!change.hasSpecs) {
1531
+ return buildSlashCommandResult("opsx:verify", [changeName], {
1532
+ ok: false,
1533
+ summary: `Change '${changeName}' has no specs to verify against`,
1534
+ humanText: `Change '${changeName}' has no specs to verify against`,
1535
+ effects: { sideEffectClass: "read" },
1536
+ nextSteps: [
1537
+ { label: "Add specs", command: `/opsx:spec ${changeName}` },
1538
+ ],
1539
+ });
1540
+ }
1541
+
1542
+ const assessmentState = await getAssessmentState(ctx.cwd, change);
1543
+ const lifecycle = getLifecycleSummary(ctx.cwd, change);
1544
+ const effectiveSubstate = lifecycle.verificationSubstate
1545
+ ?? (assessmentState.record?.outcome === "reopen" ? "reopened-work" : null);
1546
+ const effectiveReason: string | null = lifecycle.reason
1547
+ ?? (effectiveSubstate === "reopened-work" ? "The latest persisted assessment reopened work." : null);
1548
+ const effectiveNextAction = lifecycle.nextAction
1549
+ ?? (effectiveSubstate === "reopened-work"
1550
+ ? `Complete follow-up work for ${changeName}, reconcile lifecycle artifacts, then re-run /assess spec ${changeName}`
1551
+ : null);
1552
+
1553
+ if (effectiveSubstate === "archive-ready" && assessmentState.record) {
1554
+ const summaryLines = [
1555
+ `Verification state for '${changeName}': ${effectiveSubstate}`,
1556
+ ...(effectiveReason ? [`Why: ${effectiveReason}`] : []),
1557
+ ...(effectiveNextAction ? [`Next: ${effectiveNextAction}`] : []),
1558
+ "",
1559
+ ...formatAssessmentSummary(assessmentState.record),
1560
+ ];
1561
+
1562
+ return buildSlashCommandResult("opsx:verify", [changeName], {
1563
+ ok: true,
1564
+ summary: `Archive ready: ${changeName}`,
1565
+ humanText: summaryLines.join("\n"),
1566
+ data: {
1567
+ changeName,
1568
+ substate: effectiveSubstate,
1569
+ reason: effectiveReason,
1570
+ nextAction: effectiveNextAction,
1571
+ assessment: assessmentState.record,
1572
+ archiveReady: true,
1573
+ },
1574
+ effects: { sideEffectClass: "read" },
1575
+ nextSteps: effectiveNextAction ? [{ label: effectiveNextAction }] : [],
1576
+ });
1577
+ }
1578
+
1579
+ if ((effectiveSubstate === "reopened-work" || effectiveSubstate === "missing-binding" || effectiveSubstate === "awaiting-reconciliation") && assessmentState.record) {
1580
+ const summaryLines = [
1581
+ `Verification state for '${changeName}': ${effectiveSubstate}`,
1582
+ ...(effectiveReason ? [`Why: ${effectiveReason}`] : []),
1583
+ ...(effectiveNextAction ? [`Next: ${effectiveNextAction}`] : []),
1584
+ "",
1585
+ ...formatAssessmentSummary(assessmentState.record),
1586
+ ];
1587
+
1588
+ return buildSlashCommandResult("opsx:verify", [changeName], {
1589
+ ok: false,
1590
+ summary: `Verification blocked: ${changeName}`,
1591
+ humanText: summaryLines.join("\n"),
1592
+ data: {
1593
+ changeName,
1594
+ substate: effectiveSubstate,
1595
+ reason: effectiveReason,
1596
+ nextAction: effectiveNextAction,
1597
+ assessment: assessmentState.record,
1598
+ archiveReady: false,
1599
+ },
1600
+ effects: { sideEffectClass: "read" },
1601
+ nextSteps: effectiveNextAction ? [{ label: effectiveNextAction }] : [],
1602
+ });
1603
+ }
1604
+
1605
+ const refreshReason = assessmentState.status === "missing"
1606
+ ? "No persisted assessment exists yet."
1607
+ : effectiveReason ?? assessmentState.reason;
1608
+
1609
+ const content = [
1610
+ `[OpenSpec: Verify \`${changeName}\`]`,
1611
+ "",
1612
+ `Verification state: ${effectiveSubstate ?? lifecycle.verificationSubstate ?? change.stage}`,
1613
+ ...(effectiveReason ? [effectiveReason, ""] : []),
1614
+ `${refreshReason}`,
1615
+ "",
1616
+ `Run \`/assess spec ${changeName}\` now and persist the resulting structured lifecycle state by calling \`openspec_manage\` with action \`reconcile_after_assess\`, change_name \`${changeName}\`, assessment_kind \`spec\`, and the appropriate outcome.`,
1617
+ "",
1618
+ "If the assessment passes cleanly, persist outcome `pass`. If it reopens work, persist `reopen`. If the reviewer cannot determine status safely, persist `ambiguous`.",
1619
+ "",
1620
+ `After persistence, archive remains gated until the current assessment for \`${changeName}\` explicitly passes.`,
1621
+ ].join("\n");
1622
+
1623
+ if (!ctx.bridgeInvocation) {
1624
+ pi.sendMessage({
1625
+ customType: "openspec-verify",
1626
+ content,
1627
+ display: true,
1628
+ }, { triggerTurn: true });
1629
+ }
1630
+
1631
+ return buildSlashCommandResult("opsx:verify", [changeName], {
1632
+ ok: true,
1633
+ summary: `Verification assessment needed for '${changeName}'`,
1634
+ humanText: content,
1635
+ data: {
1636
+ changeName,
1637
+ substate: effectiveSubstate ?? lifecycle.verificationSubstate ?? change.stage,
1638
+ reason: refreshReason,
1639
+ nextAction: `/assess spec ${changeName}`,
1640
+ assessment: assessmentState.record,
1641
+ archiveReady: false,
1642
+ },
1643
+ effects: { sideEffectClass: "read" },
1644
+ nextSteps: [
1645
+ { label: "Run assessment", command: `/assess spec ${changeName}`, rationale: "Verify specs against implementation" },
1646
+ ],
1647
+ });
1648
+ },
1649
+ interactiveHandler: async (result, args, ctx) => {
1650
+ const data = result.data as any;
1651
+ if (data && data.archiveReady && result.ok) {
1652
+ ctx.ui.notify(result.humanText, "info");
1653
+ } else if (data && !data.archiveReady && data.substate && (data.substate === "reopened-work" || data.substate === "missing-binding" || data.substate === "awaiting-reconciliation")) {
1654
+ ctx.ui.notify(result.humanText, "warning");
1655
+ } else if (result.ok) {
1656
+ // Trigger agent message for assessment requests
1657
+ return;
1658
+ } else {
1659
+ ctx.ui.notify(result.humanText, "warning");
1660
+ }
1661
+ },
1662
+ agentHandler: async (result, _args, _ctx) => {
1663
+ const archiveReady = result.data && typeof result.data === 'object' &&
1664
+ 'archiveReady' in result.data ? (result.data as { archiveReady: boolean }).archiveReady : false;
1665
+ if (result.ok && result.humanText && !archiveReady) {
1666
+ pi.sendMessage({
1667
+ customType: "openspec-verify",
1668
+ content: result.humanText,
1669
+ display: true,
1670
+ }, { triggerTurn: true });
1671
+ }
1672
+ },
1673
+ } satisfies BridgedSlashCommand);
1674
+
1675
+ bridge.register(pi, {
1676
+ name: "opsx:archive",
1677
+ description: "Archive a completed change: /opsx:archive <change>",
1678
+ bridge: {
1679
+ agentCallable: true,
1680
+ sideEffectClass: "workspace-write",
1681
+ },
1682
+ structuredExecutor: async (args: string, ctx: SlashCommandExecutionContext) => {
1683
+ let changeName: string;
1684
+ if (ctx.bridgeInvocation) {
1685
+ try {
1686
+ const parsedArgs = JSON.parse((args || "").trim());
1687
+ changeName = parsedArgs[0] || "";
1688
+ } catch (e) {
1689
+ changeName = (args || "").trim();
1690
+ }
1691
+ } else {
1692
+ changeName = (args || "").trim();
1693
+ }
1694
+
1695
+ if (!changeName) {
1696
+ return buildSlashCommandResult("opsx:archive", [], {
1697
+ ok: false,
1698
+ summary: "Usage: /opsx:archive <change-name>",
1699
+ humanText: "Usage: /opsx:archive <change-name>",
1700
+ effects: { sideEffectClass: "workspace-write" },
1701
+ });
1702
+ }
1703
+
1704
+ const changeInfo = getChange(ctx.cwd, changeName);
1705
+ if (!changeInfo) {
1706
+ return buildSlashCommandResult("opsx:archive", [changeName], {
1707
+ ok: false,
1708
+ summary: `Change '${changeName}' not found`,
1709
+ humanText: `Change '${changeName}' not found`,
1710
+ effects: { sideEffectClass: "workspace-write" },
1711
+ });
1712
+ }
1713
+
1714
+ // Archive gate: use the canonical lifecycle resolver so that readiness
1715
+ // reported here is identical to what the status/get surfaces show.
1716
+ const lifecycle = getLifecycleSummary(ctx.cwd, changeInfo);
1717
+ if (!lifecycle.archiveReady) {
1718
+ const assessmentState = await getAssessmentState(ctx.cwd, changeInfo);
1719
+ const message = [
1720
+ `Archive refused for '${changeName}': ${lifecycle.reason ?? lifecycle.nextAction ?? "lifecycle not ready for archive."}`,
1721
+ ...(assessmentState.record ? ["", ...formatAssessmentSummary(assessmentState.record)] : []),
1722
+ ].join("\n");
1723
+
1724
+ return buildSlashCommandResult("opsx:archive", [changeName], {
1725
+ ok: false,
1726
+ summary: "Archive refused: lifecycle not ready",
1727
+ humanText: message,
1728
+ data: { lifecycle, gateRefusal: true },
1729
+ effects: { sideEffectClass: "workspace-write" },
1730
+ nextSteps: [
1731
+ { label: "Run verification", command: `/opsx:verify ${changeName}`, rationale: "Refresh assessment to unblock archive" },
1732
+ ],
1733
+ });
1734
+ }
1735
+
1736
+ const result = archiveChange(ctx.cwd, changeName);
1737
+ if (!result.archived) {
1738
+ return buildSlashCommandResult("opsx:archive", [changeName], {
1739
+ ok: false,
1740
+ summary: "Archive failed",
1741
+ humanText: result.operations.join("\n"),
1742
+ effects: { sideEffectClass: "workspace-write" },
1743
+ });
1744
+ }
1745
+
1746
+ if (changeInfo) {
1747
+ const archiveCandidates = emitArchiveCandidates({ ...changeInfo, stage: "archived" });
1748
+ if (archiveCandidates.length > 0) {
1749
+ (sharedState.lifecycleCandidateQueue ??= []).push({
1750
+ source: "openspec",
1751
+ context: `archive for '${changeName}'`,
1752
+ candidates: archiveCandidates,
1753
+ });
1754
+ result.operations.push(`Emitted ${archiveCandidates.length} lifecycle memory candidate(s)`);
1755
+ }
1756
+ }
1757
+
1758
+ // Archive gate: transition implementing → implemented in design tree
1759
+ const transitioned = transitionDesignNodesOnArchive(ctx.cwd, changeName);
1760
+ if (transitioned.length > 0) {
1761
+ result.operations.push(
1762
+ `Transitioned design node${transitioned.length > 1 ? "s" : ""} to implemented: ${transitioned.join(", ")}`,
1763
+ );
1764
+ }
1765
+
1766
+ // Auto-delete merged feature branches from transitioned design nodes
1767
+ const allBranches = resolveBoundDesignNodes(ctx.cwd, changeName)
1768
+ .flatMap((n) => n.branches ?? []);
1769
+ if (allBranches.length > 0) {
1770
+ const { deleted, skipped } = await deleteMergedBranches(pi, ctx.cwd, allBranches);
1771
+ if (deleted.length > 0) {
1772
+ result.operations.push(`Deleted merged branches: ${deleted.join(", ")}`);
1773
+ }
1774
+ if (skipped.length > 0) {
1775
+ result.operations.push(`Skipped unmerged/protected branches: ${skipped.join(", ")}`);
1776
+ }
1777
+ }
1778
+
1779
+ emitOpenSpecState(ctx.cwd, pi);
1780
+
1781
+ const summaryText = `Archived '${changeName}':\n${result.operations.map((op) => ` - ${op}`).join("\n")}`;
1782
+
1783
+ return buildSlashCommandResult("opsx:archive", [changeName], {
1784
+ ok: true,
1785
+ summary: `Archived '${changeName}'`,
1786
+ humanText: summaryText,
1787
+ data: { operations: result.operations, transitionedNodes: transitioned },
1788
+ effects: {
1789
+ sideEffectClass: "workspace-write",
1790
+ filesChanged: [`openspec/archive/${changeName}`],
1791
+ lifecycleTouched: ["openspec", ...(transitioned.length > 0 ? ["design-tree"] : [])],
1792
+ },
1793
+ nextSteps: [
1794
+ { label: "Change complete", rationale: "Specs merged to baseline" },
1795
+ ],
1796
+ });
1797
+ },
1798
+ interactiveHandler: async (result, args, ctx) => {
1799
+ if (result.ok) {
1800
+ ctx.ui.notify(result.humanText, "info");
1801
+ } else {
1802
+ ctx.ui.notify(result.humanText, "warning");
1803
+ }
1804
+ },
1805
+ } satisfies BridgedSlashCommand);
1806
+
1807
+ bridge.register(pi, {
1808
+ name: "opsx:apply",
1809
+ description: "Continue implementing a change (delegates to /cleave)",
1810
+ bridge: {
1811
+ agentCallable: true,
1812
+ sideEffectClass: "workspace-write",
1813
+ },
1814
+ structuredExecutor: async (args: string, ctx: SlashCommandExecutionContext) => {
1815
+ let changeName: string;
1816
+ if (ctx.bridgeInvocation) {
1817
+ try {
1818
+ const parsedArgs = JSON.parse((args || "").trim());
1819
+ changeName = parsedArgs[0] || "";
1820
+ } catch (e) {
1821
+ changeName = (args || "").trim();
1822
+ }
1823
+ } else {
1824
+ changeName = (args || "").trim();
1825
+ }
1826
+
1827
+ if (!changeName) {
1828
+ return buildSlashCommandResult("opsx:apply", [], {
1829
+ ok: false,
1830
+ summary: "Usage: /opsx:apply <change-name>",
1831
+ humanText: "Error: change-name required",
1832
+ effects: { sideEffectClass: "workspace-write" },
1833
+ });
1834
+ }
1835
+
1836
+ const change = getChange(ctx.cwd, changeName);
1837
+ if (!change) {
1838
+ return buildSlashCommandResult("opsx:apply", [changeName], {
1839
+ ok: false,
1840
+ summary: `Change '${changeName}' not found`,
1841
+ humanText: `Change '${changeName}' not found`,
1842
+ effects: { sideEffectClass: "workspace-write" },
1843
+ });
1844
+ }
1845
+
1846
+ if (!change.hasTasks) {
1847
+ return buildSlashCommandResult("opsx:apply", [changeName], {
1848
+ ok: false,
1849
+ summary: `Change '${changeName}' has no tasks`,
1850
+ humanText: `Change '${changeName}' has no tasks. Run /opsx:ff first.`,
1851
+ effects: { sideEffectClass: "workspace-write" },
1852
+ nextSteps: [
1853
+ { label: "Generate tasks", command: `/opsx:ff ${changeName}` },
1854
+ ],
1855
+ });
1856
+ }
1857
+
1858
+ const content = [
1859
+ `[OpenSpec: Apply \`${changeName}\`]`,
1860
+ "",
1861
+ `Continue implementing \`${changeName}\` — ${change.doneTasks}/${change.totalTasks} tasks done.`,
1862
+ "",
1863
+ "Use `/cleave` to parallelize remaining tasks, or work on them directly.",
1864
+ ].join("\n");
1865
+
1866
+ if (!ctx.bridgeInvocation) {
1867
+ pi.sendMessage({
1868
+ customType: "openspec-apply",
1869
+ content,
1870
+ display: true,
1871
+ }, { triggerTurn: true });
1872
+ }
1873
+
1874
+ return buildSlashCommandResult("opsx:apply", [changeName], {
1875
+ ok: true,
1876
+ summary: `Apply requested for '${changeName}' (${change.doneTasks}/${change.totalTasks} tasks done)`,
1877
+ humanText: content,
1878
+ data: { changeName, doneTasks: change.doneTasks, totalTasks: change.totalTasks },
1879
+ effects: { sideEffectClass: "workspace-write" },
1880
+ nextSteps: [
1881
+ { label: "Parallelize tasks", command: "/cleave", rationale: "Execute remaining tasks in parallel" },
1882
+ { label: "Work directly", rationale: "Continue implementation manually" },
1883
+ ],
1884
+ });
1885
+ },
1886
+ agentHandler: async (result, _args, _ctx) => {
1887
+ if (result.ok && result.humanText) {
1888
+ pi.sendMessage({
1889
+ customType: "openspec-apply",
1890
+ content: result.humanText,
1891
+ display: true,
1892
+ }, { triggerTurn: true });
1893
+ }
1894
+ },
1895
+ } satisfies BridgedSlashCommand);
1896
+
1897
+ // ─── Message Renderers ───────────────────────────────────────────
1898
+
1899
+ pi.registerMessageRenderer("openspec-created", (message, _options, theme) => {
1900
+ const content = (message.content as string) || "";
1901
+ return sciBanner("◎", "openspec:created", [content.split("\n")[0]], theme);
1902
+ });
1903
+
1904
+ pi.registerMessageRenderer("openspec-status", (message, _options, theme) => {
1905
+ const content = (message.content as string) || "";
1906
+ const lines = content.split("\n").filter(Boolean).slice(0, 6);
1907
+ return sciBanner("◎", "openspec:status", lines, theme);
1908
+ });
1909
+
1910
+ for (const type of ["openspec-spec-request", "openspec-ff-request", "openspec-verify", "openspec-apply"]) {
1911
+ pi.registerMessageRenderer(type, (message, _options, theme) => {
1912
+ const lines = ((message.content as string) || "").split("\n");
1913
+ const title = lines[0] || "";
1914
+ return sciBanner("◎", type.replace("openspec-", "openspec:"), [title], theme);
1915
+ });
1916
+ }
1917
+ }