@openspecui/server 3.2.2 → 3.3.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 +577 -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, 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.
@@ -2313,6 +2591,47 @@ const gitEntrySelectorSchema = z.discriminatedUnion("type", [z.object({ type: z.
2313
2591
  type: z.literal("commit"),
2314
2592
  hash: z.string().min(1)
2315
2593
  })]);
2594
+ const workflowRequestedModeSchema = z.enum([
2595
+ "compose",
2596
+ "command",
2597
+ "direct"
2598
+ ]);
2599
+ const runWorkflowInputSchema = z.discriminatedUnion("action", [
2600
+ z.object({
2601
+ action: z.enum(["explore", "propose"]),
2602
+ text: z.string()
2603
+ }),
2604
+ z.object({
2605
+ action: z.literal("new"),
2606
+ changeId: z.string(),
2607
+ schema: z.string().optional(),
2608
+ description: z.string().optional(),
2609
+ extraArgs: z.array(z.string()).default([])
2610
+ }),
2611
+ z.object({
2612
+ action: z.enum(["continue", "ff"]),
2613
+ changeId: z.string(),
2614
+ artifactId: z.string(),
2615
+ schema: z.string().optional()
2616
+ }),
2617
+ z.object({
2618
+ action: z.enum([
2619
+ "apply",
2620
+ "archive",
2621
+ "verify",
2622
+ "sync"
2623
+ ]),
2624
+ changeId: z.string(),
2625
+ schema: z.string().optional(),
2626
+ strict: z.boolean().optional()
2627
+ }),
2628
+ z.object({
2629
+ action: z.literal("bulk-archive"),
2630
+ changeIds: z.array(z.string()).optional(),
2631
+ schema: z.string().optional()
2632
+ }),
2633
+ z.object({ action: z.literal("onboard") })
2634
+ ]);
2316
2635
  function requireChangeId(changeId) {
2317
2636
  if (!changeId) throw new Error("change is required");
2318
2637
  return changeId;
@@ -2494,7 +2813,7 @@ const specRouter = router({
2494
2813
  return ctx.adapter.listSpecsWithMeta();
2495
2814
  }),
2496
2815
  get: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
2497
- return ctx.adapter.readSpec(input.id);
2816
+ return ctx.documentService.readSpec(input.id);
2498
2817
  }),
2499
2818
  getRaw: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
2500
2819
  return ctx.adapter.readSpecRaw(input.id);
@@ -2513,7 +2832,7 @@ const specRouter = router({
2513
2832
  return createReactiveSubscription(() => ctx.adapter.listSpecsWithMeta());
2514
2833
  }),
2515
2834
  subscribeOne: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
2516
- return createReactiveSubscriptionWithInput((id) => ctx.adapter.readSpec(id))(input.id);
2835
+ return createReactiveSubscriptionWithInput((id) => ctx.documentService.readSpec(id))(input.id);
2517
2836
  }),
2518
2837
  subscribeRaw: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
2519
2838
  return createReactiveSubscriptionWithInput((id) => ctx.adapter.readSpecRaw(id))(input.id);
@@ -2533,7 +2852,7 @@ const changeRouter = router({
2533
2852
  return ctx.adapter.listArchivedChanges();
2534
2853
  }),
2535
2854
  get: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
2536
- return ctx.adapter.readChange(input.id);
2855
+ return ctx.documentService.readChange(input.id);
2537
2856
  }),
2538
2857
  getRaw: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
2539
2858
  return ctx.adapter.readChangeRaw(input.id);
@@ -2585,7 +2904,7 @@ const archiveRouter = router({
2585
2904
  return ctx.adapter.listArchivedChangesWithMeta();
2586
2905
  }),
2587
2906
  get: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
2588
- return ctx.adapter.readArchivedChange(input.id);
2907
+ return ctx.documentService.readArchivedChange(input.id);
2589
2908
  }),
2590
2909
  getRaw: publicProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
2591
2910
  return ctx.adapter.readArchivedChangeRaw(input.id);
@@ -2594,7 +2913,7 @@ const archiveRouter = router({
2594
2913
  return createReactiveSubscription(() => ctx.adapter.listArchivedChangesWithMeta());
2595
2914
  }),
2596
2915
  subscribeOne: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
2597
- return createReactiveSubscriptionWithInput((id) => ctx.adapter.readArchivedChange(id))(input.id);
2916
+ return createReactiveSubscriptionWithInput((id) => ctx.documentService.readArchivedChange(id))(input.id);
2598
2917
  }),
2599
2918
  subscribeFiles: publicProcedure.input(z.object({ id: z.string() })).subscription(({ ctx, input }) => {
2600
2919
  return createReactiveSubscriptionWithInput((id) => ctx.adapter.readArchivedChangeFiles(id))(input.id);
@@ -2882,6 +3201,12 @@ const cliRouter = router({
2882
3201
  * OPSX router - CLI-driven workflow data
2883
3202
  */
2884
3203
  const opsxRouter = router({
3204
+ runWorkflow: publicProcedure.input(z.object({
3205
+ requestedMode: workflowRequestedModeSchema,
3206
+ input: runWorkflowInputSchema
3207
+ })).mutation(async ({ ctx, input }) => {
3208
+ return ctx.workflowInvocationService.runWorkflow(input.input, input.requestedMode);
3209
+ }),
2885
3210
  status: publicProcedure.input(z.object({
2886
3211
  change: z.string().optional(),
2887
3212
  schema: z.string().optional()
@@ -3350,11 +3675,11 @@ const appRouter = router({
3350
3675
  function joinParts(parts) {
3351
3676
  return parts.map((part) => part?.trim() ?? "").filter((part) => part.length > 0).join("\n\n");
3352
3677
  }
3353
- async function collectSearchDocuments(adapter) {
3678
+ async function collectSearchDocuments(adapter, documentService) {
3354
3679
  const docs = [];
3355
3680
  const specs = await adapter.listSpecsWithMeta();
3356
3681
  for (const spec of specs) {
3357
- const raw = await adapter.readSpecRaw(spec.id);
3682
+ const raw = documentService ? await documentService.readSpecRaw(spec.id, "search", "processed") : await adapter.readSpecRaw(spec.id);
3358
3683
  if (!raw) continue;
3359
3684
  docs.push({
3360
3685
  id: `spec:${spec.id}`,
@@ -3362,13 +3687,13 @@ async function collectSearchDocuments(adapter) {
3362
3687
  title: spec.name,
3363
3688
  href: `/specs/${encodeURIComponent(spec.id)}`,
3364
3689
  path: `openspec/specs/${spec.id}/spec.md`,
3365
- content: raw,
3690
+ content: typeof raw === "string" ? raw : raw.markdown,
3366
3691
  updatedAt: spec.updatedAt
3367
3692
  });
3368
3693
  }
3369
3694
  const changes = await adapter.listChangesWithMeta();
3370
3695
  for (const change of changes) {
3371
- const raw = await adapter.readChangeRaw(change.id);
3696
+ const raw = documentService ? await documentService.readChangeRaw(change.id, "search", "processed") : await adapter.readChangeRaw(change.id);
3372
3697
  if (!raw) continue;
3373
3698
  docs.push({
3374
3699
  id: `change:${change.id}`,
@@ -3377,9 +3702,9 @@ async function collectSearchDocuments(adapter) {
3377
3702
  href: `/changes/${encodeURIComponent(change.id)}`,
3378
3703
  path: `openspec/changes/${change.id}`,
3379
3704
  content: joinParts([
3380
- raw.proposal,
3381
- raw.tasks,
3382
- raw.design,
3705
+ typeof raw.proposal === "string" ? raw.proposal : raw.proposal.markdown,
3706
+ typeof raw.tasks === "string" ? raw.tasks : raw.tasks.markdown,
3707
+ typeof raw.design === "string" ? raw.design : raw.design?.markdown,
3383
3708
  ...raw.deltaSpecs.map((deltaSpec) => deltaSpec.content)
3384
3709
  ]),
3385
3710
  updatedAt: change.updatedAt
@@ -3387,7 +3712,7 @@ async function collectSearchDocuments(adapter) {
3387
3712
  }
3388
3713
  const archives = await adapter.listArchivedChangesWithMeta();
3389
3714
  for (const archive of archives) {
3390
- const raw = await adapter.readArchivedChangeRaw(archive.id);
3715
+ const raw = documentService ? await documentService.readArchivedChangeRaw(archive.id, "search", "processed") : await adapter.readArchivedChangeRaw(archive.id);
3391
3716
  if (!raw) continue;
3392
3717
  docs.push({
3393
3718
  id: `archive:${archive.id}`,
@@ -3396,9 +3721,9 @@ async function collectSearchDocuments(adapter) {
3396
3721
  href: `/archive/${encodeURIComponent(archive.id)}`,
3397
3722
  path: `openspec/changes/archive/${archive.id}`,
3398
3723
  content: joinParts([
3399
- raw.proposal,
3400
- raw.tasks,
3401
- raw.design,
3724
+ typeof raw.proposal === "string" ? raw.proposal : raw.proposal.markdown,
3725
+ typeof raw.tasks === "string" ? raw.tasks : raw.tasks.markdown,
3726
+ typeof raw.design === "string" ? raw.design : raw.design?.markdown,
3402
3727
  ...raw.deltaSpecs.map((deltaSpec) => deltaSpec.content)
3403
3728
  ]),
3404
3729
  updatedAt: archive.updatedAt
@@ -3416,8 +3741,9 @@ var SearchService = class {
3416
3741
  initPromise = null;
3417
3742
  rebuildPromise = null;
3418
3743
  rebuildTimer = null;
3419
- constructor(adapter, watcher, provider = new NodeWorkerSearchProvider()) {
3744
+ constructor(adapter, watcher, provider = new NodeWorkerSearchProvider(), documentService) {
3420
3745
  this.adapter = adapter;
3746
+ this.documentService = documentService;
3421
3747
  this.provider = provider;
3422
3748
  watcher?.on("change", () => {
3423
3749
  this.scheduleRebuild();
@@ -3463,7 +3789,7 @@ var SearchService = class {
3463
3789
  if (!forceInit && !this.initialized) return;
3464
3790
  if (this.rebuildPromise) return this.rebuildPromise;
3465
3791
  this.rebuildPromise = (async () => {
3466
- const docs = await collectSearchDocuments(this.adapter);
3792
+ const docs = await collectSearchDocuments(this.adapter, this.documentService);
3467
3793
  if (this.initialized) await this.provider.replaceAll(docs);
3468
3794
  else {
3469
3795
  await this.provider.init(docs);
@@ -3478,6 +3804,218 @@ var SearchService = class {
3478
3804
  }
3479
3805
  };
3480
3806
 
3807
+ //#endregion
3808
+ //#region src/workflow-invocation-service.ts
3809
+ const COMMAND_CAPABLE_ACTIONS = new Set([
3810
+ "propose",
3811
+ "apply",
3812
+ "archive"
3813
+ ]);
3814
+ const COMMAND_FALLBACK_REASONS = {
3815
+ continue: "Continue uses the selected artifact context, so compose mode is required.",
3816
+ ff: "Fast-forward from a change page uses the selected ready artifact, so compose mode is required."
3817
+ };
3818
+ function toErrorDiagnostic(error) {
3819
+ return {
3820
+ level: "error",
3821
+ message: error instanceof Error ? error.message : String(error)
3822
+ };
3823
+ }
3824
+ function withDiagnostics(result, diagnostics) {
3825
+ return {
3826
+ ...result,
3827
+ diagnostics: [...result.diagnostics ?? [], ...diagnostics]
3828
+ };
3829
+ }
3830
+ function resolveInvocationMode(action, requestedMode) {
3831
+ if (requestedMode !== "command" || COMMAND_CAPABLE_ACTIONS.has(action)) return {
3832
+ requestedMode,
3833
+ actualMode: requestedMode,
3834
+ fallbackReason: null
3835
+ };
3836
+ return {
3837
+ requestedMode,
3838
+ actualMode: "compose",
3839
+ fallbackReason: COMMAND_FALLBACK_REASONS[action] ?? "This action requires compose mode."
3840
+ };
3841
+ }
3842
+ function buildProposeComposePrompt(text) {
3843
+ const normalized = text.trim();
3844
+ 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");
3845
+ return [
3846
+ `Propose a new OpenSpec change for: ${normalized}`,
3847
+ "",
3848
+ "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`."
3849
+ ].join("\n");
3850
+ }
3851
+ function buildSlashCommand(input) {
3852
+ switch (input.action) {
3853
+ case "propose": {
3854
+ const normalized = input.text.trim();
3855
+ if (normalized.length === 0) return "/opsx:propose";
3856
+ if (normalized.startsWith("/opsx:")) return normalized;
3857
+ return `/opsx:propose ${normalized}`;
3858
+ }
3859
+ case "apply":
3860
+ case "archive": return `/opsx:${input.action} ${input.changeId.trim()}`;
3861
+ default: return null;
3862
+ }
3863
+ }
3864
+ async function captureCliText(execute, args, fallback) {
3865
+ const result = await execute(args);
3866
+ const text = result.stdout.trim().length > 0 ? result.stdout.trim() : fallback;
3867
+ if (result.success) return { text };
3868
+ return {
3869
+ text,
3870
+ diagnostics: [{
3871
+ level: "warning",
3872
+ message: result.stderr || `openspec command exited with code ${result.exitCode ?? "null"}`
3873
+ }]
3874
+ };
3875
+ }
3876
+ function buildFallbackPrompt(input) {
3877
+ switch (input.action) {
3878
+ case "continue": return `Continue artifact ${input.artifactId} for change ${input.changeId}.`;
3879
+ case "ff": return `Fast-forward artifact ${input.artifactId} for change ${input.changeId}.`;
3880
+ case "apply": return `Apply change ${input.changeId} based on current completed artifacts.`;
3881
+ case "archive": return `Archive change ${input.changeId} after verifying completion and risks.`;
3882
+ case "sync": return `Sync specs for change ${input.changeId}.`;
3883
+ case "verify": return `Verify change ${input.changeId}.`;
3884
+ case "bulk-archive": return `Archive completed changes${input.changeIds?.length ? `: ${input.changeIds.join(", ")}` : ""}.`;
3885
+ case "explore":
3886
+ case "propose": return buildProposeComposePrompt(input.text);
3887
+ case "new": return `Create OpenSpec change ${input.changeId}.`;
3888
+ case "onboard": return "Start OpenSpec onboarding for this project.";
3889
+ }
3890
+ }
3891
+ function buildArchivePrompt(changeId, statusText) {
3892
+ const normalized = statusText.trim();
3893
+ return [
3894
+ `Archive planning for change "${changeId}".`,
3895
+ "",
3896
+ "Current openspec status:",
3897
+ "```text",
3898
+ normalized.length > 0 ? normalized : "(no status output)",
3899
+ "```",
3900
+ "",
3901
+ "Please confirm archive readiness, highlight risks, and provide the exact next steps."
3902
+ ].join("\n");
3903
+ }
3904
+ var WorkflowInvocationService = class {
3905
+ constructor(options) {
3906
+ this.options = options;
3907
+ }
3908
+ async runWorkflow(input, requestedMode, signal = new AbortController().signal) {
3909
+ const mode = resolveInvocationMode(input.action, requestedMode);
3910
+ const run = () => this.runDefault(input, mode);
3911
+ const hooks = await this.options.hookRuntime.load();
3912
+ if (!hooks.onRunWorkflow) return run();
3913
+ try {
3914
+ return await hooks.onRunWorkflow({
3915
+ version: OPENSPECUI_HOOKS_VERSION,
3916
+ projectDir: this.options.projectDir,
3917
+ action: input.action,
3918
+ requestedMode,
3919
+ input,
3920
+ signal,
3921
+ lifecycle: this.options.hookRuntime
3922
+ }, run);
3923
+ } catch (error) {
3924
+ return withDiagnostics(await run(), [toErrorDiagnostic(error)]);
3925
+ }
3926
+ }
3927
+ async runDefault(input, mode) {
3928
+ if (mode.actualMode === "command") {
3929
+ const text = buildSlashCommand(input);
3930
+ if (text) return {
3931
+ kind: "agent-command",
3932
+ text,
3933
+ mode
3934
+ };
3935
+ }
3936
+ if (input.action === "new") {
3937
+ const args = [
3938
+ "new",
3939
+ "change",
3940
+ input.changeId.trim()
3941
+ ];
3942
+ const schema = input.schema?.trim();
3943
+ const description = input.description?.trim();
3944
+ if (schema) args.push("--schema", schema);
3945
+ if (description) args.push("--description", description);
3946
+ args.push(...input.extraArgs.map((arg) => arg.trim()).filter((arg) => arg.length > 0));
3947
+ return {
3948
+ kind: "cli-command",
3949
+ command: "openspec",
3950
+ args,
3951
+ mode
3952
+ };
3953
+ }
3954
+ if (input.action === "verify") {
3955
+ const args = [
3956
+ "validate",
3957
+ input.changeId,
3958
+ "--type",
3959
+ "change"
3960
+ ];
3961
+ if (input.strict) args.push("--strict");
3962
+ return {
3963
+ kind: "cli-command",
3964
+ command: "openspec",
3965
+ args,
3966
+ mode
3967
+ };
3968
+ }
3969
+ if (input.action === "propose" || input.action === "explore") return {
3970
+ kind: "agent-prompt",
3971
+ text: buildProposeComposePrompt(input.text),
3972
+ format: "markdown",
3973
+ mode
3974
+ };
3975
+ const executeCli = this.options.executeCli;
3976
+ if (executeCli && (input.action === "continue" || input.action === "ff" || input.action === "apply" || input.action === "archive")) {
3977
+ if ((input.action === "continue" || input.action === "ff") && !input.artifactId.trim()) return {
3978
+ kind: "agent-prompt",
3979
+ text: buildFallbackPrompt(input),
3980
+ format: "markdown",
3981
+ mode,
3982
+ diagnostics: [{
3983
+ level: "warning",
3984
+ message: "Artifact id is required for this action."
3985
+ }]
3986
+ };
3987
+ const captured = await captureCliText(executeCli, input.action === "continue" || input.action === "ff" ? [
3988
+ "instructions",
3989
+ input.artifactId,
3990
+ "--change",
3991
+ input.changeId
3992
+ ] : input.action === "apply" ? [
3993
+ "instructions",
3994
+ "apply",
3995
+ "--change",
3996
+ input.changeId
3997
+ ] : [
3998
+ "status",
3999
+ "--change",
4000
+ input.changeId
4001
+ ], buildFallbackPrompt(input));
4002
+ return {
4003
+ kind: "agent-prompt",
4004
+ text: input.action === "archive" ? buildArchivePrompt(input.changeId, captured.text) : captured.text,
4005
+ format: "markdown",
4006
+ mode,
4007
+ diagnostics: captured.diagnostics
4008
+ };
4009
+ }
4010
+ return {
4011
+ kind: "agent-prompt",
4012
+ text: buildFallbackPrompt(input),
4013
+ format: "markdown",
4014
+ mode
4015
+ };
4016
+ }
4017
+ };
4018
+
3481
4019
  //#endregion
3482
4020
  //#region src/server.ts
3483
4021
  /**
@@ -3514,8 +4052,15 @@ function createServer(config) {
3514
4052
  const configManager = new ConfigManager(config.projectDir);
3515
4053
  const cliExecutor = new CliExecutor(configManager, config.projectDir);
3516
4054
  const kernel = config.kernel;
4055
+ const hookRuntime = createHookRuntime(config.projectDir);
4056
+ const documentService = new DocumentService(config.projectDir, adapter, hookRuntime);
4057
+ const workflowInvocationService = new WorkflowInvocationService({
4058
+ projectDir: config.projectDir,
4059
+ hookRuntime,
4060
+ executeCli: (args) => cliExecutor.execute(args)
4061
+ });
3517
4062
  const watcher = config.enableWatcher !== false ? new OpenSpecWatcher(config.projectDir) : void 0;
3518
- const searchService = new SearchService(adapter, watcher);
4063
+ const searchService = new SearchService(adapter, watcher, void 0, documentService);
3519
4064
  const dashboardOverviewService = new DashboardOverviewService((reason) => loadDashboardOverview({
3520
4065
  adapter,
3521
4066
  configManager,
@@ -3550,8 +4095,10 @@ function createServer(config) {
3550
4095
  createContext: () => ({
3551
4096
  adapter,
3552
4097
  configManager,
4098
+ documentService,
3553
4099
  cliExecutor,
3554
4100
  kernel,
4101
+ workflowInvocationService,
3555
4102
  searchService,
3556
4103
  dashboardOverviewService,
3557
4104
  projectRecoveryService,
@@ -3564,8 +4111,10 @@ function createServer(config) {
3564
4111
  const createContext = () => ({
3565
4112
  adapter,
3566
4113
  configManager,
4114
+ documentService,
3567
4115
  cliExecutor,
3568
4116
  kernel,
4117
+ workflowInvocationService,
3569
4118
  searchService,
3570
4119
  dashboardOverviewService,
3571
4120
  projectRecoveryService,
@@ -3577,11 +4126,14 @@ function createServer(config) {
3577
4126
  app,
3578
4127
  adapter,
3579
4128
  configManager,
4129
+ documentService,
3580
4130
  cliExecutor,
3581
4131
  kernel,
4132
+ workflowInvocationService,
3582
4133
  searchService,
3583
4134
  dashboardOverviewService,
3584
4135
  projectRecoveryService,
4136
+ hookRuntime,
3585
4137
  watcher,
3586
4138
  createContext,
3587
4139
  port: config.port ?? 3100
@@ -3675,6 +4227,7 @@ async function startServer(config, setupApp) {
3675
4227
  preferredPort,
3676
4228
  close: async () => {
3677
4229
  kernel.dispose();
4230
+ await server.hookRuntime.dispose();
3678
4231
  wsServer.close();
3679
4232
  httpServer.close();
3680
4233
  }
@@ -3682,4 +4235,4 @@ async function startServer(config, setupApp) {
3682
4235
  }
3683
4236
 
3684
4237
  //#endregion
3685
- export { createServer, createWebSocketServer, findAvailablePort, isPortAvailable, startServer };
4238
+ 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.2",
3
+ "version": "3.3.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
  },