openspec-dashboard 0.1.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/README.md +109 -0
- package/dist/bin/cli.js +49 -0
- package/dist/bin/skill-hook.js +174 -0
- package/dist/chunk-YBLVDJ3Y.js +1232 -0
- package/dist/client/assets/index-LwI8lMI7.js +97 -0
- package/dist/client/assets/index-keJ4Pk8f.css +1 -0
- package/dist/client/index.html +23 -0
- package/dist/server/index.js +8 -0
- package/package.json +74 -0
package/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# OpenSpec Dashboard
|
|
2
|
+
|
|
3
|
+
本地可视化看板,实时展示 OpenSpec SDD(Spec-Driven Development)流程中的变更状态。在包含 `openspec/` 目录的项目中启动,即可获得带热更新的 Web 看板界面。
|
|
4
|
+
|
|
5
|
+
## 功能特性
|
|
6
|
+
|
|
7
|
+
### 📋 变更全局可视化看板
|
|
8
|
+
- **三列 Kanban 视图**:进行中、待实现、已归档
|
|
9
|
+
- 每张卡片展示变更名称、类型标签、产物进度条、创建时间
|
|
10
|
+
- 支持变更类型识别:`feat-*`、`fix-*`、`hotfix-*`
|
|
11
|
+
- 产物状态追踪:proposal → specs → design → tasks
|
|
12
|
+
|
|
13
|
+
### 📄 变更详情查看
|
|
14
|
+
- Markdown 产物内容实时渲染
|
|
15
|
+
- tasks.md checkbox 完成度统计
|
|
16
|
+
- 相关 spec 文件列表展示
|
|
17
|
+
|
|
18
|
+
### 🚀 新项目快速接入向导
|
|
19
|
+
- 三步式表单引导:基础信息 → 工作流选择 → 生成确认
|
|
20
|
+
- 支持多种技术栈:Java / Node / Python / Go
|
|
21
|
+
- 内置流程模板:标准流程 / 轻量流程 / Hotfix 流程
|
|
22
|
+
- 一键生成项目配置文件
|
|
23
|
+
|
|
24
|
+
### ⚡ 实时更新
|
|
25
|
+
- 文件系统监听(chokidar)
|
|
26
|
+
- WebSocket 增量推送
|
|
27
|
+
- 热更新无需手动刷新
|
|
28
|
+
|
|
29
|
+
### 🛠️ 技能执行面板
|
|
30
|
+
- 可视化技能选择器
|
|
31
|
+
- 命令执行终端
|
|
32
|
+
- 实时输出展示
|
|
33
|
+
|
|
34
|
+
## 快速开始
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm install
|
|
38
|
+
npm run dev
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
开发服务启动后会自动打开浏览器,展示当前项目的变更看板。
|
|
42
|
+
|
|
43
|
+
## CLI 使用
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# 默认启动(自动发现 openspec/ 目录)
|
|
47
|
+
npx openspec-dashboard
|
|
48
|
+
|
|
49
|
+
# 自定义端口
|
|
50
|
+
openspec-dashboard --port 3000
|
|
51
|
+
|
|
52
|
+
# 指定项目路径
|
|
53
|
+
openspec-dashboard --dir ./path
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 构建与生产运行
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npm run build
|
|
60
|
+
npm start
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 测试
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npm test
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## 技术栈
|
|
70
|
+
|
|
71
|
+
- **前端框架**: React 18 + Vite
|
|
72
|
+
- **样式**: TailwindCSS 3 + shadcn/ui
|
|
73
|
+
- **后端**: Node.js + Express
|
|
74
|
+
- **文件监听**: chokidar
|
|
75
|
+
- **WebSocket**: ws
|
|
76
|
+
- **Markdown 渲染**: react-markdown + remark-gfm
|
|
77
|
+
|
|
78
|
+
## 项目结构
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
openspec-dashboard/
|
|
82
|
+
├── src/
|
|
83
|
+
│ ├── server/ # 后端服务
|
|
84
|
+
│ │ ├── parser/ # OpenSpec 解析器
|
|
85
|
+
│ │ ├── routes/ # API 路由
|
|
86
|
+
│ │ ├── watcher.ts # 文件监听
|
|
87
|
+
│ │ └── ws.ts # WebSocket
|
|
88
|
+
│ └── client/ # 前端应用
|
|
89
|
+
│ ├── components/ # UI 组件
|
|
90
|
+
│ ├── hooks/ # 自定义 Hooks
|
|
91
|
+
│ └── lib/ # 工具函数
|
|
92
|
+
├── openspec/ # 示例 OpenSpec 配置
|
|
93
|
+
└── tests/ # 测试文件
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## OpenSpec SDD 流程
|
|
97
|
+
|
|
98
|
+
OpenSpec Dashboard 支持 Spec-Driven Development 流程,通过以下产物管理变更:
|
|
99
|
+
|
|
100
|
+
1. **proposal.md** - 变更提案
|
|
101
|
+
2. **specs/** - 技术规格文档
|
|
102
|
+
3. **design.md** - 设计文档
|
|
103
|
+
4. **tasks.md** - 任务清单
|
|
104
|
+
|
|
105
|
+
变更状态自动判定:
|
|
106
|
+
- **进行中**:有产物但未全部完成
|
|
107
|
+
- **待实现**:所有产物已就绪,等待 apply
|
|
108
|
+
- **已归档**:位于 `archive/` 目录下的变更
|
|
109
|
+
|
package/dist/bin/cli.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
startServer
|
|
4
|
+
} from "../chunk-YBLVDJ3Y.js";
|
|
5
|
+
|
|
6
|
+
// src/bin/cli.ts
|
|
7
|
+
import { program } from "commander";
|
|
8
|
+
import path from "path";
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import { fileURLToPath } from "url";
|
|
11
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
var __dirname = path.dirname(__filename);
|
|
13
|
+
program.name("openspec-dashboard").description("OpenSpec SDD \u672C\u5730\u53EF\u89C6\u5316\u770B\u677F").version("0.1.0").option("-p, --port <port>", "\u670D\u52A1\u7AEF\u53E3", "3456").option("-d, --dir <dir>", "\u6307\u5B9A openspec \u76EE\u5F55\u8DEF\u5F84").option("--no-open", "\u4E0D\u81EA\u52A8\u6253\u5F00\u6D4F\u89C8\u5668").action(async (opts) => {
|
|
14
|
+
const port = parseInt(opts.port, 10);
|
|
15
|
+
const openspecDir = resolveOpenspecDir(opts.dir);
|
|
16
|
+
if (!openspecDir) {
|
|
17
|
+
console.error("\u9519\u8BEF\uFF1A\u672A\u627E\u5230 openspec/ \u76EE\u5F55\u3002\u8BF7\u5728\u5305\u542B openspec/ \u7684\u9879\u76EE\u4E2D\u8FD0\u884C\uFF0C\u6216\u4F7F\u7528 --dir \u6307\u5B9A\u8DEF\u5F84\u3002");
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
const staticDir = path.resolve(__dirname, "../client");
|
|
21
|
+
const { shutdown } = await startServer({ openspecDir, port, staticDir, projectDir: path.resolve(openspecDir, "..") });
|
|
22
|
+
if (opts.open !== false) {
|
|
23
|
+
const open = await import("open");
|
|
24
|
+
await open.default(`http://localhost:${port}`);
|
|
25
|
+
}
|
|
26
|
+
process.on("SIGINT", async () => {
|
|
27
|
+
await shutdown();
|
|
28
|
+
process.exit(0);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
function resolveOpenspecDir(dir) {
|
|
32
|
+
if (dir) {
|
|
33
|
+
const abs = path.resolve(dir);
|
|
34
|
+
if (fs.existsSync(abs)) return abs;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
let current = process.cwd();
|
|
38
|
+
for (let i = 0; i < 5; i++) {
|
|
39
|
+
const candidate = path.join(current, "openspec");
|
|
40
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
|
|
41
|
+
return candidate;
|
|
42
|
+
}
|
|
43
|
+
const parent = path.dirname(current);
|
|
44
|
+
if (parent === current) break;
|
|
45
|
+
current = parent;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
program.parse();
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/bin/skill-hook.ts
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import path from "path";
|
|
6
|
+
var IDLE_STATE = {
|
|
7
|
+
skill: "",
|
|
8
|
+
change: "",
|
|
9
|
+
status: "idle",
|
|
10
|
+
phase: "creating_artifact",
|
|
11
|
+
currentTask: 0,
|
|
12
|
+
totalTasks: 0,
|
|
13
|
+
currentArtifact: null,
|
|
14
|
+
startedAt: "",
|
|
15
|
+
updatedAt: ""
|
|
16
|
+
};
|
|
17
|
+
function resolveOpenspecDir() {
|
|
18
|
+
let current = process.cwd();
|
|
19
|
+
for (let i = 0; i < 5; i++) {
|
|
20
|
+
const candidate = path.join(current, "openspec");
|
|
21
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
|
|
22
|
+
return candidate;
|
|
23
|
+
}
|
|
24
|
+
const parent = path.dirname(current);
|
|
25
|
+
if (parent === current) break;
|
|
26
|
+
current = parent;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
function inferSkillAndChange(filePath, openspecDir) {
|
|
31
|
+
const normalized = path.normalize(filePath);
|
|
32
|
+
const normalizedDir = path.normalize(openspecDir);
|
|
33
|
+
if (!normalized.startsWith(normalizedDir)) return null;
|
|
34
|
+
const relative = path.relative(normalizedDir, normalized);
|
|
35
|
+
const parts = relative.split(path.sep);
|
|
36
|
+
if (parts[0] === "changes" && parts.length >= 3) {
|
|
37
|
+
const changeName = parts[2] === "archive" ? parts[3] : parts[1];
|
|
38
|
+
const fileName = parts[parts.length - 1];
|
|
39
|
+
if (fileName === "tasks.md") {
|
|
40
|
+
return { skill: "apply", change: changeName, phase: "implementing", currentArtifact: null };
|
|
41
|
+
}
|
|
42
|
+
if (fileName === ".openspec.yaml") {
|
|
43
|
+
return { skill: "propose", change: changeName, phase: "creating_artifact", currentArtifact: null };
|
|
44
|
+
}
|
|
45
|
+
if (fileName.endsWith(".md")) {
|
|
46
|
+
const artifactId = fileName.replace(".md", "");
|
|
47
|
+
return { skill: "propose", change: changeName, phase: "creating_artifact", currentArtifact: artifactId };
|
|
48
|
+
}
|
|
49
|
+
return { skill: "continue", change: changeName, phase: "creating_artifact", currentArtifact: null };
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
function parseTaskProgress(content) {
|
|
54
|
+
const regex = /^- \[([ x])\]/gm;
|
|
55
|
+
let total = 0;
|
|
56
|
+
let completed = 0;
|
|
57
|
+
let match;
|
|
58
|
+
while ((match = regex.exec(content)) !== null) {
|
|
59
|
+
total++;
|
|
60
|
+
if (match[1] === "x") completed++;
|
|
61
|
+
}
|
|
62
|
+
return { total, completed };
|
|
63
|
+
}
|
|
64
|
+
function readCurrentState(executionJsonPath) {
|
|
65
|
+
try {
|
|
66
|
+
const raw = fs.readFileSync(executionJsonPath, "utf-8");
|
|
67
|
+
return JSON.parse(raw);
|
|
68
|
+
} catch {
|
|
69
|
+
return { ...IDLE_STATE };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function writeState(executionJsonPath, state) {
|
|
73
|
+
fs.writeFileSync(executionJsonPath, JSON.stringify(state, null, 2) + "\n");
|
|
74
|
+
}
|
|
75
|
+
function appendLog(logPath, line) {
|
|
76
|
+
const timestamp = (/* @__PURE__ */ new Date()).toLocaleTimeString("en-GB", { hour12: false });
|
|
77
|
+
const entry = `[${timestamp}] ${line}
|
|
78
|
+
`;
|
|
79
|
+
fs.appendFileSync(logPath, entry);
|
|
80
|
+
}
|
|
81
|
+
async function main() {
|
|
82
|
+
let input = "";
|
|
83
|
+
try {
|
|
84
|
+
input = await new Promise((resolve) => {
|
|
85
|
+
let data = "";
|
|
86
|
+
process.stdin.setEncoding("utf-8");
|
|
87
|
+
process.stdin.on("data", (chunk) => {
|
|
88
|
+
data += chunk;
|
|
89
|
+
});
|
|
90
|
+
process.stdin.on("end", () => resolve(data));
|
|
91
|
+
setTimeout(() => resolve(data), 500);
|
|
92
|
+
});
|
|
93
|
+
} catch {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
let ctx;
|
|
97
|
+
try {
|
|
98
|
+
ctx = JSON.parse(input);
|
|
99
|
+
} catch {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const filePath = ctx.filePath;
|
|
103
|
+
if (!filePath || !fs.existsSync(filePath)) return;
|
|
104
|
+
const openspecDir = resolveOpenspecDir();
|
|
105
|
+
if (!openspecDir) return;
|
|
106
|
+
const inferred = inferSkillAndChange(filePath, openspecDir);
|
|
107
|
+
if (!inferred) return;
|
|
108
|
+
const executionJsonPath = path.join(openspecDir, "execution.json");
|
|
109
|
+
const logPath = path.join(openspecDir, "execution.log");
|
|
110
|
+
const current = readCurrentState(executionJsonPath);
|
|
111
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
112
|
+
if (current.status === "idle" || current.change !== inferred.change) {
|
|
113
|
+
const newState = {
|
|
114
|
+
skill: inferred.skill,
|
|
115
|
+
change: inferred.change,
|
|
116
|
+
status: "running",
|
|
117
|
+
phase: inferred.phase,
|
|
118
|
+
currentTask: 0,
|
|
119
|
+
totalTasks: 0,
|
|
120
|
+
currentArtifact: inferred.currentArtifact,
|
|
121
|
+
startedAt: current.change !== inferred.change ? now : current.startedAt || now,
|
|
122
|
+
updatedAt: now
|
|
123
|
+
};
|
|
124
|
+
if (inferred.skill === "apply" && filePath.endsWith("tasks.md")) {
|
|
125
|
+
try {
|
|
126
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
127
|
+
const progress = parseTaskProgress(content);
|
|
128
|
+
newState.currentTask = progress.completed;
|
|
129
|
+
newState.totalTasks = progress.total;
|
|
130
|
+
if (progress.total > 0 && progress.completed === progress.total) {
|
|
131
|
+
newState.status = "completed";
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
writeState(executionJsonPath, newState);
|
|
137
|
+
appendLog(logPath, `Starting /opsx:${inferred.skill} on ${inferred.change}`);
|
|
138
|
+
} else {
|
|
139
|
+
const updated = {
|
|
140
|
+
...current,
|
|
141
|
+
phase: inferred.phase,
|
|
142
|
+
currentArtifact: inferred.currentArtifact ?? current.currentArtifact,
|
|
143
|
+
updatedAt: now
|
|
144
|
+
};
|
|
145
|
+
if (inferred.skill === "apply" && filePath.endsWith("tasks.md")) {
|
|
146
|
+
try {
|
|
147
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
148
|
+
const progress = parseTaskProgress(content);
|
|
149
|
+
updated.currentTask = progress.completed;
|
|
150
|
+
updated.totalTasks = progress.total;
|
|
151
|
+
if (progress.total > 0 && progress.completed === progress.total) {
|
|
152
|
+
updated.status = "completed";
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
writeState(executionJsonPath, updated);
|
|
158
|
+
if (inferred.skill === "apply" && filePath.endsWith("tasks.md")) {
|
|
159
|
+
try {
|
|
160
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
161
|
+
const progress = parseTaskProgress(content);
|
|
162
|
+
if (updated.status === "completed") {
|
|
163
|
+
appendLog(logPath, `All tasks complete (${progress.completed}/${progress.total})`);
|
|
164
|
+
} else {
|
|
165
|
+
appendLog(logPath, `Working on task ${progress.completed + 1}/${progress.total}`);
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
}
|
|
169
|
+
} else if (inferred.currentArtifact) {
|
|
170
|
+
appendLog(logPath, `Creating artifact: ${inferred.currentArtifact}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
main().catch(() => process.exit(1));
|