omegon 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitattributes +3 -0
- package/AGENTS.md +16 -0
- package/LICENSE +15 -0
- package/README.md +289 -0
- package/bin/pi.mjs +30 -0
- package/extensions/00-secrets/index.ts +1126 -0
- package/extensions/01-auth/auth.ts +401 -0
- package/extensions/01-auth/index.ts +289 -0
- package/extensions/auto-compact.ts +42 -0
- package/extensions/bootstrap/deps.ts +291 -0
- package/extensions/bootstrap/index.ts +811 -0
- package/extensions/chronos/chronos.sh +487 -0
- package/extensions/chronos/index.ts +148 -0
- package/extensions/cleave/assessment.ts +754 -0
- package/extensions/cleave/bridge.ts +31 -0
- package/extensions/cleave/conflicts.ts +250 -0
- package/extensions/cleave/dispatcher.ts +808 -0
- package/extensions/cleave/guardrails.ts +426 -0
- package/extensions/cleave/index.ts +3121 -0
- package/extensions/cleave/lifecycle-emitter.ts +20 -0
- package/extensions/cleave/openspec.ts +811 -0
- package/extensions/cleave/planner.ts +260 -0
- package/extensions/cleave/review.ts +579 -0
- package/extensions/cleave/skills.ts +355 -0
- package/extensions/cleave/types.ts +261 -0
- package/extensions/cleave/workspace.ts +861 -0
- package/extensions/cleave/worktree.ts +243 -0
- package/extensions/core-renderers.ts +253 -0
- package/extensions/dashboard/context-gauge.ts +58 -0
- package/extensions/dashboard/file-watch.ts +14 -0
- package/extensions/dashboard/footer.ts +1145 -0
- package/extensions/dashboard/git.ts +185 -0
- package/extensions/dashboard/index.ts +478 -0
- package/extensions/dashboard/memory-audit.ts +34 -0
- package/extensions/dashboard/overlay-data.ts +705 -0
- package/extensions/dashboard/overlay.ts +365 -0
- package/extensions/dashboard/render-utils.ts +54 -0
- package/extensions/dashboard/types.ts +191 -0
- package/extensions/dashboard/uri-helper.ts +45 -0
- package/extensions/debug.ts +69 -0
- package/extensions/defaults.ts +282 -0
- package/extensions/design-tree/dashboard-state.ts +161 -0
- package/extensions/design-tree/design-card.ts +362 -0
- package/extensions/design-tree/index.ts +2130 -0
- package/extensions/design-tree/lifecycle-emitter.ts +41 -0
- package/extensions/design-tree/tree.ts +1607 -0
- package/extensions/design-tree/types.ts +163 -0
- package/extensions/distill.ts +127 -0
- package/extensions/effort/index.ts +395 -0
- package/extensions/effort/tiers.ts +146 -0
- package/extensions/effort/types.ts +105 -0
- package/extensions/lib/git-state.ts +227 -0
- package/extensions/lib/local-models.ts +157 -0
- package/extensions/lib/model-preferences.ts +51 -0
- package/extensions/lib/model-routing.ts +720 -0
- package/extensions/lib/operator-fallback.ts +205 -0
- package/extensions/lib/operator-profile.ts +360 -0
- package/extensions/lib/slash-command-bridge.ts +253 -0
- package/extensions/lib/typebox-helpers.ts +16 -0
- package/extensions/local-inference/index.ts +727 -0
- package/extensions/mcp-bridge/README.md +220 -0
- package/extensions/mcp-bridge/index.ts +951 -0
- package/extensions/mcp-bridge/lib.ts +365 -0
- package/extensions/mcp-bridge/mcp.json +3 -0
- package/extensions/mcp-bridge/package.json +11 -0
- package/extensions/model-budget.ts +752 -0
- package/extensions/offline-driver.ts +403 -0
- package/extensions/openspec/archive-gate.ts +164 -0
- package/extensions/openspec/branch-cleanup.ts +64 -0
- package/extensions/openspec/dashboard-state.ts +50 -0
- package/extensions/openspec/index.ts +1917 -0
- package/extensions/openspec/lifecycle-emitter.ts +65 -0
- package/extensions/openspec/lifecycle-files.ts +70 -0
- package/extensions/openspec/lifecycle.ts +50 -0
- package/extensions/openspec/reconcile.ts +187 -0
- package/extensions/openspec/spec.ts +1385 -0
- package/extensions/openspec/types.ts +98 -0
- package/extensions/project-memory/DESIGN-global-mind.md +198 -0
- package/extensions/project-memory/README.md +202 -0
- package/extensions/project-memory/api-types.ts +382 -0
- package/extensions/project-memory/compaction-policy.ts +29 -0
- package/extensions/project-memory/core.ts +164 -0
- package/extensions/project-memory/embeddings.ts +230 -0
- package/extensions/project-memory/extraction-v2.ts +861 -0
- package/extensions/project-memory/factstore.ts +2177 -0
- package/extensions/project-memory/index.ts +3459 -0
- package/extensions/project-memory/injection-metrics.ts +91 -0
- package/extensions/project-memory/jsonl-io.ts +12 -0
- package/extensions/project-memory/lifecycle.ts +331 -0
- package/extensions/project-memory/migration.ts +293 -0
- package/extensions/project-memory/package.json +9 -0
- package/extensions/project-memory/sci-renderers.ts +7 -0
- package/extensions/project-memory/template.ts +103 -0
- package/extensions/project-memory/triggers.ts +52 -0
- package/extensions/project-memory/types.ts +102 -0
- package/extensions/render/composition/fonts/Inter-Bold.ttf +0 -0
- package/extensions/render/composition/fonts/Inter-Regular.ttf +0 -0
- package/extensions/render/composition/fonts/Tomorrow-Bold.ttf +0 -0
- package/extensions/render/composition/fonts/Tomorrow-Regular.ttf +0 -0
- package/extensions/render/composition/package-lock.json +534 -0
- package/extensions/render/composition/package.json +22 -0
- package/extensions/render/composition/render.mjs +246 -0
- package/extensions/render/composition/test-comp.tsx +87 -0
- package/extensions/render/composition/types.ts +24 -0
- package/extensions/render/excalidraw/UPSTREAM.md +81 -0
- package/extensions/render/excalidraw/elements.ts +764 -0
- package/extensions/render/excalidraw/index.ts +66 -0
- package/extensions/render/excalidraw/types.ts +223 -0
- package/extensions/render/excalidraw-renderer/pyproject.toml +8 -0
- package/extensions/render/excalidraw-renderer/render_excalidraw.py +182 -0
- package/extensions/render/excalidraw-renderer/render_template.html +59 -0
- package/extensions/render/index.ts +830 -0
- package/extensions/render/native-diagrams/index.ts +57 -0
- package/extensions/render/native-diagrams/motifs.ts +542 -0
- package/extensions/render/native-diagrams/raster.ts +8 -0
- package/extensions/render/native-diagrams/scene.ts +75 -0
- package/extensions/render/native-diagrams/spec.ts +204 -0
- package/extensions/render/native-diagrams/svg.ts +116 -0
- package/extensions/sci-ui.ts +304 -0
- package/extensions/session-log.ts +174 -0
- package/extensions/shared-state.ts +146 -0
- package/extensions/spinner-verbs.ts +91 -0
- package/extensions/style.ts +281 -0
- package/extensions/terminal-title.ts +191 -0
- package/extensions/tool-profile/index.ts +291 -0
- package/extensions/tool-profile/profiles.ts +290 -0
- package/extensions/types.d.ts +9 -0
- package/extensions/vault/index.ts +185 -0
- package/extensions/version-check.ts +90 -0
- package/extensions/view/index.ts +859 -0
- package/extensions/view/uri-resolver.ts +148 -0
- package/extensions/web-search/index.ts +182 -0
- package/extensions/web-search/providers.ts +121 -0
- package/extensions/web-ui/index.ts +110 -0
- package/extensions/web-ui/server.ts +265 -0
- package/extensions/web-ui/state.ts +462 -0
- package/extensions/web-ui/static/index.html +145 -0
- package/extensions/web-ui/types.ts +284 -0
- package/package.json +76 -0
- package/prompts/init.md +75 -0
- package/prompts/new-repo.md +54 -0
- package/prompts/oci-login.md +56 -0
- package/prompts/status.md +50 -0
- package/settings.json +4 -0
- package/skills/cleave/SKILL.md +218 -0
- package/skills/git/SKILL.md +209 -0
- package/skills/git/_reference/ci-validation.md +204 -0
- package/skills/oci/SKILL.md +338 -0
- package/skills/openspec/SKILL.md +346 -0
- package/skills/pi-extensions/SKILL.md +191 -0
- package/skills/pi-tui/SKILL.md +517 -0
- package/skills/python/SKILL.md +189 -0
- package/skills/rust/SKILL.md +268 -0
- package/skills/security/SKILL.md +206 -0
- package/skills/style/SKILL.md +264 -0
- package/skills/typescript/SKILL.md +225 -0
- package/skills/vault/SKILL.md +102 -0
- package/themes/alpharius-legacy.json +85 -0
- package/themes/alpharius.conf +59 -0
- package/themes/alpharius.json +88 -0
|
@@ -0,0 +1,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
|
+
}
|