openspecui 1.4.0 → 1.5.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 (34) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cli.mjs +9 -6
  3. package/dist/index.mjs +1 -1
  4. package/dist/{open-BVmQScxd.mjs → open-DDagk2eo.mjs} +2 -2
  5. package/dist/{src-kxKAXq88.mjs → src-16GA3our.mjs} +826 -141
  6. package/package.json +3 -3
  7. package/web/assets/{BufferResource-C85AhGv8.js → BufferResource-Bn1UWy0D.js} +1 -1
  8. package/web/assets/{CanvasRenderer-CgtmVcg4.js → CanvasRenderer-D8NiU8la.js} +1 -1
  9. package/web/assets/{Filter-Cb47-EZS.js → Filter-CRwq487x.js} +1 -1
  10. package/web/assets/{RenderTargetSystem-Cx8qJk_q.js → RenderTargetSystem-CtoB_qTm.js} +1 -1
  11. package/web/assets/{WebGLRenderer-pIi2Bx_Q.js → WebGLRenderer-BgKO8R0a.js} +1 -1
  12. package/web/assets/{WebGPURenderer-C3CBA4dG.js → WebGPURenderer-CQeL2efC.js} +1 -1
  13. package/web/assets/{browserAll-C_q9nnWk.js → browserAll-DP6sOYev.js} +1 -1
  14. package/web/assets/{ghostty-web-D6mGnCnU.js → ghostty-web-evxujSxm.js} +1 -1
  15. package/web/assets/{index-C79ew42C.js → index-4MAU81Qk.js} +1 -1
  16. package/web/assets/{index-BwQ_9hzT.js → index-B0IbsqHi.js} +1 -1
  17. package/web/assets/{index-ur_rMFp9.js → index-B147AOgf.js} +1 -1
  18. package/web/assets/{index-TCkbFaCm.js → index-BMashGQn.js} +1 -1
  19. package/web/assets/{index-D45XwNhE.js → index-BPZ3nG0r.js} +1 -1
  20. package/web/assets/{index-BBPTFxy1.js → index-BejnsZfY.js} +1 -1
  21. package/web/assets/{index-CGrYIgSe.js → index-BnT52DZ8.js} +1 -1
  22. package/web/assets/{index-BSqKqaGj.js → index-CBCPR3Qb.js} +1 -1
  23. package/web/assets/{index-LWU4Mw81.js → index-D2Tp4F9B.js} +1 -1
  24. package/web/assets/{index-CGweB5Ib.js → index-D6ardy54.js} +1 -1
  25. package/web/assets/{index-knJhpHqo.js → index-DJqmTRAR.js} +1 -1
  26. package/web/assets/{index-B5M3Dg-Q.js → index-DTeOcXKn.js} +1 -1
  27. package/web/assets/{index-C6pjde1Q.js → index-DcXyAs0z.js} +1 -1
  28. package/web/assets/{index-3eymcnUu.js → index-T8xoxmUb.js} +218 -215
  29. package/web/assets/index-Ys2MTD3W.css +1 -0
  30. package/web/assets/{index-CyqwjCgH.js → index-dSf1u0YV.js} +1 -1
  31. package/web/assets/{index-D8KhKUsi.js → index-f0QdJSzm.js} +1 -1
  32. package/web/assets/{webworkerAll-Cq063Dqj.js → webworkerAll-DA2HufNb.js} +1 -1
  33. package/web/index.html +2 -2
  34. package/web/assets/index-BImvtc4B.css +0 -1
@@ -1,5 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
- import { createServer } from "http";
2
+ import { createServer } from "node:net";
3
+ import { createServer as createServer$1 } from "http";
3
4
  import { Http2ServerRequest } from "http2";
4
5
  import { Readable } from "stream";
5
6
  import crypto from "crypto";
@@ -9,13 +10,14 @@ import { AsyncLocalStorage } from "node:async_hooks";
9
10
  import { mkdir as mkdir$1, readFile as readFile$1, readdir, rm, stat, writeFile as writeFile$1 } from "node:fs/promises";
10
11
  import { dirname as dirname$1, join as join$1, matchesGlob, relative as relative$1, resolve as resolve$1, sep } from "node:path";
11
12
  import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
12
- import { watch } from "fs";
13
13
  import { EventEmitter } from "events";
14
+ import { watch } from "fs";
14
15
  import { exec, spawn } from "child_process";
15
16
  import { promisify } from "util";
16
- import { createServer as createServer$1 } from "node:net";
17
17
  import * as pty from "@lydell/node-pty";
18
+ import { execFile } from "node:child_process";
18
19
  import { EventEmitter as EventEmitter$1 } from "node:events";
20
+ import { promisify as promisify$1 } from "node:util";
19
21
  import { Worker as Worker$1 } from "node:worker_threads";
20
22
  import { fileURLToPath } from "node:url";
21
23
 
@@ -49,6 +51,41 @@ var __toESM$1 = (mod, isNodeMode, target) => (target = mod != null ? __create$1(
49
51
  }) : target, mod));
50
52
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
51
53
 
54
+ //#endregion
55
+ //#region ../server/src/port-utils.ts
56
+ /**
57
+ * Check if a port is available by trying to listen on it.
58
+ * Uses default binding (both IPv4 and IPv6) to detect conflicts.
59
+ */
60
+ function isPortAvailable(port) {
61
+ return new Promise((resolve$2) => {
62
+ const server = createServer();
63
+ server.once("error", () => {
64
+ resolve$2(false);
65
+ });
66
+ server.once("listening", () => {
67
+ server.close(() => resolve$2(true));
68
+ });
69
+ server.listen(port);
70
+ });
71
+ }
72
+ /**
73
+ * Find an available port starting from the given port.
74
+ * Will try up to maxAttempts ports sequentially.
75
+ *
76
+ * @param startPort - The preferred port to start checking from
77
+ * @param maxAttempts - Maximum number of ports to try (default: 10)
78
+ * @returns The first available port found
79
+ * @throws Error if no available port is found in the range
80
+ */
81
+ async function findAvailablePort(startPort, maxAttempts = 10) {
82
+ for (let i = 0; i < maxAttempts; i++) {
83
+ const port = startPort + i;
84
+ if (await isPortAvailable(port)) return port;
85
+ }
86
+ throw new Error(`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`);
87
+ }
88
+
52
89
  //#endregion
53
90
  //#region ../../node_modules/.pnpm/@hono+node-server@1.19.6_hono@4.10.6/node_modules/@hono/node-server/dist/index.mjs
54
91
  var RequestError = class extends Error {
@@ -476,7 +513,7 @@ var createAdaptorServer = (options) => {
476
513
  overrideGlobalObjects: options.overrideGlobalObjects,
477
514
  autoCleanupIncoming: options.autoCleanupIncoming
478
515
  });
479
- return (options.createServer || createServer)(options.serverOptions || {}, requestListener);
516
+ return (options.createServer || createServer$1)(options.serverOptions || {}, requestListener);
480
517
  };
481
518
  var serve = (options, listeningListener) => {
482
519
  const server = createAdaptorServer(options);
@@ -697,14 +734,14 @@ var MarkdownParser = class {
697
734
  if (currentOperation === "RENAMED") {
698
735
  const fromMatch = line.match(/FROM:\s*`?###\s*Requirement:\s*(.+?)`?$/i);
699
736
  const toMatch = line.match(/TO:\s*`?###\s*Requirement:\s*(.+?)`?$/i);
700
- if (fromMatch) renameBuffer = {
701
- ...renameBuffer ?? {},
702
- from: fromMatch[1].trim()
703
- };
704
- if (toMatch) renameBuffer = {
705
- ...renameBuffer ?? {},
706
- to: toMatch[1].trim()
707
- };
737
+ if (fromMatch) {
738
+ if (!renameBuffer) renameBuffer = {};
739
+ renameBuffer.from = fromMatch[1].trim();
740
+ }
741
+ if (toMatch) {
742
+ if (!renameBuffer) renameBuffer = {};
743
+ renameBuffer.to = toMatch[1].trim();
744
+ }
708
745
  if (renameBuffer?.from && renameBuffer?.to) {
709
746
  deltas.push({
710
747
  spec: deltaSpec.specId,
@@ -789,86 +826,6 @@ var MarkdownParser = class {
789
826
  }
790
827
  };
791
828
 
792
- //#endregion
793
- //#region ../core/src/validator.ts
794
- /**
795
- * Validator for OpenSpec documents
796
- */
797
- var Validator = class {
798
- /**
799
- * Validate a spec document
800
- */
801
- validateSpec(spec) {
802
- const issues = [];
803
- if (!spec.overview || spec.overview.trim().length === 0) issues.push({
804
- severity: "ERROR",
805
- message: "Spec must have a Purpose/Overview section",
806
- path: "overview"
807
- });
808
- if (spec.requirements.length === 0) issues.push({
809
- severity: "ERROR",
810
- message: "Spec must have at least one requirement",
811
- path: "requirements"
812
- });
813
- for (const req of spec.requirements) {
814
- if (!req.text.includes("SHALL") && !req.text.includes("MUST")) issues.push({
815
- severity: "WARNING",
816
- message: `Requirement should contain "SHALL" or "MUST": ${req.id}`,
817
- path: `requirements.${req.id}`
818
- });
819
- if (req.scenarios.length === 0) issues.push({
820
- severity: "WARNING",
821
- message: `Requirement should have at least one scenario: ${req.id}`,
822
- path: `requirements.${req.id}.scenarios`
823
- });
824
- if (req.text.length > 1e3) issues.push({
825
- severity: "WARNING",
826
- message: `Requirement text is too long (max 1000 chars): ${req.id}`,
827
- path: `requirements.${req.id}.text`
828
- });
829
- }
830
- return {
831
- valid: issues.filter((i) => i.severity === "ERROR").length === 0,
832
- issues
833
- };
834
- }
835
- /**
836
- * Validate a change proposal
837
- */
838
- validateChange(change) {
839
- const issues = [];
840
- if (!change.why || change.why.length < 50) issues.push({
841
- severity: "ERROR",
842
- message: "Change \"Why\" section must be at least 50 characters",
843
- path: "why"
844
- });
845
- if (change.why && change.why.length > 500) issues.push({
846
- severity: "WARNING",
847
- message: "Change \"Why\" section should be under 500 characters",
848
- path: "why"
849
- });
850
- if (!change.whatChanges || change.whatChanges.trim().length === 0) issues.push({
851
- severity: "ERROR",
852
- message: "Change must have a \"What Changes\" section",
853
- path: "whatChanges"
854
- });
855
- if (change.deltas.length === 0) issues.push({
856
- severity: "WARNING",
857
- message: "Change should have at least one delta",
858
- path: "deltas"
859
- });
860
- if (change.deltas.length > 50) issues.push({
861
- severity: "WARNING",
862
- message: "Change has too many deltas (max 50)",
863
- path: "deltas"
864
- });
865
- return {
866
- valid: issues.filter((i) => i.severity === "ERROR").length === 0,
867
- issues
868
- };
869
- }
870
- };
871
-
872
829
  //#endregion
873
830
  //#region ../core/src/reactive-fs/reactive-state.ts
874
831
  /**
@@ -1713,6 +1670,86 @@ async function reactiveStat(path$1) {
1713
1670
  return state.get();
1714
1671
  }
1715
1672
 
1673
+ //#endregion
1674
+ //#region ../core/src/validator.ts
1675
+ /**
1676
+ * Validator for OpenSpec documents
1677
+ */
1678
+ var Validator = class {
1679
+ /**
1680
+ * Validate a spec document
1681
+ */
1682
+ validateSpec(spec) {
1683
+ const issues = [];
1684
+ if (!spec.overview || spec.overview.trim().length === 0) issues.push({
1685
+ severity: "ERROR",
1686
+ message: "Spec must have a Purpose/Overview section",
1687
+ path: "overview"
1688
+ });
1689
+ if (spec.requirements.length === 0) issues.push({
1690
+ severity: "ERROR",
1691
+ message: "Spec must have at least one requirement",
1692
+ path: "requirements"
1693
+ });
1694
+ for (const req of spec.requirements) {
1695
+ if (!req.text.includes("SHALL") && !req.text.includes("MUST")) issues.push({
1696
+ severity: "WARNING",
1697
+ message: `Requirement should contain "SHALL" or "MUST": ${req.id}`,
1698
+ path: `requirements.${req.id}`
1699
+ });
1700
+ if (req.scenarios.length === 0) issues.push({
1701
+ severity: "WARNING",
1702
+ message: `Requirement should have at least one scenario: ${req.id}`,
1703
+ path: `requirements.${req.id}.scenarios`
1704
+ });
1705
+ if (req.text.length > 1e3) issues.push({
1706
+ severity: "WARNING",
1707
+ message: `Requirement text is too long (max 1000 chars): ${req.id}`,
1708
+ path: `requirements.${req.id}.text`
1709
+ });
1710
+ }
1711
+ return {
1712
+ valid: issues.filter((i) => i.severity === "ERROR").length === 0,
1713
+ issues
1714
+ };
1715
+ }
1716
+ /**
1717
+ * Validate a change proposal
1718
+ */
1719
+ validateChange(change) {
1720
+ const issues = [];
1721
+ if (!change.why || change.why.length < 50) issues.push({
1722
+ severity: "ERROR",
1723
+ message: "Change \"Why\" section must be at least 50 characters",
1724
+ path: "why"
1725
+ });
1726
+ if (change.why && change.why.length > 500) issues.push({
1727
+ severity: "WARNING",
1728
+ message: "Change \"Why\" section should be under 500 characters",
1729
+ path: "why"
1730
+ });
1731
+ if (!change.whatChanges || change.whatChanges.trim().length === 0) issues.push({
1732
+ severity: "ERROR",
1733
+ message: "Change must have a \"What Changes\" section",
1734
+ path: "whatChanges"
1735
+ });
1736
+ if (change.deltas.length === 0) issues.push({
1737
+ severity: "WARNING",
1738
+ message: "Change should have at least one delta",
1739
+ path: "deltas"
1740
+ });
1741
+ if (change.deltas.length > 50) issues.push({
1742
+ severity: "WARNING",
1743
+ message: "Change has too many deltas (max 50)",
1744
+ path: "deltas"
1745
+ });
1746
+ return {
1747
+ valid: issues.filter((i) => i.severity === "ERROR").length === 0,
1748
+ issues
1749
+ };
1750
+ }
1751
+ };
1752
+
1716
1753
  //#endregion
1717
1754
  //#region ../core/src/adapter.ts
1718
1755
  /**
@@ -1892,17 +1929,17 @@ var OpenSpecAdapter = class {
1892
1929
  const fullPath = join(dir, name);
1893
1930
  const statInfo = await reactiveStat(fullPath);
1894
1931
  if (!statInfo) continue;
1895
- const relativePath = fullPath.slice(root.length + 1);
1932
+ const relativePath$1 = fullPath.slice(root.length + 1);
1896
1933
  if (statInfo.isDirectory) {
1897
1934
  files.push({
1898
- path: relativePath,
1935
+ path: relativePath$1,
1899
1936
  type: "directory"
1900
1937
  });
1901
1938
  files.push(...await this.collectChangeFiles(root, fullPath));
1902
1939
  } else {
1903
1940
  const content = await reactiveReadFile(fullPath);
1904
1941
  files.push({
1905
- path: relativePath,
1942
+ path: relativePath$1,
1906
1943
  type: "file",
1907
1944
  content: content ?? void 0
1908
1945
  });
@@ -6244,6 +6281,7 @@ const TerminalConfigSchema = objectType({
6244
6281
  scrollback: numberType().min(0).max(1e5).default(1e3),
6245
6282
  rendererEngine: stringType().default("xterm")
6246
6283
  });
6284
+ const DashboardConfigSchema = objectType({ trendPointLimit: numberType().int().min(20).max(500).default(100) });
6247
6285
  /**
6248
6286
  * OpenSpecUI 配置 Schema
6249
6287
  *
@@ -6255,13 +6293,15 @@ const OpenSpecUIConfigSchema = objectType({
6255
6293
  args: arrayType(stringType()).optional()
6256
6294
  }).default({}),
6257
6295
  theme: enumType(THEME_VALUES).default("system"),
6258
- terminal: TerminalConfigSchema.default(TerminalConfigSchema.parse({}))
6296
+ terminal: TerminalConfigSchema.default(TerminalConfigSchema.parse({})),
6297
+ dashboard: DashboardConfigSchema.default(DashboardConfigSchema.parse({}))
6259
6298
  });
6260
6299
  /** 默认配置(静态,用于测试和类型) */
6261
6300
  const DEFAULT_CONFIG = {
6262
6301
  cli: {},
6263
6302
  theme: "system",
6264
- terminal: TerminalConfigSchema.parse({})
6303
+ terminal: TerminalConfigSchema.parse({}),
6304
+ dashboard: DashboardConfigSchema.parse({})
6265
6305
  };
6266
6306
  /**
6267
6307
  * 配置管理器
@@ -6327,6 +6367,10 @@ var ConfigManager = class {
6327
6367
  terminal: {
6328
6368
  ...current.terminal,
6329
6369
  ...config.terminal
6370
+ },
6371
+ dashboard: {
6372
+ ...current.dashboard,
6373
+ ...config.dashboard
6330
6374
  }
6331
6375
  };
6332
6376
  const serialized = JSON.stringify(merged, null, 2);
@@ -7039,6 +7083,17 @@ async function getConfiguredTools(projectDir) {
7039
7083
  return state.get();
7040
7084
  }
7041
7085
 
7086
+ //#endregion
7087
+ //#region ../core/src/dashboard-types.ts
7088
+ const DASHBOARD_METRIC_KEYS = [
7089
+ "specifications",
7090
+ "requirements",
7091
+ "activeChanges",
7092
+ "inProgressChanges",
7093
+ "completedChanges",
7094
+ "taskCompletionPercent"
7095
+ ];
7096
+
7042
7097
  //#endregion
7043
7098
  //#region ../core/src/opsx-types.ts
7044
7099
  const ArtifactStatusSchema = objectType({
@@ -13838,10 +13893,10 @@ async function readEntriesUnderRoot(root) {
13838
13893
  const fullPath = join$1(dir, name);
13839
13894
  const statInfo = await reactiveStat(fullPath);
13840
13895
  if (!statInfo) continue;
13841
- const relativePath = toRelativePath(root, fullPath);
13896
+ const relativePath$1 = toRelativePath(root, fullPath);
13842
13897
  if (statInfo.isDirectory) {
13843
13898
  entries.push({
13844
- path: relativePath,
13899
+ path: relativePath$1,
13845
13900
  type: "directory"
13846
13901
  });
13847
13902
  entries.push(...await collectEntries(fullPath));
@@ -13849,7 +13904,7 @@ async function readEntriesUnderRoot(root) {
13849
13904
  const content = await reactiveReadFile(fullPath);
13850
13905
  const size = content ? Buffer.byteLength(content, "utf-8") : void 0;
13851
13906
  entries.push({
13852
- path: relativePath,
13907
+ path: relativePath$1,
13853
13908
  type: "file",
13854
13909
  content: content ?? void 0,
13855
13910
  size
@@ -22659,41 +22714,6 @@ var import_sender = /* @__PURE__ */ __toESM$1(require_sender(), 1);
22659
22714
  var import_websocket = /* @__PURE__ */ __toESM$1(require_websocket(), 1);
22660
22715
  var import_websocket_server = /* @__PURE__ */ __toESM$1(require_websocket_server(), 1);
22661
22716
 
22662
- //#endregion
22663
- //#region ../server/src/port-utils.ts
22664
- /**
22665
- * Check if a port is available by trying to listen on it.
22666
- * Uses default binding (both IPv4 and IPv6) to detect conflicts.
22667
- */
22668
- function isPortAvailable(port) {
22669
- return new Promise((resolve$2) => {
22670
- const server = createServer$1();
22671
- server.once("error", () => {
22672
- resolve$2(false);
22673
- });
22674
- server.once("listening", () => {
22675
- server.close(() => resolve$2(true));
22676
- });
22677
- server.listen(port);
22678
- });
22679
- }
22680
- /**
22681
- * Find an available port starting from the given port.
22682
- * Will try up to maxAttempts ports sequentially.
22683
- *
22684
- * @param startPort - The preferred port to start checking from
22685
- * @param maxAttempts - Maximum number of ports to try (default: 10)
22686
- * @returns The first available port found
22687
- * @throws Error if no available port is found in the range
22688
- */
22689
- async function findAvailablePort(startPort, maxAttempts = 10) {
22690
- for (let i = 0; i < maxAttempts; i++) {
22691
- const port = startPort + i;
22692
- if (await isPortAvailable(port)) return port;
22693
- }
22694
- throw new Error(`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`);
22695
- }
22696
-
22697
22717
  //#endregion
22698
22718
  //#region ../server/src/pty-manager.ts
22699
22719
  const DEFAULT_SCROLLBACK = 1e3;
@@ -23064,7 +23084,7 @@ function createPtyWebSocketHandler(ptyManager) {
23064
23084
  }
23065
23085
 
23066
23086
  //#endregion
23067
- //#region ../search/dist/worker-source-CMPZlh9-.mjs
23087
+ //#region ../search/src/protocol.ts
23068
23088
  const SearchDocumentKindSchema = enumType([
23069
23089
  "spec",
23070
23090
  "change",
@@ -23130,6 +23150,9 @@ const SearchWorkerResponseSchema = discriminatedUnionType("type", [
23130
23150
  message: stringType()
23131
23151
  })
23132
23152
  ]);
23153
+
23154
+ //#endregion
23155
+ //#region ../search/src/worker-source.ts
23133
23156
  const sharedRuntimeSource = String.raw`
23134
23157
  const DEFAULT_LIMIT = 50;
23135
23158
  const MAX_LIMIT = 200;
@@ -23347,6 +23370,368 @@ function createCliStreamObservable(startStream) {
23347
23370
  });
23348
23371
  }
23349
23372
 
23373
+ //#endregion
23374
+ //#region ../server/src/dashboard-git-snapshot.ts
23375
+ const execFileAsync$1 = promisify$1(execFile);
23376
+ const EMPTY_DIFF = {
23377
+ files: 0,
23378
+ insertions: 0,
23379
+ deletions: 0
23380
+ };
23381
+ async function defaultRunGit(cwd, args) {
23382
+ try {
23383
+ const { stdout } = await execFileAsync$1("git", args, {
23384
+ cwd,
23385
+ encoding: "utf8",
23386
+ maxBuffer: 8 * 1024 * 1024
23387
+ });
23388
+ return {
23389
+ ok: true,
23390
+ stdout
23391
+ };
23392
+ } catch {
23393
+ return {
23394
+ ok: false,
23395
+ stdout: ""
23396
+ };
23397
+ }
23398
+ }
23399
+ function parseShortStat(output) {
23400
+ const files = Number(/(\d+)\s+files? changed/.exec(output)?.[1] ?? 0);
23401
+ const insertions = Number(/(\d+)\s+insertions?\(\+\)/.exec(output)?.[1] ?? 0);
23402
+ const deletions = Number(/(\d+)\s+deletions?\(-\)/.exec(output)?.[1] ?? 0);
23403
+ return {
23404
+ files: Number.isFinite(files) ? files : 0,
23405
+ insertions: Number.isFinite(insertions) ? insertions : 0,
23406
+ deletions: Number.isFinite(deletions) ? deletions : 0
23407
+ };
23408
+ }
23409
+ function parseNumStat(output) {
23410
+ let files = 0;
23411
+ let insertions = 0;
23412
+ let deletions = 0;
23413
+ for (const line of output.split("\n")) {
23414
+ const trimmed = line.trim();
23415
+ if (!trimmed) continue;
23416
+ const [addRaw, deleteRaw] = trimmed.split(" ");
23417
+ if (!addRaw || !deleteRaw) continue;
23418
+ files += 1;
23419
+ if (addRaw !== "-") insertions += Number(addRaw) || 0;
23420
+ if (deleteRaw !== "-") deletions += Number(deleteRaw) || 0;
23421
+ }
23422
+ return {
23423
+ files,
23424
+ insertions,
23425
+ deletions
23426
+ };
23427
+ }
23428
+ function normalizeGitPath(path$1) {
23429
+ return path$1.replace(/\\/g, "/").replace(/^\.\//, "");
23430
+ }
23431
+ function relativePath(fromDir, target) {
23432
+ const rel = relative$1(fromDir, target);
23433
+ if (!rel || rel.length === 0) return ".";
23434
+ return rel;
23435
+ }
23436
+ function parseBranchName(branchRef, detached) {
23437
+ if (detached) return "(detached)";
23438
+ if (!branchRef) return "(unknown)";
23439
+ return branchRef.replace(/^refs\/heads\//, "");
23440
+ }
23441
+ function parseWorktreeList(porcelain) {
23442
+ const entries = [];
23443
+ let current = null;
23444
+ const flush = () => {
23445
+ if (!current) return;
23446
+ entries.push(current);
23447
+ current = null;
23448
+ };
23449
+ for (const line of porcelain.split("\n")) {
23450
+ if (line.startsWith("worktree ")) {
23451
+ flush();
23452
+ current = {
23453
+ path: line.slice(9).trim(),
23454
+ branchRef: null,
23455
+ detached: false
23456
+ };
23457
+ continue;
23458
+ }
23459
+ if (!current) continue;
23460
+ if (line.startsWith("branch ")) {
23461
+ current.branchRef = line.slice(7).trim();
23462
+ continue;
23463
+ }
23464
+ if (line === "detached") {
23465
+ current.detached = true;
23466
+ continue;
23467
+ }
23468
+ }
23469
+ flush();
23470
+ return entries;
23471
+ }
23472
+ function parseRelatedChanges(paths) {
23473
+ const related = /* @__PURE__ */ new Set();
23474
+ for (const path$1 of paths) {
23475
+ const normalized = normalizeGitPath(path$1);
23476
+ const activeMatch = /^openspec\/changes\/([^/]+)\//.exec(normalized);
23477
+ if (activeMatch?.[1]) {
23478
+ related.add(activeMatch[1]);
23479
+ continue;
23480
+ }
23481
+ const archiveMatch = /^openspec\/changes\/archive\/([^/]+)\//.exec(normalized);
23482
+ if (archiveMatch?.[1]) {
23483
+ const fullName = archiveMatch[1];
23484
+ related.add(fullName.replace(/^\d{4}-\d{2}-\d{2}-/, ""));
23485
+ }
23486
+ }
23487
+ return [...related].sort((a, b) => a.localeCompare(b));
23488
+ }
23489
+ async function resolveDefaultBranch(projectDir, runGit) {
23490
+ const remoteHead = await runGit(projectDir, [
23491
+ "symbolic-ref",
23492
+ "--quiet",
23493
+ "--short",
23494
+ "refs/remotes/origin/HEAD"
23495
+ ]);
23496
+ const remoteRef = remoteHead.stdout.trim();
23497
+ if (remoteHead.ok && remoteRef) return remoteRef;
23498
+ const localHead = await runGit(projectDir, [
23499
+ "rev-parse",
23500
+ "--abbrev-ref",
23501
+ "HEAD"
23502
+ ]);
23503
+ const localRef = localHead.stdout.trim();
23504
+ if (localHead.ok && localRef && localRef !== "HEAD") return localRef;
23505
+ return "main";
23506
+ }
23507
+ async function collectCommitEntries(options) {
23508
+ const { worktreePath, defaultBranch, maxCommitEntries, runGit } = options;
23509
+ const entries = [];
23510
+ const commits = await runGit(worktreePath, [
23511
+ "log",
23512
+ "--format=%H%x1f%s",
23513
+ `-n${maxCommitEntries}`,
23514
+ `${defaultBranch}..HEAD`
23515
+ ]);
23516
+ if (commits.ok) for (const line of commits.stdout.split("\n")) {
23517
+ if (!line.trim()) continue;
23518
+ const [hash, title = ""] = line.split("");
23519
+ if (!hash) continue;
23520
+ const diffResult = await runGit(worktreePath, [
23521
+ "show",
23522
+ "--numstat",
23523
+ "--format=",
23524
+ hash
23525
+ ]);
23526
+ const changedFiles = (await runGit(worktreePath, [
23527
+ "show",
23528
+ "--name-only",
23529
+ "--format=",
23530
+ hash
23531
+ ])).stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
23532
+ entries.push({
23533
+ type: "commit",
23534
+ hash,
23535
+ title: title.trim() || hash.slice(0, 7),
23536
+ relatedChanges: parseRelatedChanges(changedFiles),
23537
+ diff: diffResult.ok ? parseNumStat(diffResult.stdout) : EMPTY_DIFF
23538
+ });
23539
+ }
23540
+ const trackedResult = await runGit(worktreePath, [
23541
+ "diff",
23542
+ "--numstat",
23543
+ "HEAD"
23544
+ ]);
23545
+ const trackedFilesResult = await runGit(worktreePath, [
23546
+ "diff",
23547
+ "--name-only",
23548
+ "HEAD"
23549
+ ]);
23550
+ const untrackedResult = await runGit(worktreePath, [
23551
+ "ls-files",
23552
+ "--others",
23553
+ "--exclude-standard"
23554
+ ]);
23555
+ const trackedFiles = trackedFilesResult.stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
23556
+ const untrackedFiles = untrackedResult.stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
23557
+ const allUncommittedFiles = new Set([...trackedFiles, ...untrackedFiles]);
23558
+ const trackedDiff = trackedResult.ok ? parseNumStat(trackedResult.stdout) : EMPTY_DIFF;
23559
+ entries.push({
23560
+ type: "uncommitted",
23561
+ title: "Uncommitted",
23562
+ relatedChanges: parseRelatedChanges([...allUncommittedFiles]),
23563
+ diff: {
23564
+ files: allUncommittedFiles.size,
23565
+ insertions: trackedDiff.insertions,
23566
+ deletions: trackedDiff.deletions
23567
+ }
23568
+ });
23569
+ return entries;
23570
+ }
23571
+ async function collectWorktree(options) {
23572
+ const { projectDir, worktree, defaultBranch, runGit, maxCommitEntries } = options;
23573
+ const worktreePath = resolve$1(worktree.path);
23574
+ const resolvedProjectDir = resolve$1(projectDir);
23575
+ const aheadBehindResult = await runGit(worktreePath, [
23576
+ "rev-list",
23577
+ "--left-right",
23578
+ "--count",
23579
+ `${defaultBranch}...HEAD`
23580
+ ]);
23581
+ let ahead = 0;
23582
+ let behind = 0;
23583
+ if (aheadBehindResult.ok) {
23584
+ const [behindRaw, aheadRaw] = aheadBehindResult.stdout.trim().split(/\s+/);
23585
+ ahead = Number(aheadRaw) || 0;
23586
+ behind = Number(behindRaw) || 0;
23587
+ }
23588
+ const diffResult = await runGit(worktreePath, [
23589
+ "diff",
23590
+ "--shortstat",
23591
+ `${defaultBranch}...HEAD`
23592
+ ]);
23593
+ const diff = diffResult.ok ? parseShortStat(diffResult.stdout) : EMPTY_DIFF;
23594
+ const entries = await collectCommitEntries({
23595
+ worktreePath,
23596
+ defaultBranch,
23597
+ maxCommitEntries,
23598
+ runGit
23599
+ });
23600
+ return {
23601
+ path: worktreePath,
23602
+ relativePath: relativePath(resolvedProjectDir, worktreePath),
23603
+ branchName: parseBranchName(worktree.branchRef, worktree.detached),
23604
+ isCurrent: resolvedProjectDir === worktreePath,
23605
+ ahead,
23606
+ behind,
23607
+ diff,
23608
+ entries
23609
+ };
23610
+ }
23611
+ async function buildDashboardGitSnapshot(options) {
23612
+ const runGit = options.runGit ?? defaultRunGit;
23613
+ const maxCommitEntries = options.maxCommitEntries ?? 8;
23614
+ const resolvedProjectDir = resolve$1(options.projectDir);
23615
+ const defaultBranch = await resolveDefaultBranch(resolvedProjectDir, runGit);
23616
+ const worktreeResult = await runGit(resolvedProjectDir, [
23617
+ "worktree",
23618
+ "list",
23619
+ "--porcelain"
23620
+ ]);
23621
+ const parsed = worktreeResult.ok ? parseWorktreeList(worktreeResult.stdout) : [];
23622
+ const baseWorktrees = parsed.length > 0 ? parsed : [{
23623
+ path: resolvedProjectDir,
23624
+ branchRef: null,
23625
+ detached: false
23626
+ }];
23627
+ const worktrees = await Promise.all(baseWorktrees.map((worktree) => collectWorktree({
23628
+ projectDir: resolvedProjectDir,
23629
+ worktree,
23630
+ defaultBranch,
23631
+ runGit,
23632
+ maxCommitEntries
23633
+ })));
23634
+ worktrees.sort((a, b) => {
23635
+ if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1;
23636
+ return a.branchName.localeCompare(b.branchName);
23637
+ });
23638
+ return {
23639
+ defaultBranch,
23640
+ worktrees
23641
+ };
23642
+ }
23643
+
23644
+ //#endregion
23645
+ //#region ../server/src/dashboard-time-trends.ts
23646
+ const MIN_TREND_POINT_LIMIT = 20;
23647
+ const MAX_TREND_POINT_LIMIT = 500;
23648
+ const DEFAULT_TREND_POINT_LIMIT = 100;
23649
+ const TARGET_TREND_BARS = 20;
23650
+ const DAY_MS = 1440 * 60 * 1e3;
23651
+ function clampPointLimit(pointLimit) {
23652
+ if (!Number.isFinite(pointLimit)) return DEFAULT_TREND_POINT_LIMIT;
23653
+ return Math.max(MIN_TREND_POINT_LIMIT, Math.min(MAX_TREND_POINT_LIMIT, Math.trunc(pointLimit)));
23654
+ }
23655
+ function createEmptyTrendSeries() {
23656
+ return Object.fromEntries(DASHBOARD_METRIC_KEYS.map((metric) => [metric, []]));
23657
+ }
23658
+ function normalizeEvents(events, pointLimit) {
23659
+ return events.filter((event) => Number.isFinite(event.ts) && event.ts > 0 && Number.isFinite(event.value)).sort((a, b) => a.ts - b.ts).slice(-pointLimit);
23660
+ }
23661
+ function buildTimeWindow(options) {
23662
+ const { probeEvents, targetBars, rightEdgeTs } = options;
23663
+ if (probeEvents.length === 0) return null;
23664
+ const probeEnd = probeEvents[probeEvents.length - 1].ts;
23665
+ const end = typeof rightEdgeTs === "number" && Number.isFinite(rightEdgeTs) && rightEdgeTs > 0 ? Math.max(probeEnd, rightEdgeTs) : probeEnd;
23666
+ const probeStart = probeEvents[0].ts;
23667
+ const rangeMs = Math.max(1, end - probeStart);
23668
+ const bucketMs = rangeMs >= DAY_MS ? Math.max(DAY_MS, Math.ceil(rangeMs / targetBars / DAY_MS) * DAY_MS) : Math.max(1, Math.ceil(rangeMs / targetBars));
23669
+ const windowStart = end - bucketMs * targetBars;
23670
+ return {
23671
+ windowStart,
23672
+ bucketMs,
23673
+ bucketEnds: Array.from({ length: targetBars }, (_, index) => windowStart + bucketMs * (index + 1))
23674
+ };
23675
+ }
23676
+ function bucketizeTrend(events, reducer, rightEdgeTs) {
23677
+ if (events.length === 0) return [];
23678
+ const timeWindow = buildTimeWindow({
23679
+ probeEvents: events,
23680
+ targetBars: TARGET_TREND_BARS,
23681
+ rightEdgeTs
23682
+ });
23683
+ if (!timeWindow) return [];
23684
+ const { windowStart, bucketMs, bucketEnds } = timeWindow;
23685
+ const sums = Array.from({ length: bucketEnds.length }, () => 0);
23686
+ const counts = Array.from({ length: bucketEnds.length }, () => 0);
23687
+ let baseline = 0;
23688
+ for (const event of events) {
23689
+ if (event.ts <= windowStart) {
23690
+ if (reducer === "sum-cumulative") baseline += event.value;
23691
+ continue;
23692
+ }
23693
+ const offset = event.ts - windowStart;
23694
+ const index = Math.max(0, Math.min(bucketEnds.length - 1, Math.ceil(offset / bucketMs) - 1));
23695
+ sums[index] += event.value;
23696
+ counts[index] += 1;
23697
+ }
23698
+ let cumulative = baseline;
23699
+ let carry = baseline !== 0 ? baseline : events[0].value;
23700
+ return bucketEnds.map((ts, index) => {
23701
+ if (reducer === "sum") return {
23702
+ ts,
23703
+ value: sums[index]
23704
+ };
23705
+ if (reducer === "sum-cumulative") {
23706
+ cumulative += sums[index];
23707
+ return {
23708
+ ts,
23709
+ value: cumulative
23710
+ };
23711
+ }
23712
+ if (counts[index] > 0) carry = sums[index] / counts[index];
23713
+ return {
23714
+ ts,
23715
+ value: carry
23716
+ };
23717
+ });
23718
+ }
23719
+ function buildDashboardTimeTrends(options) {
23720
+ const pointLimit = clampPointLimit(options.pointLimit);
23721
+ const trends = createEmptyTrendSeries();
23722
+ for (const metric of DASHBOARD_METRIC_KEYS) {
23723
+ if (options.availability[metric].state !== "ok") continue;
23724
+ trends[metric] = bucketizeTrend(normalizeEvents(options.events[metric], pointLimit), options.reducers?.[metric] ?? "sum", options.rightEdgeTs);
23725
+ }
23726
+ return {
23727
+ trends,
23728
+ trendMeta: {
23729
+ pointLimit,
23730
+ lastUpdatedAt: options.timestamp
23731
+ }
23732
+ };
23733
+ }
23734
+
23350
23735
  //#endregion
23351
23736
  //#region ../server/src/reactive-kv.ts
23352
23737
  /**
@@ -23467,6 +23852,76 @@ function createReactiveSubscriptionWithInput(task) {
23467
23852
  const t = initTRPC.context().create();
23468
23853
  const router = t.router;
23469
23854
  const publicProcedure = t.procedure;
23855
+ const execFileAsync = promisify$1(execFile);
23856
+ const dashboardGitTaskStatusEmitter = new EventEmitter$1();
23857
+ dashboardGitTaskStatusEmitter.setMaxListeners(200);
23858
+ const dashboardGitTaskStatus = {
23859
+ running: false,
23860
+ inFlight: 0,
23861
+ lastStartedAt: null,
23862
+ lastFinishedAt: null,
23863
+ lastReason: null,
23864
+ lastError: null
23865
+ };
23866
+ function getDashboardGitTaskStatus() {
23867
+ return { ...dashboardGitTaskStatus };
23868
+ }
23869
+ function emitDashboardGitTaskStatus() {
23870
+ dashboardGitTaskStatusEmitter.emit("change", getDashboardGitTaskStatus());
23871
+ }
23872
+ function beginDashboardGitTask(reason) {
23873
+ dashboardGitTaskStatus.inFlight += 1;
23874
+ dashboardGitTaskStatus.running = true;
23875
+ dashboardGitTaskStatus.lastStartedAt = Date.now();
23876
+ dashboardGitTaskStatus.lastReason = reason;
23877
+ dashboardGitTaskStatus.lastError = null;
23878
+ emitDashboardGitTaskStatus();
23879
+ }
23880
+ function endDashboardGitTask(error) {
23881
+ dashboardGitTaskStatus.inFlight = Math.max(0, dashboardGitTaskStatus.inFlight - 1);
23882
+ dashboardGitTaskStatus.running = dashboardGitTaskStatus.inFlight > 0;
23883
+ dashboardGitTaskStatus.lastFinishedAt = Date.now();
23884
+ if (error) dashboardGitTaskStatus.lastError = error instanceof Error ? error.message : String(error);
23885
+ emitDashboardGitTaskStatus();
23886
+ }
23887
+ function parseGitDirFromDotGitFile(content) {
23888
+ const line = content.split(/\r?\n/).map((item) => item.trim()).find((item) => item.startsWith("gitdir:"));
23889
+ if (!line) return null;
23890
+ const rawPath = line.slice(7).trim();
23891
+ return rawPath.length > 0 ? rawPath : null;
23892
+ }
23893
+ function getDashboardGitRefreshStampPath(projectDir) {
23894
+ return join$1(projectDir, "openspec", ".openspecui-dashboard-git-refresh.stamp");
23895
+ }
23896
+ async function touchDashboardGitRefreshStamp(projectDir, reason) {
23897
+ const stampPath = getDashboardGitRefreshStampPath(projectDir);
23898
+ await mkdir$1(dirname$1(stampPath), { recursive: true });
23899
+ await writeFile$1(stampPath, `${Date.now()} ${reason}\n`, "utf8");
23900
+ }
23901
+ async function registerDashboardGitReactiveDeps(projectDir) {
23902
+ await reactiveReadDir(projectDir, {
23903
+ includeHidden: true,
23904
+ exclude: ["node_modules"]
23905
+ });
23906
+ await reactiveReadFile(getDashboardGitRefreshStampPath(projectDir));
23907
+ const dotGitPath = join$1(projectDir, ".git");
23908
+ if (!await reactiveExists(dotGitPath)) return;
23909
+ const dotGitFileContent = await reactiveReadFile(dotGitPath);
23910
+ if (dotGitFileContent !== null) {
23911
+ const gitDirRaw = parseGitDirFromDotGitFile(dotGitFileContent);
23912
+ if (!gitDirRaw) return;
23913
+ const gitDirPath = resolve$1(projectDir, gitDirRaw);
23914
+ await reactiveReadDir(gitDirPath, { includeHidden: true });
23915
+ await reactiveReadFile(join$1(gitDirPath, "HEAD"));
23916
+ await reactiveReadFile(join$1(gitDirPath, "index"));
23917
+ await reactiveReadFile(join$1(gitDirPath, "packed-refs"));
23918
+ return;
23919
+ }
23920
+ await reactiveReadDir(dotGitPath, { includeHidden: true });
23921
+ await reactiveReadFile(join$1(dotGitPath, "HEAD"));
23922
+ await reactiveReadFile(join$1(dotGitPath, "index"));
23923
+ await reactiveReadFile(join$1(dotGitPath, "packed-refs"));
23924
+ }
23470
23925
  function requireChangeId(changeId) {
23471
23926
  if (!changeId) throw new Error("change is required");
23472
23927
  return changeId;
@@ -23548,6 +24003,200 @@ function buildSystemStatus(ctx) {
23548
24003
  watcherLastReinitializeReason: runtime?.lastReinitializeReason ?? null
23549
24004
  };
23550
24005
  }
24006
+ function resolveTrendTimestamp(primary, secondary) {
24007
+ if (typeof primary === "number" && Number.isFinite(primary) && primary > 0) return primary;
24008
+ if (typeof secondary === "number" && Number.isFinite(secondary) && secondary > 0) return secondary;
24009
+ return null;
24010
+ }
24011
+ function parseDatedIdTimestamp(id) {
24012
+ const match$1 = /^(\d{4})-(\d{2})-(\d{2})(?:-|$)/.exec(id);
24013
+ if (!match$1) return null;
24014
+ const year = Number(match$1[1]);
24015
+ const month = Number(match$1[2]);
24016
+ const day = Number(match$1[3]);
24017
+ if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return null;
24018
+ if (month < 1 || month > 12) return null;
24019
+ if (day < 1 || day > 31) return null;
24020
+ const ts = Date.UTC(year, month - 1, day);
24021
+ return Number.isFinite(ts) ? ts : null;
24022
+ }
24023
+ function createEmptyTriColorTrends() {
24024
+ return Object.fromEntries(DASHBOARD_METRIC_KEYS.map((metric) => [metric, []]));
24025
+ }
24026
+ async function readLatestCommitTimestamp(projectDir) {
24027
+ try {
24028
+ const { stdout } = await execFileAsync("git", [
24029
+ "log",
24030
+ "-1",
24031
+ "--format=%ct"
24032
+ ], {
24033
+ cwd: projectDir,
24034
+ maxBuffer: 1024 * 1024,
24035
+ encoding: "utf8"
24036
+ });
24037
+ const seconds = Number(stdout.trim());
24038
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : null;
24039
+ } catch {
24040
+ return null;
24041
+ }
24042
+ }
24043
+ async function fetchDashboardOverview(ctx, reason = "dashboard-refresh") {
24044
+ if (contextStorage.getStore()) await registerDashboardGitReactiveDeps(ctx.projectDir);
24045
+ const now = Date.now();
24046
+ const [specMetas, changeMetas, archiveMetas] = await Promise.all([
24047
+ ctx.adapter.listSpecsWithMeta(),
24048
+ ctx.adapter.listChangesWithMeta(),
24049
+ ctx.adapter.listArchivedChangesWithMeta()
24050
+ ]);
24051
+ const archivedChanges = (await Promise.all(archiveMetas.map(async (meta) => {
24052
+ const change = await ctx.adapter.readArchivedChange(meta.id);
24053
+ if (!change) return null;
24054
+ return {
24055
+ id: meta.id,
24056
+ createdAt: meta.createdAt,
24057
+ updatedAt: meta.updatedAt,
24058
+ tasksCompleted: change.tasks.filter((task) => task.completed).length
24059
+ };
24060
+ }))).filter((item) => item !== null);
24061
+ const specifications = (await Promise.all(specMetas.map(async (meta) => {
24062
+ const spec = await ctx.adapter.readSpec(meta.id);
24063
+ if (!spec) return null;
24064
+ return {
24065
+ id: meta.id,
24066
+ name: meta.name,
24067
+ requirements: spec.requirements.length,
24068
+ updatedAt: meta.updatedAt
24069
+ };
24070
+ }))).filter((item) => item !== null).sort((a, b) => b.requirements - a.requirements || b.updatedAt - a.updatedAt);
24071
+ const activeChanges = changeMetas.map((change) => ({
24072
+ id: change.id,
24073
+ name: change.name,
24074
+ progress: change.progress,
24075
+ updatedAt: change.updatedAt
24076
+ }));
24077
+ const requirements = specifications.reduce((sum, spec) => sum + spec.requirements, 0);
24078
+ const tasksTotal = activeChanges.reduce((sum, change) => sum + change.progress.total, 0);
24079
+ const tasksCompleted = activeChanges.reduce((sum, change) => sum + change.progress.completed, 0);
24080
+ const archivedTasksCompleted = archivedChanges.reduce((sum, change) => sum + change.tasksCompleted, 0);
24081
+ const taskCompletionPercent = tasksTotal > 0 ? Math.round(tasksCompleted / tasksTotal * 100) : null;
24082
+ const inProgressChanges = activeChanges.filter((change) => change.progress.total > 0 && change.progress.completed < change.progress.total).length;
24083
+ const specificationTrendEvents = specMetas.flatMap((spec) => {
24084
+ const ts = resolveTrendTimestamp(spec.createdAt, spec.updatedAt);
24085
+ return ts === null ? [] : [{
24086
+ ts,
24087
+ value: 1
24088
+ }];
24089
+ });
24090
+ const completedTrendEvents = archivedChanges.flatMap((archive) => {
24091
+ const ts = parseDatedIdTimestamp(archive.id) ?? resolveTrendTimestamp(archive.updatedAt, archive.createdAt);
24092
+ return ts === null ? [] : [{
24093
+ ts,
24094
+ value: archive.tasksCompleted
24095
+ }];
24096
+ });
24097
+ const specMetaById = new Map(specMetas.map((meta) => [meta.id, meta]));
24098
+ const requirementTrendEvents = specifications.flatMap((spec) => {
24099
+ const meta = specMetaById.get(spec.id);
24100
+ const ts = resolveTrendTimestamp(meta?.updatedAt, meta?.createdAt);
24101
+ return ts === null ? [] : [{
24102
+ ts,
24103
+ value: spec.requirements
24104
+ }];
24105
+ });
24106
+ const hasObjectiveSpecificationTrend = specificationTrendEvents.length > 0 || specifications.length === 0;
24107
+ const hasObjectiveRequirementTrend = requirementTrendEvents.length > 0 || requirements === 0;
24108
+ const hasObjectiveCompletedTrend = completedTrendEvents.length > 0 || archiveMetas.length === 0;
24109
+ const config = await ctx.configManager.readConfig();
24110
+ beginDashboardGitTask(reason);
24111
+ let latestCommitTs = null;
24112
+ let git;
24113
+ try {
24114
+ const gitSnapshotPromise = buildDashboardGitSnapshot({ projectDir: ctx.projectDir }).catch(() => ({
24115
+ defaultBranch: "main",
24116
+ worktrees: []
24117
+ }));
24118
+ latestCommitTs = await readLatestCommitTimestamp(ctx.projectDir);
24119
+ git = await gitSnapshotPromise;
24120
+ } catch (error) {
24121
+ endDashboardGitTask(error);
24122
+ throw error;
24123
+ }
24124
+ endDashboardGitTask(null);
24125
+ const cardAvailability = {
24126
+ specifications: hasObjectiveSpecificationTrend ? { state: "ok" } : {
24127
+ state: "invalid",
24128
+ reason: "objective-history-unavailable"
24129
+ },
24130
+ requirements: hasObjectiveRequirementTrend ? { state: "ok" } : {
24131
+ state: "invalid",
24132
+ reason: "objective-history-unavailable"
24133
+ },
24134
+ activeChanges: {
24135
+ state: "invalid",
24136
+ reason: "objective-history-unavailable"
24137
+ },
24138
+ inProgressChanges: {
24139
+ state: "invalid",
24140
+ reason: "objective-history-unavailable"
24141
+ },
24142
+ completedChanges: hasObjectiveCompletedTrend ? { state: "ok" } : {
24143
+ state: "invalid",
24144
+ reason: "objective-history-unavailable"
24145
+ },
24146
+ taskCompletionPercent: {
24147
+ state: "invalid",
24148
+ reason: taskCompletionPercent === null ? "semantic-uncomputable" : "objective-history-unavailable"
24149
+ }
24150
+ };
24151
+ const trendKinds = {
24152
+ specifications: "monotonic",
24153
+ requirements: "monotonic",
24154
+ activeChanges: "bidirectional",
24155
+ inProgressChanges: "bidirectional",
24156
+ completedChanges: "monotonic",
24157
+ taskCompletionPercent: "bidirectional"
24158
+ };
24159
+ const { trends: baselineTrends, trendMeta } = buildDashboardTimeTrends({
24160
+ pointLimit: config.dashboard.trendPointLimit,
24161
+ timestamp: now,
24162
+ rightEdgeTs: latestCommitTs,
24163
+ availability: cardAvailability,
24164
+ events: {
24165
+ specifications: specificationTrendEvents,
24166
+ requirements: requirementTrendEvents,
24167
+ activeChanges: [],
24168
+ inProgressChanges: [],
24169
+ completedChanges: completedTrendEvents,
24170
+ taskCompletionPercent: []
24171
+ },
24172
+ reducers: {
24173
+ specifications: "sum",
24174
+ requirements: "sum",
24175
+ completedChanges: "sum"
24176
+ }
24177
+ });
24178
+ return {
24179
+ summary: {
24180
+ specifications: specifications.length,
24181
+ requirements,
24182
+ activeChanges: activeChanges.length,
24183
+ inProgressChanges,
24184
+ completedChanges: archiveMetas.length,
24185
+ archivedTasksCompleted,
24186
+ tasksTotal,
24187
+ tasksCompleted,
24188
+ taskCompletionPercent
24189
+ },
24190
+ trends: baselineTrends,
24191
+ triColorTrends: createEmptyTriColorTrends(),
24192
+ trendKinds,
24193
+ cardAvailability,
24194
+ trendMeta,
24195
+ specifications,
24196
+ activeChanges,
24197
+ git
24198
+ };
24199
+ }
23551
24200
  /**
23552
24201
  * Spec router - spec CRUD operations
23553
24202
  */
@@ -23755,15 +24404,17 @@ const configRouter = router({
23755
24404
  "dark",
23756
24405
  "system"
23757
24406
  ]).optional(),
23758
- terminal: TerminalConfigSchema.omit({ rendererEngine: true }).partial().extend({ rendererEngine: TerminalRendererEngineSchema.optional() }).optional()
24407
+ terminal: TerminalConfigSchema.omit({ rendererEngine: true }).partial().extend({ rendererEngine: TerminalRendererEngineSchema.optional() }).optional(),
24408
+ dashboard: DashboardConfigSchema.partial().optional()
23759
24409
  })).mutation(async ({ ctx, input }) => {
23760
24410
  const hasCliCommand = input.cli !== void 0 && Object.prototype.hasOwnProperty.call(input.cli, "command");
23761
24411
  const hasCliArgs = input.cli !== void 0 && Object.prototype.hasOwnProperty.call(input.cli, "args");
23762
24412
  if (hasCliCommand && !hasCliArgs) {
23763
24413
  await ctx.configManager.setCliCommand(input.cli?.command ?? "");
23764
- if (input.theme !== void 0 || input.terminal !== void 0) await ctx.configManager.writeConfig({
24414
+ if (input.theme !== void 0 || input.terminal !== void 0 || input.dashboard !== void 0) await ctx.configManager.writeConfig({
23765
24415
  theme: input.theme,
23766
- terminal: input.terminal
24416
+ terminal: input.terminal,
24417
+ dashboard: input.dashboard
23767
24418
  });
23768
24419
  return { success: true };
23769
24420
  }
@@ -24220,9 +24871,43 @@ const systemRouter = router({
24220
24871
  })
24221
24872
  });
24222
24873
  /**
24874
+ * Dashboard router - objective project overview for UI
24875
+ */
24876
+ const dashboardRouter = router({
24877
+ get: publicProcedure.query(async ({ ctx }) => {
24878
+ return fetchDashboardOverview(ctx, "dashboard.get");
24879
+ }),
24880
+ subscribe: publicProcedure.subscription(({ ctx }) => {
24881
+ return createReactiveSubscription(async () => {
24882
+ return fetchDashboardOverview(ctx, "dashboard.subscribe");
24883
+ });
24884
+ }),
24885
+ refreshGitSnapshot: publicProcedure.input(objectType({ reason: stringType().optional() }).optional()).mutation(async ({ ctx, input }) => {
24886
+ const reason = input?.reason?.trim() || "manual-refresh";
24887
+ await touchDashboardGitRefreshStamp(ctx.projectDir, reason);
24888
+ return { success: true };
24889
+ }),
24890
+ gitTaskStatus: publicProcedure.query(() => {
24891
+ return getDashboardGitTaskStatus();
24892
+ }),
24893
+ subscribeGitTaskStatus: publicProcedure.subscription(() => {
24894
+ return observable((emit) => {
24895
+ emit.next(getDashboardGitTaskStatus());
24896
+ const handler = (status) => {
24897
+ emit.next(status);
24898
+ };
24899
+ dashboardGitTaskStatusEmitter.on("change", handler);
24900
+ return () => {
24901
+ dashboardGitTaskStatusEmitter.off("change", handler);
24902
+ };
24903
+ });
24904
+ })
24905
+ });
24906
+ /**
24223
24907
  * Main app router
24224
24908
  */
24225
24909
  const appRouter = router({
24910
+ dashboard: dashboardRouter,
24226
24911
  spec: specRouter,
24227
24912
  change: changeRouter,
24228
24913
  archive: archiveRouter,
@@ -24237,7 +24922,7 @@ const appRouter = router({
24237
24922
  });
24238
24923
 
24239
24924
  //#endregion
24240
- //#region ../search/dist/node.mjs
24925
+ //#region ../search/src/node-worker-provider.ts
24241
24926
  function requestId() {
24242
24927
  return Math.random().toString(36).slice(2);
24243
24928
  }
@@ -24685,4 +25370,4 @@ async function startServer$1(options = {}) {
24685
25370
  }
24686
25371
 
24687
25372
  //#endregion
24688
- export { SchemaInfoSchema as a, CliExecutor as c, __commonJS$1 as d, __toESM$1 as f, SchemaDetailSchema as i, ConfigManager as l, createServer$2 as n, SchemaResolutionSchema as o, require_dist as r, TemplatesSchema as s, startServer$1 as t, OpenSpecAdapter as u };
25373
+ export { SchemaInfoSchema as a, CliExecutor as c, OpenSpecAdapter as d, __commonJS$1 as f, SchemaDetailSchema as i, ConfigManager as l, createServer$2 as n, SchemaResolutionSchema as o, __toESM$1 as p, require_dist as r, TemplatesSchema as s, startServer$1 as t, DEFAULT_CONFIG as u };