pi-openspec 1.0.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/src/index.ts ADDED
@@ -0,0 +1,778 @@
1
+ /**
2
+ * Pi OpenSpec Extension
3
+ *
4
+ * Wraps the OpenSpec CLI into pi /openspec commands with:
5
+ * - Auto-naming: NNNNN-<date>-<slug> for change directories
6
+ * - Number shorthand: /openspec status 3 → resolves to full name
7
+ * - Session-start detection of openspec projects
8
+ * - Auto-refresh hooks on spec file edits
9
+ *
10
+ * Commands:
11
+ * /openspec new <name> — create a new change
12
+ * /openspec status [id] — show artifact status
13
+ * /openspec next [id] [artifact] — get instructions for next artifact
14
+ * /openspec validate [id] — validate a change
15
+ * /openspec archive [id] — archive a completed change
16
+ * /openspec list — list all changes
17
+ * /openspec list --specs — list all specs
18
+ * /openspec init — initialize openspec in project
19
+ * /openspec schemas — list available schemas
20
+ */
21
+
22
+ import * as fs from "node:fs"
23
+ import * as path from "node:path"
24
+ import { execSync } from "node:child_process"
25
+ import type {
26
+ ExtensionAPI,
27
+ ExtensionCommandContext,
28
+ } from "@earendil-works/pi-coding-agent"
29
+ import type { AutocompleteItem } from "@earendil-works/pi-tui"
30
+
31
+ // ───────────────────────────────────────────────────────────────────
32
+ // Types
33
+ // ───────────────────────────────────────────────────────────────────
34
+
35
+ interface ChangeEntry {
36
+ name: string
37
+ completedTasks: number
38
+ totalTasks: number
39
+ lastModified: string
40
+ status: string
41
+ }
42
+
43
+ interface ArtifactInfo {
44
+ id: string
45
+ outputPath: string
46
+ status: string
47
+ missingDeps?: string[]
48
+ }
49
+
50
+ interface StatusResult {
51
+ changeName: string
52
+ schemaName: string
53
+ isComplete: boolean
54
+ artifacts: ArtifactInfo[]
55
+ nextSteps: string[]
56
+ }
57
+
58
+ // ───────────────────────────────────────────────────────────────────
59
+ // Constants
60
+ // ───────────────────────────────────────────────────────────────────
61
+
62
+ // Matches: NNNNN-YYYY-MM-DD-slug-name
63
+ const NAMED_RE = /^(\d{5})-(\d{4}-\d{2}-\d{2})-(.+)$/
64
+
65
+ const ARTIFACT_COLORS: Record<string, string> = {
66
+ done: "✅",
67
+ ready: "🔵",
68
+ blocked: "⏳",
69
+ }
70
+
71
+ // ───────────────────────────────────────────────────────────────────
72
+ // Helpers
73
+ // ───────────────────────────────────────────────────────────────────
74
+
75
+ function changesDir(cwd: string): string {
76
+ return path.join(cwd, "openspec", "changes")
77
+ }
78
+
79
+ function hasOpenSpec(cwd: string): boolean {
80
+ return fs.existsSync(path.join(cwd, "openspec"))
81
+ }
82
+
83
+ function today(): string {
84
+ return new Date().toISOString().slice(0, 10)
85
+ }
86
+
87
+ function toSlug(name: string): string {
88
+ return name
89
+ .trim()
90
+ .toLowerCase()
91
+ .replace(/\s+/g, "-")
92
+ .replace(/[^a-z0-9-]/g, "")
93
+ .replace(/-+/g, "-")
94
+ .replace(/^-|-$/g, "")
95
+ }
96
+
97
+ /**
98
+ * Scan existing change directories and return sorted list.
99
+ * Supports both our NNNNN-date-name format and plain openspec names.
100
+ */
101
+ function scanChanges(cwd: string): string[] {
102
+ const dir = changesDir(cwd)
103
+ if (!fs.existsSync(dir)) return []
104
+ return fs
105
+ .readdirSync(dir)
106
+ .filter((entry) => {
107
+ const full = path.join(dir, entry)
108
+ return fs.statSync(full).isDirectory()
109
+ })
110
+ .sort()
111
+ }
112
+
113
+ /**
114
+ * Extract the numeric prefix from a NNNNN-date-name directory.
115
+ * Returns 0 for directories without our naming scheme.
116
+ */
117
+ function extractNumber(dirname: string): number {
118
+ const m = NAMED_RE.exec(dirname)
119
+ return m ? parseInt(m[1], 10) : 0
120
+ }
121
+
122
+ /**
123
+ * Get the next sequential number across all changes.
124
+ */
125
+ function nextNumber(cwd: string): number {
126
+ const changes = scanChanges(cwd)
127
+ if (changes.length === 0) return 1
128
+ const max = Math.max(...changes.map(extractNumber))
129
+ return max + 1
130
+ }
131
+
132
+ /**
133
+ * Resolve a user-provided identifier to a change directory name.
134
+ * Accepts:
135
+ * - A number (e.g. "3") → finds change with that prefix
136
+ * - A full name (e.g. "00003-2026-06-04-add-auth")
137
+ * - A partial slug (e.g. "add-auth") → fuzzy match
138
+ */
139
+ function resolveChange(cwd: string, id: string): string | null {
140
+ const changes = scanChanges(cwd)
141
+ if (changes.length === 0) return null
142
+
143
+ const trimmed = id.trim()
144
+
145
+ // Try numeric lookup
146
+ const num = parseInt(trimmed, 10)
147
+ if (!Number.isNaN(num) && String(num) === trimmed) {
148
+ const padded = String(num).padStart(5, "0")
149
+ const match = changes.find((c) => c.startsWith(padded + "-"))
150
+ if (match) return match
151
+ }
152
+
153
+ // Exact match
154
+ if (changes.includes(trimmed)) return trimmed
155
+
156
+ // Suffix/substring match
157
+ const lower = trimmed.toLowerCase()
158
+ const suffixMatch = changes.find((c) =>
159
+ c.toLowerCase().endsWith("-" + lower),
160
+ )
161
+ if (suffixMatch) return suffixMatch
162
+
163
+ const substringMatch = changes.find((c) => c.toLowerCase().includes(lower))
164
+ if (substringMatch) return substringMatch
165
+
166
+ return null
167
+ }
168
+
169
+ /**
170
+ * Run an openspec CLI command and return stdout.
171
+ * Throws on non-zero exit.
172
+ */
173
+ function run(cwd: string, args: string, opts?: { json?: boolean }): string {
174
+ const jsonFlag = opts?.json ? " --json" : ""
175
+ const cmd = `openspec ${args}${jsonFlag}`
176
+ try {
177
+ return execSync(cmd, {
178
+ cwd,
179
+ encoding: "utf8",
180
+ timeout: 30_000,
181
+ stdio: ["pipe", "pipe", "pipe"],
182
+ }).trim()
183
+ } catch (err: unknown) {
184
+ const e = err as { stderr?: string; message?: string }
185
+ throw new Error(
186
+ e.stderr?.trim() || e.message || "openspec command failed",
187
+ { cause: err },
188
+ )
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Run openspec and parse JSON output.
194
+ */
195
+ function runJSON<T>(cwd: string, args: string): T {
196
+ const raw = run(cwd, args, { json: true })
197
+ return JSON.parse(raw) as T
198
+ }
199
+
200
+ /**
201
+ * Find the most recent or only active change.
202
+ * Returns null if zero or multiple changes exist.
203
+ */
204
+ function inferActiveChange(cwd: string): string | null {
205
+ const changes = scanChanges(cwd)
206
+ if (changes.length === 1) return changes[0]
207
+ if (changes.length === 0) return null
208
+
209
+ // Multiple changes — pick most recently modified
210
+ const dir = changesDir(cwd)
211
+ let latest = ""
212
+ let latestTime = 0
213
+ for (const name of changes) {
214
+ const stat = fs.statSync(path.join(dir, name))
215
+ if (stat.mtimeMs > latestTime) {
216
+ latestTime = stat.mtimeMs
217
+ latest = name
218
+ }
219
+ }
220
+ return latest || null
221
+ }
222
+
223
+ // ───────────────────────────────────────────────────────────────────
224
+ // Formatting helpers
225
+ // ───────────────────────────────────────────────────────────────────
226
+
227
+ function formatStatus(status: StatusResult): string {
228
+ const lines: string[] = []
229
+ const icon = status.isComplete ? "✅" : "🔄"
230
+ lines.push(`${icon} **${status.changeName}** (${status.schemaName})`)
231
+ lines.push("")
232
+
233
+ // Artifact table
234
+ lines.push("| Artifact | Status | Dependencies |")
235
+ lines.push("|----------|--------|--------------|")
236
+ for (const a of status.artifacts) {
237
+ const statusIcon = ARTIFACT_COLORS[a.status] ?? a.status
238
+ const deps = a.missingDeps?.join(", ") ?? "—"
239
+ lines.push(`| ${a.id} | ${statusIcon} ${a.status} | ${deps} |`)
240
+ }
241
+
242
+ if (status.nextSteps.length > 0) {
243
+ lines.push("")
244
+ lines.push("**Next steps:**")
245
+ for (const step of status.nextSteps) {
246
+ lines.push(`- ${step}`)
247
+ }
248
+ }
249
+
250
+ return lines.join("\n")
251
+ }
252
+
253
+ function formatChangeList(changes: ChangeEntry[], _cwd: string): string {
254
+ if (changes.length === 0) {
255
+ return "No active changes. Create one with `/openspec new <name>`"
256
+ }
257
+
258
+ const lines: string[] = []
259
+ lines.push("| # | Change | Tasks | Status | Modified |")
260
+ lines.push("|---|--------|-------|--------|----------|")
261
+
262
+ for (const c of changes) {
263
+ const m = NAMED_RE.exec(c.name)
264
+ const num = m ? m[1] : "—"
265
+ const tasks =
266
+ c.totalTasks > 0 ? `${c.completedTasks}/${c.totalTasks}` : "—"
267
+ const date = c.lastModified.slice(0, 10)
268
+ lines.push(`| ${num} | ${c.name} | ${tasks} | ${c.status} | ${date} |`)
269
+ }
270
+
271
+ return lines.join("\n")
272
+ }
273
+
274
+ // ───────────────────────────────────────────────────────────────────
275
+ // Subcommand handlers
276
+ // ───────────────────────────────────────────────────────────────────
277
+
278
+ async function handleNew(
279
+ name: string,
280
+ ctx: ExtensionCommandContext,
281
+ pi: ExtensionAPI,
282
+ ): Promise<void> {
283
+ if (!hasOpenSpec(ctx.cwd)) {
284
+ ctx.ui.notify(
285
+ "No openspec/ directory found. Run `/openspec init` first.",
286
+ "error",
287
+ )
288
+ return
289
+ }
290
+
291
+ const cleaned = name.replace(/^["']|["']$/g, "").trim()
292
+ if (!cleaned) {
293
+ // No name — ask the agent to infer one
294
+ const num = nextNumber(ctx.cwd)
295
+ const padded = String(num).padStart(5, "0")
296
+ pi.sendUserMessage(
297
+ `The user ran \`/openspec new\` with no name. ` +
298
+ `Infer a concise change name from the current conversation ` +
299
+ `context.\n\n` +
300
+ `Then run: \`/openspec new <inferred-name>\`\n\n` +
301
+ `Next number: **${padded}**, today: ${today()}`,
302
+ )
303
+ return
304
+ }
305
+
306
+ const slug = toSlug(cleaned)
307
+ if (!slug) {
308
+ ctx.ui.notify(
309
+ "Name produced an empty slug — use letters and numbers.",
310
+ "error",
311
+ )
312
+ return
313
+ }
314
+
315
+ const num = nextNumber(ctx.cwd)
316
+ const padded = String(num).padStart(5, "0")
317
+ const fullName = `${padded}-${today()}-${slug}`
318
+
319
+ try {
320
+ run(ctx.cwd, `new change "${fullName}"`)
321
+ ctx.ui.notify(`✅ Created change: ${fullName}`, "info")
322
+
323
+ // Feed the agent instructions for the first artifact
324
+ pi.sendUserMessage(
325
+ `New OpenSpec change **${fullName}** created.\n\n` +
326
+ `The spec-driven workflow is: ` +
327
+ `proposal → specs → design → tasks.\n\n` +
328
+ `The first artifact to write is **proposal**. ` +
329
+ `Run \`/openspec next ${num} proposal\` to get ` +
330
+ `enriched instructions, or start writing ` +
331
+ `\`openspec/changes/${fullName}/proposal.md\` ` +
332
+ `directly.`,
333
+ )
334
+ } catch (err: unknown) {
335
+ const msg = err instanceof Error ? err.message : String(err)
336
+ ctx.ui.notify(`❌ Failed to create change: ${msg}`, "error")
337
+ }
338
+ }
339
+
340
+ async function handleStatus(
341
+ arg: string,
342
+ ctx: ExtensionCommandContext,
343
+ ): Promise<void> {
344
+ if (!hasOpenSpec(ctx.cwd)) {
345
+ ctx.ui.notify(
346
+ "No openspec/ directory found. Run `/openspec init` first.",
347
+ "error",
348
+ )
349
+ return
350
+ }
351
+
352
+ const trimmed = arg.trim()
353
+ const changeName = trimmed
354
+ ? resolveChange(ctx.cwd, trimmed)
355
+ : inferActiveChange(ctx.cwd)
356
+
357
+ if (!changeName) {
358
+ if (trimmed) {
359
+ ctx.ui.notify(
360
+ `Change "${trimmed}" not found. ` +
361
+ `Run \`/openspec list\` to see available changes.`,
362
+ "error",
363
+ )
364
+ } else {
365
+ ctx.ui.notify(
366
+ "No active changes found. " +
367
+ "Create one with `/openspec new <name>`.",
368
+ "info",
369
+ )
370
+ }
371
+ return
372
+ }
373
+
374
+ try {
375
+ const result = runJSON<StatusResult>(
376
+ ctx.cwd,
377
+ `status --change "${changeName}"`,
378
+ )
379
+ ctx.ui.notify(formatStatus(result), "info")
380
+ } catch (err: unknown) {
381
+ const msg = err instanceof Error ? err.message : String(err)
382
+ ctx.ui.notify(`❌ Status failed: ${msg}`, "error")
383
+ }
384
+ }
385
+
386
+ async function handleNext(
387
+ arg: string,
388
+ ctx: ExtensionCommandContext,
389
+ pi: ExtensionAPI,
390
+ ): Promise<void> {
391
+ if (!hasOpenSpec(ctx.cwd)) {
392
+ ctx.ui.notify(
393
+ "No openspec/ directory found. Run `/openspec init` first.",
394
+ "error",
395
+ )
396
+ return
397
+ }
398
+
399
+ const parts = arg.trim().split(/\s+/)
400
+ const idPart = parts[0] ?? ""
401
+ const artifactPart = parts[1] ?? ""
402
+
403
+ // Resolve the change
404
+ const changeName = idPart
405
+ ? resolveChange(ctx.cwd, idPart)
406
+ : inferActiveChange(ctx.cwd)
407
+
408
+ if (!changeName) {
409
+ if (idPart) {
410
+ ctx.ui.notify(`Change "${idPart}" not found.`, "error")
411
+ } else {
412
+ ctx.ui.notify(
413
+ "No active changes. Create one with `/openspec new <name>`.",
414
+ "info",
415
+ )
416
+ }
417
+ return
418
+ }
419
+
420
+ // If no artifact specified, find the next ready one
421
+ let artifact = artifactPart
422
+ if (!artifact) {
423
+ try {
424
+ const status = runJSON<StatusResult>(
425
+ ctx.cwd,
426
+ `status --change "${changeName}"`,
427
+ )
428
+ const ready = status.artifacts.find((a) => a.status === "ready")
429
+ if (ready) {
430
+ artifact = ready.id
431
+ } else if (status.isComplete) {
432
+ ctx.ui.notify(
433
+ `✅ All artifacts for **${changeName}** are complete. ` +
434
+ `Run \`/openspec archive ${changeName}\` ` +
435
+ `when ready.`,
436
+ "info",
437
+ )
438
+ return
439
+ } else {
440
+ ctx.ui.notify(
441
+ `No ready artifacts for **${changeName}**. ` +
442
+ `Check status with \`/openspec status\`.`,
443
+ "warning",
444
+ )
445
+ return
446
+ }
447
+ } catch {
448
+ ctx.ui.notify(
449
+ "Could not determine next artifact. " +
450
+ "Specify one: proposal, specs, design, tasks",
451
+ "warning",
452
+ )
453
+ return
454
+ }
455
+ }
456
+
457
+ try {
458
+ const result = runJSON<{
459
+ instruction: string
460
+ template: string
461
+ resolvedOutputPath: string
462
+ description: string
463
+ unlocks: string[]
464
+ }>(ctx.cwd, `instructions ${artifact} --change "${changeName}"`)
465
+
466
+ const relPath = path.relative(ctx.cwd, result.resolvedOutputPath)
467
+
468
+ pi.sendUserMessage(
469
+ `## OpenSpec: Write **${artifact}** for ${changeName}\n\n` +
470
+ `**Output:** \`${relPath}\`\n\n` +
471
+ `**Description:** ${result.description}\n\n` +
472
+ `**Unlocks:** ${result.unlocks.join(", ") || "nothing"}\n\n` +
473
+ `---\n\n` +
474
+ `### Instructions\n\n${result.instruction}\n\n` +
475
+ `---\n\n` +
476
+ `### Template\n\n\`\`\`markdown\n${result.template}\`\`\`\n\n` +
477
+ `Write the artifact at \`${relPath}\` following ` +
478
+ `the instructions above. When done, run ` +
479
+ `\`/openspec status\` to check progress.`,
480
+ )
481
+ } catch (err: unknown) {
482
+ const msg = err instanceof Error ? err.message : String(err)
483
+ ctx.ui.notify(`❌ Failed to get instructions: ${msg}`, "error")
484
+ }
485
+ }
486
+
487
+ async function handleValidate(
488
+ arg: string,
489
+ ctx: ExtensionCommandContext,
490
+ ): Promise<void> {
491
+ if (!hasOpenSpec(ctx.cwd)) {
492
+ ctx.ui.notify("No openspec/ directory found.", "error")
493
+ return
494
+ }
495
+
496
+ const trimmed = arg.trim()
497
+
498
+ try {
499
+ let output: string
500
+ if (trimmed) {
501
+ const changeName = resolveChange(ctx.cwd, trimmed)
502
+ if (!changeName) {
503
+ ctx.ui.notify(`Change "${trimmed}" not found.`, "error")
504
+ return
505
+ }
506
+ output = run(ctx.cwd, `validate "${changeName}" --type change`)
507
+ } else {
508
+ output = run(ctx.cwd, "validate --all")
509
+ }
510
+ ctx.ui.notify(output || "✅ Validation passed.", "info")
511
+ } catch (err: unknown) {
512
+ const msg = err instanceof Error ? err.message : String(err)
513
+ ctx.ui.notify(`❌ Validation failed:\n${msg}`, "error")
514
+ }
515
+ }
516
+
517
+ async function handleArchive(
518
+ arg: string,
519
+ ctx: ExtensionCommandContext,
520
+ pi: ExtensionAPI,
521
+ ): Promise<void> {
522
+ if (!hasOpenSpec(ctx.cwd)) {
523
+ ctx.ui.notify("No openspec/ directory found.", "error")
524
+ return
525
+ }
526
+
527
+ const parts = arg.trim().split(/\s+/)
528
+ const idPart = parts[0] ?? ""
529
+ const confirmed = parts.includes("--yes")
530
+
531
+ if (!idPart) {
532
+ ctx.ui.notify("Usage: /openspec archive <id> [--yes]", "warning")
533
+ return
534
+ }
535
+
536
+ const changeName = resolveChange(ctx.cwd, idPart)
537
+ if (!changeName) {
538
+ ctx.ui.notify(`Change "${idPart}" not found.`, "error")
539
+ return
540
+ }
541
+
542
+ if (!confirmed) {
543
+ // Gate: validate first, then ask agent to confirm
544
+ pi.sendUserMessage(
545
+ `Archiving change **${changeName}**.\n\n` +
546
+ `Before archiving, please:\n` +
547
+ `1. Run \`/openspec validate ${idPart}\` ` +
548
+ `to check the change is valid\n` +
549
+ `2. Verify all tasks are complete with ` +
550
+ `\`/openspec status ${idPart}\`\n` +
551
+ `3. Run any project quality gates ` +
552
+ `(build, test, lint)\n\n` +
553
+ `If everything passes, run ` +
554
+ `\`/openspec archive ${idPart} --yes\` ` +
555
+ `to finalize.`,
556
+ )
557
+ return
558
+ }
559
+
560
+ try {
561
+ const output = run(ctx.cwd, `archive "${changeName}" --yes`)
562
+ ctx.ui.notify(`✅ Archived: ${changeName}\n${output}`, "info")
563
+ } catch (err: unknown) {
564
+ const msg = err instanceof Error ? err.message : String(err)
565
+ ctx.ui.notify(`❌ Archive failed: ${msg}`, "error")
566
+ }
567
+ }
568
+
569
+ async function handleList(
570
+ arg: string,
571
+ ctx: ExtensionCommandContext,
572
+ ): Promise<void> {
573
+ if (!hasOpenSpec(ctx.cwd)) {
574
+ ctx.ui.notify(
575
+ "No openspec/ directory found. Run `/openspec init` first.",
576
+ "error",
577
+ )
578
+ return
579
+ }
580
+
581
+ const isSpecs = arg.trim() === "--specs"
582
+
583
+ try {
584
+ if (isSpecs) {
585
+ const output = run(ctx.cwd, "list --specs")
586
+ ctx.ui.notify(output || "No specs found.", "info")
587
+ } else {
588
+ const result = runJSON<{ changes: ChangeEntry[] }>(ctx.cwd, "list")
589
+ ctx.ui.notify(formatChangeList(result.changes, ctx.cwd), "info")
590
+ }
591
+ } catch (err: unknown) {
592
+ const msg = err instanceof Error ? err.message : String(err)
593
+ ctx.ui.notify(`❌ List failed: ${msg}`, "error")
594
+ }
595
+ }
596
+
597
+ async function handleInit(ctx: ExtensionCommandContext): Promise<void> {
598
+ if (hasOpenSpec(ctx.cwd)) {
599
+ ctx.ui.notify(
600
+ "OpenSpec is already initialized in this project.",
601
+ "info",
602
+ )
603
+ return
604
+ }
605
+
606
+ try {
607
+ const output = run(ctx.cwd, "init --tools pi")
608
+ ctx.ui.notify(`✅ OpenSpec initialized.\n${output}`, "info")
609
+ } catch (err: unknown) {
610
+ const msg = err instanceof Error ? err.message : String(err)
611
+ ctx.ui.notify(`❌ Init failed: ${msg}`, "error")
612
+ }
613
+ }
614
+
615
+ async function handleSchemas(ctx: ExtensionCommandContext): Promise<void> {
616
+ try {
617
+ const output = run(ctx.cwd, "schemas")
618
+ ctx.ui.notify(output || "No schemas found.", "info")
619
+ } catch (err: unknown) {
620
+ const msg = err instanceof Error ? err.message : String(err)
621
+ ctx.ui.notify(`❌ ${msg}`, "error")
622
+ }
623
+ }
624
+
625
+ function showHelp(ctx: ExtensionCommandContext): void {
626
+ ctx.ui.notify(
627
+ [
628
+ "OpenSpec commands:",
629
+ "",
630
+ " /openspec new <name> — create a new change",
631
+ " /openspec status [id] — artifact status",
632
+ " /openspec next [id] [artifact] — instructions for next artifact",
633
+ " /openspec validate [id] — validate a change",
634
+ " /openspec archive <id> [--yes] — archive completed change",
635
+ " /openspec list — list active changes",
636
+ " /openspec list --specs — list specs",
637
+ " /openspec init — initialize openspec",
638
+ " /openspec schemas — list workflow schemas",
639
+ "",
640
+ "Identifiers: use number (3), full name, or slug (add-auth).",
641
+ ].join("\n"),
642
+ "info",
643
+ )
644
+ }
645
+
646
+ // ───────────────────────────────────────────────────────────────────
647
+ // Extension entry point
648
+ // ───────────────────────────────────────────────────────────────────
649
+
650
+ export default function (pi: ExtensionAPI) {
651
+ // ─── /openspec command ───────────────────────────────────────
652
+ pi.registerCommand("openspec", {
653
+ description:
654
+ "OpenSpec — new | status | next | validate | " +
655
+ "archive | list | init | schemas",
656
+
657
+ getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
658
+ const subs = [
659
+ "new ",
660
+ "status ",
661
+ "next ",
662
+ "validate ",
663
+ "archive ",
664
+ "list",
665
+ "list --specs",
666
+ "init",
667
+ "schemas",
668
+ ]
669
+ const items = subs
670
+ .filter((s) => s.startsWith(prefix))
671
+ .map((s) => ({
672
+ value: s,
673
+ label: s.trim(),
674
+ }))
675
+ return items.length > 0 ? items : null
676
+ },
677
+
678
+ handler: async (args, ctx) => {
679
+ const parts = (args ?? "").trim().split(/\s+/)
680
+ const sub = parts[0]?.toLowerCase() ?? ""
681
+ const rest = parts.slice(1).join(" ")
682
+
683
+ switch (sub) {
684
+ case "new":
685
+ return handleNew(rest, ctx, pi)
686
+ case "status":
687
+ return handleStatus(rest, ctx)
688
+ case "next":
689
+ return handleNext(rest, ctx, pi)
690
+ case "validate":
691
+ return handleValidate(rest, ctx)
692
+ case "archive":
693
+ return handleArchive(rest, ctx, pi)
694
+ case "list":
695
+ return handleList(rest, ctx)
696
+ case "init":
697
+ return handleInit(ctx)
698
+ case "schemas":
699
+ return handleSchemas(ctx)
700
+ default:
701
+ showHelp(ctx)
702
+ }
703
+ },
704
+ })
705
+
706
+ // ─── Session start: detect openspec projects ─────────────────
707
+ pi.on("session_start", async (_event, ctx) => {
708
+ if (!hasOpenSpec(ctx.cwd)) return
709
+
710
+ try {
711
+ const result = runJSON<{ changes: ChangeEntry[] }>(ctx.cwd, "list")
712
+ const count = result.changes.length
713
+ if (count > 0) {
714
+ ctx.ui.notify(
715
+ `📋 OpenSpec: ${count} active change(s). ` +
716
+ `Use \`/openspec list\` to view.`,
717
+ "info",
718
+ )
719
+ }
720
+ } catch {
721
+ // CLI not available or not an openspec project — skip
722
+ }
723
+ })
724
+
725
+ // ─── Auto-refresh: notify on spec file edits ─────────────────
726
+ pi.on("tool_result", async (event, ctx) => {
727
+ if (event.toolName !== "write" && event.toolName !== "edit") {
728
+ return
729
+ }
730
+
731
+ const filePath = (event.input as { path: string }).path ?? ""
732
+ if (!filePath.includes("openspec/changes/")) return
733
+
734
+ // Resolve to absolute path
735
+ const abs = path.isAbsolute(filePath)
736
+ ? filePath
737
+ : path.join(ctx.cwd, filePath)
738
+
739
+ // Extract change name from path
740
+ const changesRoot = changesDir(ctx.cwd)
741
+ const rel = path.relative(changesRoot, abs)
742
+ const changeName = rel.split(path.sep)[0]
743
+ if (!changeName) return
744
+
745
+ try {
746
+ const result = runJSON<StatusResult>(
747
+ ctx.cwd,
748
+ `status --change "${changeName}"`,
749
+ )
750
+
751
+ const done = result.artifacts.filter(
752
+ (a) => a.status === "done",
753
+ ).length
754
+ const total = result.artifacts.length
755
+
756
+ if (result.isComplete) {
757
+ ctx.ui.notify(
758
+ `✅ All artifacts complete for ` +
759
+ `**${changeName}**! ` +
760
+ `Run \`/openspec archive\` when ready.`,
761
+ "info",
762
+ )
763
+ } else {
764
+ const ready = result.artifacts.find((a) => a.status === "ready")
765
+ if (ready) {
766
+ ctx.ui.notify(
767
+ `📋 ${changeName}: ${done}/${total} ` +
768
+ `artifacts done. ` +
769
+ `Next: **${ready.id}**`,
770
+ "info",
771
+ )
772
+ }
773
+ }
774
+ } catch {
775
+ // Status check failed — skip silently
776
+ }
777
+ })
778
+ }