@openspecui/server 3.2.3 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.mjs +601 -24
  2. package/package.json +2 -2
package/dist/index.mjs CHANGED
@@ -1,17 +1,17 @@
1
+ import { CliExecutor, CodeEditorThemeSchema, ConfigManager, DASHBOARD_METRIC_KEYS, DashboardConfigSchema, GitConfigSchema, HOSTED_SHELL_PROTOCOL_VERSION, MarkdownParser, OPENSPECUI_HOOKS_VERSION, OpenSpecAdapter, OpenSpecWatcher, OpsxConfigSchema, OpsxKernel, PtyClientMessageSchema, ReactiveContext, TerminalConfigSchema, TerminalRendererEngineSchema, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, getDetectedProjectTools, getToolInitStates, getWatcherRuntimeStatus, initWatcherPool, isWatcherPoolInitialized, resolveTerminalShellDefaults, sniffGlobalCli, subscribeWatcherRuntimeStatus } from "@openspecui/core";
2
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
3
+ import { access, mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
1
5
  import { createServer as createServer$1 } from "node:net";
2
6
  import { serve } from "@hono/node-server";
3
- import { CliExecutor, CodeEditorThemeSchema, ConfigManager, DASHBOARD_METRIC_KEYS, DashboardConfigSchema, GitConfigSchema, HOSTED_SHELL_PROTOCOL_VERSION, OpenSpecAdapter, OpenSpecWatcher, OpsxConfigSchema, OpsxKernel, PtyClientMessageSchema, ReactiveContext, TerminalConfigSchema, TerminalRendererEngineSchema, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, getDetectedProjectTools, getToolInitStates, getWatcherRuntimeStatus, initWatcherPool, isWatcherPoolInitialized, sniffGlobalCli, subscribeWatcherRuntimeStatus } from "@openspecui/core";
4
7
  import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
5
8
  import { applyWSSHandler } from "@trpc/server/adapters/ws";
6
9
  import { Hono } from "hono";
7
10
  import { cors } from "hono/cors";
8
11
  import { readFileSync } from "node:fs";
9
- import { basename, dirname, join, relative, resolve, sep } from "node:path";
10
- import { fileURLToPath } from "node:url";
11
12
  import { WebSocketServer } from "ws";
12
13
  import { EventEmitter } from "node:events";
13
14
  import { execFile } from "node:child_process";
14
- import { mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
15
15
  import { promisify } from "node:util";
16
16
  import * as pty from "@lydell/node-pty";
17
17
  import { EventEmitter as EventEmitter$1 } from "events";
@@ -21,6 +21,284 @@ import { observable } from "@trpc/server/observable";
21
21
  import { z } from "zod";
22
22
  import { NodeWorkerSearchProvider } from "@openspecui/search/node";
23
23
 
24
+ //#region src/document-service.ts
25
+ function toErrorDiagnostic$1(error) {
26
+ return {
27
+ level: "error",
28
+ message: error instanceof Error ? error.message : String(error)
29
+ };
30
+ }
31
+ function isNotNull(value) {
32
+ return value !== null;
33
+ }
34
+ var DocumentService = class {
35
+ parser = new MarkdownParser();
36
+ constructor(projectDir, adapter, hookRuntime) {
37
+ this.projectDir = projectDir;
38
+ this.adapter = adapter;
39
+ this.hookRuntime = hookRuntime;
40
+ }
41
+ async readProjectMd(consumer = "view", mode = "processed") {
42
+ const source = await this.adapter.readProjectMd();
43
+ if (source === null) return null;
44
+ return this.processDocument({
45
+ consumer,
46
+ mode,
47
+ document: {
48
+ stage: "project",
49
+ kind: "project",
50
+ relativePath: "openspec/project.md",
51
+ absolutePath: join(this.projectDir, "openspec", "project.md")
52
+ },
53
+ source
54
+ });
55
+ }
56
+ async readSpecRaw(specId, consumer = "view", mode = "processed") {
57
+ const source = await this.adapter.readSpecRaw(specId);
58
+ if (source === null) return null;
59
+ return this.processDocument({
60
+ consumer,
61
+ mode,
62
+ document: {
63
+ stage: "main",
64
+ kind: "spec",
65
+ specId,
66
+ relativePath: `openspec/specs/${specId}/spec.md`,
67
+ absolutePath: join(this.projectDir, "openspec", "specs", specId, "spec.md")
68
+ },
69
+ source
70
+ });
71
+ }
72
+ async readSpec(specId, consumer = "view", mode = "processed") {
73
+ try {
74
+ const content = await this.readSpecRaw(specId, consumer, mode);
75
+ if (!content) return null;
76
+ return this.parser.parseSpec(specId, content.markdown);
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+ async readChangeRaw(changeId, consumer = "view", mode = "processed") {
82
+ const raw = await this.adapter.readChangeRaw(changeId);
83
+ if (!raw) return null;
84
+ const process$1 = (kind, relativePath$1, source) => this.processDocument({
85
+ consumer,
86
+ mode,
87
+ document: {
88
+ stage: "change",
89
+ kind,
90
+ changeId,
91
+ relativePath: relativePath$1,
92
+ absolutePath: join(this.projectDir, relativePath$1)
93
+ },
94
+ source
95
+ });
96
+ const [proposal, tasks, design, deltaSpecs] = await Promise.all([
97
+ process$1("proposal", `openspec/changes/${changeId}/proposal.md`, raw.proposal),
98
+ process$1("tasks", `openspec/changes/${changeId}/tasks.md`, raw.tasks),
99
+ raw.design ? process$1("design", `openspec/changes/${changeId}/design.md`, raw.design) : Promise.resolve(void 0),
100
+ Promise.all(raw.deltaSpecs.map(async (deltaSpec) => {
101
+ const result = await process$1("delta-spec", `openspec/changes/${changeId}/specs/${deltaSpec.specId}/spec.md`, deltaSpec.content);
102
+ return {
103
+ specId: deltaSpec.specId,
104
+ content: result.markdown,
105
+ sourceContent: deltaSpec.content
106
+ };
107
+ }))
108
+ ]);
109
+ return {
110
+ proposal,
111
+ tasks,
112
+ design,
113
+ deltaSpecs
114
+ };
115
+ }
116
+ async readChange(changeId, consumer = "view", mode = "processed") {
117
+ try {
118
+ const raw = await this.readChangeRaw(changeId, consumer, mode);
119
+ if (!raw) return null;
120
+ return this.parser.parseChange(changeId, raw.proposal.markdown, raw.tasks.markdown, {
121
+ design: raw.design?.markdown,
122
+ deltaSpecs: raw.deltaSpecs
123
+ });
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+ async readArchivedChangeRaw(changeId, consumer = "view", mode = "processed") {
129
+ const raw = await this.adapter.readArchivedChangeRaw(changeId);
130
+ if (!raw) return null;
131
+ const process$1 = (kind, relativePath$1, source) => this.processDocument({
132
+ consumer,
133
+ mode,
134
+ document: {
135
+ stage: "archive",
136
+ kind,
137
+ changeId,
138
+ relativePath: relativePath$1,
139
+ absolutePath: join(this.projectDir, relativePath$1)
140
+ },
141
+ source
142
+ });
143
+ const [proposal, tasks, design, deltaSpecs] = await Promise.all([
144
+ process$1("proposal", `openspec/changes/archive/${changeId}/proposal.md`, raw.proposal),
145
+ process$1("tasks", `openspec/changes/archive/${changeId}/tasks.md`, raw.tasks),
146
+ raw.design ? process$1("design", `openspec/changes/archive/${changeId}/design.md`, raw.design) : Promise.resolve(void 0),
147
+ Promise.all(raw.deltaSpecs.map(async (deltaSpec) => {
148
+ const result = await process$1("delta-spec", `openspec/changes/archive/${changeId}/specs/${deltaSpec.specId}/spec.md`, deltaSpec.content);
149
+ return {
150
+ specId: deltaSpec.specId,
151
+ content: result.markdown,
152
+ sourceContent: deltaSpec.content
153
+ };
154
+ }))
155
+ ]);
156
+ return {
157
+ proposal,
158
+ tasks,
159
+ design,
160
+ deltaSpecs
161
+ };
162
+ }
163
+ async readArchivedChange(changeId, consumer = "view", mode = "processed") {
164
+ try {
165
+ const raw = await this.readArchivedChangeRaw(changeId, consumer, mode);
166
+ if (!raw) return null;
167
+ return this.parser.parseChange(changeId, raw.proposal.markdown, raw.tasks.markdown, {
168
+ design: raw.design?.markdown,
169
+ deltaSpecs: raw.deltaSpecs
170
+ });
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+ async readChangeFiles(changeId, consumer = "view", mode = "processed") {
176
+ const files = await this.adapter.readChangeFiles(changeId);
177
+ return this.processChangeFiles("change", changeId, files, consumer, mode);
178
+ }
179
+ async readArchivedChangeFiles(changeId, consumer = "view", mode = "processed") {
180
+ const files = await this.adapter.readArchivedChangeFiles(changeId);
181
+ return this.processChangeFiles("archive", changeId, files, consumer, mode);
182
+ }
183
+ async processChangeFiles(stage, changeId, files, consumer, mode) {
184
+ const root = stage === "change" ? `openspec/changes/${changeId}` : `openspec/changes/archive/${changeId}`;
185
+ return (await Promise.all(files.map(async (file) => {
186
+ if (file.type !== "file" || file.content === void 0 || !file.path.endsWith(".md")) return file;
187
+ const kind = this.inferChangeFileKind(file.path);
188
+ if (!kind) return file;
189
+ const result = await this.processDocument({
190
+ consumer,
191
+ mode,
192
+ document: {
193
+ stage,
194
+ kind,
195
+ changeId,
196
+ relativePath: `${root}/${file.path}`,
197
+ absolutePath: join(this.projectDir, root, file.path)
198
+ },
199
+ source: file.content
200
+ });
201
+ return {
202
+ ...file,
203
+ content: result.markdown
204
+ };
205
+ }))).filter(isNotNull);
206
+ }
207
+ inferChangeFileKind(path) {
208
+ if (path === "proposal.md") return "proposal";
209
+ if (path === "tasks.md") return "tasks";
210
+ if (path === "design.md") return "design";
211
+ if (/^specs\/[^/]+\/spec\.md$/.test(path)) return "delta-spec";
212
+ return null;
213
+ }
214
+ async processDocument(input) {
215
+ const read = async () => ({
216
+ markdown: input.source,
217
+ sourceMarkdown: input.source
218
+ });
219
+ if (input.mode === "source") return read();
220
+ const hooks = await this.hookRuntime.load();
221
+ if (!hooks.onReadDocument) return read();
222
+ try {
223
+ return {
224
+ ...await hooks.onReadDocument({
225
+ version: OPENSPECUI_HOOKS_VERSION,
226
+ projectDir: this.projectDir,
227
+ consumer: input.consumer,
228
+ document: input.document,
229
+ signal: new AbortController().signal,
230
+ lifecycle: this.hookRuntime
231
+ }, read),
232
+ sourceMarkdown: input.source
233
+ };
234
+ } catch (error) {
235
+ const fallback = await read();
236
+ return {
237
+ ...fallback,
238
+ diagnostics: [...fallback.diagnostics ?? [], toErrorDiagnostic$1(error)]
239
+ };
240
+ }
241
+ }
242
+ };
243
+
244
+ //#endregion
245
+ //#region src/hook-runtime.ts
246
+ const OPENSPECUI_HOOKS_RELATIVE_PATH = "openspec/openspecui.hooks.ts";
247
+ function isOnReadDocumentHook(value) {
248
+ return typeof value === "function";
249
+ }
250
+ function isOnRunWorkflowHook(value) {
251
+ return typeof value === "function";
252
+ }
253
+ function normalizeHooksModule(moduleValue) {
254
+ if (!moduleValue || typeof moduleValue !== "object") return {};
255
+ const record = moduleValue;
256
+ const defaultRecord = record.default && typeof record.default === "object" ? record.default : {};
257
+ const moduleExportsRecord = record["module.exports"] && typeof record["module.exports"] === "object" ? record["module.exports"] : {};
258
+ return {
259
+ onReadDocument: isOnReadDocumentHook(record.onReadDocument) ? record.onReadDocument : isOnReadDocumentHook(defaultRecord.onReadDocument) ? defaultRecord.onReadDocument : isOnReadDocumentHook(moduleExportsRecord.onReadDocument) ? moduleExportsRecord.onReadDocument : void 0,
260
+ onRunWorkflow: isOnRunWorkflowHook(record.onRunWorkflow) ? record.onRunWorkflow : isOnRunWorkflowHook(defaultRecord.onRunWorkflow) ? defaultRecord.onRunWorkflow : isOnRunWorkflowHook(moduleExportsRecord.onRunWorkflow) ? moduleExportsRecord.onRunWorkflow : void 0
261
+ };
262
+ }
263
+ async function pathExists$1(path) {
264
+ try {
265
+ await access(path);
266
+ return true;
267
+ } catch {
268
+ return false;
269
+ }
270
+ }
271
+ var ProjectHookRuntime = class {
272
+ hooksPath;
273
+ hooksPromise = null;
274
+ disposeCallbacks = /* @__PURE__ */ new Set();
275
+ constructor(projectDir) {
276
+ this.hooksPath = join(projectDir, OPENSPECUI_HOOKS_RELATIVE_PATH);
277
+ }
278
+ async load() {
279
+ if (this.hooksPromise) return this.hooksPromise;
280
+ this.hooksPromise = this.loadFresh().catch(() => ({}));
281
+ return this.hooksPromise;
282
+ }
283
+ onDispose(cleanup) {
284
+ this.disposeCallbacks.add(cleanup);
285
+ }
286
+ async dispose() {
287
+ const callbacks = [...this.disposeCallbacks];
288
+ this.disposeCallbacks.clear();
289
+ await Promise.allSettled(callbacks.map((cleanup) => cleanup()));
290
+ }
291
+ async loadFresh() {
292
+ if (!await pathExists$1(this.hooksPath)) return {};
293
+ const { tsImport } = await import("tsx/esm/api");
294
+ return normalizeHooksModule(await tsImport(`${pathToFileURL(this.hooksPath).href}?t=${Date.now()}`, { parentURL: pathToFileURL(this.hooksPath).href }));
295
+ }
296
+ };
297
+ function createHookRuntime(projectDir) {
298
+ return new ProjectHookRuntime(projectDir);
299
+ }
300
+
301
+ //#endregion
24
302
  //#region src/port-utils.ts
25
303
  /**
26
304
  * Check if a port is available by trying to listen on it.
@@ -1145,6 +1423,15 @@ function resolveDefaultShell(platform, env) {
1145
1423
  if (platform === "windows") return env.ComSpec?.trim() || "cmd.exe";
1146
1424
  return env.SHELL?.trim() || "/bin/sh";
1147
1425
  }
1426
+ function resolvePtyShellDefaults(opts) {
1427
+ return resolveTerminalShellDefaults({
1428
+ platform: opts.platform,
1429
+ env: {
1430
+ SHELL: opts.env.SHELL,
1431
+ ComSpec: opts.env.ComSpec
1432
+ }
1433
+ });
1434
+ }
1148
1435
  function resolvePtyCommand(opts) {
1149
1436
  const command = opts.command?.trim();
1150
1437
  if (command) return {
@@ -1282,6 +1569,12 @@ var PtyManager = class {
1282
1569
  this.defaultCwd = defaultCwd;
1283
1570
  this.platform = detectPtyPlatform();
1284
1571
  }
1572
+ getShellDefaults() {
1573
+ return resolvePtyShellDefaults({
1574
+ platform: this.platform,
1575
+ env: process.env
1576
+ });
1577
+ }
1285
1578
  create(opts) {
1286
1579
  const id = `pty-${++this.idCounter}`;
1287
1580
  const session = new PtySession(id, {
@@ -2313,6 +2606,47 @@ const gitEntrySelectorSchema = z.discriminatedUnion("type", [z.object({ type: z.
2313
2606
  type: z.literal("commit"),
2314
2607
  hash: z.string().min(1)
2315
2608
  })]);
2609
+ const workflowRequestedModeSchema = z.enum([
2610
+ "compose",
2611
+ "command",
2612
+ "direct"
2613
+ ]);
2614
+ const runWorkflowInputSchema = z.discriminatedUnion("action", [
2615
+ z.object({
2616
+ action: z.enum(["explore", "propose"]),
2617
+ text: z.string()
2618
+ }),
2619
+ z.object({
2620
+ action: z.literal("new"),
2621
+ changeId: z.string(),
2622
+ schema: z.string().optional(),
2623
+ description: z.string().optional(),
2624
+ extraArgs: z.array(z.string()).default([])
2625
+ }),
2626
+ z.object({
2627
+ action: z.enum(["continue", "ff"]),
2628
+ changeId: z.string(),
2629
+ artifactId: z.string(),
2630
+ schema: z.string().optional()
2631
+ }),
2632
+ z.object({
2633
+ action: z.enum([
2634
+ "apply",
2635
+ "archive",
2636
+ "verify",
2637
+ "sync"
2638
+ ]),
2639
+ changeId: z.string(),
2640
+ schema: z.string().optional(),
2641
+ strict: z.boolean().optional()
2642
+ }),
2643
+ z.object({
2644
+ action: z.literal("bulk-archive"),
2645
+ changeIds: z.array(z.string()).optional(),
2646
+ schema: z.string().optional()
2647
+ }),
2648
+ z.object({ action: z.literal("onboard") })
2649
+ ]);
2316
2650
  function requireChangeId(changeId) {
2317
2651
  if (!changeId) throw new Error("change is required");
2318
2652
  return changeId;
@@ -2494,7 +2828,7 @@ const specRouter = router({
2494
2828
  return ctx.adapter.listSpecsWithMeta();
2495
2829
  }),
2496
2830
  get: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
2497
- return ctx.adapter.readSpec(input.id);
2831
+ return ctx.documentService.readSpec(input.id);
2498
2832
  }),
2499
2833
  getRaw: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
2500
2834
  return ctx.adapter.readSpecRaw(input.id);
@@ -2513,7 +2847,7 @@ const specRouter = router({
2513
2847
  return createReactiveSubscription(() => ctx.adapter.listSpecsWithMeta());
2514
2848
  }),
2515
2849
  subscribeOne: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
2516
- return createReactiveSubscriptionWithInput((id) => ctx.adapter.readSpec(id))(input.id);
2850
+ return createReactiveSubscriptionWithInput((id) => ctx.documentService.readSpec(id))(input.id);
2517
2851
  }),
2518
2852
  subscribeRaw: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
2519
2853
  return createReactiveSubscriptionWithInput((id) => ctx.adapter.readSpecRaw(id))(input.id);
@@ -2533,7 +2867,7 @@ const changeRouter = router({
2533
2867
  return ctx.adapter.listArchivedChanges();
2534
2868
  }),
2535
2869
  get: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
2536
- return ctx.adapter.readChange(input.id);
2870
+ return ctx.documentService.readChange(input.id);
2537
2871
  }),
2538
2872
  getRaw: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
2539
2873
  return ctx.adapter.readChangeRaw(input.id);
@@ -2585,7 +2919,7 @@ const archiveRouter = router({
2585
2919
  return ctx.adapter.listArchivedChangesWithMeta();
2586
2920
  }),
2587
2921
  get: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
2588
- return ctx.adapter.readArchivedChange(input.id);
2922
+ return ctx.documentService.readArchivedChange(input.id);
2589
2923
  }),
2590
2924
  getRaw: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
2591
2925
  return ctx.adapter.readArchivedChangeRaw(input.id);
@@ -2594,7 +2928,7 @@ const archiveRouter = router({
2594
2928
  return createReactiveSubscription(() => ctx.adapter.listArchivedChangesWithMeta());
2595
2929
  }),
2596
2930
  subscribeOne: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
2597
- return createReactiveSubscriptionWithInput((id) => ctx.adapter.readArchivedChange(id))(input.id);
2931
+ return createReactiveSubscriptionWithInput((id) => ctx.documentService.readArchivedChange(id))(input.id);
2598
2932
  }),
2599
2933
  subscribeFiles: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
2600
2934
  return createReactiveSubscriptionWithInput((id) => ctx.adapter.readArchivedChangeFiles(id))(input.id);
@@ -2720,6 +3054,15 @@ const configRouter = router({
2720
3054
  }),
2721
3055
  subscribe: publicProcedure.subscription(({ ctx }) => {
2722
3056
  return createReactiveSubscription(() => ctx.configManager.readConfig());
3057
+ }),
3058
+ getTerminalShellDefaults: publicProcedure.query(async () => {
3059
+ return resolveTerminalShellDefaults({
3060
+ platform: process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "common",
3061
+ env: {
3062
+ SHELL: process.env.SHELL,
3063
+ ComSpec: process.env.ComSpec
3064
+ }
3065
+ });
2723
3066
  })
2724
3067
  });
2725
3068
  /**
@@ -2882,6 +3225,12 @@ const cliRouter = router({
2882
3225
  * OPSX router - CLI-driven workflow data
2883
3226
  */
2884
3227
  const opsxRouter = router({
3228
+ runWorkflow: publicProcedure.input(z.object({
3229
+ requestedMode: workflowRequestedModeSchema,
3230
+ input: runWorkflowInputSchema
3231
+ })).mutation(async ({ ctx, input }) => {
3232
+ return ctx.workflowInvocationService.runWorkflow(input.input, input.requestedMode);
3233
+ }),
2885
3234
  status: publicProcedure.input(z.object({
2886
3235
  change: z.string().optional(),
2887
3236
  schema: z.string().optional()
@@ -3350,11 +3699,11 @@ const appRouter = router({
3350
3699
  function joinParts(parts) {
3351
3700
  return parts.map((part) => part?.trim() ?? "").filter((part) => part.length > 0).join("\n\n");
3352
3701
  }
3353
- async function collectSearchDocuments(adapter) {
3702
+ async function collectSearchDocuments(adapter, documentService) {
3354
3703
  const docs = [];
3355
3704
  const specs = await adapter.listSpecsWithMeta();
3356
3705
  for (const spec of specs) {
3357
- const raw = await adapter.readSpecRaw(spec.id);
3706
+ const raw = documentService ? await documentService.readSpecRaw(spec.id, "search", "processed") : await adapter.readSpecRaw(spec.id);
3358
3707
  if (!raw) continue;
3359
3708
  docs.push({
3360
3709
  id: `spec:${spec.id}`,
@@ -3362,13 +3711,13 @@ async function collectSearchDocuments(adapter) {
3362
3711
  title: spec.name,
3363
3712
  href: `/specs/${encodeURIComponent(spec.id)}`,
3364
3713
  path: `openspec/specs/${spec.id}/spec.md`,
3365
- content: raw,
3714
+ content: typeof raw === "string" ? raw : raw.markdown,
3366
3715
  updatedAt: spec.updatedAt
3367
3716
  });
3368
3717
  }
3369
3718
  const changes = await adapter.listChangesWithMeta();
3370
3719
  for (const change of changes) {
3371
- const raw = await adapter.readChangeRaw(change.id);
3720
+ const raw = documentService ? await documentService.readChangeRaw(change.id, "search", "processed") : await adapter.readChangeRaw(change.id);
3372
3721
  if (!raw) continue;
3373
3722
  docs.push({
3374
3723
  id: `change:${change.id}`,
@@ -3377,9 +3726,9 @@ async function collectSearchDocuments(adapter) {
3377
3726
  href: `/changes/${encodeURIComponent(change.id)}`,
3378
3727
  path: `openspec/changes/${change.id}`,
3379
3728
  content: joinParts([
3380
- raw.proposal,
3381
- raw.tasks,
3382
- raw.design,
3729
+ typeof raw.proposal === "string" ? raw.proposal : raw.proposal.markdown,
3730
+ typeof raw.tasks === "string" ? raw.tasks : raw.tasks.markdown,
3731
+ typeof raw.design === "string" ? raw.design : raw.design?.markdown,
3383
3732
  ...raw.deltaSpecs.map((deltaSpec) => deltaSpec.content)
3384
3733
  ]),
3385
3734
  updatedAt: change.updatedAt
@@ -3387,7 +3736,7 @@ async function collectSearchDocuments(adapter) {
3387
3736
  }
3388
3737
  const archives = await adapter.listArchivedChangesWithMeta();
3389
3738
  for (const archive of archives) {
3390
- const raw = await adapter.readArchivedChangeRaw(archive.id);
3739
+ const raw = documentService ? await documentService.readArchivedChangeRaw(archive.id, "search", "processed") : await adapter.readArchivedChangeRaw(archive.id);
3391
3740
  if (!raw) continue;
3392
3741
  docs.push({
3393
3742
  id: `archive:${archive.id}`,
@@ -3396,9 +3745,9 @@ async function collectSearchDocuments(adapter) {
3396
3745
  href: `/archive/${encodeURIComponent(archive.id)}`,
3397
3746
  path: `openspec/changes/archive/${archive.id}`,
3398
3747
  content: joinParts([
3399
- raw.proposal,
3400
- raw.tasks,
3401
- raw.design,
3748
+ typeof raw.proposal === "string" ? raw.proposal : raw.proposal.markdown,
3749
+ typeof raw.tasks === "string" ? raw.tasks : raw.tasks.markdown,
3750
+ typeof raw.design === "string" ? raw.design : raw.design?.markdown,
3402
3751
  ...raw.deltaSpecs.map((deltaSpec) => deltaSpec.content)
3403
3752
  ]),
3404
3753
  updatedAt: archive.updatedAt
@@ -3416,8 +3765,9 @@ var SearchService = class {
3416
3765
  initPromise = null;
3417
3766
  rebuildPromise = null;
3418
3767
  rebuildTimer = null;
3419
- constructor(adapter, watcher, provider = new NodeWorkerSearchProvider()) {
3768
+ constructor(adapter, watcher, provider = new NodeWorkerSearchProvider(), documentService) {
3420
3769
  this.adapter = adapter;
3770
+ this.documentService = documentService;
3421
3771
  this.provider = provider;
3422
3772
  watcher?.on("change", () => {
3423
3773
  this.scheduleRebuild();
@@ -3463,7 +3813,7 @@ var SearchService = class {
3463
3813
  if (!forceInit && !this.initialized) return;
3464
3814
  if (this.rebuildPromise) return this.rebuildPromise;
3465
3815
  this.rebuildPromise = (async () => {
3466
- const docs = await collectSearchDocuments(this.adapter);
3816
+ const docs = await collectSearchDocuments(this.adapter, this.documentService);
3467
3817
  if (this.initialized) await this.provider.replaceAll(docs);
3468
3818
  else {
3469
3819
  await this.provider.init(docs);
@@ -3478,6 +3828,218 @@ var SearchService = class {
3478
3828
  }
3479
3829
  };
3480
3830
 
3831
+ //#endregion
3832
+ //#region src/workflow-invocation-service.ts
3833
+ const COMMAND_CAPABLE_ACTIONS = new Set([
3834
+ "propose",
3835
+ "apply",
3836
+ "archive"
3837
+ ]);
3838
+ const COMMAND_FALLBACK_REASONS = {
3839
+ continue: "Continue uses the selected artifact context, so compose mode is required.",
3840
+ ff: "Fast-forward from a change page uses the selected ready artifact, so compose mode is required."
3841
+ };
3842
+ function toErrorDiagnostic(error) {
3843
+ return {
3844
+ level: "error",
3845
+ message: error instanceof Error ? error.message : String(error)
3846
+ };
3847
+ }
3848
+ function withDiagnostics(result, diagnostics) {
3849
+ return {
3850
+ ...result,
3851
+ diagnostics: [...result.diagnostics ?? [], ...diagnostics]
3852
+ };
3853
+ }
3854
+ function resolveInvocationMode(action, requestedMode) {
3855
+ if (requestedMode !== "command" || COMMAND_CAPABLE_ACTIONS.has(action)) return {
3856
+ requestedMode,
3857
+ actualMode: requestedMode,
3858
+ fallbackReason: null
3859
+ };
3860
+ return {
3861
+ requestedMode,
3862
+ actualMode: "compose",
3863
+ fallbackReason: COMMAND_FALLBACK_REASONS[action] ?? "This action requires compose mode."
3864
+ };
3865
+ }
3866
+ function buildProposeComposePrompt(text) {
3867
+ const normalized = text.trim();
3868
+ if (normalized.length === 0) return ["Propose a new OpenSpec change.", "Ask me what to build before creating files if the request is unclear."].join("\n");
3869
+ return [
3870
+ `Propose a new OpenSpec change for: ${normalized}`,
3871
+ "",
3872
+ "Use the OpenSpec propose workflow. If an openspec-propose skill is available, follow it. Otherwise derive a kebab-case change name, run `openspec new change \"<name>\"`, inspect `openspec status --change \"<name>\" --json`, and create every apply-required artifact using `openspec instructions <artifact-id> --change \"<name>\" --json`."
3873
+ ].join("\n");
3874
+ }
3875
+ function buildSlashCommand(input) {
3876
+ switch (input.action) {
3877
+ case "propose": {
3878
+ const normalized = input.text.trim();
3879
+ if (normalized.length === 0) return "/opsx:propose";
3880
+ if (normalized.startsWith("/opsx:")) return normalized;
3881
+ return `/opsx:propose ${normalized}`;
3882
+ }
3883
+ case "apply":
3884
+ case "archive": return `/opsx:${input.action} ${input.changeId.trim()}`;
3885
+ default: return null;
3886
+ }
3887
+ }
3888
+ async function captureCliText(execute, args, fallback) {
3889
+ const result = await execute(args);
3890
+ const text = result.stdout.trim().length > 0 ? result.stdout.trim() : fallback;
3891
+ if (result.success) return { text };
3892
+ return {
3893
+ text,
3894
+ diagnostics: [{
3895
+ level: "warning",
3896
+ message: result.stderr || `openspec command exited with code ${result.exitCode ?? "null"}`
3897
+ }]
3898
+ };
3899
+ }
3900
+ function buildFallbackPrompt(input) {
3901
+ switch (input.action) {
3902
+ case "continue": return `Continue artifact ${input.artifactId} for change ${input.changeId}.`;
3903
+ case "ff": return `Fast-forward artifact ${input.artifactId} for change ${input.changeId}.`;
3904
+ case "apply": return `Apply change ${input.changeId} based on current completed artifacts.`;
3905
+ case "archive": return `Archive change ${input.changeId} after verifying completion and risks.`;
3906
+ case "sync": return `Sync specs for change ${input.changeId}.`;
3907
+ case "verify": return `Verify change ${input.changeId}.`;
3908
+ case "bulk-archive": return `Archive completed changes${input.changeIds?.length ? `: ${input.changeIds.join(", ")}` : ""}.`;
3909
+ case "explore":
3910
+ case "propose": return buildProposeComposePrompt(input.text);
3911
+ case "new": return `Create OpenSpec change ${input.changeId}.`;
3912
+ case "onboard": return "Start OpenSpec onboarding for this project.";
3913
+ }
3914
+ }
3915
+ function buildArchivePrompt(changeId, statusText) {
3916
+ const normalized = statusText.trim();
3917
+ return [
3918
+ `Archive planning for change "${changeId}".`,
3919
+ "",
3920
+ "Current openspec status:",
3921
+ "```text",
3922
+ normalized.length > 0 ? normalized : "(no status output)",
3923
+ "```",
3924
+ "",
3925
+ "Please confirm archive readiness, highlight risks, and provide the exact next steps."
3926
+ ].join("\n");
3927
+ }
3928
+ var WorkflowInvocationService = class {
3929
+ constructor(options) {
3930
+ this.options = options;
3931
+ }
3932
+ async runWorkflow(input, requestedMode, signal = new AbortController().signal) {
3933
+ const mode = resolveInvocationMode(input.action, requestedMode);
3934
+ const run = () => this.runDefault(input, mode);
3935
+ const hooks = await this.options.hookRuntime.load();
3936
+ if (!hooks.onRunWorkflow) return run();
3937
+ try {
3938
+ return await hooks.onRunWorkflow({
3939
+ version: OPENSPECUI_HOOKS_VERSION,
3940
+ projectDir: this.options.projectDir,
3941
+ action: input.action,
3942
+ requestedMode,
3943
+ input,
3944
+ signal,
3945
+ lifecycle: this.options.hookRuntime
3946
+ }, run);
3947
+ } catch (error) {
3948
+ return withDiagnostics(await run(), [toErrorDiagnostic(error)]);
3949
+ }
3950
+ }
3951
+ async runDefault(input, mode) {
3952
+ if (mode.actualMode === "command") {
3953
+ const text = buildSlashCommand(input);
3954
+ if (text) return {
3955
+ kind: "agent-command",
3956
+ text,
3957
+ mode
3958
+ };
3959
+ }
3960
+ if (input.action === "new") {
3961
+ const args = [
3962
+ "new",
3963
+ "change",
3964
+ input.changeId.trim()
3965
+ ];
3966
+ const schema = input.schema?.trim();
3967
+ const description = input.description?.trim();
3968
+ if (schema) args.push("--schema", schema);
3969
+ if (description) args.push("--description", description);
3970
+ args.push(...input.extraArgs.map((arg) => arg.trim()).filter((arg) => arg.length > 0));
3971
+ return {
3972
+ kind: "cli-command",
3973
+ command: "openspec",
3974
+ args,
3975
+ mode
3976
+ };
3977
+ }
3978
+ if (input.action === "verify") {
3979
+ const args = [
3980
+ "validate",
3981
+ input.changeId,
3982
+ "--type",
3983
+ "change"
3984
+ ];
3985
+ if (input.strict) args.push("--strict");
3986
+ return {
3987
+ kind: "cli-command",
3988
+ command: "openspec",
3989
+ args,
3990
+ mode
3991
+ };
3992
+ }
3993
+ if (input.action === "propose" || input.action === "explore") return {
3994
+ kind: "agent-prompt",
3995
+ text: buildProposeComposePrompt(input.text),
3996
+ format: "markdown",
3997
+ mode
3998
+ };
3999
+ const executeCli = this.options.executeCli;
4000
+ if (executeCli && (input.action === "continue" || input.action === "ff" || input.action === "apply" || input.action === "archive")) {
4001
+ if ((input.action === "continue" || input.action === "ff") && !input.artifactId.trim()) return {
4002
+ kind: "agent-prompt",
4003
+ text: buildFallbackPrompt(input),
4004
+ format: "markdown",
4005
+ mode,
4006
+ diagnostics: [{
4007
+ level: "warning",
4008
+ message: "Artifact id is required for this action."
4009
+ }]
4010
+ };
4011
+ const captured = await captureCliText(executeCli, input.action === "continue" || input.action === "ff" ? [
4012
+ "instructions",
4013
+ input.artifactId,
4014
+ "--change",
4015
+ input.changeId
4016
+ ] : input.action === "apply" ? [
4017
+ "instructions",
4018
+ "apply",
4019
+ "--change",
4020
+ input.changeId
4021
+ ] : [
4022
+ "status",
4023
+ "--change",
4024
+ input.changeId
4025
+ ], buildFallbackPrompt(input));
4026
+ return {
4027
+ kind: "agent-prompt",
4028
+ text: input.action === "archive" ? buildArchivePrompt(input.changeId, captured.text) : captured.text,
4029
+ format: "markdown",
4030
+ mode,
4031
+ diagnostics: captured.diagnostics
4032
+ };
4033
+ }
4034
+ return {
4035
+ kind: "agent-prompt",
4036
+ text: buildFallbackPrompt(input),
4037
+ format: "markdown",
4038
+ mode
4039
+ };
4040
+ }
4041
+ };
4042
+
3481
4043
  //#endregion
3482
4044
  //#region src/server.ts
3483
4045
  /**
@@ -3514,8 +4076,15 @@ function createServer(config) {
3514
4076
  const configManager = new ConfigManager(config.projectDir);
3515
4077
  const cliExecutor = new CliExecutor(configManager, config.projectDir);
3516
4078
  const kernel = config.kernel;
4079
+ const hookRuntime = createHookRuntime(config.projectDir);
4080
+ const documentService = new DocumentService(config.projectDir, adapter, hookRuntime);
4081
+ const workflowInvocationService = new WorkflowInvocationService({
4082
+ projectDir: config.projectDir,
4083
+ hookRuntime,
4084
+ executeCli: (args) => cliExecutor.execute(args)
4085
+ });
3517
4086
  const watcher = config.enableWatcher !== false ? new OpenSpecWatcher(config.projectDir) : void 0;
3518
- const searchService = new SearchService(adapter, watcher);
4087
+ const searchService = new SearchService(adapter, watcher, void 0, documentService);
3519
4088
  const dashboardOverviewService = new DashboardOverviewService((reason) => loadDashboardOverview({
3520
4089
  adapter,
3521
4090
  configManager,
@@ -3550,8 +4119,10 @@ function createServer(config) {
3550
4119
  createContext: () => ({
3551
4120
  adapter,
3552
4121
  configManager,
4122
+ documentService,
3553
4123
  cliExecutor,
3554
4124
  kernel,
4125
+ workflowInvocationService,
3555
4126
  searchService,
3556
4127
  dashboardOverviewService,
3557
4128
  projectRecoveryService,
@@ -3564,8 +4135,10 @@ function createServer(config) {
3564
4135
  const createContext = () => ({
3565
4136
  adapter,
3566
4137
  configManager,
4138
+ documentService,
3567
4139
  cliExecutor,
3568
4140
  kernel,
4141
+ workflowInvocationService,
3569
4142
  searchService,
3570
4143
  dashboardOverviewService,
3571
4144
  projectRecoveryService,
@@ -3577,11 +4150,14 @@ function createServer(config) {
3577
4150
  app,
3578
4151
  adapter,
3579
4152
  configManager,
4153
+ documentService,
3580
4154
  cliExecutor,
3581
4155
  kernel,
4156
+ workflowInvocationService,
3582
4157
  searchService,
3583
4158
  dashboardOverviewService,
3584
4159
  projectRecoveryService,
4160
+ hookRuntime,
3585
4161
  watcher,
3586
4162
  createContext,
3587
4163
  port: config.port ?? 3100
@@ -3675,6 +4251,7 @@ async function startServer(config, setupApp) {
3675
4251
  preferredPort,
3676
4252
  close: async () => {
3677
4253
  kernel.dispose();
4254
+ await server.hookRuntime.dispose();
3678
4255
  wsServer.close();
3679
4256
  httpServer.close();
3680
4257
  }
@@ -3682,4 +4259,4 @@ async function startServer(config, setupApp) {
3682
4259
  }
3683
4260
 
3684
4261
  //#endregion
3685
- export { createServer, createWebSocketServer, findAvailablePort, isPortAvailable, startServer };
4262
+ export { DocumentService, OPENSPECUI_HOOKS_RELATIVE_PATH, ProjectHookRuntime, WorkflowInvocationService, createHookRuntime, createServer, createWebSocketServer, findAvailablePort, isPortAvailable, startServer };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openspecui/server",
3
- "version": "3.2.3",
3
+ "version": "3.4.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.mjs",
6
6
  "exports": {
@@ -25,6 +25,7 @@
25
25
  "@openspecui/search": "workspace:*",
26
26
  "@trpc/server": "^11.0.0",
27
27
  "hono": "^4.7.3",
28
+ "tsx": "^4.19.2",
28
29
  "ws": "^8.18.0",
29
30
  "yaml": "^2.8.0",
30
31
  "yargs": "^18.0.0",
@@ -35,7 +36,6 @@
35
36
  "@types/ws": "^8.5.13",
36
37
  "@types/yargs": "^17.0.35",
37
38
  "tsdown": "^0.16.6",
38
- "tsx": "^4.19.2",
39
39
  "typescript": "^5.7.2",
40
40
  "vitest": "^4.1.0"
41
41
  },