@ifrankwang/openspec-agents 0.146.0 → 0.147.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.
@@ -7268,6 +7268,18 @@ function prefixIssues(path, issues) {
7268
7268
  function unwrapMessage(message) {
7269
7269
  return typeof message === "string" ? message : message?.message;
7270
7270
  }
7271
+ function sizeCaps(defChecks) {
7272
+ const caps = [];
7273
+ for (const ch of defChecks ?? []) {
7274
+ const d = ch._zod.def;
7275
+ if (!/_(length|size)$|_equals$/.test(d.check))
7276
+ break;
7277
+ const max = d.maximum ?? d.length ?? d.size;
7278
+ if (max >= 0)
7279
+ caps.push([max, ch]);
7280
+ }
7281
+ return caps;
7282
+ }
7271
7283
  function attachSchema(issues, start, inst) {
7272
7284
  var _a;
7273
7285
  for (let i = start;i < issues.length; i++) {
@@ -8270,7 +8282,7 @@ ${content.join(`
8270
8282
  var version = {
8271
8283
  major: 4,
8272
8284
  minor: 6,
8273
- patch: 5
8285
+ patch: 6
8274
8286
  };
8275
8287
 
8276
8288
  // node_modules/zod/v4/core/schemas.js
@@ -8843,6 +8855,29 @@ var $ZodNever = /* @__PURE__ */ $constructor("$ZodNever", (inst, def) => {
8843
8855
  return payload;
8844
8856
  };
8845
8857
  });
8858
+ function capSize(inst, size) {
8859
+ const caps = sizeCaps(inst._zod.def.checks);
8860
+ if (!caps.length)
8861
+ return;
8862
+ const cap = Math.min(...caps.map((c) => c[0]));
8863
+ const parse = inst._zod.parse;
8864
+ inst._zod.parse = (payload, ctx) => {
8865
+ const n = size(payload.value);
8866
+ if (n > cap && !ctx?.skipChecks && ctx?.direction !== "backward") {
8867
+ for (const [max, ch] of sizeCaps(inst._zod.def.checks)) {
8868
+ if (n > max && ch._zod.def.when(payload)) {
8869
+ const start = payload.issues.length;
8870
+ ch._zod.check(payload);
8871
+ payload.issues[start].continue = false;
8872
+ attachSchema(payload.issues, start, inst);
8873
+ return payload;
8874
+ }
8875
+ }
8876
+ }
8877
+ return parse(payload, ctx);
8878
+ };
8879
+ }
8880
+ var arraySize = (input) => Array.isArray(input) ? input.length : -1;
8846
8881
  function handleArrayResult(result, final, index) {
8847
8882
  if (result.issues.length) {
8848
8883
  final.issues.push(...prefixIssues(index, result.issues));
@@ -8886,6 +8921,7 @@ var $ZodArray = /* @__PURE__ */ $constructor("$ZodArray", (inst, def) => {
8886
8921
  }
8887
8922
  return payload;
8888
8923
  };
8924
+ capSize(inst, arraySize);
8889
8925
  });
8890
8926
  function handlePropertyResult(result, final, key, input, optin, optout) {
8891
8927
  const isPresent = key in input;
@@ -30533,7 +30569,7 @@ async function ensureDefaultUnattended(args, ctx) {
30533
30569
  }
30534
30570
  } catch {}
30535
30571
  }
30536
- var PKG_VERSION = "0.146.0";
30572
+ var PKG_VERSION = "0.147.0";
30537
30573
  function buildMcpServer(worktree, opts = {}) {
30538
30574
  const mcp = new McpServer({ name: "openspec-agents", version: PKG_VERSION });
30539
30575
  for (const [name, spec] of Object.entries(TOOL_SPECS)) {
@@ -30560,9 +30596,9 @@ function buildMcpServer(worktree, opts = {}) {
30560
30596
  }
30561
30597
 
30562
30598
  // src/adapters/mcp-common/index.ts
30563
- import { createServer as createServer3 } from "node:http";
30599
+ import { createServer as createServer2 } from "node:http";
30564
30600
  import { randomUUID } from "node:crypto";
30565
- import { join as join5 } from "node:path";
30601
+ import { join as join4 } from "node:path";
30566
30602
 
30567
30603
  // node_modules/@hono/node-server/dist/constants-BLSFu_RU.mjs
30568
30604
  var X_ALREADY_SENT = "x-hono-already-sent";
@@ -32790,158 +32826,9 @@ class StreamableHTTPServerTransport {
32790
32826
  this._webStandardTransport.closeStandaloneSSEStream();
32791
32827
  }
32792
32828
  }
32793
-
32794
- // src/adapters/opencode/dashboard.ts
32795
- import { createServer as createServer2 } from "node:http";
32796
-
32797
- // src/core/dashboard.ts
32798
- import { readdirSync as readdirSync4, existsSync as existsSync5 } from "node:fs";
32799
- import { readFile as readFile5 } from "node:fs/promises";
32800
- import path7 from "path";
32801
- function toWorkItemCard(item) {
32802
- const agentVerdicts = Object.entries(item.tags).map(([key, verdict]) => {
32803
- const sep = key.indexOf(":");
32804
- return {
32805
- stepId: sep === -1 ? key : key.slice(0, sep),
32806
- agentKey: sep === -1 ? key : key.slice(sep + 1),
32807
- verdict
32808
- };
32809
- });
32810
- const suspendReason = typeof item.metadata["suspend_reason"] === "string" ? item.metadata["suspend_reason"] : undefined;
32811
- return {
32812
- id: item.id,
32813
- type: item.type,
32814
- title: item.title,
32815
- description: item.description,
32816
- phase: item.phase,
32817
- suspended: item.suspended,
32818
- suspendReason,
32819
- currentStep: item.currentStep,
32820
- labels: item.labels,
32821
- source: item.source,
32822
- severity: item.severity,
32823
- agentVerdicts,
32824
- children: item.children.map((child) => ({
32825
- id: child.id,
32826
- type: child.type,
32827
- title: child.title,
32828
- phase: child.phase,
32829
- severity: child.severity
32830
- }))
32831
- };
32832
- }
32833
- function projectState(state) {
32834
- return {
32835
- active: true,
32836
- changeId: state.changeId,
32837
- currentTaskGroupId: state.taskGroupId,
32838
- baseBranch: state.baseBranch,
32839
- createdAt: state.createdAt,
32840
- updatedAt: state.updatedAt,
32841
- workItems: state.workItems,
32842
- workItemCards: state.workItems.map(toWorkItemCard)
32843
- };
32844
- }
32845
- async function readDashboardState(worktree, changeId) {
32846
- if (changeId) {
32847
- const state = await readStateByWorktree(worktree, changeId);
32848
- if (!state)
32849
- return null;
32850
- return projectState(state);
32851
- }
32852
- const stateDir = getStateDir(worktree);
32853
- if (!existsSync5(stateDir))
32854
- return null;
32855
- const files = readdirSync4(stateDir).filter((f) => f.endsWith(".json"));
32856
- const results = [];
32857
- for (const f of files) {
32858
- try {
32859
- const raw = await readFile5(path7.join(stateDir, f), "utf-8");
32860
- const state = JSON.parse(raw);
32861
- if (!state.changeId)
32862
- continue;
32863
- results.push(projectState(state));
32864
- } catch {}
32865
- }
32866
- if (results.length === 1)
32867
- return results[0];
32868
- return results.length > 0 ? results : null;
32869
- }
32870
-
32871
- // src/dashboard/page.ts
32872
- import { fileURLToPath as fileURLToPath3 } from "node:url";
32873
- import { readFileSync as readFileSync5, existsSync as existsSync6 } from "node:fs";
32874
- import { join as join3 } from "node:path";
32875
- var _html = null;
32876
- function resolveDashboardHtml() {
32877
- const __dirname2 = fileURLToPath3(new URL(".", import.meta.url));
32878
- const bundled = join3(__dirname2, "dashboard", "index.html");
32879
- if (existsSync6(bundled))
32880
- return bundled;
32881
- return join3(__dirname2, "../../assets/dashboard/index.html");
32882
- }
32883
- function getDashboardPage() {
32884
- if (_html)
32885
- return _html;
32886
- _html = readFileSync5(resolveDashboardHtml(), "utf-8");
32887
- return _html;
32888
- }
32889
-
32890
- // src/adapters/opencode/dashboard.ts
32891
- var BASE_PORT = 4519;
32892
- var MAX_ATTEMPTS = 20;
32893
- var servers = new Map;
32894
- async function startDashboard(worktree) {
32895
- if (servers.has(worktree))
32896
- return;
32897
- const pageHtml = getDashboardPage();
32898
- for (let port = BASE_PORT;port < BASE_PORT + MAX_ATTEMPTS; port++) {
32899
- try {
32900
- const server = createServer2(async (req, res) => {
32901
- try {
32902
- const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1"}`);
32903
- if (url.pathname === "/api/state") {
32904
- try {
32905
- const data = await readDashboardState(worktree);
32906
- res.writeHead(200, { "content-type": "application/json;charset=utf-8" });
32907
- res.end(JSON.stringify(data ?? { active: false }));
32908
- } catch (err) {
32909
- res.writeHead(500, { "content-type": "application/json;charset=utf-8" });
32910
- res.end(JSON.stringify({ active: false, error: String(err) }));
32911
- }
32912
- return;
32913
- }
32914
- res.writeHead(200, { "content-type": "text/html;charset=utf-8" });
32915
- res.end(pageHtml);
32916
- } catch (err) {
32917
- console.error("[dashboard]", err.message);
32918
- res.writeHead(500, { "content-type": "text/plain;charset=utf-8" });
32919
- res.end("internal error");
32920
- }
32921
- });
32922
- await new Promise((resolve, reject) => {
32923
- server.once("error", (err) => {
32924
- if (err.code === "EADDRINUSE") {
32925
- reject(err);
32926
- } else {
32927
- console.error("[dashboard]", err.message);
32928
- resolve();
32929
- }
32930
- });
32931
- server.listen(port, "127.0.0.1", () => resolve());
32932
- });
32933
- servers.set(worktree, server);
32934
- console.log(`[dashboard] 编排进度看板 http://127.0.0.1:${port}`);
32935
- return;
32936
- } catch {
32937
- continue;
32938
- }
32939
- }
32940
- console.error(`[dashboard] 无法启动编排进度看板:端口 ${BASE_PORT}-${BASE_PORT + MAX_ATTEMPTS - 1} 均被占用`);
32941
- }
32942
32829
  // src/core/workflow/collector.ts
32943
- import { readdirSync as readdirSync5, readFileSync as readFileSync6 } from "node:fs";
32944
- import { join as join4 } from "node:path";
32830
+ import { readdirSync as readdirSync4, readFileSync as readFileSync5 } from "node:fs";
32831
+ import { join as join3 } from "node:path";
32945
32832
  var DEFAULT_POLL_INTERVAL_MS = 30000;
32946
32833
  var SOURCE = "openspec";
32947
32834
  var CHANGE_LABEL = "openspec-change";
@@ -32975,10 +32862,10 @@ class OpenSpecCollector {
32975
32862
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
32976
32863
  }
32977
32864
  async pull() {
32978
- const changesDir = join4(this.openspecDir, "changes");
32865
+ const changesDir = join3(this.openspecDir, "changes");
32979
32866
  let entries;
32980
32867
  try {
32981
- entries = readdirSync5(changesDir, { withFileTypes: true });
32868
+ entries = readdirSync4(changesDir, { withFileTypes: true });
32982
32869
  } catch {
32983
32870
  return [];
32984
32871
  }
@@ -32987,7 +32874,7 @@ class OpenSpecCollector {
32987
32874
  if (!entry.isDirectory())
32988
32875
  continue;
32989
32876
  try {
32990
- const proposalText = readFileSync6(join4(changesDir, entry.name, "proposal.md"), "utf8");
32877
+ const proposalText = readFileSync5(join3(changesDir, entry.name, "proposal.md"), "utf8");
32991
32878
  refs.push({ changeName: entry.name, proposalText });
32992
32879
  } catch {
32993
32880
  continue;
@@ -33129,8 +33016,7 @@ var DEFAULT_PORT = 4525;
33129
33016
  var MAX_PORT_ATTEMPTS = 20;
33130
33017
  function startSideEffects(worktree) {
33131
33018
  try {
33132
- startDashboard(worktree);
33133
- registerCollector(new OpenSpecCollector({ openspecDir: join5(worktree, "openspec") }));
33019
+ registerCollector(new OpenSpecCollector({ openspecDir: join4(worktree, "openspec") }));
33134
33020
  registerCollector(new AdoCollector);
33135
33021
  readContextFromWorktree(worktree).then((ctx) => {
33136
33022
  if (ctx?.changeId && ctx?.taskGroupId) {
@@ -33145,7 +33031,7 @@ async function startMcpServer(opts) {
33145
33031
  const port = opts.port ?? DEFAULT_PORT;
33146
33032
  const hostname = opts.hostname ?? "127.0.0.1";
33147
33033
  const transports = new Map;
33148
- const server = createServer3(async (req, res) => {
33034
+ const server = createServer2(async (req, res) => {
33149
33035
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? hostname}`);
33150
33036
  if (url.pathname === "/mcp") {
33151
33037
  const sessionId = Array.isArray(req.headers["mcp-session-id"]) ? req.headers["mcp-session-id"][0] : req.headers["mcp-session-id"];
@@ -33207,7 +33093,7 @@ async function startMcpServer(opts) {
33207
33093
  }
33208
33094
 
33209
33095
  // src/adapters/mcp-common/cli.ts
33210
- import { fileURLToPath as fileURLToPath4 } from "node:url";
33096
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
33211
33097
  import { realpathSync } from "node:fs";
33212
33098
  function parseArgs(argv) {
33213
33099
  const args = new Map;
@@ -33243,7 +33129,7 @@ async function main(argv) {
33243
33129
  startMcpServer(opts);
33244
33130
  }
33245
33131
  try {
33246
- if (realpathSync(fileURLToPath4(import.meta.url)) === realpathSync(process.argv[1] ?? "")) {
33132
+ if (realpathSync(fileURLToPath3(import.meta.url)) === realpathSync(process.argv[1] ?? "")) {
33247
33133
  main(process.argv.slice(2));
33248
33134
  }
33249
33135
  } catch {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ifrankwang/openspec-agents",
3
- "version": "0.146.0",
3
+ "version": "0.147.0",
4
4
  "description": "OpenSpec change orchestration plugin for AI coding agents (Claude Code, Codex, ZCode, DeepSeek Harness, OpenCode)",
5
5
  "author": "ifrankwang",
6
6
  "license": "MIT",
@@ -57,6 +57,14 @@ export const DSH_NATIVE_TOOLS_ROW_NAME = `${DSH_PLUGIN_NAME}${DSH_NATIVE_TOOLS_E
57
57
  /** cordis patch 中挂载原生工具插件的 insert 行 id。 */
58
58
  export const DSH_NATIVE_TOOLS_ROW_ID = "openspec-opx-tools"
59
59
 
60
+ /**
61
+ * skill 根在插件包内的相对目录(分段写法:assets/skills)。
62
+ * patch 的 customSkillDirs 引用与构建落位共用本常量:两处各写一份字面量会各自漂移成
63
+ * 「patch 指 assets/skills、产物落位包根 skills/」,DSH 一个 skill 都加载不到——历史缺陷即此。
64
+ * 同一份 patch 同时服务 npm 根包与 dist 插件包两种安装形态,包根(仓库根)亦按 assets/skills 落位。
65
+ */
66
+ export const DSH_SKILLS_DIR_SEGMENTS = ["assets", "skills"] as const
67
+
60
68
  /** 原生工具插件与 skill 根的基础 patch;子代理工具行由 buildDshPatchContent 动态追加。 */
61
69
  const DSH_BASE_PATCH_YAML = `# DeepSeek Harness (DSH) bundle patch: expose OpenSpec orchestration tools and skills.
62
70
  # The patch is applied as a bundle layer under dsh.profile.bundles.
@@ -78,7 +86,7 @@ const DSH_BASE_PATCH_YAML = `# DeepSeek Harness (DSH) bundle patch: expose OpenS
78
86
  providerName: openspec-filesystem
79
87
  includeDefaultRoots: false
80
88
  customSkillDirs:
81
- - !!js "process.getBuiltinModule('node:url').fileURLToPath(new URL('./node_modules/${DSH_PLUGIN_NAME}/assets/skills', baseUrl))"
89
+ - !!js "process.getBuiltinModule('node:url').fileURLToPath(new URL('./node_modules/${DSH_PLUGIN_NAME}/${DSH_SKILLS_DIR_SEGMENTS.join("/")}', baseUrl))"
82
90
  `
83
91
 
84
92
  interface DshSubagentEntry {
@@ -177,14 +185,14 @@ export type DeepSeekHarnessPluginBuildResult = PluginPackageResult
177
185
  export function buildDeepSeekHarnessPlugin(outDir: string = DEEP_SEEK_HARNESS_PLUGIN_DIR): DeepSeekHarnessPluginBuildResult {
178
186
  rmSync(outDir, { recursive: true, force: true })
179
187
  mkdirSync(join(outDir, "agents"), { recursive: true })
180
- mkdirSync(join(outDir, "skills"), { recursive: true })
181
188
  mkdirSync(join(outDir, "assets"), { recursive: true })
189
+ mkdirSync(join(outDir, ...DSH_SKILLS_DIR_SEGMENTS), { recursive: true })
182
190
  mkdirSync(join(outDir, ".dsh-plugin"), { recursive: true })
183
191
 
184
192
  const version = readPkgVersion()
185
193
  // DSH 通过上方 subagent 工具消费 agent 定义;agents/ 目录仍保留原始 markdown 供参考/审计。
186
194
  const agents = buildAgents(join(outDir, "agents"))
187
- const skills = buildSkills(join(outDir, "skills"))
195
+ const skills = buildSkills(join(outDir, ...DSH_SKILLS_DIR_SEGMENTS))
188
196
  bundleDshNativeTools(outDir)
189
197
  copyWorkflows(outDir)
190
198
  copyLicense(outDir)
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * MCP Server 启动入口:HTTP transport(node:http)承载 6 个 opx_* 工具,
3
- * 并承载 dashboard/collector/poller 副作用(原 OpenCode 插件壳职责迁移至此)。
3
+ * 并承载 collector/poller 副作用(原 OpenCode 插件壳职责迁移至此)。
4
4
  * 非 OpenCode agent(claude code / codex / zcode)经 MCP client 接入同一套编排状态机。
5
5
  */
6
6
  import { createServer, type Server } from "node:http"
@@ -8,7 +8,6 @@ import { randomUUID } from "node:crypto"
8
8
  import { join } from "node:path"
9
9
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"
10
10
  import { buildMcpServer } from "./server.ts"
11
- import { startDashboard } from "../opencode/dashboard.ts"
12
11
  import { OpenSpecCollector, AdoCollector, registerCollector, startPolling } from "../../core/workflow/index.ts"
13
12
  import { readContextFromWorktree } from "../../core/state.ts"
14
13
 
@@ -23,10 +22,9 @@ export interface McpServerOptions {
23
22
  export const DEFAULT_PORT = 4525
24
23
  const MAX_PORT_ATTEMPTS = 20
25
24
 
26
- /** 启动 dashboard/collector/poller 副作用(原插件壳职责,单一宿主 = MCP server 进程)。 */
25
+ /** 启动 collector/poller 副作用(原插件壳职责,单一宿主 = MCP server 进程)。 */
27
26
  function startSideEffects(worktree: string): void {
28
27
  try {
29
- void startDashboard(worktree)
30
28
  // 注册内置收集器(OpenSpec + ADO 占位)并启动定时拉取;worktree 为动态上下文在此注入。
31
29
  registerCollector(new OpenSpecCollector({ openspecDir: join(worktree, "openspec") }))
32
30
  registerCollector(new AdoCollector())
@@ -37,7 +35,7 @@ function startSideEffects(worktree: string): void {
37
35
  }
38
36
  })
39
37
  } catch {
40
- // dashboard/调度启动失败不影响编排功能
38
+ // 调度启动失败不影响编排功能
41
39
  }
42
40
  }
43
41
 
@@ -12,7 +12,7 @@ import { writeAgents } from "./agents.ts"
12
12
  * - 随包 skill 走 skill 域 transform 逐项注册,path 指向包内 SKILL.md 以保留附属文件基准;
13
13
  * - 子代理以文件形式投放至项目 `.opencode/agents/`(V2 插件接口无新增 agent 的能力)。
14
14
  *
15
- * dashboard/collector/poller 副作用由 MCP server 进程承担(src/adapters/mcp-common/),
15
+ * collector/poller 副作用由 MCP server 进程承担(src/adapters/mcp-common/),
16
16
  * 插件壳不启动任何后台任务。
17
17
  *
18
18
  * 插件 ID 稳定为 openspec-agents:V2 以该 ID 标识插件、隔离其持久化存储,并用于配置中的
@@ -22,7 +22,7 @@ export const OPENCODE_PLUGIN_ID = "openspec-agents"
22
22
 
23
23
  /**
24
24
  * 构造 opx MCP server 配置。入口为包内 .mcp-server/cli.mjs(prepack 构建的自包含 bundle),
25
- * --worktree 指向当前加载本插件的项目根(ctx.location.directory),dashboard/collector 副作用
25
+ * --worktree 指向当前加载本插件的项目根(ctx.location.directory),collector 副作用
26
26
  * 与状态读写均以此为根。
27
27
  */
28
28
  function mcpServerConfig(projectRoot: string) {
@@ -35,8 +35,6 @@ export function readPkgVersion(): string {
35
35
  export function bundleMcpServer(pluginDir: string): string {
36
36
  const out = join(pluginDir, ".mcp-server", "cli.mjs")
37
37
  mkdirSync(join(pluginDir, ".mcp-server"), { recursive: true })
38
- // dashboard 页面资源随 bundle 同目录放置(page.ts 按此布局探测)
39
- cpSync(resolve("assets", "dashboard"), join(pluginDir, ".mcp-server", "dashboard"), { recursive: true })
40
38
  const result = spawnSync(
41
39
  "bun",
42
40
  [
package/src/core/index.ts CHANGED
@@ -65,9 +65,6 @@ export {
65
65
  renderAgentSummaries, formatFilePath, renderTaskItem, formatSeverity,
66
66
  } from "./views.ts"
67
67
 
68
- // dashboard
69
- export { readDashboardState } from "./dashboard.ts"
70
-
71
68
  // constants
72
69
  export {
73
70
  STATE_DIR_NAME, STATE_SUBDIR_NAME,
@@ -46,14 +46,12 @@ export interface RuntimeTool {
46
46
  /** 工具注册表:工具名 → 定义。 */
47
47
  export type ToolRegistry = Record<string, RuntimeTool>
48
48
 
49
- /** 运行时配置注入:agent/skill 注入指令与可选 dashboard 配置。 */
49
+ /** 运行时配置注入:agent/skill 注入指令。 */
50
50
  export interface IRuntimeConfig {
51
51
  /** 分发的 agent 定义根目录(各适配器按自身注入格式消费)。 */
52
52
  agentsDir?: string
53
53
  /** 分发的 skill 根目录列表(各适配器按自身发现机制注册)。 */
54
54
  skillsDirs?: string[]
55
- /** 可选编排进度看板。 */
56
- dashboard?: { port?: number; hostname?: string }
57
55
  }
58
56
 
59
57
  /**
@@ -1,295 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="zh-CN">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>编排进度看板 · 5 列看板</title>
7
- <style>
8
- @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
9
- :root{
10
- --bg:#f8fafc;--surface:#fff;--surface-2:#f1f5f9;
11
- --ink:#0f172a;--muted:#475569;--quiet:#94a3b8;
12
- --line:#e2e8f0;--line-2:#cbd5e1;
13
- --blue:#2563eb;--blue-soft:#eff6ff;
14
- --green:#16a34a;--green-soft:#f0fdf4;
15
- --amber:#d97706;--amber-soft:#fffbeb;
16
- --red:#dc2626;--red-soft:#fef2f2;
17
- --violet:#7c3aed;--violet-soft:#f5f3ff;
18
- --teal:#0d9488;--teal-soft:#f0fdfa;
19
- --radius:12px;--radius-sm:8px;--pill:999px;
20
- --shadow:0 1px 2px rgba(15,23,42,.04),0 6px 16px rgba(15,23,42,.04);
21
- --sans:'Inter',system-ui,sans-serif;
22
- --mono:'JetBrains Mono',ui-monospace,monospace;
23
- }
24
- *{box-sizing:border-box}html{font-size:15px}body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);line-height:1.55;-webkit-font-smoothing:antialiased}
25
- button{font:inherit;cursor:pointer}button:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
26
- .shell{max-width:1440px;margin:0 auto;padding:32px 28px 80px}
27
- /* header */
28
- .topbar{display:flex;justify-content:space-between;align-items:flex-start;gap:24px;padding-bottom:26px;border-bottom:1px solid var(--line)}
29
- .brand{display:flex;flex-direction:column;gap:8px}
30
- .badge{display:inline-flex;align-items:center;gap:8px;font:600 .68rem/1 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
31
- .badge::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 0 4px var(--green-soft)}
32
- h1{margin:0;font-size:1.6rem;font-weight:700;letter-spacing:-.03em}
33
- .meta{display:flex;gap:28px;text-align:right;font-size:.8rem;color:var(--muted)}
34
- .meta span{display:flex;flex-direction:column;gap:5px}.meta strong{color:var(--ink);font-weight:600}
35
- /* 5 列看板 */
36
- .board{display:grid;grid-template-columns:repeat(5,1fr);gap:16px;align-items:start;padding-top:24px}
37
- .col{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden;display:flex;flex-direction:column}
38
- .col-head{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:14px 16px;border-bottom:1px solid var(--line)}
39
- .col-head h3{margin:0;font-size:.92rem;font-weight:700}
40
- .col-count{font:600 .78rem/1 var(--mono);padding:4px 10px;background:var(--surface-2);border-radius:var(--pill);color:var(--muted)}
41
- .col-body{max-height:calc(100vh - 280px);overflow:auto;padding:12px;display:flex;flex-direction:column;gap:12px}
42
- .board-empty{grid-column:1/-1;padding:48px 0;text-align:center;color:var(--quiet);font-size:.9rem}
43
- /* 卡片 */
44
- .card{position:relative;background:var(--surface);border:1px solid var(--line);border-left-width:3px;border-radius:var(--radius-sm);box-shadow:0 1px 2px rgba(15,23,42,.04);padding:12px 14px;display:flex;flex-direction:column;gap:8px}
45
- .card.task{border-left-color:var(--blue)}
46
- .card.issue{border-left-color:var(--violet)}
47
- .card.suspended{border-style:dashed;opacity:.94}
48
- .card-head{display:flex;align-items:center;gap:8px}
49
- .type-tag{flex:0 0 auto;width:22px;height:22px;border-radius:6px;display:inline-flex;align-items:center;justify-content:center;font:700 .7rem/1 var(--mono);color:#fff}
50
- .type-tag.task{background:var(--blue)}.type-tag.issue{background:var(--violet)}
51
- .src-icon{flex:0 0 auto;font-size:.85rem;color:var(--quiet);line-height:1}
52
- .card-title{margin:0;font-size:.9rem;font-weight:700;line-height:1.4;flex:1}
53
- .card-desc{font-size:.78rem;color:var(--muted);line-height:1.5}
54
- .card-desc.collapsed{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
55
- .desc-toggle{margin-top:-4px;font-size:.72rem;color:var(--blue);background:none;border:none;padding:0;cursor:pointer;text-align:left}
56
- .card-labels{display:flex;flex-wrap:wrap;gap:6px}
57
- .chip{display:inline-flex;align-items:center;padding:2px 8px;border-radius:var(--pill);font:500 .68rem/1.4 var(--mono);background:var(--surface-2);color:var(--muted);border:1px solid var(--line)}
58
- .card-children{display:flex;flex-direction:column;gap:6px;border-top:1px dashed var(--line);padding-top:8px}
59
- .child-row{display:flex;align-items:center;gap:6px;font-size:.76rem;color:var(--muted)}
60
- .child-type{flex:0 0 auto;width:16px;height:16px;border-radius:4px;display:inline-flex;align-items:center;justify-content:center;font:700 .58rem/1 var(--mono);color:#fff}
61
- .child-type.task{background:var(--blue)}.child-type.issue{background:var(--violet)}
62
- .child-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
63
- .child-phase{flex:0 0 auto;padding:1px 6px;border-radius:var(--pill);font:600 .62rem/1.3 var(--mono);background:var(--surface-2);color:var(--muted);border:1px solid var(--line)}
64
- .card-foot{display:flex;justify-content:space-between;align-items:flex-end;gap:8px;margin-top:auto;border-top:1px solid var(--line);padding-top:8px}
65
- .card-verdicts{display:flex;flex-wrap:wrap;gap:6px}
66
- .verdict{display:inline-flex;align-items:center;gap:3px;padding:2px 7px;border-radius:var(--pill);font:600 .68rem/1.4 var(--mono);background:var(--surface-2);color:var(--muted)}
67
- .verdict.passed{background:var(--green-soft);color:var(--green)}
68
- .verdict.failed{background:var(--red-soft);color:var(--red)}
69
- .verdict.pending{background:var(--surface-2);color:var(--quiet)}
70
- .card-meta{display:flex;align-items:center;gap:6px;flex-shrink:0}
71
- .step-tag{font:.68rem/1.4 var(--mono);color:var(--quiet)}
72
- .suspend-tag{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:var(--pill);font:600 .7rem/1.4 var(--mono);background:var(--amber-soft);color:var(--amber);border:1px solid var(--amber);cursor:pointer}
73
- .suspend-reason{margin-top:4px;font-size:.72rem;color:var(--amber);background:var(--amber-soft);border-radius:var(--radius-sm);padding:6px 8px}
74
- /* severity 色条 */
75
- .sev-bar{position:absolute;top:0;left:0;right:0;height:4px;border-radius:var(--radius-sm) var(--radius-sm) 0 0}
76
- .sev-bar.critical{background:var(--red)}.sev-bar.high{background:var(--amber)}.sev-bar.medium{background:var(--violet)}.sev-bar.low{background:var(--teal)}.sev-bar.info{background:var(--line-2)}
77
- /* WorkItem 详情弹窗 */
78
- .modal-open{overflow:hidden}
79
- .modal-overlay{position:fixed;inset:0;z-index:100;background:rgba(15,23,42,.45);display:flex;align-items:flex-start;justify-content:center;padding:48px 16px;overflow:auto}
80
- .modal-overlay[hidden]{display:none}
81
- .modal-dialog{background:var(--surface);border-radius:var(--radius);box-shadow:0 20px 50px rgba(15,23,42,.2);max-width:640px;width:100%;max-height:calc(100vh - 96px);display:flex;flex-direction:column}
82
- .modal-head{display:flex;align-items:center;gap:12px;padding:14px 18px;border-bottom:1px solid var(--line)}
83
- .modal-title{flex:1;font-size:.92rem;font-weight:700}
84
- .modal-close{background:none;border:none;font-size:1.2rem;color:var(--muted);line-height:1;padding:4px 8px;border-radius:6px}
85
- .modal-close:hover{background:var(--surface-2);color:var(--ink)}
86
- .modal-back{background:none;border:1px solid var(--line);border-radius:var(--radius-sm);font-size:.78rem;color:var(--muted);padding:4px 10px}
87
- .modal-back:hover{background:var(--surface-2);color:var(--ink)}
88
- .modal-back[hidden]{display:none}
89
- .modal-body{padding:18px;overflow:auto;display:flex;flex-direction:column;gap:14px}
90
- .detail-title{margin:0;font-size:1.1rem;font-weight:700}
91
- .detail-block{display:flex;flex-direction:column;gap:8px}
92
- .detail-block h5{margin:0;font-size:.68rem;letter-spacing:.06em;text-transform:uppercase;color:var(--quiet)}
93
- .meta-row{display:flex;gap:10px;font-size:.8rem;padding:4px 0;border-bottom:1px dashed var(--line)}
94
- .meta-key{flex:0 0 96px;color:var(--quiet);font:500 .76rem/1.4 var(--mono)}
95
- .meta-val{flex:1;color:var(--ink);word-break:break-word}
96
- .detail-desc{margin:0;font-size:.82rem;color:var(--muted);white-space:pre-wrap}
97
- .detail-verdicts{display:flex;flex-wrap:wrap;gap:6px}
98
- .detail-verdict{display:inline-flex;align-items:center;gap:3px;padding:2px 7px;border-radius:var(--pill);font:600 .68rem/1.4 var(--mono);background:var(--surface-2);color:var(--muted)}
99
- .detail-verdict.passed{background:var(--green-soft);color:var(--green)}
100
- .detail-verdict.failed{background:var(--red-soft);color:var(--red)}
101
- .detail-verdict.pending{background:var(--surface-2);color:var(--quiet)}
102
- .detail-children{display:flex;flex-direction:column;gap:6px}
103
- .detail-child{display:flex;align-items:center;gap:8px;padding:8px 10px;border:1px solid var(--line);border-radius:var(--radius-sm);font-size:.8rem;cursor:pointer}
104
- .detail-child:hover{background:var(--surface-2);border-color:var(--line-2)}
105
- @media(max-width:1024px){.board{grid-template-columns:repeat(2,1fr)}}
106
- @media(max-width:640px){.board{grid-template-columns:1fr}.topbar{flex-direction:column;gap:14px}.meta{text-align:left}}
107
- </style>
108
- </head>
109
- <body>
110
- <div class="shell">
111
- <header class="topbar">
112
- <div class="brand">
113
- <div class="badge" id="connBadge"><span>实时同步</span></div>
114
- <h1>编排进度看板</h1>
115
- </div>
116
- <div class="meta">
117
- <span>变更编号<strong id="changeId">-</strong></span>
118
- <span>基准分支<strong id="baseBranch">-</strong></span>
119
- <span>最后更新<strong id="updatedAt">-</strong></span>
120
- </div>
121
- </header>
122
- <main id="dashboard"></main>
123
- </div>
124
- <div class="modal-overlay" id="wiModal" hidden>
125
- <div class="modal-dialog" role="dialog" aria-modal="true" aria-label="工作项详情">
126
- <header class="modal-head">
127
- <button class="modal-back" id="wiBack" hidden>← 返回</button>
128
- <span class="modal-title">工作项详情</span>
129
- <button class="modal-close" id="wiClose" aria-label="关闭">×</button>
130
- </header>
131
- <div class="modal-body" id="wiBody"></div>
132
- </div>
133
- </div>
134
- <script>
135
- const COLUMNS=[['todo','待办'],['in_progress','处理中'],['review','审核中'],['done','完成'],['cancelled','已取消']];
136
- const SOURCE_ICONS={openspec:'◈',ado:'◆',custom:'✚'};
137
- const VERDICT_ICONS={passed:'✓',failed:'✕',pending:'○'};
138
- const AGENT_SHORT={'openspec-architect':'architect','openspec-developer':'dev','openspec-reviewer-tool':'tool','openspec-reviewer-task':'task','openspec-reviewer-style':'style','openspec-reviewer-architecture':'arch','openspec-reviewer-performance':'perf','openspec-reviewer-security':'sec','openspec-reviewer-maintainability':'maint'};
139
- let dashboardState={};
140
- function esc(v){return String(v??'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','\'':'&#39;','"':'&quot;'}[c]))}
141
- function cls(v){return String(v??'').replace(/[^a-zA-Z0-9_-]/g,'_')}
142
- function shortAgent(key){if(AGENT_SHORT[key])return AGENT_SHORT[key];const m=/^openspec-reviewer-(.+)$/.exec(key)||/^openspec-(.+)$/.exec(key);return m?m[1]:(key||'')}
143
- function descBlock(text){if(!text)return '';if(text.length<90)return `<div class="card-desc">${esc(text)}</div>`;return `<div class="card-desc collapsed">${esc(text)}</div><button class="desc-toggle">展开</button>`}
144
- function verdictDot(v){return `<span class="verdict ${cls(v.verdict)}" title="${esc(v.stepId)}">${esc(shortAgent(v.agentKey))}:${VERDICT_ICONS[v.verdict]||'○'}</span>`}
145
- function renderCard(c){
146
- const isIssue=c.type==='issue';
147
- const typeTag=`<span class="type-tag ${isIssue?'issue':'task'}">${isIssue?'I':'T'}</span>`;
148
- const srcIcon=`<span class="src-icon" title="${esc(c.source||'')}">${SOURCE_ICONS[c.source]||'●'}</span>`;
149
- const sevBar=isIssue&&c.severity?`<span class="sev-bar ${cls(String(c.severity).toLowerCase())}" title="${esc(c.severity)}"></span>`:'';
150
- const labels=(c.labels||[]).map(l=>`<span class="chip">${esc(l)}</span>`).join('');
151
- const step=c.currentStep?`<span class="step-tag">${esc(c.currentStep)}</span>`:'';
152
- const suspTag=c.suspended?'<span class="suspend-tag" role="button" tabindex="0">⏸ 暂停</span>':'';
153
- const suspReason=c.suspended?`<div class="suspend-reason" hidden>${esc(c.suspendReason||'未提供暂停原因')}</div>`:'';
154
- const verdicts=(c.agentVerdicts||[]).map(verdictDot).join('');
155
- const children=(c.children||[]).map(ch=>`<div class="child-row"><span class="child-type ${cls(ch.type)}">${ch.type==='issue'?'I':'T'}</span><span class="child-title">${esc(ch.title)}</span>${ch.type==='task'?`<span class="child-phase">${esc(ch.phase)}</span>`:''}</div>`).join('');
156
- return `<article class="card ${isIssue?'issue':'task'}${c.suspended?' suspended':''}" data-id="${esc(c.id)}">${sevBar}
157
- <div class="card-head">${typeTag}${srcIcon}<h4 class="card-title">${esc(c.title)}</h4></div>
158
- ${descBlock(c.description)}
159
- ${labels?`<div class="card-labels">${labels}</div>`:''}
160
- ${children?`<div class="card-children">${children}</div>`:''}
161
- <div class="card-foot">${verdicts?`<div class="card-verdicts">${verdicts}</div>`:'<span></span>'}<div class="card-meta">${step}${suspTag}</div></div>
162
- ${suspReason}
163
- </article>`;
164
- }
165
- function renderBoard(){
166
- const cards=dashboardState.workItemCards||[];
167
- if(!cards.length)return `<section class="board"><div class="board-empty">暂无工作项数据(旧格式状态或未初始化)</div></section>`;
168
- return `<section class="board">${COLUMNS.map(([key,label])=>{
169
- const items=cards.filter(c=>c.phase===key);
170
- return `<div class="col" data-col="${key}"><header class="col-head"><h3>${label}</h3><span class="col-count">${items.length}</span></header><div class="col-body">${items.map(renderCard).join('')}</div></div>`;
171
- }).join('')}</section>`;
172
- }
173
- function render(){
174
- document.getElementById('changeId').textContent=dashboardState.changeId||'-';
175
- document.getElementById('baseBranch').textContent=dashboardState.baseBranch||'-';
176
- document.getElementById('updatedAt').textContent=dashboardState.updatedAt?new Date(dashboardState.updatedAt).toLocaleString():'-';
177
- document.getElementById('dashboard').innerHTML=renderBoard();
178
- document.querySelectorAll('.desc-toggle').forEach(btn=>btn.addEventListener('click',e=>{
179
- const d=e.target.previousElementSibling;
180
- if(d&&d.classList.contains('card-desc')){d.classList.toggle('collapsed');e.target.textContent=d.classList.contains('collapsed')?'展开':'收起'}
181
- }));
182
- document.querySelectorAll('.suspend-tag').forEach(tag=>tag.addEventListener('click',e=>{
183
- const card=e.target.closest('.card');
184
- const reason=card&&card.querySelector('.suspend-reason');
185
- if(reason)reason.hidden=!reason.hidden;
186
- }));
187
- if(modalState.id)refreshModal();
188
- }
189
- /* WorkItem 详情弹窗 */
190
- let modalState={id:null,back:[]};
191
- function findWorkItem(items,id){
192
- for(const it of items||[]){
193
- if(it.id===id)return it;
194
- if(it.children&&it.children.length){
195
- const found=findWorkItem(it.children,id);
196
- if(found)return found;
197
- }
198
- }
199
- return null;
200
- }
201
- function renderTags(tags){
202
- const verdicts=Object.entries(tags||{}).map(([key,verdict])=>{
203
- const sep=key.indexOf(':');
204
- const stepId=sep===-1?key:key.slice(0,sep);
205
- const agentKey=sep===-1?key:key.slice(sep+1);
206
- return `<span class="detail-verdict ${cls(verdict)}" title="${esc(key)}">${esc(shortAgent(agentKey))}:${VERDICT_ICONS[verdict]||'○'}</span>`;
207
- }).join('');
208
- return verdicts?`<div class="detail-block"><h5>Agent 裁决</h5><div class="detail-verdicts">${verdicts}</div></div>`:'';
209
- }
210
- function renderWriteback(wb){
211
- if(!wb)return '';
212
- const rows=Object.entries(wb).filter(([k])=>!k.startsWith('_')).map(([k,v])=>{
213
- const val=typeof v==='object'?JSON.stringify(v):String(v);
214
- return `<div class="meta-row"><span class="meta-key">${esc(k)}</span><span class="meta-val">${esc(val)}</span></div>`;
215
- }).join('');
216
- return rows?`<div class="detail-block"><h5>写回</h5>${rows}</div>`:'';
217
- }
218
- function renderMeta(metadata){
219
- const rows=Object.entries(metadata||{}).filter(([k])=>!k.startsWith('_')).map(([k,v])=>{
220
- const val=typeof v==='object'?JSON.stringify(v):String(v);
221
- return `<div class="meta-row"><span class="meta-key">${esc(k)}</span><span class="meta-val">${esc(val)}</span></div>`;
222
- }).join('');
223
- return rows?`<div class="detail-block"><h5>元数据</h5>${rows}</div>`:'';
224
- }
225
- function renderChildren(children){
226
- if(!children||!children.length)return '';
227
- const rows=(children||[]).map(ch=>`<div class="detail-child" data-id="${esc(ch.id)}" role="button" tabindex="0">
228
- <span class="child-type ${cls(ch.type)}">${ch.type==='issue'?'I':'T'}</span>
229
- <span class="child-title">${esc(ch.title)}</span>
230
- <span class="child-phase">${esc(ch.phase)}</span>
231
- </div>`).join('');
232
- return `<div class="detail-block"><h5>Children</h5><div class="detail-children">${rows}</div></div>`;
233
- }
234
- function renderDetail(item){
235
- const rows=[['类型',item.type],['来源',item.source],['ID',item.id]];
236
- if(item.externalId)rows.push(['外部ID',item.externalId]);
237
- rows.push(['阶段',item.phase]);
238
- if(item.severity)rows.push(['严重级别',item.severity]);
239
- rows.push(['当前步骤',item.currentStep||'-']);
240
- const kv=rows.map(([k,v])=>`<div class="meta-row"><span class="meta-key">${esc(k)}</span><span class="meta-val">${esc(v)}</span></div>`).join('');
241
- return `<h3 class="detail-title">${esc(item.title)}</h3>
242
- ${item.suspended?`<div class="suspend-reason">⏸ 暂停:${esc(typeof (item.metadata||{})['suspend_reason']==='string'?(item.metadata||{})['suspend_reason']:'未提供暂停原因')}</div>`:''}
243
- ${item.description?`<div class="detail-block"><h5>描述</h5><p class="detail-desc">${esc(item.description)}</p></div>`:''}
244
- <div class="detail-block"><h5>字段</h5>${kv}</div>
245
- ${item.labels&&item.labels.length?`<div class="detail-block"><h5>标签</h5><div class="card-labels">${item.labels.map(l=>`<span class="chip">${esc(l)}</span>`).join('')}</div></div>`:''}
246
- ${renderTags(item.tags)}
247
- ${renderWriteback(item.writeback)}
248
- ${renderMeta(item.metadata)}
249
- ${renderChildren(item.children)}`;
250
- }
251
- function refreshModal(){
252
- const modal=document.getElementById('wiModal');
253
- const item=findWorkItem(dashboardState.workItems||[],modalState.id);
254
- if(!item){closeDetail();return;}
255
- document.getElementById('wiBody').innerHTML=renderDetail(item);
256
- document.getElementById('wiBack').hidden=!modalState.back.length;
257
- modal.hidden=false;
258
- document.body.classList.add('modal-open');
259
- }
260
- function openDetail(id,back){
261
- modalState={id,back:back||[]};
262
- refreshModal();
263
- }
264
- function closeDetail(){
265
- modalState={id:null,back:[]};
266
- const modal=document.getElementById('wiModal');
267
- if(modal)modal.hidden=true;
268
- document.body.classList.remove('modal-open');
269
- }
270
- document.addEventListener('click',e=>{
271
- if(e.target.closest('.desc-toggle,.suspend-tag'))return;
272
- const card=e.target.closest('.card');
273
- if(card&&card.dataset.id)openDetail(card.dataset.id,[]);
274
- });
275
- document.getElementById('wiModal').addEventListener('click',e=>{
276
- if(e.target.closest('#wiBack')){
277
- const prev=modalState.back[modalState.back.length-1];
278
- openDetail(prev,modalState.back.slice(0,-1));
279
- return;
280
- }
281
- if(e.target.classList.contains('modal-overlay')){closeDetail();return;}
282
- if(e.target.closest('.modal-close')){closeDetail();return;}
283
- const child=e.target.closest('.detail-child');
284
- if(child&&child.dataset.id)openDetail(child.dataset.id,[...modalState.back,modalState.id]);
285
- });
286
- document.addEventListener('keydown',e=>{
287
- if(e.key==='Escape'){const modal=document.getElementById('wiModal');if(modal&&!modal.hidden)closeDetail();}
288
- });
289
- async function loadState(){try{const res=await fetch('/api/state');if(!res.ok)throw new Error('fetch failed');const data=await res.json();if(data&&data.active===true){dashboardState=data;render()}}catch(e){console.error('[dashboard] load state failed',e)}}
290
- render();
291
- loadState();
292
- setInterval(loadState,2000);
293
- </script>
294
- </body>
295
- </html>
@@ -1,295 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="zh-CN">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>编排进度看板 · 5 列看板</title>
7
- <style>
8
- @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
9
- :root{
10
- --bg:#f8fafc;--surface:#fff;--surface-2:#f1f5f9;
11
- --ink:#0f172a;--muted:#475569;--quiet:#94a3b8;
12
- --line:#e2e8f0;--line-2:#cbd5e1;
13
- --blue:#2563eb;--blue-soft:#eff6ff;
14
- --green:#16a34a;--green-soft:#f0fdf4;
15
- --amber:#d97706;--amber-soft:#fffbeb;
16
- --red:#dc2626;--red-soft:#fef2f2;
17
- --violet:#7c3aed;--violet-soft:#f5f3ff;
18
- --teal:#0d9488;--teal-soft:#f0fdfa;
19
- --radius:12px;--radius-sm:8px;--pill:999px;
20
- --shadow:0 1px 2px rgba(15,23,42,.04),0 6px 16px rgba(15,23,42,.04);
21
- --sans:'Inter',system-ui,sans-serif;
22
- --mono:'JetBrains Mono',ui-monospace,monospace;
23
- }
24
- *{box-sizing:border-box}html{font-size:15px}body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);line-height:1.55;-webkit-font-smoothing:antialiased}
25
- button{font:inherit;cursor:pointer}button:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
26
- .shell{max-width:1440px;margin:0 auto;padding:32px 28px 80px}
27
- /* header */
28
- .topbar{display:flex;justify-content:space-between;align-items:flex-start;gap:24px;padding-bottom:26px;border-bottom:1px solid var(--line)}
29
- .brand{display:flex;flex-direction:column;gap:8px}
30
- .badge{display:inline-flex;align-items:center;gap:8px;font:600 .68rem/1 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
31
- .badge::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 0 4px var(--green-soft)}
32
- h1{margin:0;font-size:1.6rem;font-weight:700;letter-spacing:-.03em}
33
- .meta{display:flex;gap:28px;text-align:right;font-size:.8rem;color:var(--muted)}
34
- .meta span{display:flex;flex-direction:column;gap:5px}.meta strong{color:var(--ink);font-weight:600}
35
- /* 5 列看板 */
36
- .board{display:grid;grid-template-columns:repeat(5,1fr);gap:16px;align-items:start;padding-top:24px}
37
- .col{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden;display:flex;flex-direction:column}
38
- .col-head{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:14px 16px;border-bottom:1px solid var(--line)}
39
- .col-head h3{margin:0;font-size:.92rem;font-weight:700}
40
- .col-count{font:600 .78rem/1 var(--mono);padding:4px 10px;background:var(--surface-2);border-radius:var(--pill);color:var(--muted)}
41
- .col-body{max-height:calc(100vh - 280px);overflow:auto;padding:12px;display:flex;flex-direction:column;gap:12px}
42
- .board-empty{grid-column:1/-1;padding:48px 0;text-align:center;color:var(--quiet);font-size:.9rem}
43
- /* 卡片 */
44
- .card{position:relative;background:var(--surface);border:1px solid var(--line);border-left-width:3px;border-radius:var(--radius-sm);box-shadow:0 1px 2px rgba(15,23,42,.04);padding:12px 14px;display:flex;flex-direction:column;gap:8px}
45
- .card.task{border-left-color:var(--blue)}
46
- .card.issue{border-left-color:var(--violet)}
47
- .card.suspended{border-style:dashed;opacity:.94}
48
- .card-head{display:flex;align-items:center;gap:8px}
49
- .type-tag{flex:0 0 auto;width:22px;height:22px;border-radius:6px;display:inline-flex;align-items:center;justify-content:center;font:700 .7rem/1 var(--mono);color:#fff}
50
- .type-tag.task{background:var(--blue)}.type-tag.issue{background:var(--violet)}
51
- .src-icon{flex:0 0 auto;font-size:.85rem;color:var(--quiet);line-height:1}
52
- .card-title{margin:0;font-size:.9rem;font-weight:700;line-height:1.4;flex:1}
53
- .card-desc{font-size:.78rem;color:var(--muted);line-height:1.5}
54
- .card-desc.collapsed{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
55
- .desc-toggle{margin-top:-4px;font-size:.72rem;color:var(--blue);background:none;border:none;padding:0;cursor:pointer;text-align:left}
56
- .card-labels{display:flex;flex-wrap:wrap;gap:6px}
57
- .chip{display:inline-flex;align-items:center;padding:2px 8px;border-radius:var(--pill);font:500 .68rem/1.4 var(--mono);background:var(--surface-2);color:var(--muted);border:1px solid var(--line)}
58
- .card-children{display:flex;flex-direction:column;gap:6px;border-top:1px dashed var(--line);padding-top:8px}
59
- .child-row{display:flex;align-items:center;gap:6px;font-size:.76rem;color:var(--muted)}
60
- .child-type{flex:0 0 auto;width:16px;height:16px;border-radius:4px;display:inline-flex;align-items:center;justify-content:center;font:700 .58rem/1 var(--mono);color:#fff}
61
- .child-type.task{background:var(--blue)}.child-type.issue{background:var(--violet)}
62
- .child-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
63
- .child-phase{flex:0 0 auto;padding:1px 6px;border-radius:var(--pill);font:600 .62rem/1.3 var(--mono);background:var(--surface-2);color:var(--muted);border:1px solid var(--line)}
64
- .card-foot{display:flex;justify-content:space-between;align-items:flex-end;gap:8px;margin-top:auto;border-top:1px solid var(--line);padding-top:8px}
65
- .card-verdicts{display:flex;flex-wrap:wrap;gap:6px}
66
- .verdict{display:inline-flex;align-items:center;gap:3px;padding:2px 7px;border-radius:var(--pill);font:600 .68rem/1.4 var(--mono);background:var(--surface-2);color:var(--muted)}
67
- .verdict.passed{background:var(--green-soft);color:var(--green)}
68
- .verdict.failed{background:var(--red-soft);color:var(--red)}
69
- .verdict.pending{background:var(--surface-2);color:var(--quiet)}
70
- .card-meta{display:flex;align-items:center;gap:6px;flex-shrink:0}
71
- .step-tag{font:.68rem/1.4 var(--mono);color:var(--quiet)}
72
- .suspend-tag{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:var(--pill);font:600 .7rem/1.4 var(--mono);background:var(--amber-soft);color:var(--amber);border:1px solid var(--amber);cursor:pointer}
73
- .suspend-reason{margin-top:4px;font-size:.72rem;color:var(--amber);background:var(--amber-soft);border-radius:var(--radius-sm);padding:6px 8px}
74
- /* severity 色条 */
75
- .sev-bar{position:absolute;top:0;left:0;right:0;height:4px;border-radius:var(--radius-sm) var(--radius-sm) 0 0}
76
- .sev-bar.critical{background:var(--red)}.sev-bar.high{background:var(--amber)}.sev-bar.medium{background:var(--violet)}.sev-bar.low{background:var(--teal)}.sev-bar.info{background:var(--line-2)}
77
- /* WorkItem 详情弹窗 */
78
- .modal-open{overflow:hidden}
79
- .modal-overlay{position:fixed;inset:0;z-index:100;background:rgba(15,23,42,.45);display:flex;align-items:flex-start;justify-content:center;padding:48px 16px;overflow:auto}
80
- .modal-overlay[hidden]{display:none}
81
- .modal-dialog{background:var(--surface);border-radius:var(--radius);box-shadow:0 20px 50px rgba(15,23,42,.2);max-width:640px;width:100%;max-height:calc(100vh - 96px);display:flex;flex-direction:column}
82
- .modal-head{display:flex;align-items:center;gap:12px;padding:14px 18px;border-bottom:1px solid var(--line)}
83
- .modal-title{flex:1;font-size:.92rem;font-weight:700}
84
- .modal-close{background:none;border:none;font-size:1.2rem;color:var(--muted);line-height:1;padding:4px 8px;border-radius:6px}
85
- .modal-close:hover{background:var(--surface-2);color:var(--ink)}
86
- .modal-back{background:none;border:1px solid var(--line);border-radius:var(--radius-sm);font-size:.78rem;color:var(--muted);padding:4px 10px}
87
- .modal-back:hover{background:var(--surface-2);color:var(--ink)}
88
- .modal-back[hidden]{display:none}
89
- .modal-body{padding:18px;overflow:auto;display:flex;flex-direction:column;gap:14px}
90
- .detail-title{margin:0;font-size:1.1rem;font-weight:700}
91
- .detail-block{display:flex;flex-direction:column;gap:8px}
92
- .detail-block h5{margin:0;font-size:.68rem;letter-spacing:.06em;text-transform:uppercase;color:var(--quiet)}
93
- .meta-row{display:flex;gap:10px;font-size:.8rem;padding:4px 0;border-bottom:1px dashed var(--line)}
94
- .meta-key{flex:0 0 96px;color:var(--quiet);font:500 .76rem/1.4 var(--mono)}
95
- .meta-val{flex:1;color:var(--ink);word-break:break-word}
96
- .detail-desc{margin:0;font-size:.82rem;color:var(--muted);white-space:pre-wrap}
97
- .detail-verdicts{display:flex;flex-wrap:wrap;gap:6px}
98
- .detail-verdict{display:inline-flex;align-items:center;gap:3px;padding:2px 7px;border-radius:var(--pill);font:600 .68rem/1.4 var(--mono);background:var(--surface-2);color:var(--muted)}
99
- .detail-verdict.passed{background:var(--green-soft);color:var(--green)}
100
- .detail-verdict.failed{background:var(--red-soft);color:var(--red)}
101
- .detail-verdict.pending{background:var(--surface-2);color:var(--quiet)}
102
- .detail-children{display:flex;flex-direction:column;gap:6px}
103
- .detail-child{display:flex;align-items:center;gap:8px;padding:8px 10px;border:1px solid var(--line);border-radius:var(--radius-sm);font-size:.8rem;cursor:pointer}
104
- .detail-child:hover{background:var(--surface-2);border-color:var(--line-2)}
105
- @media(max-width:1024px){.board{grid-template-columns:repeat(2,1fr)}}
106
- @media(max-width:640px){.board{grid-template-columns:1fr}.topbar{flex-direction:column;gap:14px}.meta{text-align:left}}
107
- </style>
108
- </head>
109
- <body>
110
- <div class="shell">
111
- <header class="topbar">
112
- <div class="brand">
113
- <div class="badge" id="connBadge"><span>实时同步</span></div>
114
- <h1>编排进度看板</h1>
115
- </div>
116
- <div class="meta">
117
- <span>变更编号<strong id="changeId">-</strong></span>
118
- <span>基准分支<strong id="baseBranch">-</strong></span>
119
- <span>最后更新<strong id="updatedAt">-</strong></span>
120
- </div>
121
- </header>
122
- <main id="dashboard"></main>
123
- </div>
124
- <div class="modal-overlay" id="wiModal" hidden>
125
- <div class="modal-dialog" role="dialog" aria-modal="true" aria-label="工作项详情">
126
- <header class="modal-head">
127
- <button class="modal-back" id="wiBack" hidden>← 返回</button>
128
- <span class="modal-title">工作项详情</span>
129
- <button class="modal-close" id="wiClose" aria-label="关闭">×</button>
130
- </header>
131
- <div class="modal-body" id="wiBody"></div>
132
- </div>
133
- </div>
134
- <script>
135
- const COLUMNS=[['todo','待办'],['in_progress','处理中'],['review','审核中'],['done','完成'],['cancelled','已取消']];
136
- const SOURCE_ICONS={openspec:'◈',ado:'◆',custom:'✚'};
137
- const VERDICT_ICONS={passed:'✓',failed:'✕',pending:'○'};
138
- const AGENT_SHORT={'openspec-architect':'architect','openspec-developer':'dev','openspec-reviewer-tool':'tool','openspec-reviewer-task':'task','openspec-reviewer-style':'style','openspec-reviewer-architecture':'arch','openspec-reviewer-performance':'perf','openspec-reviewer-security':'sec','openspec-reviewer-maintainability':'maint'};
139
- let dashboardState={};
140
- function esc(v){return String(v??'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','\'':'&#39;','"':'&quot;'}[c]))}
141
- function cls(v){return String(v??'').replace(/[^a-zA-Z0-9_-]/g,'_')}
142
- function shortAgent(key){if(AGENT_SHORT[key])return AGENT_SHORT[key];const m=/^openspec-reviewer-(.+)$/.exec(key)||/^openspec-(.+)$/.exec(key);return m?m[1]:(key||'')}
143
- function descBlock(text){if(!text)return '';if(text.length<90)return `<div class="card-desc">${esc(text)}</div>`;return `<div class="card-desc collapsed">${esc(text)}</div><button class="desc-toggle">展开</button>`}
144
- function verdictDot(v){return `<span class="verdict ${cls(v.verdict)}" title="${esc(v.stepId)}">${esc(shortAgent(v.agentKey))}:${VERDICT_ICONS[v.verdict]||'○'}</span>`}
145
- function renderCard(c){
146
- const isIssue=c.type==='issue';
147
- const typeTag=`<span class="type-tag ${isIssue?'issue':'task'}">${isIssue?'I':'T'}</span>`;
148
- const srcIcon=`<span class="src-icon" title="${esc(c.source||'')}">${SOURCE_ICONS[c.source]||'●'}</span>`;
149
- const sevBar=isIssue&&c.severity?`<span class="sev-bar ${cls(String(c.severity).toLowerCase())}" title="${esc(c.severity)}"></span>`:'';
150
- const labels=(c.labels||[]).map(l=>`<span class="chip">${esc(l)}</span>`).join('');
151
- const step=c.currentStep?`<span class="step-tag">${esc(c.currentStep)}</span>`:'';
152
- const suspTag=c.suspended?'<span class="suspend-tag" role="button" tabindex="0">⏸ 暂停</span>':'';
153
- const suspReason=c.suspended?`<div class="suspend-reason" hidden>${esc(c.suspendReason||'未提供暂停原因')}</div>`:'';
154
- const verdicts=(c.agentVerdicts||[]).map(verdictDot).join('');
155
- const children=(c.children||[]).map(ch=>`<div class="child-row"><span class="child-type ${cls(ch.type)}">${ch.type==='issue'?'I':'T'}</span><span class="child-title">${esc(ch.title)}</span>${ch.type==='task'?`<span class="child-phase">${esc(ch.phase)}</span>`:''}</div>`).join('');
156
- return `<article class="card ${isIssue?'issue':'task'}${c.suspended?' suspended':''}" data-id="${esc(c.id)}">${sevBar}
157
- <div class="card-head">${typeTag}${srcIcon}<h4 class="card-title">${esc(c.title)}</h4></div>
158
- ${descBlock(c.description)}
159
- ${labels?`<div class="card-labels">${labels}</div>`:''}
160
- ${children?`<div class="card-children">${children}</div>`:''}
161
- <div class="card-foot">${verdicts?`<div class="card-verdicts">${verdicts}</div>`:'<span></span>'}<div class="card-meta">${step}${suspTag}</div></div>
162
- ${suspReason}
163
- </article>`;
164
- }
165
- function renderBoard(){
166
- const cards=dashboardState.workItemCards||[];
167
- if(!cards.length)return `<section class="board"><div class="board-empty">暂无工作项数据(旧格式状态或未初始化)</div></section>`;
168
- return `<section class="board">${COLUMNS.map(([key,label])=>{
169
- const items=cards.filter(c=>c.phase===key);
170
- return `<div class="col" data-col="${key}"><header class="col-head"><h3>${label}</h3><span class="col-count">${items.length}</span></header><div class="col-body">${items.map(renderCard).join('')}</div></div>`;
171
- }).join('')}</section>`;
172
- }
173
- function render(){
174
- document.getElementById('changeId').textContent=dashboardState.changeId||'-';
175
- document.getElementById('baseBranch').textContent=dashboardState.baseBranch||'-';
176
- document.getElementById('updatedAt').textContent=dashboardState.updatedAt?new Date(dashboardState.updatedAt).toLocaleString():'-';
177
- document.getElementById('dashboard').innerHTML=renderBoard();
178
- document.querySelectorAll('.desc-toggle').forEach(btn=>btn.addEventListener('click',e=>{
179
- const d=e.target.previousElementSibling;
180
- if(d&&d.classList.contains('card-desc')){d.classList.toggle('collapsed');e.target.textContent=d.classList.contains('collapsed')?'展开':'收起'}
181
- }));
182
- document.querySelectorAll('.suspend-tag').forEach(tag=>tag.addEventListener('click',e=>{
183
- const card=e.target.closest('.card');
184
- const reason=card&&card.querySelector('.suspend-reason');
185
- if(reason)reason.hidden=!reason.hidden;
186
- }));
187
- if(modalState.id)refreshModal();
188
- }
189
- /* WorkItem 详情弹窗 */
190
- let modalState={id:null,back:[]};
191
- function findWorkItem(items,id){
192
- for(const it of items||[]){
193
- if(it.id===id)return it;
194
- if(it.children&&it.children.length){
195
- const found=findWorkItem(it.children,id);
196
- if(found)return found;
197
- }
198
- }
199
- return null;
200
- }
201
- function renderTags(tags){
202
- const verdicts=Object.entries(tags||{}).map(([key,verdict])=>{
203
- const sep=key.indexOf(':');
204
- const stepId=sep===-1?key:key.slice(0,sep);
205
- const agentKey=sep===-1?key:key.slice(sep+1);
206
- return `<span class="detail-verdict ${cls(verdict)}" title="${esc(key)}">${esc(shortAgent(agentKey))}:${VERDICT_ICONS[verdict]||'○'}</span>`;
207
- }).join('');
208
- return verdicts?`<div class="detail-block"><h5>Agent 裁决</h5><div class="detail-verdicts">${verdicts}</div></div>`:'';
209
- }
210
- function renderWriteback(wb){
211
- if(!wb)return '';
212
- const rows=Object.entries(wb).filter(([k])=>!k.startsWith('_')).map(([k,v])=>{
213
- const val=typeof v==='object'?JSON.stringify(v):String(v);
214
- return `<div class="meta-row"><span class="meta-key">${esc(k)}</span><span class="meta-val">${esc(val)}</span></div>`;
215
- }).join('');
216
- return rows?`<div class="detail-block"><h5>写回</h5>${rows}</div>`:'';
217
- }
218
- function renderMeta(metadata){
219
- const rows=Object.entries(metadata||{}).filter(([k])=>!k.startsWith('_')).map(([k,v])=>{
220
- const val=typeof v==='object'?JSON.stringify(v):String(v);
221
- return `<div class="meta-row"><span class="meta-key">${esc(k)}</span><span class="meta-val">${esc(val)}</span></div>`;
222
- }).join('');
223
- return rows?`<div class="detail-block"><h5>元数据</h5>${rows}</div>`:'';
224
- }
225
- function renderChildren(children){
226
- if(!children||!children.length)return '';
227
- const rows=(children||[]).map(ch=>`<div class="detail-child" data-id="${esc(ch.id)}" role="button" tabindex="0">
228
- <span class="child-type ${cls(ch.type)}">${ch.type==='issue'?'I':'T'}</span>
229
- <span class="child-title">${esc(ch.title)}</span>
230
- <span class="child-phase">${esc(ch.phase)}</span>
231
- </div>`).join('');
232
- return `<div class="detail-block"><h5>Children</h5><div class="detail-children">${rows}</div></div>`;
233
- }
234
- function renderDetail(item){
235
- const rows=[['类型',item.type],['来源',item.source],['ID',item.id]];
236
- if(item.externalId)rows.push(['外部ID',item.externalId]);
237
- rows.push(['阶段',item.phase]);
238
- if(item.severity)rows.push(['严重级别',item.severity]);
239
- rows.push(['当前步骤',item.currentStep||'-']);
240
- const kv=rows.map(([k,v])=>`<div class="meta-row"><span class="meta-key">${esc(k)}</span><span class="meta-val">${esc(v)}</span></div>`).join('');
241
- return `<h3 class="detail-title">${esc(item.title)}</h3>
242
- ${item.suspended?`<div class="suspend-reason">⏸ 暂停:${esc(typeof (item.metadata||{})['suspend_reason']==='string'?(item.metadata||{})['suspend_reason']:'未提供暂停原因')}</div>`:''}
243
- ${item.description?`<div class="detail-block"><h5>描述</h5><p class="detail-desc">${esc(item.description)}</p></div>`:''}
244
- <div class="detail-block"><h5>字段</h5>${kv}</div>
245
- ${item.labels&&item.labels.length?`<div class="detail-block"><h5>标签</h5><div class="card-labels">${item.labels.map(l=>`<span class="chip">${esc(l)}</span>`).join('')}</div></div>`:''}
246
- ${renderTags(item.tags)}
247
- ${renderWriteback(item.writeback)}
248
- ${renderMeta(item.metadata)}
249
- ${renderChildren(item.children)}`;
250
- }
251
- function refreshModal(){
252
- const modal=document.getElementById('wiModal');
253
- const item=findWorkItem(dashboardState.workItems||[],modalState.id);
254
- if(!item){closeDetail();return;}
255
- document.getElementById('wiBody').innerHTML=renderDetail(item);
256
- document.getElementById('wiBack').hidden=!modalState.back.length;
257
- modal.hidden=false;
258
- document.body.classList.add('modal-open');
259
- }
260
- function openDetail(id,back){
261
- modalState={id,back:back||[]};
262
- refreshModal();
263
- }
264
- function closeDetail(){
265
- modalState={id:null,back:[]};
266
- const modal=document.getElementById('wiModal');
267
- if(modal)modal.hidden=true;
268
- document.body.classList.remove('modal-open');
269
- }
270
- document.addEventListener('click',e=>{
271
- if(e.target.closest('.desc-toggle,.suspend-tag'))return;
272
- const card=e.target.closest('.card');
273
- if(card&&card.dataset.id)openDetail(card.dataset.id,[]);
274
- });
275
- document.getElementById('wiModal').addEventListener('click',e=>{
276
- if(e.target.closest('#wiBack')){
277
- const prev=modalState.back[modalState.back.length-1];
278
- openDetail(prev,modalState.back.slice(0,-1));
279
- return;
280
- }
281
- if(e.target.classList.contains('modal-overlay')){closeDetail();return;}
282
- if(e.target.closest('.modal-close')){closeDetail();return;}
283
- const child=e.target.closest('.detail-child');
284
- if(child&&child.dataset.id)openDetail(child.dataset.id,[...modalState.back,modalState.id]);
285
- });
286
- document.addEventListener('keydown',e=>{
287
- if(e.key==='Escape'){const modal=document.getElementById('wiModal');if(modal&&!modal.hidden)closeDetail();}
288
- });
289
- async function loadState(){try{const res=await fetch('/api/state');if(!res.ok)throw new Error('fetch failed');const data=await res.json();if(data&&data.active===true){dashboardState=data;render()}}catch(e){console.error('[dashboard] load state failed',e)}}
290
- render();
291
- loadState();
292
- setInterval(loadState,2000);
293
- </script>
294
- </body>
295
- </html>
@@ -1,64 +0,0 @@
1
- import { createServer } from "node:http"
2
- import type { Server } from "node:http"
3
- import { readDashboardState } from "../../core/dashboard.ts"
4
- import { getDashboardPage } from "../../dashboard/page.ts"
5
-
6
- const BASE_PORT = 4519
7
- const MAX_ATTEMPTS = 20
8
-
9
- const servers = new Map<string, Server>()
10
-
11
- export async function startDashboard(worktree: string): Promise<void> {
12
- if (servers.has(worktree)) return
13
-
14
- const pageHtml = getDashboardPage()
15
-
16
- // 端口占用自动递增:EADDRINUSE 走 server error 事件(异步),经 once("error") + await listen
17
- // 判定重试;其余启动期错误仅记录不中断。修复前 try/catch 同步捕获永不触发,换端口失效。
18
- for (let port = BASE_PORT; port < BASE_PORT + MAX_ATTEMPTS; port++) {
19
- try {
20
- const server = createServer(async (req, res) => {
21
- try {
22
- const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1"}`)
23
- if (url.pathname === "/api/state") {
24
- try {
25
- const data = await readDashboardState(worktree)
26
- res.writeHead(200, { "content-type": "application/json;charset=utf-8" })
27
- res.end(JSON.stringify(data ?? { active: false }))
28
- } catch (err) {
29
- res.writeHead(500, { "content-type": "application/json;charset=utf-8" })
30
- res.end(JSON.stringify({ active: false, error: String(err) }))
31
- }
32
- return
33
- }
34
- res.writeHead(200, { "content-type": "text/html;charset=utf-8" })
35
- res.end(pageHtml)
36
- } catch (err) {
37
- console.error("[dashboard]", (err as Error).message)
38
- res.writeHead(500, { "content-type": "text/plain;charset=utf-8" })
39
- res.end("internal error")
40
- }
41
- })
42
- await new Promise<void>((resolve, reject) => {
43
- server.once("error", (err: NodeJS.ErrnoException) => {
44
- if (err.code === "EADDRINUSE") {
45
- reject(err)
46
- } else {
47
- console.error("[dashboard]", err.message)
48
- resolve()
49
- }
50
- })
51
- server.listen(port, "127.0.0.1", () => resolve())
52
- })
53
- servers.set(worktree, server)
54
- console.log(`[dashboard] 编排进度看板 http://127.0.0.1:${port}`)
55
- return
56
- } catch {
57
- continue
58
- }
59
- }
60
-
61
- console.error(
62
- `[dashboard] 无法启动编排进度看板:端口 ${BASE_PORT}-${BASE_PORT + MAX_ATTEMPTS - 1} 均被占用`
63
- )
64
- }
@@ -1,101 +0,0 @@
1
- import type { OrchestrateState } from "./types.ts"
2
- import { readStateByWorktree, readStateByChangeId, getStateDir } from "./state.ts"
3
- import { readdirSync, existsSync } from "node:fs"
4
- import { readFile } from "node:fs/promises"
5
- import path from "path"
6
- import type { WorkItem, WorkItemType, WorkItemPhase, Verdict } from "./workflow/types.ts"
7
-
8
- /** 看板卡片投影:WorkItem → 前端可渲染结构。 */
9
- export interface WorkItemCard {
10
- id: string
11
- type: WorkItemType
12
- title: string
13
- description: string
14
- phase: WorkItemPhase
15
- suspended: boolean
16
- suspendReason?: string
17
- currentStep: string | null
18
- labels: string[]
19
- source: string
20
- severity?: string
21
- agentVerdicts: Array<{ stepId: string; agentKey: string; verdict: Verdict }>
22
- children: Array<{ id: string; type: WorkItemType; title: string; phase: WorkItemPhase; severity?: string }>
23
- }
24
-
25
- /** taskGroup → 看板投影(旧三面板视图已随单轨化移除)。 */
26
-
27
- /**
28
- * WorkItem → 看板卡片。
29
- * tags 键为 `${stepId}:${agentKey}`,拆解为可渲染的 agent 裁决数组;
30
- * metadata 仅暴露无下划线前缀的业务字段(suspend_reason),内部字段(_ 前缀)不泄露。
31
- */
32
- function toWorkItemCard(item: WorkItem): WorkItemCard {
33
- const agentVerdicts = Object.entries(item.tags).map(([key, verdict]) => {
34
- const sep = key.indexOf(":")
35
- return {
36
- stepId: sep === -1 ? key : key.slice(0, sep),
37
- agentKey: sep === -1 ? key : key.slice(sep + 1),
38
- verdict,
39
- }
40
- })
41
- const suspendReason =
42
- typeof item.metadata["suspend_reason"] === "string" ? (item.metadata["suspend_reason"] as string) : undefined
43
- return {
44
- id: item.id,
45
- type: item.type,
46
- title: item.title,
47
- description: item.description,
48
- phase: item.phase,
49
- suspended: item.suspended,
50
- suspendReason,
51
- currentStep: item.currentStep,
52
- labels: item.labels,
53
- source: item.source,
54
- severity: item.severity,
55
- agentVerdicts,
56
- children: item.children.map((child) => ({
57
- id: child.id,
58
- type: child.type,
59
- title: child.title,
60
- phase: child.phase,
61
- severity: child.severity,
62
- })),
63
- }
64
- }
65
-
66
- /** OrchestrateState → 看板投影(workItems 单轨 + workItemCards 5 列视图)。 */
67
- function projectState(state: OrchestrateState) {
68
- return {
69
- active: true,
70
- changeId: state.changeId,
71
- currentTaskGroupId: state.taskGroupId,
72
- baseBranch: state.baseBranch,
73
- createdAt: state.createdAt,
74
- updatedAt: state.updatedAt,
75
- workItems: state.workItems,
76
- workItemCards: state.workItems.map(toWorkItemCard),
77
- }
78
- }
79
-
80
- export async function readDashboardState(worktree: string, changeId?: string) {
81
- if (changeId) {
82
- const state = await readStateByWorktree(worktree, changeId)
83
- if (!state) return null
84
- return projectState(state)
85
- }
86
-
87
- const stateDir = getStateDir(worktree)
88
- if (!existsSync(stateDir)) return null
89
- const files = readdirSync(stateDir).filter(f => f.endsWith(".json"))
90
- const results: ReturnType<typeof projectState>[] = []
91
- for (const f of files) {
92
- try {
93
- const raw = await readFile(path.join(stateDir, f), "utf-8")
94
- const state = JSON.parse(raw) as OrchestrateState
95
- if (!state.changeId) continue
96
- results.push(projectState(state))
97
- } catch { /* skip unreadable */ }
98
- }
99
- if (results.length === 1) return results[0]
100
- return results.length > 0 ? results : null
101
- }
@@ -1,19 +0,0 @@
1
- import { fileURLToPath } from "node:url"
2
- import { readFileSync, existsSync } from "node:fs"
3
- import { join } from "node:path"
4
-
5
- let _html: string | null = null
6
-
7
- /** 页面资源定位:bundle 形态(ZCode 插件包)资源随 bundle 同目录放置;源码/包形态位于 ../../assets/dashboard/。 */
8
- function resolveDashboardHtml(): string {
9
- const __dirname = fileURLToPath(new URL(".", import.meta.url))
10
- const bundled = join(__dirname, "dashboard", "index.html")
11
- if (existsSync(bundled)) return bundled
12
- return join(__dirname, "../../assets/dashboard/index.html")
13
- }
14
-
15
- export function getDashboardPage(): string {
16
- if (_html) return _html
17
- _html = readFileSync(resolveDashboardHtml(), "utf-8")
18
- return _html
19
- }