@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.
- package/.mcp-server/cli.mjs +49 -163
- package/package.json +1 -1
- package/src/adapters/deepseek-harness/index.ts +11 -3
- package/src/adapters/mcp-common/index.ts +3 -5
- package/src/adapters/opencode/index.ts +2 -2
- package/src/adapters/plugin-common/index.ts +0 -2
- package/src/core/index.ts +0 -3
- package/src/core/provider.ts +1 -3
- package/.mcp-server/dashboard/index.html +0 -295
- package/assets/dashboard/index.html +0 -295
- package/src/adapters/opencode/dashboard.ts +0 -64
- package/src/core/dashboard.ts +0 -101
- package/src/dashboard/page.ts +0 -19
package/.mcp-server/cli.mjs
CHANGED
|
@@ -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:
|
|
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.
|
|
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
|
|
30599
|
+
import { createServer as createServer2 } from "node:http";
|
|
30564
30600
|
import { randomUUID } from "node:crypto";
|
|
30565
|
-
import { join as
|
|
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
|
|
32944
|
-
import { join as
|
|
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 =
|
|
32865
|
+
const changesDir = join3(this.openspecDir, "changes");
|
|
32979
32866
|
let entries;
|
|
32980
32867
|
try {
|
|
32981
|
-
entries =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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.
|
|
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}/
|
|
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,
|
|
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
|
-
* 并承载
|
|
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
|
-
/** 启动
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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),
|
|
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
package/src/core/provider.ts
CHANGED
|
@@ -46,14 +46,12 @@ export interface RuntimeTool {
|
|
|
46
46
|
/** 工具注册表:工具名 → 定义。 */
|
|
47
47
|
export type ToolRegistry = Record<string, RuntimeTool>
|
|
48
48
|
|
|
49
|
-
/** 运行时配置注入:agent/skill
|
|
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=>({'&':'&','<':'<','>':'>','\'':''','"':'"'}[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=>({'&':'&','<':'<','>':'>','\'':''','"':'"'}[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
|
-
}
|
package/src/core/dashboard.ts
DELETED
|
@@ -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
|
-
}
|
package/src/dashboard/page.ts
DELETED
|
@@ -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
|
-
}
|